fuzzyworld

when-am-i / commit

feat: add repo link and error reporting via GitLab Service Desk

e6fe2bc724635ffff922da6cc27939f9a011c182 · Pavel Safronov · 2026-06-17 00:01:04 -0700 · parents d96c425a66d5

feat: add repo link and error reporting via GitLab Service Desk

- Footer (Attribution): "Source code" link to the repo and a "Report an issue"
  link.
- src/utils/report.ts: build a mailto to the project's GitLab Service Desk,
  prefilled with concise context — build version/commit, browser, URL, and
  current app state — plus, for crashes, the error message and a trimmed stack.
  Body length is capped so mail clients don't truncate it.
- ErrorBoundary catches render errors and shows a recoverable fallback with a
  prefilled report link; global error/unhandledrejection handlers surface async
  errors as a dismissible banner in App.
- vite.config.ts: inject __APP_VERSION__ / __GIT_COMMIT__ / __BUILD_TIME__
  (commit falls back to CI_COMMIT_SHORT_SHA, then "unknown") for the reports.
- README: document issue reporting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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