when-am-i / commit
feat: add repo link and error reporting via GitLab Service Desk
README.md | 8 +++ src/App.tsx | 30 ++++++++++- src/components/Attribution.tsx | 5 ++ src/components/ErrorBoundary.tsx | 48 +++++++++++++++++ src/main.tsx | 8 ++- src/utils/report.ts | 112 +++++++++++++++++++++++++++++++++++++++ src/vite-env.d.ts | 5 ++ vite.config.ts | 20 +++++++ 8 files changed, 234 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eda962b..a53e2be 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,14 @@ npm test # Vitest unit tests npm run test:watch # Vitest in watch mode ``` +## Reporting issues + +The app footer links to the [source repository](https://gitlab.com/PavelSafronov/what-year-is-it) +and a **Report an issue** link. The latter opens a pre-addressed email to the +project's GitLab Service Desk (no GitLab account needed) prefilled with context +— build version/commit, browser, URL, and the current selection. If the app +crashes, the error report additionally includes the error message and stack. + ## Project Structure - `src/components/` — React components diff --git a/src/App.tsx b/src/App.tsx index 47ed9f6..2cda57b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,20 @@ import calendar from './assets/calendar.png'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { MapPicker } from './components/MapPicker'; import { Attribution } from './components/Attribution'; +import { reportMailtoHref, setReportContext, subscribeReportErrors } from './utils/report'; function App() { const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(null); + const [runtimeError, setRuntimeError] = useState<unknown>(null); + + // Keep error reports in sync with the current app state. + useEffect(() => { + setReportContext({ picked }); + }, [picked]); + + // Surface async errors caught by the global handlers as a dismissible banner. + useEffect(() => subscribeReportErrors(setRuntimeError), []); return ( <div> @@ -13,6 +23,24 @@ function App() { <h1 style={{ margin: 0 }}>WhatYearIsIt</h1> </div> <p>Pick a spot on the map and a date to see what year it was there!</p> + {runtimeError != null && ( + <div + role="alert" + style={{ + margin: '12px 0', + padding: 12, + border: '1px solid #d33', + borderRadius: 6, + background: '#fff5f5', + }} + > + An unexpected error occurred.{' '} + <a href={reportMailtoHref(runtimeError)}>Send an error report</a>{' '} + <button type="button" onClick={() => setRuntimeError(null)}> + Dismiss + </button> + </div> + )} <MapPicker onPick={(lat: number, lng: number) => setPicked({ lat, lng })} /> {picked && ( <div style={{ marginTop: 16 }}> diff --git a/src/components/Attribution.tsx b/src/components/Attribution.tsx index c706ce5..ba406fb 100644 --- a/src/components/Attribution.tsx +++ b/src/components/Attribution.tsx @@ -1,7 +1,12 @@ import React from 'react'; +import { REPO_URL, reportMailtoHref } from '../utils/report'; const Attribution: React.FC = () => ( <footer style={{ marginTop: 32, fontSize: '0.9em', color: '#555' }}> + <p style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}> + <a href={REPO_URL} target="_blank" rel="noopener noreferrer">Source code</a> + <a href={reportMailtoHref()}>Report an issue</a> + </p> <p> <a href="https://www.flaticon.com/free-icons/calendar" title="calendar icons">Calendar icons created by Icongeek26 - Flaticon</a> </p> diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e33a0da --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { reportMailtoHref } from '../utils/report'; + +interface Props { + children: React.ReactNode; +} + +interface State { + error: Error | null; +} + +// Catches render-time errors anywhere below it and shows a recoverable fallback +// with a prefilled error report link (async errors are caught by the global +// handlers in report.ts instead). +class ErrorBoundary extends React.Component<Props, State> { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error('[what-year-is-it] render error:', error, info); + } + + render(): React.ReactNode { + const { error } = this.state; + if (!error) return this.props.children; + + return ( + <div + role="alert" + style={{ padding: 24, maxWidth: 640, margin: '40px auto', fontFamily: 'sans-serif' }} + > + <h2>Something went wrong</h2> + <p>Sorry — the app hit an unexpected error. You can help by sending a report.</p> + <p style={{ display: 'flex', gap: 12, alignItems: 'center' }}> + <a href={reportMailtoHref(error)}>Send an error report</a> + <button type="button" onClick={() => window.location.reload()}> + Reload + </button> + </p> + </div> + ); + } +} + +export { ErrorBoundary }; diff --git a/src/main.tsx b/src/main.tsx index 9dbd147..a91b7bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { installGlobalErrorHandlers } from './utils/report'; import './index.css'; +installGlobalErrorHandlers(); + ReactDOM.createRoot(document.getElementById('root')!).render( <React.StrictMode> - <App /> + <ErrorBoundary> + <App /> + </ErrorBoundary> </React.StrictMode> ); diff --git a/src/utils/report.ts b/src/utils/report.ts new file mode 100644 index 0000000..eb79dc5 --- /dev/null +++ b/src/utils/report.ts @@ -0,0 +1,112 @@ +// Error / issue reporting via GitLab Service Desk. +// +// Emails to SERVICE_DESK_EMAIL open issues in the project, so users can report +// without a GitLab account. We build a `mailto:` link prefilled with concise, +// actionable context (build metadata, browser, URL, current app state, and — +// for crashes — the error message + a trimmed stack). Kept short because some +// mail clients truncate long mailto bodies. + +export const REPO_URL = 'https://gitlab.com/PavelSafronov/what-year-is-it'; + +export const SERVICE_DESK_EMAIL = + 'contact-project+pavelsafronov-what-year-is-it-80432471-issue-@incoming.gitlab.com'; + +const MAX_BODY = 1800; +const MAX_STACK = 1000; + +// ── Current app state, shared into reports without prop-drilling ────────────── + +export interface ReportContext { + picked?: { lat: number; lng: number } | null; +} + +let context: ReportContext = {}; + +/** Update the app state included in reports (called by App on changes). */ +export function setReportContext(next: ReportContext): void { + context = next; +} + +// ── Runtime error capture ───────────────────────────────────────────────────── + +type ErrorListener = (error: unknown) => void; +const listeners = new Set<ErrorListener>(); + +/** Subscribe to captured runtime errors. Returns an unsubscribe function. */ +export function subscribeReportErrors(listener: ErrorListener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Record a runtime error and notify subscribers (e.g. to show a banner). */ +export function reportRuntimeError(error: unknown): void { + console.error('[what-year-is-it] captured error:', error); + listeners.forEach((listener) => listener(error)); +} + +let handlersInstalled = false; + +/** Capture errors React's ErrorBoundary can't see (async, event handlers). */ +export function installGlobalErrorHandlers(): void { + if (handlersInstalled || typeof window === 'undefined') return; + handlersInstalled = true; + window.addEventListener('error', (e) => reportRuntimeError(e.error ?? e.message)); + window.addEventListener('unhandledrejection', (e) => reportRuntimeError(e.reason)); +} + +// ── Report body construction ────────────────────────────────────────────────── + +function errorToText(error: unknown): { message: string; stack?: string } { + if (error instanceof Error) return { message: error.message, stack: error.stack }; + return { message: String(error) }; +} + +function envLines(): string[] { + const lines = [ + `Version: ${__APP_VERSION__}`, + `Commit: ${__GIT_COMMIT__}`, + `Build: ${__BUILD_TIME__}`, + ]; + if (typeof location !== 'undefined') lines.push(`URL: ${location.href}`); + if (typeof navigator !== 'undefined') lines.push(`Browser: ${navigator.userAgent}`); + return lines; +} + +function stateLines(): string[] { + const lines: string[] = []; + if (context.picked) { + lines.push(`Picked location: ${context.picked.lat.toFixed(4)}, ${context.picked.lng.toFixed(4)}`); + } + return lines; +} + +/** Build the plain-text report body. Pass an error for crash reports. */ +export function buildReportBody(error?: unknown): string { + const sections: string[] = []; + + if (error) { + const { message, stack } = errorToText(error); + let block = `Error: ${message}`; + if (stack) block += `\n\n${stack.slice(0, MAX_STACK)}`; + sections.push(block); + } else { + sections.push('Describe the problem:\n\n\n'); + } + + const state = stateLines(); + if (state.length) sections.push(`App state:\n${state.join('\n')}`); + sections.push(`Environment:\n${envLines().join('\n')}`); + + let body = sections.join('\n\n---\n\n'); + if (body.length > MAX_BODY) body = `${body.slice(0, MAX_BODY)}\n…(truncated)`; + return body; +} + +/** A `mailto:` href to the service desk, prefilled with context. */ +export function reportMailtoHref(error?: unknown): string { + const subject = `[what-year-is-it] ${error ? 'Error report' : 'Issue report'}`; + const body = buildReportBody(error); + return `mailto:${SERVICE_DESK_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8379221..f28a359 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,3 +2,8 @@ // vite/client already declares asset imports (*.png, *.svg, …) and the typings // for import.meta.glob used by the calendar data loader. + +// Build metadata injected via vite.config.ts `define`. +declare const __APP_VERSION__: string; +declare const __GIT_COMMIT__: string; +declare const __BUILD_TIME__: string; diff --git a/vite.config.ts b/vite.config.ts index ba2ad3e..d6f250a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,30 @@ /// <reference types="vitest/config" /> +import { execSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +// Build metadata, surfaced in error reports so we know which deployed build a +// report came from. Commit falls back to "unknown" outside a git checkout (CI +// without history, tarball builds, etc.). +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')); +const gitCommit = (() => { + if (process.env.CI_COMMIT_SHORT_SHA) return process.env.CI_COMMIT_SHORT_SHA; + try { + return execSync('git rev-parse --short HEAD').toString().trim(); + } catch { + return 'unknown'; + } +})(); + export default defineConfig({ base: '/what-year-is-it/', plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + __GIT_COMMIT__: JSON.stringify(gitCommit), + __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + }, server: { // Bind/open on 127.0.0.1 rather than `localhost`. Some browsers (e.g. // Firefox here) pin `localhost` to HTTPS via HSTS — set by other local