feat(observability): add handleError hook with errorId and redesigned error page #608

Merged
marcel merged 11 commits from feat/issue-462-handle-error-hook into main 2026-05-17 12:38:10 +02:00
15 changed files with 369 additions and 99 deletions

View File

@@ -0,0 +1,86 @@
# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit
**Date:** 2026-05-17
**Status:** Accepted
**Deciders:** Marcel Raddatz
---
## Context
The Familienarchiv had no client-side error reporting. When a user encountered a crash
or unhandled error in the SvelteKit frontend, there was no way for the operator to
observe it — errors were invisible until a user manually reported them. A GlitchTip
instance (self-hosted, Sentry-compatible) was already running as part of the
observability stack (`docker-compose.observability.yml`). The backend already reported
server-side errors to it.
We needed a way to:
1. Capture frontend errors automatically and route them to GlitchTip.
2. Give users a visible error identifier they can include in a support message.
3. Do this without leaking personally identifiable information (PII) from the family
archive — documents contain personal histories, names, and relationships.
---
## Decision
Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to:
- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`.
- Pass a callback to `Sentry.handleErrorWithSentry()` that returns
`{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry
captured the event, or a fresh `crypto.randomUUID()` as fallback.
- Display the `errorId` on the `+error.svelte` page so users can include it in a
report to the operator.
The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that
development and CI builds without a DSN configured do not send any events.
`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but
cannot read them. It is safe to include in the client bundle per the Sentry security
model; it does not require rotation like a password.
---
## Alternatives considered
**Sentry SaaS** — rejected. The archive contains private family documents and personal
history. Sending error events with stack traces to a US-hosted third party is
inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on
the same Hetzner VPS keeps all data on infrastructure the operator controls.
**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles
SvelteKit's hook lifecycle, source-map upload, and event grouping automatically.
Reimplementing this would cost significant engineering time for no benefit.
**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users
can only describe what happened in natural language, making it hard to correlate a
report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI
cost.
---
## Consequences
**Positive:**
- Frontend errors are now observable without requiring user reports.
- Users can provide an `errorId` that maps directly to a GlitchTip event.
- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in
captured events.
- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping
GlitchTip load low on the shared VPS.
**Negative / trade-offs:**
- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be
reviewed for changes to the default PII scrubbing behaviour.
- The `handleError` callback in both hooks returns a hardcoded English message
(`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page
will always show English text when the hooks are active, regardless of the user's
locale. This is acceptable because: (a) the error page is a last-resort fallback
not part of normal UX, (b) the `errorId` is the actionable information, not the
message text. A future ADR may address this if internationalised error messages
become a requirement.
- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event
(e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId`
is always present, but that UUID will not appear in GlitchTip.

View File

@@ -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(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(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(admin, familienarchiv, "Manages via browser", "HTTPS")
Rel(member, familienarchiv, "Searches, reads, and transcribes 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, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
@enduml @enduml

View File

@@ -38,7 +38,10 @@ export default defineConfig(
'no-undef': 'off', 'no-undef': 'off',
// This rule is designed for Svelte 5's own routing system using resolve(). // This rule is designed for Svelte 5's own routing system using resolve().
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed. // In SvelteKit, <a href> 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'], files: ['**/*.spec.ts', '**/*.test.ts'],
rules: { rules: {

View File

@@ -473,7 +473,7 @@
"dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Gesch.", "dashboard_reader_stats_stories_short": "Gesch.",
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}", "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", "dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter", "doc_status_placeholder": "Platzhalter",
"doc_status_uploaded": "Hochgeladen", "doc_status_uploaded": "Hochgeladen",
@@ -773,19 +773,15 @@
"admin_invite_created_title": "Einladung erstellt", "admin_invite_created_title": "Einladung erstellt",
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:", "admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?", "admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
"greeting_morning": "Guten Morgen, {name}.", "greeting_morning": "Guten Morgen, {name}.",
"greeting_day": "Hallo, {name}.", "greeting_day": "Hallo, {name}.",
"greeting_evening": "Guten Abend, {name}.", "greeting_evening": "Guten Abend, {name}.",
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
"dashboard_blocks": "{count} Abschnitte", "dashboard_blocks": "{count} Abschnitte",
"dashboard_resume_cta": "Weitertranskribieren", "dashboard_resume_cta": "Weitertranskribieren",
"dashboard_resume_other": "oder anderen Brief wählen", "dashboard_resume_other": "oder anderen Brief wählen",
"dashboard_empty_title": "Noch kein Dokument begonnen", "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_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
"dashboard_empty_cta": "Zum Archiv", "dashboard_empty_cta": "Zum Archiv",
"dashboard_mission_caption": "Offene Aufgaben", "dashboard_mission_caption": "Offene Aufgaben",
"queue_segment": "Segmentieren", "queue_segment": "Segmentieren",
"queue_segment_blurb": "Seiten aufteilen", "queue_segment_blurb": "Seiten aufteilen",
@@ -795,7 +791,6 @@
"queue_review_blurb": "Texte kontrollieren", "queue_review_blurb": "Texte kontrollieren",
"queue_n_open": "{n} offen", "queue_n_open": "{n} offen",
"queue_show_all": "Alle anzeigen →", "queue_show_all": "Alle anzeigen →",
"pulse_eyebrow": "Diese Woche", "pulse_eyebrow": "Diese Woche",
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.", "pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
"pulse_you": "Du selbst hast {pages} davon bearbeitet.", "pulse_you": "Du selbst hast {pages} davon bearbeitet.",
@@ -803,19 +798,15 @@
"pulse_transcribed": "Textstellen markiert", "pulse_transcribed": "Textstellen markiert",
"pulse_reviewed": "Textstellen transkribiert", "pulse_reviewed": "Textstellen transkribiert",
"pulse_uploaded": "Dokumente hochgeladen", "pulse_uploaded": "Dokumente hochgeladen",
"feed_caption": "Kommentare & Aktivität", "feed_caption": "Kommentare & Aktivität",
"feed_show_all": "Alle anzeigen", "feed_show_all": "Alle anzeigen",
"feed_for_you": "für dich", "feed_for_you": "für dich",
"audit_action_text_saved": "hat Text gespeichert in", "audit_action_text_saved": "hat Text gespeichert in",
"audit_action_file_uploaded": "hat eine Datei hochgeladen:", "audit_action_file_uploaded": "hat eine Datei hochgeladen:",
"audit_action_annotation_created": "hat eine Markierung erstellt in", "audit_action_annotation_created": "hat eine Markierung erstellt in",
"audit_action_comment_added": "hat kommentiert:", "audit_action_comment_added": "hat kommentiert:",
"audit_action_mention_created": "hat dich erwähnt in", "audit_action_mention_created": "hat dich erwähnt in",
"dropzone_release": "Loslassen zum Hochladen", "dropzone_release": "Loslassen zum Hochladen",
"chronik_page_title": "Aktivitäten", "chronik_page_title": "Aktivitäten",
"chronik_for_you_caption": "Für dich", "chronik_for_you_caption": "Für dich",
"chronik_for_you_count": "{count} neu", "chronik_for_you_count": "{count} neu",
@@ -859,9 +850,7 @@
"pagination_page_of": "Seite {page} von {total}", "pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation", "pagination_nav_label": "Seitennavigation",
"pagination_page_button": "Seite {page}", "pagination_page_button": "Seite {page}",
"common_opens_new_tab": "(öffnet in neuem Tab)", "common_opens_new_tab": "(öffnet in neuem Tab)",
"transcribe_coach_title": "Erste Transkription?", "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_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.", "transcribe_coach_step_1_title": "Rahmen ziehen.",
@@ -871,10 +860,8 @@
"transcribe_coach_step_3_title": "Speichert automatisch.", "transcribe_coach_step_3_title": "Speichert automatisch.",
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗", "transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗", "transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus", "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.", "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_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_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.", "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_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung", "bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet", "bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum", "nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten", "nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"geschichten_index_title": "Geschichten", "geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte", "geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle", "geschichten_filter_all_pill": "Alle",
@@ -973,7 +957,6 @@
"geschichten_card_attach_action": "+ Geschichte anhängen", "geschichten_card_attach_action": "+ Geschichte anhängen",
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}", "geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
"geschichten_card_show_all": "Alle anzeigen", "geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte", "geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…", "geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_editor_status_draft": "ENTWURF", "geschichte_editor_status_draft": "ENTWURF",
@@ -1000,14 +983,11 @@
"geschichte_editor_toolbar_h3": "Unterüberschrift", "geschichte_editor_toolbar_h3": "Unterüberschrift",
"geschichte_editor_toolbar_ul": "Aufzählung", "geschichte_editor_toolbar_ul": "Aufzählung",
"geschichte_editor_toolbar_ol": "Nummerierte Liste", "geschichte_editor_toolbar_ol": "Nummerierte Liste",
"geschichte_delete_confirm_title": "Geschichte löschen?", "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.", "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_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.", "error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.", "error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
"relation_parent_of": "Elternteil von", "relation_parent_of": "Elternteil von",
"relation_child_of": "Kind von", "relation_child_of": "Kind von",
"relation_spouse_of": "Ehegatte", "relation_spouse_of": "Ehegatte",
@@ -1018,7 +998,6 @@
"relation_doctor": "Arzt", "relation_doctor": "Arzt",
"relation_neighbor": "Nachbar", "relation_neighbor": "Nachbar",
"relation_other": "Sonstige", "relation_other": "Sonstige",
"relation_inferred_parent": "Elternteil", "relation_inferred_parent": "Elternteil",
"relation_inferred_child": "Kind", "relation_inferred_child": "Kind",
"relation_inferred_spouse": "Ehegatte", "relation_inferred_spouse": "Ehegatte",
@@ -1036,9 +1015,7 @@
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin", "relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
"relation_inferred_cousin_1": "Cousin/Cousine", "relation_inferred_cousin_1": "Cousin/Cousine",
"relation_inferred_distant": "Weitläufige Verwandtschaft", "relation_inferred_distant": "Weitläufige Verwandtschaft",
"doc_details_field_relationship": "Verwandtschaft", "doc_details_field_relationship": "Verwandtschaft",
"stammbaum_empty_heading": "Noch keine Familienmitglieder", "stammbaum_empty_heading": "Noch keine Familienmitglieder",
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.", "stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
"stammbaum_empty_link": "→ Zur Personenliste", "stammbaum_empty_link": "→ Zur Personenliste",
@@ -1050,7 +1027,6 @@
"stammbaum_zoom_in": "Vergrößern", "stammbaum_zoom_in": "Vergrößern",
"stammbaum_zoom_out": "Verkleinern", "stammbaum_zoom_out": "Verkleinern",
"stammbaum_generations": "Generationen", "stammbaum_generations": "Generationen",
"relation_error_duplicate": "Diese Beziehung gibt es bereits.", "relation_error_duplicate": "Diese Beziehung gibt es bereits.",
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.", "relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.", "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_from_year": "Von Jahr",
"relation_form_field_to_year": "Bis Jahr", "relation_form_field_to_year": "Bis Jahr",
"relation_form_year_placeholder": "z.B. 1920", "relation_form_year_placeholder": "z.B. 1920",
"person_relationships_heading": "Beziehungen", "person_relationships_heading": "Beziehungen",
"person_relationships_empty": "Noch keine Beziehungen bekannt.", "person_relationships_empty": "Noch keine Beziehungen bekannt.",
"timeline_aria_label": "Zeitachse Dokumentdichte", "timeline_aria_label": "Zeitachse Dokumentdichte",
"timeline_clear_selection": "Auswahl zurücksetzen", "timeline_clear_selection": "Auswahl zurücksetzen",
"timeline_zoom_reset": "Zurück zur Übersicht", "timeline_zoom_reset": "Zurück zur Übersicht",
"timeline_bar_aria_singular": "{when}, 1 Dokument", "timeline_bar_aria_singular": "{when}, 1 Dokument",
"timeline_bar_aria_plural": "{when}, {count} Dokumente", "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!"
} }

View File

@@ -473,7 +473,7 @@
"dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Stor.", "dashboard_reader_stats_stories_short": "Stor.",
"dashboard_reader_draft_meta": "Draft · last edited {relative}", "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", "dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder", "doc_status_placeholder": "Placeholder",
"doc_status_uploaded": "Uploaded", "doc_status_uploaded": "Uploaded",
@@ -773,19 +773,15 @@
"admin_invite_created_title": "Invite created", "admin_invite_created_title": "Invite created",
"admin_invite_created_desc": "Share this link with the person you are inviting:", "admin_invite_created_desc": "Share this link with the person you are inviting:",
"admin_invite_revoke_confirm": "Really revoke this invite?", "admin_invite_revoke_confirm": "Really revoke this invite?",
"greeting_morning": "Good morning, {name}.", "greeting_morning": "Good morning, {name}.",
"greeting_day": "Hello, {name}.", "greeting_day": "Hello, {name}.",
"greeting_evening": "Good evening, {name}.", "greeting_evening": "Good evening, {name}.",
"dashboard_resume_label": "Continue where you left off",
"dashboard_blocks": "{count} sections", "dashboard_blocks": "{count} sections",
"dashboard_resume_cta": "Continue transcribing", "dashboard_resume_cta": "Continue transcribing",
"dashboard_resume_other": "or choose another document", "dashboard_resume_other": "or choose another document",
"dashboard_empty_title": "No document started yet", "dashboard_empty_title": "No document started yet",
"dashboard_empty_body": "Choose a document from the archive to start transcribing.", "dashboard_empty_body": "Choose a document from the archive to start transcribing.",
"dashboard_empty_cta": "To the archive", "dashboard_empty_cta": "To the archive",
"dashboard_mission_caption": "Open tasks", "dashboard_mission_caption": "Open tasks",
"queue_segment": "Segment", "queue_segment": "Segment",
"queue_segment_blurb": "Split pages", "queue_segment_blurb": "Split pages",
@@ -795,7 +791,6 @@
"queue_review_blurb": "Check texts", "queue_review_blurb": "Check texts",
"queue_n_open": "{n} open", "queue_n_open": "{n} open",
"queue_show_all": "Show all →", "queue_show_all": "Show all →",
"pulse_eyebrow": "This week", "pulse_eyebrow": "This week",
"pulse_headline": "You have worked on {pages} pages.", "pulse_headline": "You have worked on {pages} pages.",
"pulse_you": "You personally worked on {pages} of them.", "pulse_you": "You personally worked on {pages} of them.",
@@ -803,19 +798,15 @@
"pulse_transcribed": "Passages annotated", "pulse_transcribed": "Passages annotated",
"pulse_reviewed": "Passages transcribed", "pulse_reviewed": "Passages transcribed",
"pulse_uploaded": "Documents uploaded", "pulse_uploaded": "Documents uploaded",
"feed_caption": "Comments & activity", "feed_caption": "Comments & activity",
"feed_show_all": "Show all", "feed_show_all": "Show all",
"feed_for_you": "for you", "feed_for_you": "for you",
"audit_action_text_saved": "saved text in", "audit_action_text_saved": "saved text in",
"audit_action_file_uploaded": "uploaded a file:", "audit_action_file_uploaded": "uploaded a file:",
"audit_action_annotation_created": "created an annotation in", "audit_action_annotation_created": "created an annotation in",
"audit_action_comment_added": "commented:", "audit_action_comment_added": "commented:",
"audit_action_mention_created": "mentioned you in", "audit_action_mention_created": "mentioned you in",
"dropzone_release": "Release to upload", "dropzone_release": "Release to upload",
"chronik_page_title": "Activity", "chronik_page_title": "Activity",
"chronik_for_you_caption": "For you", "chronik_for_you_caption": "For you",
"chronik_for_you_count": "{count} new", "chronik_for_you_count": "{count} new",
@@ -859,9 +850,7 @@
"pagination_page_of": "Page {page} of {total}", "pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination", "pagination_nav_label": "Pagination",
"pagination_page_button": "Page {page}", "pagination_page_button": "Page {page}",
"common_opens_new_tab": "(opens in new tab)", "common_opens_new_tab": "(opens in new tab)",
"transcribe_coach_title": "First transcription?", "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_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.", "transcribe_coach_step_1_title": "Draw a frame.",
@@ -871,10 +860,8 @@
"transcribe_coach_step_3_title": "Saves automatically.", "transcribe_coach_step_3_title": "Saves automatically.",
"transcribe_coach_footer_kurrent": "Kurrent help ↗", "transcribe_coach_footer_kurrent": "Kurrent help ↗",
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗", "transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
"transcription_mode_help_label": "Read and edit mode", "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.", "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
"richtlinien_title": "Transcription Guidelines", "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_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.", "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_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit", "bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited", "bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree", "nav_stammbaum": "Family tree",
"nav_geschichten": "Stories", "nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.", "error_geschichte_not_found": "The story was not found.",
"geschichten_index_title": "Stories", "geschichten_index_title": "Stories",
"geschichten_new_button": "New story", "geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All", "geschichten_filter_all_pill": "All",
@@ -973,7 +957,6 @@
"geschichten_card_attach_action": "+ Attach a story", "geschichten_card_attach_action": "+ Attach a story",
"geschichten_card_show_all_for_person": "All stories about {name}", "geschichten_card_show_all_for_person": "All stories about {name}",
"geschichten_card_show_all": "Show all", "geschichten_card_show_all": "Show all",
"geschichte_editor_title_placeholder": "Story title", "geschichte_editor_title_placeholder": "Story title",
"geschichte_editor_body_placeholder": "Write your story here…", "geschichte_editor_body_placeholder": "Write your story here…",
"geschichte_editor_status_draft": "DRAFT", "geschichte_editor_status_draft": "DRAFT",
@@ -1000,14 +983,11 @@
"geschichte_editor_toolbar_h3": "Subheading", "geschichte_editor_toolbar_h3": "Subheading",
"geschichte_editor_toolbar_ul": "Bulleted list", "geschichte_editor_toolbar_ul": "Bulleted list",
"geschichte_editor_toolbar_ol": "Numbered list", "geschichte_editor_toolbar_ol": "Numbered list",
"geschichte_delete_confirm_title": "Delete story?", "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.", "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_relationship_not_found": "Relationship not found.",
"error_circular_relationship": "This relationship would form a cycle.", "error_circular_relationship": "This relationship would form a cycle.",
"error_duplicate_relationship": "This relationship already exists.", "error_duplicate_relationship": "This relationship already exists.",
"relation_parent_of": "Parent of", "relation_parent_of": "Parent of",
"relation_child_of": "Child of", "relation_child_of": "Child of",
"relation_spouse_of": "Spouse", "relation_spouse_of": "Spouse",
@@ -1018,7 +998,6 @@
"relation_doctor": "Doctor", "relation_doctor": "Doctor",
"relation_neighbor": "Neighbour", "relation_neighbor": "Neighbour",
"relation_other": "Other", "relation_other": "Other",
"relation_inferred_parent": "Parent", "relation_inferred_parent": "Parent",
"relation_inferred_child": "Child", "relation_inferred_child": "Child",
"relation_inferred_spouse": "Spouse", "relation_inferred_spouse": "Spouse",
@@ -1036,9 +1015,7 @@
"relation_inferred_sibling_inlaw": "Sibling-in-law", "relation_inferred_sibling_inlaw": "Sibling-in-law",
"relation_inferred_cousin_1": "Cousin", "relation_inferred_cousin_1": "Cousin",
"relation_inferred_distant": "Distant relative", "relation_inferred_distant": "Distant relative",
"doc_details_field_relationship": "Relationship", "doc_details_field_relationship": "Relationship",
"stammbaum_empty_heading": "No family members yet", "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_body": "Mark a person as a family member on their edit page so they appear here.",
"stammbaum_empty_link": "→ Go to person list", "stammbaum_empty_link": "→ Go to person list",
@@ -1050,7 +1027,6 @@
"stammbaum_zoom_in": "Zoom in", "stammbaum_zoom_in": "Zoom in",
"stammbaum_zoom_out": "Zoom out", "stammbaum_zoom_out": "Zoom out",
"stammbaum_generations": "Generations", "stammbaum_generations": "Generations",
"relation_error_duplicate": "This relationship already exists.", "relation_error_duplicate": "This relationship already exists.",
"relation_error_circular": "This relationship would form a cycle.", "relation_error_circular": "This relationship would form a cycle.",
"relation_error_self": "A person cannot be related to themselves.", "relation_error_self": "A person cannot be related to themselves.",
@@ -1073,14 +1049,15 @@
"relation_form_field_from_year": "From year", "relation_form_field_from_year": "From year",
"relation_form_field_to_year": "To year", "relation_form_field_to_year": "To year",
"relation_form_year_placeholder": "e.g. 1920", "relation_form_year_placeholder": "e.g. 1920",
"person_relationships_heading": "Relationships", "person_relationships_heading": "Relationships",
"person_relationships_empty": "No relationships known yet.", "person_relationships_empty": "No relationships known yet.",
"timeline_aria_label": "Document density timeline", "timeline_aria_label": "Document density timeline",
"timeline_clear_selection": "Clear selection", "timeline_clear_selection": "Clear selection",
"timeline_zoom_reset": "Reset zoom", "timeline_zoom_reset": "Reset zoom",
"timeline_bar_aria_singular": "{when}, 1 document", "timeline_bar_aria_singular": "{when}, 1 document",
"timeline_bar_aria_plural": "{when}, {count} documents", "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!"
} }

View File

@@ -473,7 +473,7 @@
"dashboard_reader_stats_persons_short": "Pers.", "dashboard_reader_stats_persons_short": "Pers.",
"dashboard_reader_stats_stories_short": "Hist.", "dashboard_reader_stats_stories_short": "Hist.",
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}", "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", "dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador", "doc_status_placeholder": "Marcador",
"doc_status_uploaded": "Cargado", "doc_status_uploaded": "Cargado",
@@ -773,19 +773,15 @@
"admin_invite_created_title": "Invitación creada", "admin_invite_created_title": "Invitación creada",
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:", "admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?", "admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
"greeting_morning": "Buenos días, {name}.", "greeting_morning": "Buenos días, {name}.",
"greeting_day": "Hola, {name}.", "greeting_day": "Hola, {name}.",
"greeting_evening": "Buenas noches, {name}.", "greeting_evening": "Buenas noches, {name}.",
"dashboard_resume_label": "Continuar donde lo dejaste",
"dashboard_blocks": "{count} secciones", "dashboard_blocks": "{count} secciones",
"dashboard_resume_cta": "Continuar transcripción", "dashboard_resume_cta": "Continuar transcripción",
"dashboard_resume_other": "o elige otro documento", "dashboard_resume_other": "o elige otro documento",
"dashboard_empty_title": "Aún no has comenzado ningún 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_body": "Elige un documento del archivo para empezar a transcribir.",
"dashboard_empty_cta": "Al archivo", "dashboard_empty_cta": "Al archivo",
"dashboard_mission_caption": "Tareas pendientes", "dashboard_mission_caption": "Tareas pendientes",
"queue_segment": "Segmentar", "queue_segment": "Segmentar",
"queue_segment_blurb": "Dividir páginas", "queue_segment_blurb": "Dividir páginas",
@@ -795,7 +791,6 @@
"queue_review_blurb": "Controlar textos", "queue_review_blurb": "Controlar textos",
"queue_n_open": "{n} pendiente", "queue_n_open": "{n} pendiente",
"queue_show_all": "Ver todo →", "queue_show_all": "Ver todo →",
"pulse_eyebrow": "Esta semana", "pulse_eyebrow": "Esta semana",
"pulse_headline": "Habéis trabajado {pages} páginas.", "pulse_headline": "Habéis trabajado {pages} páginas.",
"pulse_you": "Tú mismo has trabajado {pages} de ellas.", "pulse_you": "Tú mismo has trabajado {pages} de ellas.",
@@ -803,19 +798,15 @@
"pulse_transcribed": "Fragmentos anotados", "pulse_transcribed": "Fragmentos anotados",
"pulse_reviewed": "Fragmentos transcritos", "pulse_reviewed": "Fragmentos transcritos",
"pulse_uploaded": "Documentos subidos", "pulse_uploaded": "Documentos subidos",
"feed_caption": "Comentarios y actividad", "feed_caption": "Comentarios y actividad",
"feed_show_all": "Ver todo", "feed_show_all": "Ver todo",
"feed_for_you": "para ti", "feed_for_you": "para ti",
"audit_action_text_saved": "guardó texto en", "audit_action_text_saved": "guardó texto en",
"audit_action_file_uploaded": "subió un archivo:", "audit_action_file_uploaded": "subió un archivo:",
"audit_action_annotation_created": "creó una anotación en", "audit_action_annotation_created": "creó una anotación en",
"audit_action_comment_added": "comentó:", "audit_action_comment_added": "comentó:",
"audit_action_mention_created": "te mencionó en", "audit_action_mention_created": "te mencionó en",
"dropzone_release": "Suelta para subir", "dropzone_release": "Suelta para subir",
"chronik_page_title": "Actividades", "chronik_page_title": "Actividades",
"chronik_for_you_caption": "Para ti", "chronik_for_you_caption": "Para ti",
"chronik_for_you_count": "{count} nuevas", "chronik_for_you_count": "{count} nuevas",
@@ -859,9 +850,7 @@
"pagination_page_of": "Página {page} de {total}", "pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación", "pagination_nav_label": "Paginación",
"pagination_page_button": "Página {page}", "pagination_page_button": "Página {page}",
"common_opens_new_tab": "(abre en pestaña nueva)", "common_opens_new_tab": "(abre en pestaña nueva)",
"transcribe_coach_title": "¿Primera transcripción?", "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_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.", "transcribe_coach_step_1_title": "Dibujar un marco.",
@@ -871,10 +860,8 @@
"transcribe_coach_step_3_title": "Se guarda automáticamente.", "transcribe_coach_step_3_title": "Se guarda automáticamente.",
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗", "transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗", "transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
"transcription_mode_help_label": "Modo lectura y edició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.", "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_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_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.", "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_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva", "bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}", "bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico", "nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias", "nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.", "error_geschichte_not_found": "No se encontró la historia.",
"geschichten_index_title": "Historias", "geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia", "geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas", "geschichten_filter_all_pill": "Todas",
@@ -973,7 +957,6 @@
"geschichten_card_attach_action": "+ Adjuntar historia", "geschichten_card_attach_action": "+ Adjuntar historia",
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}", "geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
"geschichten_card_show_all": "Mostrar todas", "geschichten_card_show_all": "Mostrar todas",
"geschichte_editor_title_placeholder": "Título de la historia", "geschichte_editor_title_placeholder": "Título de la historia",
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…", "geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
"geschichte_editor_status_draft": "BORRADOR", "geschichte_editor_status_draft": "BORRADOR",
@@ -1000,14 +983,11 @@
"geschichte_editor_toolbar_h3": "Subencabezado", "geschichte_editor_toolbar_h3": "Subencabezado",
"geschichte_editor_toolbar_ul": "Lista con viñetas", "geschichte_editor_toolbar_ul": "Lista con viñetas",
"geschichte_editor_toolbar_ol": "Lista numerada", "geschichte_editor_toolbar_ol": "Lista numerada",
"geschichte_delete_confirm_title": "¿Eliminar historia?", "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.", "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_relationship_not_found": "La relación no fue encontrada.",
"error_circular_relationship": "Esta relación crearía un ciclo.", "error_circular_relationship": "Esta relación crearía un ciclo.",
"error_duplicate_relationship": "Esta relación ya existe.", "error_duplicate_relationship": "Esta relación ya existe.",
"relation_parent_of": "Progenitor de", "relation_parent_of": "Progenitor de",
"relation_child_of": "Hijo/a de", "relation_child_of": "Hijo/a de",
"relation_spouse_of": "Cónyuge", "relation_spouse_of": "Cónyuge",
@@ -1018,7 +998,6 @@
"relation_doctor": "Médico", "relation_doctor": "Médico",
"relation_neighbor": "Vecino/a", "relation_neighbor": "Vecino/a",
"relation_other": "Otro", "relation_other": "Otro",
"relation_inferred_parent": "Progenitor", "relation_inferred_parent": "Progenitor",
"relation_inferred_child": "Hijo/a", "relation_inferred_child": "Hijo/a",
"relation_inferred_spouse": "Cónyuge", "relation_inferred_spouse": "Cónyuge",
@@ -1036,9 +1015,7 @@
"relation_inferred_sibling_inlaw": "Cuñado/a", "relation_inferred_sibling_inlaw": "Cuñado/a",
"relation_inferred_cousin_1": "Primo/a", "relation_inferred_cousin_1": "Primo/a",
"relation_inferred_distant": "Pariente lejano", "relation_inferred_distant": "Pariente lejano",
"doc_details_field_relationship": "Parentesco", "doc_details_field_relationship": "Parentesco",
"stammbaum_empty_heading": "Aún no hay miembros de la familia", "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_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", "stammbaum_empty_link": "→ Ir a la lista de personas",
@@ -1050,7 +1027,6 @@
"stammbaum_zoom_in": "Acercar", "stammbaum_zoom_in": "Acercar",
"stammbaum_zoom_out": "Alejar", "stammbaum_zoom_out": "Alejar",
"stammbaum_generations": "Generaciones", "stammbaum_generations": "Generaciones",
"relation_error_duplicate": "Esta relación ya existe.", "relation_error_duplicate": "Esta relación ya existe.",
"relation_error_circular": "Esta relación crearía un ciclo.", "relation_error_circular": "Esta relación crearía un ciclo.",
"relation_error_self": "Una persona no puede estar relacionada consigo misma.", "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_from_year": "Desde año",
"relation_form_field_to_year": "Hasta año", "relation_form_field_to_year": "Hasta año",
"relation_form_year_placeholder": "ej. 1920", "relation_form_year_placeholder": "ej. 1920",
"person_relationships_heading": "Relaciones", "person_relationships_heading": "Relaciones",
"person_relationships_empty": "Aún no se conocen relaciones.", "person_relationships_empty": "Aún no se conocen relaciones.",
"timeline_aria_label": "Cronología de densidad de documentos", "timeline_aria_label": "Cronología de densidad de documentos",
"timeline_clear_selection": "Borrar selección", "timeline_clear_selection": "Borrar selección",
"timeline_zoom_reset": "Restablecer zoom", "timeline_zoom_reset": "Restablecer zoom",
"timeline_bar_aria_singular": "{when}, 1 documento", "timeline_bar_aria_singular": "{when}, 1 documento",
"timeline_bar_aria_plural": "{when}, {count} documentos", "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!"
} }

View File

@@ -26,6 +26,11 @@ declare global {
interface PageData { interface PageData {
user?: User; // Available in $page.data.user user?: User; // Available in $page.data.user
} }
interface Error {
message: string;
errorId?: string;
}
} }
} }

View File

@@ -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');
});
});

View File

@@ -1,10 +1,16 @@
import * as Sentry from '@sentry/sveltekit'; 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({ Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN, dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE, environment: import.meta.env.MODE,
tracesSampleRate: 1.0, tracesSampleRate: 0.1,
sendDefaultPii: false,
enabled: !!import.meta.env.VITE_SENTRY_DSN 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 };
});

View File

@@ -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');
});
});

View File

@@ -6,10 +6,13 @@ import { env } from 'process';
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime'; import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
import { detectLocale } from '$lib/shared/server/locale'; 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({ Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN, dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE, environment: import.meta.env.MODE,
tracesSampleRate: 1.0, tracesSampleRate: 0.1,
sendDefaultPii: false,
enabled: !!import.meta.env.VITE_SENTRY_DSN enabled: !!import.meta.env.VITE_SENTRY_DSN
}); });
@@ -122,4 +125,7 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); 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 };
});

View File

@@ -1,13 +1,53 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
let copied = $state(false);
function copyId() {
const id = page.error?.errorId;
if (!id) return;
if (!navigator.clipboard) return;
navigator.clipboard.writeText(id).then(
() => {
copied = true;
setTimeout(() => (copied = false), 2000);
},
() => {
/* clipboard denied or unavailable — select-all on the <code> element remains */
}
);
}
</script> </script>
<svelte:head> <svelte:head>
<title>{m.page_title_error()}</title> <title>{m.page_title_error()}</title>
</svelte:head> </svelte:head>
<div class="px-4 py-12 text-center font-sans"> <main class="px-4 py-12 text-center font-sans">
<p class="font-sans text-6xl font-bold text-ink">{page.status}</p> <h1 class="mb-2 font-serif text-2xl font-bold text-ink">{m.page_title_error()}</h1>
<p class="mt-2 font-sans text-sm text-ink-2">{page.error?.message ?? 'Internal Error'}</p> <p class="mb-8 font-sans text-sm text-ink-2">
</div> {page.error?.message ?? m.error_internal_error()}
</p>
<p class="mb-4 font-mono text-4xl font-bold text-ink">{page.status}</p>
{#if page.error?.errorId}
<div class="mt-6 flex flex-col items-center gap-3">
<p class="font-sans text-xs tracking-widest text-ink-2 uppercase">
{m.error_page_id_label()}
</p>
<code
class="rounded border border-line bg-surface px-3 py-1 font-mono text-sm text-ink select-all"
>
{page.error.errorId}
</code>
<button
class="min-h-[44px] min-w-[44px] rounded-sm bg-brand-navy px-5 py-2 font-sans text-sm text-white transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
onclick={copyId}
aria-label={m.error_copy_id_label()}
>
<span aria-live="polite">{copied ? m.error_copied() : m.error_copy_id_label()}</span>
</button>
</div>
{/if}
</main>

View File

@@ -24,7 +24,6 @@ export const GET: RequestHandler = async ({ url, fetch }) => {
} }
const data = await response.json(); const data = await response.json();
console.log('Tags Data', data);
// 4. Daten zurück an den Browser schicken // 4. Daten zurück an den Browser schicken
return json(data); return json(data);

View File

@@ -4,7 +4,10 @@ import { page as browserPage } from 'vitest/browser';
const mockPage = { const mockPage = {
status: 500, 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', () => ({ 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); afterEach(cleanup);
async function loadComponent() { async function loadComponent() {
@@ -20,7 +33,7 @@ async function loadComponent() {
} }
describe('+error.svelte', () => { describe('+error.svelte', () => {
it('renders the page status code prominently', async () => { it('renders the page status code', async () => {
mockPage.status = 404; mockPage.status = 404;
mockPage.error = { message: 'Not Found' }; mockPage.error = { message: 'Not Found' };
@@ -40,13 +53,79 @@ describe('+error.svelte', () => {
await expect.element(browserPage.getByText('Database unavailable')).toBeVisible(); 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.status = 500;
mockPage.error = null; mockPage.error = null;
const ErrorPage = await loadComponent(); const ErrorPage = await loadComponent();
render(ErrorPage); 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();
});
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();
}); });
}); });

View File

@@ -71,7 +71,8 @@ export default defineConfig({
'src/lib/shared/utils/**', 'src/lib/shared/utils/**',
'src/lib/shared/server/**', 'src/lib/shared/server/**',
'src/lib/shared/discussion/**', 'src/lib/shared/discussion/**',
'src/lib/document/**' 'src/lib/document/**',
'src/hooks.server.ts'
], ],
exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'], exclude: ['**/*.svelte', '**/*.svelte.ts', '**/__mocks__/**'],
thresholds: { thresholds: {