From 97aa3720948b2d8182b1df7730340d1306f935d9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:40:12 +0200 Subject: [PATCH 01/11] feat(observability): add App.Error interface with errorId to app.d.ts Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 6057acbc..82a7a981 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -26,6 +26,11 @@ declare global { interface PageData { user?: User; // Available in $page.data.user } + + interface Error { + message: string; + errorId?: string; + } } } -- 2.49.1 From a9c82ec481969b23560079dac356c8ddfda75c76 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:41:24 +0200 Subject: [PATCH 02/11] feat(observability): add handleError callback to hooks.server.ts returning errorId Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 58 +++++++++++++++++++++++++++++++ frontend/src/hooks.server.ts | 6 +++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks.server.test.ts diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts new file mode 100644 index 00000000..b24f2e16 --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@sentry/sveltekit', () => ({ + init: vi.fn(), + handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn, + lastEventId: vi.fn(() => 'sentry-event-id-abc123') +})); + +vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() })); +vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) })); +vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() })); +vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 })); +vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') })); + +const makeEvent = () => ({ + url: { pathname: '/documents/123' }, + locals: {} +}); + +describe('hooks.server handleError', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns Sentry lastEventId as errorId', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123'); + + const { handleError } = await import('./hooks.server'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: makeEvent(), + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toBe('sentry-event-id-abc123'); + expect(result.message).toBe('An unexpected error occurred'); + }); + + it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue(undefined); + + const { handleError } = await import('./hooks.server'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: makeEvent(), + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(result.message).toBe('An unexpected error occurred'); + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 4baaa592..710e269a 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -10,6 +10,7 @@ Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, tracesSampleRate: 1.0, + sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); @@ -122,4 +123,7 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); -export const handleError = Sentry.handleErrorWithSentry(); +export const handleError = Sentry.handleErrorWithSentry(() => { + const errorId = Sentry.lastEventId() ?? crypto.randomUUID(); + return { message: 'An unexpected error occurred', errorId }; +}); -- 2.49.1 From dff81f7bfb6b6e0fb50039f62b8a1c6ec3d25c7a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:41:58 +0200 Subject: [PATCH 03/11] feat(observability): add handleError callback to hooks.client.ts returning errorId Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 91ed4b10..c837a49f 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -4,7 +4,11 @@ Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, tracesSampleRate: 1.0, + sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); -export const handleError = Sentry.handleErrorWithSentry(); +export const handleError = Sentry.handleErrorWithSentry(() => { + const errorId = Sentry.lastEventId() ?? crypto.randomUUID(); + return { message: 'An unexpected error occurred', errorId }; +}); -- 2.49.1 From 96ea7e681588e0efdd0f0cc9b33acedaf0b1a8af Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:44:32 +0200 Subject: [PATCH 04/11] feat(observability): redesign +error.svelte with errorId display and copy button Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 33 +++------------ frontend/messages/en.json | 33 +++------------ frontend/messages/es.json | 33 +++------------ frontend/src/routes/+error.svelte | 42 +++++++++++++++++-- frontend/src/routes/error.svelte.test.ts | 53 ++++++++++++++++++++++-- 5 files changed, 102 insertions(+), 92 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 090c5725..38067459 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Gesch.", "dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}", - "dashboard_resume_label": "Zuletzt geöffnet:", + "dashboard_resume_label": "Weiter, wo du aufgehört hast", "dashboard_resume_fallback": "Unbekanntes Dokument", "doc_status_placeholder": "Platzhalter", "doc_status_uploaded": "Hochgeladen", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Einladung erstellt", "admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:", "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", - "greeting_morning": "Guten Morgen, {name}.", "greeting_day": "Hallo, {name}.", "greeting_evening": "Guten Abend, {name}.", - - "dashboard_resume_label": "Weiter, wo du aufgehört hast", "dashboard_blocks": "{count} Abschnitte", "dashboard_resume_cta": "Weitertranskribieren", "dashboard_resume_other": "oder anderen Brief wählen", "dashboard_empty_title": "Noch kein Dokument begonnen", "dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.", "dashboard_empty_cta": "Zum Archiv", - "dashboard_mission_caption": "Offene Aufgaben", "queue_segment": "Segmentieren", "queue_segment_blurb": "Seiten aufteilen", @@ -795,7 +791,6 @@ "queue_review_blurb": "Texte kontrollieren", "queue_n_open": "{n} offen", "queue_show_all": "Alle anzeigen →", - "pulse_eyebrow": "Diese Woche", "pulse_headline": "Ihr habt {pages} Seiten bearbeitet.", "pulse_you": "Du selbst hast {pages} davon bearbeitet.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Textstellen markiert", "pulse_reviewed": "Textstellen transkribiert", "pulse_uploaded": "Dokumente hochgeladen", - "feed_caption": "Kommentare & Aktivität", "feed_show_all": "Alle anzeigen", "feed_for_you": "für dich", - "audit_action_text_saved": "hat Text gespeichert in", "audit_action_file_uploaded": "hat eine Datei hochgeladen:", "audit_action_annotation_created": "hat eine Markierung erstellt in", "audit_action_comment_added": "hat kommentiert:", "audit_action_mention_created": "hat dich erwähnt in", - "dropzone_release": "Loslassen zum Hochladen", - "chronik_page_title": "Aktivitäten", "chronik_for_you_caption": "Für dich", "chronik_for_you_count": "{count} neu", @@ -859,9 +850,7 @@ "pagination_page_of": "Seite {page} von {total}", "pagination_nav_label": "Seitennavigation", "pagination_page_button": "Seite {page}", - "common_opens_new_tab": "(öffnet in neuem Tab)", - "transcribe_coach_title": "Erste Transkription?", "transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:", "transcribe_coach_step_1_title": "Rahmen ziehen.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Speichert automatisch.", "transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗", "transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗", - "transcription_mode_help_label": "Lese- und Bearbeitungsmodus", "transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.", - "richtlinien_title": "Transkriptions-Richtlinien", "richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.", "richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.", "bulk_edit_topbar_title": "Massenbearbeitung", "bulk_edit_count_pill": "{count} werden bearbeitet", - "nav_stammbaum": "Stammbaum", "nav_geschichten": "Geschichten", - "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", - "geschichten_index_title": "Geschichten", "geschichten_new_button": "Neue Geschichte", "geschichten_filter_all_pill": "Alle", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Geschichte anhängen", "geschichten_card_show_all_for_person": "Alle Geschichten zu {name}", "geschichten_card_show_all": "Alle anzeigen", - "geschichte_editor_title_placeholder": "Titel der Geschichte", "geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…", "geschichte_editor_status_draft": "ENTWURF", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Unterüberschrift", "geschichte_editor_toolbar_ul": "Aufzählung", "geschichte_editor_toolbar_ol": "Nummerierte Liste", - "geschichte_delete_confirm_title": "Geschichte löschen?", "geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.", - "error_relationship_not_found": "Die Beziehung wurde nicht gefunden.", "error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.", "error_duplicate_relationship": "Diese Beziehung gibt es bereits.", - "relation_parent_of": "Elternteil von", "relation_child_of": "Kind von", "relation_spouse_of": "Ehegatte", @@ -1018,7 +998,6 @@ "relation_doctor": "Arzt", "relation_neighbor": "Nachbar", "relation_other": "Sonstige", - "relation_inferred_parent": "Elternteil", "relation_inferred_child": "Kind", "relation_inferred_spouse": "Ehegatte", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Schwager/Schwägerin", "relation_inferred_cousin_1": "Cousin/Cousine", "relation_inferred_distant": "Weitläufige Verwandtschaft", - "doc_details_field_relationship": "Verwandtschaft", - "stammbaum_empty_heading": "Noch keine Familienmitglieder", "stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.", "stammbaum_empty_link": "→ Zur Personenliste", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Vergrößern", "stammbaum_zoom_out": "Verkleinern", "stammbaum_generations": "Generationen", - "relation_error_duplicate": "Diese Beziehung gibt es bereits.", "relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.", "relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "Von Jahr", "relation_form_field_to_year": "Bis Jahr", "relation_form_year_placeholder": "z.B. 1920", - "person_relationships_heading": "Beziehungen", "person_relationships_empty": "Noch keine Beziehungen bekannt.", - "timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_clear_selection": "Auswahl zurücksetzen", "timeline_zoom_reset": "Zurück zur Übersicht", "timeline_bar_aria_singular": "{when}, 1 Dokument", "timeline_bar_aria_plural": "{when}, {count} Dokumente", - "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt" + "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt", + "error_page_id_label": "Fehler-ID", + "error_copy_id_label": "ID kopieren", + "error_copied": "Kopiert!" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 34c23200..2ecb565d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Stor.", "dashboard_reader_draft_meta": "Draft · last edited {relative}", - "dashboard_resume_label": "Last opened:", + "dashboard_resume_label": "Continue where you left off", "dashboard_resume_fallback": "Unknown document", "doc_status_placeholder": "Placeholder", "doc_status_uploaded": "Uploaded", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Invite created", "admin_invite_created_desc": "Share this link with the person you are inviting:", "admin_invite_revoke_confirm": "Really revoke this invite?", - "greeting_morning": "Good morning, {name}.", "greeting_day": "Hello, {name}.", "greeting_evening": "Good evening, {name}.", - - "dashboard_resume_label": "Continue where you left off", "dashboard_blocks": "{count} sections", "dashboard_resume_cta": "Continue transcribing", "dashboard_resume_other": "or choose another document", "dashboard_empty_title": "No document started yet", "dashboard_empty_body": "Choose a document from the archive to start transcribing.", "dashboard_empty_cta": "To the archive", - "dashboard_mission_caption": "Open tasks", "queue_segment": "Segment", "queue_segment_blurb": "Split pages", @@ -795,7 +791,6 @@ "queue_review_blurb": "Check texts", "queue_n_open": "{n} open", "queue_show_all": "Show all →", - "pulse_eyebrow": "This week", "pulse_headline": "You have worked on {pages} pages.", "pulse_you": "You personally worked on {pages} of them.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Passages annotated", "pulse_reviewed": "Passages transcribed", "pulse_uploaded": "Documents uploaded", - "feed_caption": "Comments & activity", "feed_show_all": "Show all", "feed_for_you": "for you", - "audit_action_text_saved": "saved text in", "audit_action_file_uploaded": "uploaded a file:", "audit_action_annotation_created": "created an annotation in", "audit_action_comment_added": "commented:", "audit_action_mention_created": "mentioned you in", - "dropzone_release": "Release to upload", - "chronik_page_title": "Activity", "chronik_for_you_caption": "For you", "chronik_for_you_count": "{count} new", @@ -859,9 +850,7 @@ "pagination_page_of": "Page {page} of {total}", "pagination_nav_label": "Pagination", "pagination_page_button": "Page {page}", - "common_opens_new_tab": "(opens in new tab)", - "transcribe_coach_title": "First transcription?", "transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:", "transcribe_coach_step_1_title": "Draw a frame.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Saves automatically.", "transcribe_coach_footer_kurrent": "Kurrent help ↗", "transcribe_coach_footer_richtlinien": "Transcription guidelines ↗", - "transcription_mode_help_label": "Read and edit mode", "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.", - "richtlinien_title": "Transcription Guidelines", "richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.", "richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "Could not load filter results — please retry.", "bulk_edit_topbar_title": "Bulk edit", "bulk_edit_count_pill": "{count} will be edited", - "nav_stammbaum": "Family tree", "nav_geschichten": "Stories", - "error_geschichte_not_found": "The story was not found.", - "geschichten_index_title": "Stories", "geschichten_new_button": "New story", "geschichten_filter_all_pill": "All", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Attach a story", "geschichten_card_show_all_for_person": "All stories about {name}", "geschichten_card_show_all": "Show all", - "geschichte_editor_title_placeholder": "Story title", "geschichte_editor_body_placeholder": "Write your story here…", "geschichte_editor_status_draft": "DRAFT", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Subheading", "geschichte_editor_toolbar_ul": "Bulleted list", "geschichte_editor_toolbar_ol": "Numbered list", - "geschichte_delete_confirm_title": "Delete story?", "geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.", - "error_relationship_not_found": "Relationship not found.", "error_circular_relationship": "This relationship would form a cycle.", "error_duplicate_relationship": "This relationship already exists.", - "relation_parent_of": "Parent of", "relation_child_of": "Child of", "relation_spouse_of": "Spouse", @@ -1018,7 +998,6 @@ "relation_doctor": "Doctor", "relation_neighbor": "Neighbour", "relation_other": "Other", - "relation_inferred_parent": "Parent", "relation_inferred_child": "Child", "relation_inferred_spouse": "Spouse", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Sibling-in-law", "relation_inferred_cousin_1": "Cousin", "relation_inferred_distant": "Distant relative", - "doc_details_field_relationship": "Relationship", - "stammbaum_empty_heading": "No family members yet", "stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.", "stammbaum_empty_link": "→ Go to person list", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Zoom in", "stammbaum_zoom_out": "Zoom out", "stammbaum_generations": "Generations", - "relation_error_duplicate": "This relationship already exists.", "relation_error_circular": "This relationship would form a cycle.", "relation_error_self": "A person cannot be related to themselves.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "From year", "relation_form_field_to_year": "To year", "relation_form_year_placeholder": "e.g. 1920", - "person_relationships_heading": "Relationships", "person_relationships_empty": "No relationships known yet.", - "timeline_aria_label": "Document density timeline", "timeline_clear_selection": "Clear selection", "timeline_zoom_reset": "Reset zoom", "timeline_bar_aria_singular": "{when}, 1 document", "timeline_bar_aria_plural": "{when}, {count} documents", - "timeline_dragging_aria_live": "Range {from} to {to} selected" + "timeline_dragging_aria_live": "Range {from} to {to} selected", + "error_page_id_label": "Error ID", + "error_copy_id_label": "Copy ID", + "error_copied": "Copied!" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 4e487ad8..a68b663d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -473,7 +473,7 @@ "dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_stories_short": "Hist.", "dashboard_reader_draft_meta": "Borrador · editado hace {relative}", - "dashboard_resume_label": "Último abierto:", + "dashboard_resume_label": "Continuar donde lo dejaste", "dashboard_resume_fallback": "Documento desconocido", "doc_status_placeholder": "Marcador", "doc_status_uploaded": "Cargado", @@ -773,19 +773,15 @@ "admin_invite_created_title": "Invitación creada", "admin_invite_created_desc": "Comparte este enlace con la persona invitada:", "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", - "greeting_morning": "Buenos días, {name}.", "greeting_day": "Hola, {name}.", "greeting_evening": "Buenas noches, {name}.", - - "dashboard_resume_label": "Continuar donde lo dejaste", "dashboard_blocks": "{count} secciones", "dashboard_resume_cta": "Continuar transcripción", "dashboard_resume_other": "o elige otro documento", "dashboard_empty_title": "Aún no has comenzado ningún documento", "dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.", "dashboard_empty_cta": "Al archivo", - "dashboard_mission_caption": "Tareas pendientes", "queue_segment": "Segmentar", "queue_segment_blurb": "Dividir páginas", @@ -795,7 +791,6 @@ "queue_review_blurb": "Controlar textos", "queue_n_open": "{n} pendiente", "queue_show_all": "Ver todo →", - "pulse_eyebrow": "Esta semana", "pulse_headline": "Habéis trabajado {pages} páginas.", "pulse_you": "Tú mismo has trabajado {pages} de ellas.", @@ -803,19 +798,15 @@ "pulse_transcribed": "Fragmentos anotados", "pulse_reviewed": "Fragmentos transcritos", "pulse_uploaded": "Documentos subidos", - "feed_caption": "Comentarios y actividad", "feed_show_all": "Ver todo", "feed_for_you": "para ti", - "audit_action_text_saved": "guardó texto en", "audit_action_file_uploaded": "subió un archivo:", "audit_action_annotation_created": "creó una anotación en", "audit_action_comment_added": "comentó:", "audit_action_mention_created": "te mencionó en", - "dropzone_release": "Suelta para subir", - "chronik_page_title": "Actividades", "chronik_for_you_caption": "Para ti", "chronik_for_you_count": "{count} nuevas", @@ -859,9 +850,7 @@ "pagination_page_of": "Página {page} de {total}", "pagination_nav_label": "Paginación", "pagination_page_button": "Página {page}", - "common_opens_new_tab": "(abre en pestaña nueva)", - "transcribe_coach_title": "¿Primera transcripción?", "transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:", "transcribe_coach_step_1_title": "Dibujar un marco.", @@ -871,10 +860,8 @@ "transcribe_coach_step_3_title": "Se guarda automáticamente.", "transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗", "transcribe_coach_footer_richtlinien": "Normas de transcripción ↗", - "transcription_mode_help_label": "Modo lectura y edición", "transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.", - "richtlinien_title": "Normas de transcripción", "richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.", "richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.", @@ -948,12 +935,9 @@ "bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.", "bulk_edit_topbar_title": "Edición masiva", "bulk_edit_count_pill": "Se editarán {count}", - "nav_stammbaum": "Árbol genealógico", "nav_geschichten": "Historias", - "error_geschichte_not_found": "No se encontró la historia.", - "geschichten_index_title": "Historias", "geschichten_new_button": "Nueva historia", "geschichten_filter_all_pill": "Todas", @@ -973,7 +957,6 @@ "geschichten_card_attach_action": "+ Adjuntar historia", "geschichten_card_show_all_for_person": "Todas las historias sobre {name}", "geschichten_card_show_all": "Mostrar todas", - "geschichte_editor_title_placeholder": "Título de la historia", "geschichte_editor_body_placeholder": "Escribe tu historia aquí…", "geschichte_editor_status_draft": "BORRADOR", @@ -1000,14 +983,11 @@ "geschichte_editor_toolbar_h3": "Subencabezado", "geschichte_editor_toolbar_ul": "Lista con viñetas", "geschichte_editor_toolbar_ol": "Lista numerada", - "geschichte_delete_confirm_title": "¿Eliminar historia?", "geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.", - "error_relationship_not_found": "La relación no fue encontrada.", "error_circular_relationship": "Esta relación crearía un ciclo.", "error_duplicate_relationship": "Esta relación ya existe.", - "relation_parent_of": "Progenitor de", "relation_child_of": "Hijo/a de", "relation_spouse_of": "Cónyuge", @@ -1018,7 +998,6 @@ "relation_doctor": "Médico", "relation_neighbor": "Vecino/a", "relation_other": "Otro", - "relation_inferred_parent": "Progenitor", "relation_inferred_child": "Hijo/a", "relation_inferred_spouse": "Cónyuge", @@ -1036,9 +1015,7 @@ "relation_inferred_sibling_inlaw": "Cuñado/a", "relation_inferred_cousin_1": "Primo/a", "relation_inferred_distant": "Pariente lejano", - "doc_details_field_relationship": "Parentesco", - "stammbaum_empty_heading": "Aún no hay miembros de la familia", "stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.", "stammbaum_empty_link": "→ Ir a la lista de personas", @@ -1050,7 +1027,6 @@ "stammbaum_zoom_in": "Acercar", "stammbaum_zoom_out": "Alejar", "stammbaum_generations": "Generaciones", - "relation_error_duplicate": "Esta relación ya existe.", "relation_error_circular": "Esta relación crearía un ciclo.", "relation_error_self": "Una persona no puede estar relacionada consigo misma.", @@ -1073,14 +1049,15 @@ "relation_form_field_from_year": "Desde año", "relation_form_field_to_year": "Hasta año", "relation_form_year_placeholder": "ej. 1920", - "person_relationships_heading": "Relaciones", "person_relationships_empty": "Aún no se conocen relaciones.", - "timeline_aria_label": "Cronología de densidad de documentos", "timeline_clear_selection": "Borrar selección", "timeline_zoom_reset": "Restablecer zoom", "timeline_bar_aria_singular": "{when}, 1 documento", "timeline_bar_aria_plural": "{when}, {count} documentos", - "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado" + "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado", + "error_page_id_label": "ID de error", + "error_copy_id_label": "Copiar ID", + "error_copied": "¡Copiado!" } diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index c64461e4..f910cb33 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -1,13 +1,47 @@ {m.page_title_error()} -
-

{page.status}

-

{page.error?.message ?? 'Internal Error'}

-
+
+

{m.page_title_error()}

+

+ {page.error?.message ?? m.error_internal_error()} +

+

{page.status}

+ + {#if page.error?.errorId} +
+

+ {m.error_page_id_label()} +

+ + {page.error.errorId} + + +
+ {/if} +
diff --git a/frontend/src/routes/error.svelte.test.ts b/frontend/src/routes/error.svelte.test.ts index 097dc3f6..7ed53214 100644 --- a/frontend/src/routes/error.svelte.test.ts +++ b/frontend/src/routes/error.svelte.test.ts @@ -4,7 +4,10 @@ import { page as browserPage } from 'vitest/browser'; const mockPage = { status: 500, - error: { message: 'Internal Error' } as { message: string } | null + error: { message: 'Internal Error', errorId: undefined } as { + message: string; + errorId?: string; + } | null }; vi.mock('$app/state', () => ({ @@ -13,6 +16,16 @@ vi.mock('$app/state', () => ({ } })); +vi.mock('$lib/paraglide/messages.js', () => ({ + m: { + page_title_error: () => 'Es ist etwas schiefgelaufen.', + error_internal_error: () => 'Ein unerwarteter Fehler ist aufgetreten.', + error_page_id_label: () => 'Fehler-ID', + error_copy_id_label: () => 'ID kopieren', + error_copied: () => 'Kopiert!' + } +})); + afterEach(cleanup); async function loadComponent() { @@ -20,7 +33,7 @@ async function loadComponent() { } describe('+error.svelte', () => { - it('renders the page status code prominently', async () => { + it('renders the page status code', async () => { mockPage.status = 404; mockPage.error = { message: 'Not Found' }; @@ -40,13 +53,45 @@ describe('+error.svelte', () => { await expect.element(browserPage.getByText('Database unavailable')).toBeVisible(); }); - it('falls back to the literal "Internal Error" when page.error is null', async () => { + it('falls back to error_internal_error message when page.error is null', async () => { mockPage.status = 500; mockPage.error = null; const ErrorPage = await loadComponent(); render(ErrorPage); - await expect.element(browserPage.getByText('Internal Error')).toBeVisible(); + await expect + .element(browserPage.getByText('Ein unerwarteter Fehler ist aufgetreten.')) + .toBeVisible(); + }); + + it('shows errorId when page.error.errorId is set', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('abc-123-def')).toBeVisible(); + }); + + it('shows copy button when errorId is present', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByRole('button', { name: 'ID kopieren' })).toBeVisible(); + }); + + it('does not render errorId section when errorId is absent', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke' }; + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await expect.element(browserPage.getByText('Fehler-ID')).not.toBeInTheDocument(); }); }); -- 2.49.1 From 59b18039ed790f9ffd9ce2b2008fc84b5dc921d5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:45:49 +0200 Subject: [PATCH 05/11] refactor(observability): remove console.log from tags proxy and enforce no-console lint rule Co-Authored-By: Claude Sonnet 4.6 --- frontend/eslint.config.js | 12 +++++++++++- frontend/src/routes/api/tags/+server.ts | 1 - 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b110ade9..5ef17b59 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -38,7 +38,10 @@ export default defineConfig( 'no-undef': 'off', // This rule is designed for Svelte 5's own routing system using resolve(). // In SvelteKit, and goto() from $app/navigation are the correct patterns — resolve() is not needed. - 'svelte/no-navigation-without-resolve': 'off' + 'svelte/no-navigation-without-resolve': 'off', + // Prevents accidental console.log left in source. console.warn and console.error + // are still permitted for intentional server-side logging (e.g. hooks.server.ts). + 'no-console': ['error', { allow: ['warn', 'error'] }] } }, { @@ -71,6 +74,13 @@ export default defineConfig( ] } }, + { + // E2E tests use console.log for diagnostic output — allow it there. + files: ['e2e/**'], + rules: { + 'no-console': 'off' + } + }, { files: ['**/*.spec.ts', '**/*.test.ts'], rules: { diff --git a/frontend/src/routes/api/tags/+server.ts b/frontend/src/routes/api/tags/+server.ts index 1e2e760d..f985380e 100644 --- a/frontend/src/routes/api/tags/+server.ts +++ b/frontend/src/routes/api/tags/+server.ts @@ -24,7 +24,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => { } const data = await response.json(); - console.log('Tags Data', data); // 4. Daten zurück an den Browser schicken return json(data); -- 2.49.1 From 2023ea29314ad364d5950fd51b74aaefee1e881b Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 09:46:17 +0200 Subject: [PATCH 06/11] docs(c4): add GlitchTip as external error-tracking system to L1 context diagram Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture/c4/l1-context.puml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture/c4/l1-context.puml b/docs/architecture/c4/l1-context.puml index 8d2efb8f..d31ae49f 100644 --- a/docs/architecture/c4/l1-context.puml +++ b/docs/architecture/c4/l1-context.puml @@ -8,9 +8,11 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents") System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.") +System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.") Rel(admin, familienarchiv, "Manages via browser", "HTTPS") Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS") Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP") +Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS") @enduml -- 2.49.1 From c779ec59f97632df816217ae0e898fb2e4bad552 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 10:24:35 +0200 Subject: [PATCH 07/11] feat(observability): guard navigator.clipboard and handle rejection in copyId Adds availability guard (navigator.clipboard may be undefined in non-HTTPS contexts) and a rejection handler so clipboard-denied errors are silently caught rather than becoming unhandled promise rejections. Tests cover the success feedback and the silent-failure path. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+error.svelte | 14 +++++++--- frontend/src/routes/error.svelte.test.ts | 34 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index f910cb33..56ce49c4 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -7,10 +7,16 @@ let copied = $state(false); function copyId() { const id = page.error?.errorId; if (!id) return; - navigator.clipboard.writeText(id).then(() => { - copied = true; - setTimeout(() => (copied = false), 2000); - }); + if (!navigator.clipboard) return; + navigator.clipboard.writeText(id).then( + () => { + copied = true; + setTimeout(() => (copied = false), 2000); + }, + () => { + /* clipboard denied or unavailable — select-all on the element remains */ + } + ); } diff --git a/frontend/src/routes/error.svelte.test.ts b/frontend/src/routes/error.svelte.test.ts index 7ed53214..0ceca01f 100644 --- a/frontend/src/routes/error.svelte.test.ts +++ b/frontend/src/routes/error.svelte.test.ts @@ -94,4 +94,38 @@ describe('+error.svelte', () => { await expect.element(browserPage.getByText('Fehler-ID')).not.toBeInTheDocument(); }); + + it('shows "Kopiert!" after clicking the copy button', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + writable: true + }); + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await browserPage.getByRole('button', { name: 'ID kopieren' }).click(); + await expect.element(browserPage.getByText('Kopiert!')).toBeVisible(); + }); + + it('does not show "Kopiert!" when clipboard write is rejected', async () => { + mockPage.status = 500; + mockPage.error = { message: 'Something broke', errorId: 'abc-123-def' }; + + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + configurable: true, + writable: true + }); + + const ErrorPage = await loadComponent(); + render(ErrorPage); + + await browserPage.getByRole('button', { name: 'ID kopieren' }).click(); + await expect.element(browserPage.getByText('Kopiert!')).not.toBeInTheDocument(); + }); }); -- 2.49.1 From af42113fca38effffa17674ea296c71b78c44a5d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 10:25:30 +0200 Subject: [PATCH 08/11] test(observability): add hooks.client.test.ts unit tests for handleError callback Two tests matching the existing hooks.server.test.ts coverage: returns Sentry lastEventId as errorId; falls back to crypto.randomUUID when lastEventId returns undefined. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.client.test.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 frontend/src/hooks.client.test.ts diff --git a/frontend/src/hooks.client.test.ts b/frontend/src/hooks.client.test.ts new file mode 100644 index 00000000..46cb8ad2 --- /dev/null +++ b/frontend/src/hooks.client.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@sentry/sveltekit', () => ({ + init: vi.fn(), + handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn, + lastEventId: vi.fn(() => 'sentry-event-id-abc123') +})); + +describe('hooks.client handleError', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('returns Sentry lastEventId as errorId', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123'); + + const { handleError } = await import('./hooks.client'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: {}, + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toBe('sentry-event-id-abc123'); + expect(result.message).toBe('An unexpected error occurred'); + }); + + it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => { + const Sentry = await import('@sentry/sveltekit'); + vi.mocked(Sentry.lastEventId).mockReturnValue(undefined); + + const { handleError } = await import('./hooks.client'); + const result = (handleError as (args: unknown) => { message: string; errorId: string })({ + error: new Error('boom'), + event: {}, + status: 500, + message: 'Internal Error' + }); + + expect(result.errorId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + expect(result.message).toBe('An unexpected error occurred'); + }); +}); -- 2.49.1 From 9e236200721237c50bc328e4ca8d1a117b774580 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 10:26:03 +0200 Subject: [PATCH 09/11] refactor(observability): add hooks.server.ts to coverage include in vite.config.ts The handleError callback in hooks.server.ts is now gated by the 80% branch coverage threshold along with the rest of the server-side logic. Co-Authored-By: Claude Sonnet 4.6 --- frontend/vite.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4c75bc70..02f2cc48 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -71,7 +71,8 @@ export default defineConfig({ 'src/lib/shared/utils/**', 'src/lib/shared/server/**', 'src/lib/shared/discussion/**', - 'src/lib/document/**' + 'src/lib/document/**', + 'src/hooks.server.ts' ], exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'], thresholds: { -- 2.49.1 From b2e31c3c1ba0f2e6198a86d0b25f5de764285d23 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 10:27:01 +0200 Subject: [PATCH 10/11] refactor(observability): lower trace sample rate, add DSN comment, improve status visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lower tracesSampleRate from 1.0 to 0.1 in both hooks (errors still captured at 100%; trace volume reduced for self-hosted GlitchTip on shared VPS) - Add comment explaining VITE_SENTRY_DSN is a write-only ingest key, safe in client bundle — prevents accidental rotation as if it were a password - Restore HTTP status code prominence: text-4xl font-bold (was text-xs text-ink-3) - Add min-w-[44px] to copy button for WCAG 2.2 minimum touch target Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.client.ts | 4 +++- frontend/src/hooks.server.ts | 4 +++- frontend/src/routes/+error.svelte | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index c837a49f..3cfcd365 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,9 +1,11 @@ import * as Sentry from '@sentry/sveltekit'; +// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip +// but cannot read them. Safe to include in the client bundle per Sentry security model. Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, - tracesSampleRate: 1.0, + tracesSampleRate: 0.1, sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 710e269a..2c155dce 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -6,10 +6,12 @@ import { env } from 'process'; import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; import { detectLocale } from '$lib/shared/server/locale'; +// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip +// but cannot read them. Safe to include in the client bundle per Sentry security model. Sentry.init({ dsn: import.meta.env.VITE_SENTRY_DSN, environment: import.meta.env.MODE, - tracesSampleRate: 1.0, + tracesSampleRate: 0.1, sendDefaultPii: false, enabled: !!import.meta.env.VITE_SENTRY_DSN }); diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte index 56ce49c4..96f98e57 100644 --- a/frontend/src/routes/+error.svelte +++ b/frontend/src/routes/+error.svelte @@ -29,7 +29,7 @@ function copyId() {

{page.error?.message ?? m.error_internal_error()}

-

{page.status}

+

{page.status}

{#if page.error?.errorId}
@@ -42,7 +42,7 @@ function copyId() { {page.error.errorId}