fuzzyworld

when-am-i / files / src / utils / report.ts

report.ts

Blame: the commit that last changed each line · file · history

b9ae7fbb 2026-10-27 Pavel Safronov1// Error / issue reporting by email.
e6fe2bc7 2026-06-17 Pavel Safronov2//
b9ae7fbb 2026-10-27 Pavel Safronov3// Emails to SERVICE_DESK_EMAIL go to Pavel's bug-report address (forward-only,
4// shared by his apps; `+when-am-i` tells them apart), so users can report
5// without any account. We build a `mailto:` link prefilled with concise,
e6fe2bc7 2026-06-17 Pavel Safronov6// actionable context (build metadata, browser, URL, current app state, and —
7// for crashes — the error message + a trimmed stack). Kept short because some
8// mail clients truncate long mailto bodies.
9
c2d75cfd 2026-10-27 Pavel Safronov10export const REPO_URL = 'https://code.fuzzyworld.net/when-am-i';
e6fe2bc7 2026-06-17 Pavel Safronov11
34c4dd59 2026-06-18 Pavel Safronov12/** Link to a repo-relative file on the default branch (for data-source links). */
13export function repoFileUrl(path: string): string {
c2d75cfd 2026-10-27 Pavel Safronov14 return `${REPO_URL}/blob/${path}.html`;
34c4dd59 2026-06-18 Pavel Safronov15}
16
b9ae7fbb 2026-10-27 Pavel Safronov17export const SERVICE_DESK_EMAIL = 'bugs-k7q4+when-am-i@fuzzyworld.net';
e6fe2bc7 2026-06-17 Pavel Safronov18
19const MAX_BODY = 1800;
20const MAX_STACK = 1000;
21
22// ── Current app state, shared into reports without prop-drilling ──────────────
23
24export interface ReportContext {
25 picked?: { lat: number; lng: number } | null;
34c4dd59 2026-06-18 Pavel Safronov26 date?: string;
e6fe2bc7 2026-06-17 Pavel Safronov27}
28
29let context: ReportContext = {};
30
31/** Update the app state included in reports (called by App on changes). */
32export function setReportContext(next: ReportContext): void {
33 context = next;
34}
35
36// ── Runtime error capture ─────────────────────────────────────────────────────
37
38type ErrorListener = (error: unknown) => void;
39const listeners = new Set<ErrorListener>();
40
41/** Subscribe to captured runtime errors. Returns an unsubscribe function. */
42export function subscribeReportErrors(listener: ErrorListener): () => void {
43 listeners.add(listener);
44 return () => {
45 listeners.delete(listener);
46 };
47}
48
49/** Record a runtime error and notify subscribers (e.g. to show a banner). */
50export function reportRuntimeError(error: unknown): void {
6b2790fa 2026-06-20 Pavel Safronov51 console.error('[when-am-i] captured error:', error);
e6fe2bc7 2026-06-17 Pavel Safronov52 listeners.forEach((listener) => listener(error));
53}
54
55let handlersInstalled = false;
56
57/** Capture errors React's ErrorBoundary can't see (async, event handlers). */
58export function installGlobalErrorHandlers(): void {
59 if (handlersInstalled || typeof window === 'undefined') return;
60 handlersInstalled = true;
61 window.addEventListener('error', (e) => reportRuntimeError(e.error ?? e.message));
62 window.addEventListener('unhandledrejection', (e) => reportRuntimeError(e.reason));
63}
64
65// ── Report body construction ──────────────────────────────────────────────────
66
67function errorToText(error: unknown): { message: string; stack?: string } {
68 if (error instanceof Error) return { message: error.message, stack: error.stack };
69 return { message: String(error) };
70}
71
72function envLines(): string[] {
73 const lines = [
74 `Version: ${__APP_VERSION__}`,
75 `Commit: ${__GIT_COMMIT__}`,
76 `Build: ${__BUILD_TIME__}`,
77 ];
78 if (typeof location !== 'undefined') lines.push(`URL: ${location.href}`);
79 if (typeof navigator !== 'undefined') lines.push(`Browser: ${navigator.userAgent}`);
80 return lines;
81}
82
34c4dd59 2026-06-18 Pavel Safronov83function stateLines(ctx: ReportContext): string[] {
e6fe2bc7 2026-06-17 Pavel Safronov84 const lines: string[] = [];
34c4dd59 2026-06-18 Pavel Safronov85 if (ctx.picked) {
86 lines.push(`Picked location: ${ctx.picked.lat.toFixed(4)}, ${ctx.picked.lng.toFixed(4)}`);
e6fe2bc7 2026-06-17 Pavel Safronov87 }
34c4dd59 2026-06-18 Pavel Safronov88 if (ctx.date) lines.push(`Date: ${ctx.date}`);
e6fe2bc7 2026-06-17 Pavel Safronov89 return lines;
90}
91
34c4dd59 2026-06-18 Pavel Safronov92/**
93 * Build the plain-text report body. Pass an error for crash reports. `ctx`
94 * defaults to the module context (used by the async global handlers); the UI
95 * passes the live context explicitly so render-time links aren't stale.
96 */
97export function buildReportBody(error?: unknown, ctx: ReportContext = context): string {
e6fe2bc7 2026-06-17 Pavel Safronov98 const sections: string[] = [];
99
100 if (error) {
101 const { message, stack } = errorToText(error);
102 let block = `Error: ${message}`;
103 if (stack) block += `\n\n${stack.slice(0, MAX_STACK)}`;
104 sections.push(block);
105 } else {
106 sections.push('Describe the problem:\n\n\n');
107 }
108
34c4dd59 2026-06-18 Pavel Safronov109 const state = stateLines(ctx);
e6fe2bc7 2026-06-17 Pavel Safronov110 if (state.length) sections.push(`App state:\n${state.join('\n')}`);
111 sections.push(`Environment:\n${envLines().join('\n')}`);
112
113 let body = sections.join('\n\n---\n\n');
114 if (body.length > MAX_BODY) body = `${body.slice(0, MAX_BODY)}\n…(truncated)`;
115 return body;
116}
117
118/** A `mailto:` href to the service desk, prefilled with context. */
34c4dd59 2026-06-18 Pavel Safronov119export function reportMailtoHref(error?: unknown, ctx: ReportContext = context): string {
6b2790fa 2026-06-20 Pavel Safronov120 const subject = `[when-am-i] ${error ? 'Error report' : 'Issue report'}`;
34c4dd59 2026-06-18 Pavel Safronov121 const body = buildReportBody(error, ctx);
e6fe2bc7 2026-06-17 Pavel Safronov122 return `mailto:${SERVICE_DESK_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
123}