From f7386742b4a5f1fa76aa959a4b4481f397172631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:31:51 +0200 Subject: [PATCH] feat(web): show the error id on server failures and add the support info and report surfaces --- messages/de.json | 28 +++- messages/en.json | 28 +++- messages/es.json | 28 +++- messages/fr.json | 28 +++- messages/it.json | 28 +++- messages/ko.json | 28 +++- messages/pl.json | 28 +++- .../error-display-helper-guard.test.ts | 103 +++++++++++++ src/app/admin/[section]/renderer.tsx | 3 + src/app/global-error.tsx | 51 ++++++ src/components/admin/_shared.tsx | 4 +- .../admin/ai-server-key-section.tsx | 4 +- src/components/admin/assistant-section.tsx | 4 +- src/components/admin/backups-section.tsx | 19 ++- .../admin/central-codex-section.tsx | 10 +- src/components/admin/encryption-section.tsx | 6 +- src/components/admin/glitchtip-section.tsx | 4 +- .../admin/invite-tokens-section.tsx | 13 +- src/components/admin/key-backup-card.tsx | 11 +- .../admin/login-overview-section.tsx | 5 +- .../admin/measurement-maintenance-card.tsx | 6 +- .../admin/module-availability-section.tsx | 4 +- src/components/admin/reminders-section.tsx | 9 +- src/components/admin/support-report-card.tsx | 128 ++++++++++++++++ src/components/admin/umami-section.tsx | 4 +- .../admin/user-management-section.tsx | 7 +- .../admin/web-push-vapid-section.tsx | 7 +- .../custom-metrics/custom-metric-detail.tsx | 4 +- .../custom-metric-history-list.tsx | 8 +- src/components/cycle/use-cycle.ts | 13 +- .../documents/document-detail-sheet.tsx | 17 +- .../documents/document-facts-review.tsx | 4 +- .../documents/document-link-picker.tsx | 6 +- src/components/documents/documents-view.tsx | 23 ++- src/components/encounters/encounter-sheet.tsx | 8 +- src/components/error-details.tsx | 8 +- src/components/errors/error-id-line.tsx | 73 +++++++++ src/components/illness/episode-menu.tsx | 4 +- .../coach-panel/coach-conversation.tsx | 19 ++- .../coach-panel/copy-table-button.tsx | 5 +- .../insights/coach-panel/memory-note.tsx | 3 +- .../insights/coach-panel/message-actions.tsx | 11 +- .../coach-panel/reminder-suggestion-card.tsx | 5 +- .../insights/coach-panel/sources-rail.tsx | 4 +- .../coach-panel/suggested-action-card.tsx | 4 +- .../insights/coach-panel/use-coach.ts | 5 +- .../insights/insights-edit-mode.tsx | 6 +- .../insights/insights-tab-strip.tsx | 3 +- .../insights/pattern-dismiss-action.tsx | 9 +- src/components/labs/biomarker-manager.tsx | 7 +- src/components/labs/lab-biomarker-detail.tsx | 4 +- src/components/labs/lab-history-list.tsx | 8 +- src/components/labs/ocr-review-dialog.tsx | 11 +- .../measurements/measurement-list.tsx | 13 +- .../detail/efficacy/efficacy-tab.tsx | 7 +- .../medications/dose-history-add-dialog.tsx | 8 +- .../medications/dose-history-ledger.tsx | 24 ++- .../medications/glp1-medication-card.tsx | 5 +- .../medications/intake-edit-dialog.tsx | 7 +- .../medications/intake-history-editable.tsx | 13 +- .../medications/log-intake-dialog.tsx | 3 +- .../medications/medication-card.tsx | 5 +- .../medication-categories-sheet.tsx | 19 ++- .../scheduling/schedule-history-timeline.tsx | 12 +- .../scheduling/schedule-times-editor.tsx | 7 +- .../medications/sections/api-tokens-row.tsx | 10 +- .../sections/auto-mark-taken-section.tsx | 7 +- .../medications/sections/courses-section.tsx | 5 +- .../sections/destructive-zone-section.tsx | 25 ++- .../sections/intake-tracking-section.tsx | 7 +- .../sections/inventory-dialogs.tsx | 19 ++- .../sections/inventory-section.tsx | 7 +- .../sections/notifications-section.tsx | 7 +- .../sections/phase-config-sheet.tsx | 4 +- .../medications/sections/settings-section.tsx | 13 +- .../medications/side-effects-section.tsx | 7 +- src/components/medications/take-all-due.ts | 5 +- .../medications/use-medication-intake.ts | 19 ++- .../mood/__tests__/mood-qol-fixes.test.tsx | 6 +- .../mood/manage/archived-tags-card.tsx | 13 +- .../mood/manage/tag-editor-sheet.tsx | 8 +- .../mood/manage/tag-groups-card.tsx | 16 +- .../mood/manage/tag-manager-card.tsx | 16 +- src/components/mood/mood-list.tsx | 13 +- src/components/onboarding/baseline-form.tsx | 5 +- src/components/onboarding/confirm-screen.tsx | 5 +- src/components/onboarding/done-screen.tsx | 5 +- .../onboarding/first-result-screen.tsx | 9 +- src/components/onboarding/question-screen.tsx | 5 +- src/components/onboarding/units-screen.tsx | 5 +- src/components/onboarding/welcome-screen.tsx | 5 +- src/components/outcome/outcome-toast.ts | 3 +- .../practitioners/practitioner-list.tsx | 6 +- .../practitioners/practitioner-sheet.tsx | 3 +- src/components/providers.tsx | 3 +- .../records/about-me-note-manager.tsx | 3 +- .../records/ai-profile-inclusion-manager.tsx | 3 +- .../records/allergy-free-text-note.tsx | 3 +- src/components/records/allergy-manager.tsx | 4 +- src/components/records/conditions-manager.tsx | 5 +- .../records/emergency-profile-manager.tsx | 4 +- .../records/family-history-manager.tsx | 4 +- .../records/health-profile-facts-manager.tsx | 6 +- src/components/settings/about-section.tsx | 4 + .../access/managed-profile-handover.tsx | 7 +- .../settings/account-section/setup-card.tsx | 5 +- .../settings/ai/runtime-actions-row.tsx | 5 +- src/components/settings/api-section.tsx | 11 +- .../settings/coach-memory-section.tsx | 11 +- .../settings/dashboard-layout-section.tsx | 3 +- .../settings/environment-section.tsx | 38 +++-- .../settings/insights-pill-order-section.tsx | 3 +- .../integrations/connections-panel.tsx | 5 +- src/components/settings/mcp-section.tsx | 24 +-- src/components/settings/score-section.tsx | 15 +- src/components/settings/sources-section.tsx | 6 +- src/components/settings/support-info-card.tsx | 123 +++++++++++++++ .../settings/thresholds-editor-section.tsx | 7 +- src/components/targets/target-edit-sheet.tsx | 7 +- .../vaccinations/booster-mint-prompt.tsx | 4 +- .../vaccinations/custom-vaccine-sheet.tsx | 9 +- .../vaccination-document-suggestion.tsx | 6 +- .../vaccinations/vaccination-sheet.tsx | 9 +- src/hooks/use-account-switch.ts | 4 +- src/hooks/use-auth.ts | 4 +- src/hooks/use-module-toggle.ts | 3 +- src/lib/api/__tests__/api-fetch.test.ts | 48 ++++++ src/lib/api/api-fetch.ts | 30 +++- src/lib/errors/__tests__/error-id.test.ts | 98 ++++++++++++ src/lib/errors/__tests__/error-toast.test.tsx | 48 ++++++ src/lib/errors/error-id.ts | 129 ++++++++++++++++ src/lib/errors/error-toast.tsx | 64 ++++++++ src/lib/queries/use-documents-layout.ts | 5 +- src/lib/queries/use-medication-list-layout.ts | 5 +- .../support/__tests__/support-info.test.ts | 123 +++++++++++++++ src/lib/support/redact.ts | 3 +- src/lib/support/support-info.ts | 145 ++++++++++++++++++ .../integration/admin-support-report.test.ts | 3 +- 138 files changed, 1982 insertions(+), 318 deletions(-) create mode 100644 src/__tests__/error-display-helper-guard.test.ts create mode 100644 src/components/admin/support-report-card.tsx create mode 100644 src/components/errors/error-id-line.tsx create mode 100644 src/components/settings/support-info-card.tsx create mode 100644 src/lib/errors/__tests__/error-id.test.ts create mode 100644 src/lib/errors/__tests__/error-toast.test.tsx create mode 100644 src/lib/errors/error-id.ts create mode 100644 src/lib/errors/error-toast.tsx create mode 100644 src/lib/support/__tests__/support-info.test.ts create mode 100644 src/lib/support/support-info.ts diff --git a/messages/de.json b/messages/de.json index 5e17d1bf6..f467801d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationMinutes": "{minutes} Min.", "durationClockMinutes": "{time} Min.", - "durationClockHours": "{time} Std." + "durationClockHours": "{time} Std.", + "errorIdLabel": "Fehler-ID", + "errorIdCopy": "Fehler-ID kopieren", + "errorIdCopied": "Fehler-ID kopiert" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentation", "credits": "Credits & Datenquellen", "linksHeading": "Quellen & Dokumentation", - "newerAvailable": "Neue Version: {tag}" + "newerAvailable": "Neue Version: {tag}", + "support": { + "title": "Support", + "description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.", + "copy": "Support-Info kopieren", + "copied": "Kopiert", + "copyFailed": "Die Support-Info konnte nicht zusammengestellt werden." + } }, "testConnection": { "test": "Verbindung testen", @@ -7753,7 +7763,8 @@ "eventDetails": "Event-Details", "closeDetails": "Details schließen", "processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.", - "cardTitle": "Letzte Ereignisse" + "cardTitle": "Letzte Ereignisse", + "filterErrorId": "Fehler-ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." }, "loginOverviewCardTitle": "Anmeldeereignisse", - "userColumn": "Nutzer" + "userColumn": "Nutzer", + "supportReport": { + "title": "Support-Bericht", + "description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.", + "body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.", + "copy": "Kopieren", + "copied": "Kopiert", + "download": "Herunterladen", + "loadError": "Der Support-Bericht konnte nicht erstellt werden." + } }, "achievements": { "title": "Erfolge", diff --git a/messages/en.json b/messages/en.json index b08ee23b9..6afd60c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}h {minutes}m", "durationMinutes": "{minutes}m", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "Error ID", + "errorIdCopy": "Copy error ID", + "errorIdCopied": "Error ID copied" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Credits & data attributions", "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}" + "newerAvailable": "Newer version: {tag}", + "support": { + "title": "Support", + "description": "A summary for a bug report, without your name, email or health values.", + "copy": "Copy support info", + "copied": "Copied", + "copyFailed": "The support info could not be put together." + } }, "testConnection": { "test": "Test connection", @@ -7753,7 +7763,8 @@ "eventDetails": "Event details", "closeDetails": "Close details", "processDetail": "Buffer is volatile and capped at 500 entries.", - "cardTitle": "Recent events" + "cardTitle": "Recent events", + "filterErrorId": "Error ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." }, "loginOverviewCardTitle": "Sign-in events", - "userColumn": "User" + "userColumn": "User", + "supportReport": { + "title": "Support report", + "description": "A summary of this instance for a bug report, without emails, IDs or values.", + "body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.", + "copy": "Copy", + "copied": "Copied", + "download": "Download", + "loadError": "The support report could not be created." + } }, "achievements": { "title": "Achievements", diff --git a/messages/es.json b/messages/es.json index 9bf9001d7..8a5ba0bf7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID de error", + "errorIdCopy": "Copiar ID de error", + "errorIdCopied": "ID de error copiado" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentación", "credits": "Créditos y atribuciones de datos", "linksHeading": "Fuentes y documentación", - "newerAvailable": "Nueva versión: {tag}" + "newerAvailable": "Nueva versión: {tag}", + "support": { + "title": "Soporte", + "description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.", + "copy": "Copiar información de soporte", + "copied": "Copiado", + "copyFailed": "No se pudo reunir la información de soporte." + } }, "testConnection": { "test": "Probar conexión", @@ -7753,7 +7763,8 @@ "eventDetails": "Detalles del evento", "closeDetails": "Cerrar detalles", "processDetail": "El búfer es volátil y está limitado a 500 entradas.", - "cardTitle": "Eventos recientes" + "cardTitle": "Eventos recientes", + "filterErrorId": "ID de error" }, "backups": { "title": "Copias de seguridad", @@ -8120,7 +8131,16 @@ "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." }, "loginOverviewCardTitle": "Eventos de inicio de sesión", - "userColumn": "Usuario" + "userColumn": "Usuario", + "supportReport": { + "title": "Informe de soporte", + "description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.", + "body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.", + "copy": "Copiar", + "copied": "Copiado", + "download": "Descargar", + "loadError": "No se pudo crear el informe de soporte." + } }, "achievements": { "title": "Logros", diff --git a/messages/fr.json b/messages/fr.json index 878a0384b..5d3bd5fd1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID d'erreur", + "errorIdCopy": "Copier l'ID d'erreur", + "errorIdCopied": "ID d'erreur copié" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Crédits et attributions de données", "linksHeading": "Sources et documentation", - "newerAvailable": "Nouvelle version : {tag}" + "newerAvailable": "Nouvelle version : {tag}", + "support": { + "title": "Assistance", + "description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.", + "copy": "Copier les infos d'assistance", + "copied": "Copié", + "copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées." + } }, "testConnection": { "test": "Tester la connexion", @@ -7753,7 +7763,8 @@ "eventDetails": "Détails de l'événement", "closeDetails": "Fermer les détails", "processDetail": "Le tampon est volatil et limité à 500 entrées.", - "cardTitle": "Événements récents" + "cardTitle": "Événements récents", + "filterErrorId": "ID d'erreur" }, "backups": { "title": "Sauvegardes", @@ -8120,7 +8131,16 @@ "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." }, "loginOverviewCardTitle": "Événements de connexion", - "userColumn": "Utilisateur" + "userColumn": "Utilisateur", + "supportReport": { + "title": "Rapport d'assistance", + "description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.", + "body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.", + "copy": "Copier", + "copied": "Copié", + "download": "Télécharger", + "loadError": "Le rapport d'assistance n'a pas pu être créé." + } }, "achievements": { "title": "Réussites", diff --git a/messages/it.json b/messages/it.json index 2c3c7df29..adaf75f83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID errore", + "errorIdCopy": "Copia ID errore", + "errorIdCopied": "ID errore copiato" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentazione", "credits": "Crediti e attribuzioni dei dati", "linksHeading": "Fonti e documentazione", - "newerAvailable": "Nuova versione: {tag}" + "newerAvailable": "Nuova versione: {tag}", + "support": { + "title": "Assistenza", + "description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.", + "copy": "Copia le info di assistenza", + "copied": "Copiato", + "copyFailed": "Non è stato possibile raccogliere le info di assistenza." + } }, "testConnection": { "test": "Prova connessione", @@ -7753,7 +7763,8 @@ "eventDetails": "Dettagli dell'evento", "closeDetails": "Chiudi dettagli", "processDetail": "Il buffer è volatile e limitato a 500 voci.", - "cardTitle": "Eventi recenti" + "cardTitle": "Eventi recenti", + "filterErrorId": "ID errore" }, "backups": { "title": "Backup", @@ -8120,7 +8131,16 @@ "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." }, "loginOverviewCardTitle": "Eventi di accesso", - "userColumn": "Utente" + "userColumn": "Utente", + "supportReport": { + "title": "Rapporto di assistenza", + "description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.", + "body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.", + "copy": "Copia", + "copied": "Copiato", + "download": "Scarica", + "loadError": "Non è stato possibile creare il rapporto di assistenza." + } }, "achievements": { "title": "Obiettivi", diff --git a/messages/ko.json b/messages/ko.json index 1d65cae91..e958a666a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}시간 {minutes}분", "durationMinutes": "{minutes}분", "durationClockMinutes": "{time}분", - "durationClockHours": "{time}시간" + "durationClockHours": "{time}시간", + "errorIdLabel": "오류 ID", + "errorIdCopy": "오류 ID 복사", + "errorIdCopied": "오류 ID 복사됨" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "문서", "credits": "크레딧 및 데이터 출처", "linksHeading": "출처 및 문서", - "newerAvailable": "새 버전: {tag}" + "newerAvailable": "새 버전: {tag}", + "support": { + "title": "지원", + "description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.", + "copy": "지원 정보 복사", + "copied": "복사됨", + "copyFailed": "지원 정보를 만들 수 없습니다." + } }, "testConnection": { "test": "연결 테스트", @@ -7729,7 +7739,8 @@ "eventDetails": "이벤트 상세", "closeDetails": "상세 닫기", "processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.", - "cardTitle": "최근 이벤트" + "cardTitle": "최근 이벤트", + "filterErrorId": "오류 ID" }, "backups": { "title": "백업", @@ -8120,7 +8131,16 @@ "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." }, "loginOverviewCardTitle": "로그인 이벤트", - "userColumn": "사용자" + "userColumn": "사용자", + "supportReport": { + "title": "지원 보고서", + "description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.", + "body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.", + "copy": "복사", + "copied": "복사됨", + "download": "다운로드", + "loadError": "지원 보고서를 만들 수 없습니다." + } }, "achievements": { "title": "업적", diff --git a/messages/pl.json b/messages/pl.json index 6093866f9..4cf8b75a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} godz. {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} godz." + "durationClockHours": "{time} godz.", + "errorIdLabel": "ID błędu", + "errorIdCopy": "Kopiuj ID błędu", + "errorIdCopied": "Skopiowano ID błędu" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentacja", "credits": "Autorzy i źródła danych", "linksHeading": "Źródła i dokumentacja", - "newerAvailable": "Nowa wersja: {tag}" + "newerAvailable": "Nowa wersja: {tag}", + "support": { + "title": "Pomoc", + "description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.", + "copy": "Kopiuj informacje dla pomocy", + "copied": "Skopiowano", + "copyFailed": "Nie udało się zebrać informacji dla pomocy." + } }, "testConnection": { "test": "Testuj połączenie", @@ -7753,7 +7763,8 @@ "eventDetails": "Szczegóły zdarzenia", "closeDetails": "Zamknij szczegóły", "processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.", - "cardTitle": "Ostatnie zdarzenia" + "cardTitle": "Ostatnie zdarzenia", + "filterErrorId": "ID błędu" }, "backups": { "title": "Kopie zapasowe", @@ -8120,7 +8131,16 @@ "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." }, "loginOverviewCardTitle": "Zdarzenia logowania", - "userColumn": "Użytkownik" + "userColumn": "Użytkownik", + "supportReport": { + "title": "Raport dla pomocy", + "description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.", + "body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.", + "copy": "Kopiuj", + "copied": "Skopiowano", + "download": "Pobierz", + "loadError": "Nie udało się utworzyć raportu dla pomocy." + } }, "achievements": { "title": "Osiągnięcia", diff --git a/src/__tests__/error-display-helper-guard.test.ts b/src/__tests__/error-display-helper-guard.test.ts new file mode 100644 index 000000000..bb1f2b20c --- /dev/null +++ b/src/__tests__/error-display-helper-guard.test.ts @@ -0,0 +1,103 @@ +/** + * Every error display goes through the one helper that shows an error id. + * + * A self-hoster reporting a failure needs the id the operator finds it by. The + * web shows it on a server failure with a copy button, through two doors only: + * + * - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds + * the id when its `cause` is a 5xx and is otherwise exactly `toast.error`; + * - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the + * root boundary that renders without any provider. + * + * A raw `toast.error(` anywhere else is a toast that can never carry the id, + * and a route boundary that skips the helper is a crash screen without it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const HELPER = "lib/errors/error-toast.tsx"; + +/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */ +const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g; +const HELPER_CALL = /\bshowErrorToast\s*\(/g; + +/** + * Route boundaries that deliberately show no error id, with the reason. Each + * entry is a decision, not a convenience. + */ +const BOUNDARY_EXEMPT: Readonly> = { + // The clinician share view is opened by someone outside the instance, with + // no account and no way to reach its operator; it shows a retry and nothing + // a probe could learn from, by design (see the file's header). + "app/c/error.tsx": "public share view, no operator relationship", +}; + +const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (rel) => + !rel.includes("__tests__/") && + !/\.test\.tsx?$/.test(rel) && + !rel.startsWith("generated/"), +); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("error display helper guard", () => { + it("finds the helper's own toast.error, so the matcher works", () => { + const own = read(HELPER).match(RAW_TOAST_ERROR) ?? []; + expect(own.length).toBeGreaterThan(0); + }); + + it("allows no toast.error outside the helper", () => { + const offenders = files + .filter((rel) => rel !== HELPER) + .filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0) + .sort(); + expect(offenders).toEqual([]); + }); + + it("routes the app's error toasts through showErrorToast", () => { + const calls = files.reduce( + (n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length, + 0, + ); + // 200 call sites when the helper landed; a count far below it means the + // matcher or the walk stopped seeing them. + expect(calls).toBeGreaterThan(180); + }); + + it("renders every route error boundary through the error id helper", () => { + const boundaries = files.filter((rel) => + /^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel), + ); + expect(boundaries.length).toBeGreaterThanOrEqual(3); + const missing = boundaries + .filter((rel) => !(rel in BOUNDARY_EXEMPT)) + .filter((rel) => { + const source = read(rel); + return !( + / { + const source = read("components/error-details.tsx"); + expect(source).toMatch(/ + {/* v1.43 — the redacted report for a bug report. */} + {/* v1.42 — the table maintenance after the tombstone purge. */} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 536dbe0aa..0bcb37ce8 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; +import { errorIdOf, recordErrorId, shortErrorId } from "@/lib/errors/error-id"; import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; /** @@ -22,12 +23,29 @@ export default function GlobalError({ reloadOnceForChunkError(error); }, [error]); + // The id the operator finds this failure by. No i18n provider exists at + // this level, so the line is the same static bilingual pair as the heading. + const errorId = errorIdOf(error, { unexpected: true }); + useEffect(() => { + if (errorId) recordErrorId(errorId); + }, [errorId]); + + async function handleCopyId() { + if (!errorId) return; + try { + await navigator.clipboard.writeText(errorId); + } catch { + window.prompt("Error ID", errorId); + } + } + async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. const loc = typeof window !== "undefined" ? window.location : null; const payload = { message: error.message, digest: error.digest, + errorId, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), urlPath: loc?.pathname ?? null, @@ -91,6 +109,39 @@ export default function GlobalError({ > {error.message || "Unknown error"} + {errorId && ( +

+ Fehler-ID / Error ID + {shortErrorId(errorId)} + +

+ )}
+ + + + ); +} diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index b1b109739..7be6ec909 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -50,10 +51,11 @@ export function UmamiSection() { toast.success(message); }, onError: (error) => { - toast.error( + showErrorToast( error instanceof Error ? error.message : t("admin.monitoringTestFailed"), + { cause: error }, ); }, }); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index e6b0ff13a..9fa1176e5 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -12,6 +12,7 @@ import { Users, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; @@ -99,10 +100,11 @@ export function UserManagementSection() { toast.success(t("common.saved")); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.settingsSaveError"), + { cause: err }, ); }, }); @@ -144,10 +146,11 @@ export function UserManagementSection() { setLogoutTarget(null); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.section.users.forceLogoutFailed"), + { cause: err }, ); }, }); diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index e56efc591..6de2dc105 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -96,7 +97,7 @@ export function WebPushVapidSection() { } if (!res.ok) { - toast.error(t("admin.webPushVapidGenerateFailed")); + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: res }); return; } @@ -113,8 +114,8 @@ export function WebPushVapidSection() { queryKey: queryKeys.adminSettings(), }); toast.success(t("admin.webPushVapidGenerated")); - } catch { - toast.error(t("admin.webPushVapidGenerateFailed")); + } catch (failure) { + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: failure }); } finally { setGenerating(false); } diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 485f4dde3..c31201cf9 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -11,6 +11,7 @@ import { } from "@tanstack/react-query"; import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -162,7 +163,8 @@ export function CustomMetricDetail({ queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); router.push("/measurements"); }, - onError: () => toast.error(t("customMetrics.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.deleteError"), { cause: failure }), }); const description = metric?.description?.trim() diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index d71ab8982..aadfe0b95 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -96,8 +97,8 @@ export function CustomMetricHistoryList({ }); invalidate(); toast.success(t("customMetrics.entry.restoredToast")); - } catch { - toast.error(t("customMetrics.entry.restoreError")); + } catch (failure) { + showErrorToast(t("customMetrics.entry.restoreError"), { cause: failure }); } } @@ -113,7 +114,8 @@ export function CustomMetricHistoryList({ }, }); }, - onError: () => toast.error(t("customMetrics.entry.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.entry.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 1377d3ffd..213a2b193 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -12,6 +12,7 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, @@ -233,7 +234,8 @@ export function useDeleteCustomSymptom() { onSuccess: () => invalidateKeys(qc, cycleDependentKeys), // v1.16.4 — a failed delete used to fail silently (the sheet clears the // chip optimistically), so surface the rejection. - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } @@ -355,7 +357,8 @@ export function useStartPeriod() { }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -375,7 +378,8 @@ export function useEndPeriod() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.periodEndSaved")); }, - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -391,7 +395,8 @@ export function useDeleteDayLog() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.deleteSuccess")); }, - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 5ac47bbc9..56052eb88 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -35,6 +35,7 @@ import { import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; @@ -567,11 +568,11 @@ export function DocumentDetailSheet({ ? error.meta.reason : ""; if (reason === "purged") { - toast.error(t("documents.error.purged")); + showErrorToast(t("documents.error.purged"), { cause: error }); } else if (reason === "duplicateExists") { - toast.error(t("documents.error.duplicateExists")); + showErrorToast(t("documents.error.duplicateExists"), { cause: error }); } else { - toast.error(t("documents.toast.restoreFailed")); + showErrorToast(t("documents.toast.restoreFailed"), { cause: error }); } }, }); @@ -681,16 +682,17 @@ export function DocumentDetailSheet({ onSuccess: (result) => { if ("summary" in result) { if (result.persistence === "withheld") { - toast.error(result.summary); + showErrorToast(result.summary); } else if (result.persistence === "failed") { - toast.error(t("documents.detail.saveError")); + showErrorToast(t("documents.detail.saveError")); } } void queryClient.invalidateQueries({ queryKey: queryKeys.inboundDocument(documentId), }); }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; @@ -709,7 +711,8 @@ export function DocumentDetailSheet({ ? tCount("documents.ai.readStaged", result.labFactsStaged) : t("documents.ai.readDone"), ), - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c17019dad..2bf29dc82 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -28,6 +28,7 @@ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -635,7 +636,8 @@ export function DocumentFactsSection({ toast.info(t("documents.review.extractEmpty")); } }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ) } diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 3cc1edcc4..59076672f 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -17,6 +17,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -92,10 +93,11 @@ export function DocumentLinkPicker({ ), onSuccess: (data) => { if (data.results.some((r) => !r.ok)) { - toast.error(t("documents.bulk.failed")); + showErrorToast(t("documents.bulk.failed")); } }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), onSettled: () => { void invalidateKeys(queryClient, [queryKeys.documents()]); }, diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 8959f6888..b313056cc 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -33,6 +33,7 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { Button } from "@/components/ui/button"; @@ -488,7 +489,10 @@ export function DocumentsView() { }) : t("documents.contentIndex.indexAllNothing"), ), - onError: () => toast.error(t("documents.contentIndex.indexAllError")), + onError: (failure) => + showErrorToast(t("documents.contentIndex.indexAllError"), { + cause: failure, + }), }); }, [reindexAll, t]); @@ -665,7 +669,7 @@ export function DocumentsView() { (results: DocumentBulkResultDto[], successMessage: string) => { const failed = results.filter((r) => !r.ok).length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -700,7 +704,8 @@ export function DocumentsView() { ); clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -719,7 +724,10 @@ export function DocumentsView() { count: results.filter((r) => r.ok).length, }), ), - onError: () => toast.error(t("documents.toast.restoreFailed")), + onError: (failure) => + showErrorToast(t("documents.toast.restoreFailed"), { + cause: failure, + }), }, ); }, @@ -735,7 +743,7 @@ export function DocumentsView() { const okIds = results.filter((r) => r.ok).map((r) => r.id); const failed = results.length - okIds.length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -756,7 +764,8 @@ export function DocumentsView() { } clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -794,7 +803,7 @@ export function DocumentsView() { t("documents.card.untitled"), ); if (resolved.overCap) { - toast.error( + showErrorToast( t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), ); return; diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 2f2a3a21a..463442272 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -34,6 +34,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -130,7 +131,7 @@ export function EncounterSheet({ const message = err instanceof Error ? err.message : t("encounters.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -205,7 +206,10 @@ export function EncounterSheet({ onClick={() => { remove.mutate(encounter.id, { onSuccess: () => onOpenChange(false), - onError: () => toast.error(t("encounters.deleteFailed")), + onError: (failure) => + showErrorToast(t("encounters.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/error-details.tsx b/src/components/error-details.tsx index f15466ad0..2de077ddb 100644 --- a/src/components/error-details.tsx +++ b/src/components/error-details.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { Copy, CheckCircle2, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "@/lib/errors/error-id"; import { useTranslations } from "@/lib/i18n/context"; export interface ErrorDetailsProps { @@ -29,6 +31,9 @@ export function ErrorDetails({ }: ErrorDetailsProps) { const { t, locale } = useTranslations(); const [copied, setCopied] = useState(false); + // The id the operator finds this failure by: the request id of an API + // failure that reached the boundary, else the server render's digest. + const errorId = requestId ?? errorIdOf(error, { unexpected: true }); async function handleCopy() { // Only capture the pathname — some flows carry OAuth codes / state tokens @@ -47,7 +52,7 @@ export function ErrorDetails({ digest: error.digest, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), - requestId: requestId ?? null, + requestId: errorId, urlPath, searchParamCount, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, @@ -81,6 +86,7 @@ export function ErrorDetails({
{error.message || t("common.unknownError")}
+ {errorId && }
{reset && ( + + ); +} diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index 6cfa233c5..1625f820e 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -10,6 +10,7 @@ import { useState } from "react"; import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { @@ -69,7 +70,8 @@ export function EpisodeMenu({ label: t("common.undo"), onClick: () => { restore.mutate(episode.id, { - onError: () => toast.error(t("illness.restoreError")), + onError: (failure) => + showErrorToast(t("illness.restoreError"), { cause: failure }), }); }, }, diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 51302a748..0ac00a043 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; @@ -335,8 +336,10 @@ export function CoachConversation({ onSuccess: (next) => { queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); }, - onError: () => { - toast.error(t("insights.coach.guided.dismissError")); + onError: (failure) => { + showErrorToast(t("insights.coach.guided.dismissError"), { + cause: failure, + }); }, }); @@ -451,7 +454,7 @@ export function CoachConversation({ } useEffect(() => { if (!invalidToastKey) return; - toast.error(t("insights.coach.attach.notFoundError")); + showErrorToast(t("insights.coach.attach.notFoundError")); }, [invalidToastKey, t]); // The pills row above the composer. On a fresh chat every pill is a staged id; @@ -559,8 +562,10 @@ export function CoachConversation({ headers: { "Idempotency-Key": randomId() }, body: form, }); - } catch { - toast.error(t("insights.coach.attach.uploadError")); + } catch (failure) { + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: failure, + }); continue; } const parsed = parseUploadResponse( @@ -568,7 +573,9 @@ export function CoachConversation({ await response.text(), ); if (!parsed.ok) { - toast.error(t("insights.coach.attach.uploadError")); + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: response, + }); continue; } const doc = parsed.document; diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx index 29758f854..da1995278 100644 --- a/src/components/insights/coach-panel/copy-table-button.tsx +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DropdownMenu, @@ -61,8 +62,8 @@ export function CopyTableButton({ grid, caption }: CopyTableButtonProps) { setCopied(false); timer.current = null; }, COPY_CONFIRM_MS); - } catch { - toast.error(t(COACH_RESULT_UI_KEYS.copyFailed)); + } catch (failure) { + showErrorToast(t(COACH_RESULT_UI_KEYS.copyFailed), { cause: failure }); } }, [grid, caption, t], diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx index 9b0f87fba..24b04f4b0 100644 --- a/src/components/insights/coach-panel/memory-note.tsx +++ b/src/components/insights/coach-panel/memory-note.tsx @@ -17,6 +17,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -83,7 +84,7 @@ export function CoachMemoryNote({ const facts = useCoachFacts({ enabled: saved && !liveFact && !undone }); const forget = useForgetCoachFact({ onSuccess: () => setUndone(true), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); if (!saved || undone || !note.factId) return null; const fact = diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx index eba8cb999..31354db88 100644 --- a/src/components/insights/coach-panel/message-actions.tsx +++ b/src/components/insights/coach-panel/message-actions.tsx @@ -18,6 +18,7 @@ import { useCallback, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BookmarkCheck, BookmarkPlus, @@ -139,8 +140,8 @@ function CopyMessageButton({ await navigator.clipboard.writeText(text); setCopied(true); window.setTimeout(() => setCopied(false), 1500); - } catch { - toast.error(t("insights.coach.copyMessageError")); + } catch (failure) { + showErrorToast(t("insights.coach.copyMessageError"), { cause: failure }); } }, [content, strip, t]); // Absent where the Clipboard API is (plain-HTTP self-hosts) rather than @@ -309,8 +310,10 @@ function RememberMessageButton({ messageId }: { messageId: string }) { ), ); }, - onError: () => { - toast.error(t("insights.coach.rememberMessage.failed")); + onError: (failure) => { + showErrorToast(t("insights.coach.rememberMessage.failed"), { + cause: failure, + }); }, }); diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index 789ebbba7..476c6277a 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellPlus, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -60,8 +61,8 @@ export function ReminderSuggestionCard({ : "dismissed", ); }, - onError: () => { - toast.error(t("coach.reminderSuggestion.failed")); + onError: (failure) => { + showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure }); }, }); diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 404da80a7..a55fbbd20 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Eye, Loader2, Target } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -98,7 +99,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { onSettled: () => setPending(null), // v1.16.4 — a failed save used to revert the switch silently once the // spinner cleared; a toast names the rejection. - onError: () => toast.error(t("insights.coach.prefsSaveError")), + onError: (failure) => + showErrorToast(t("insights.coach.prefsSaveError"), { cause: failure }), }); } diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index fa5694bd6..5ba5cf205 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Check, Loader2, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -53,7 +54,8 @@ export function SuggestedActionCard({ } setSettled("applied"); }, - onError: () => toast.error(t("coach.suggestedAction.failed")), + onError: (failure) => + showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }), }); if (settled) { diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 3aa90e76a..c0c77d30a 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -16,6 +16,7 @@ import { } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { CoachActivity, @@ -521,7 +522,7 @@ export function useDeleteCoachConversationWithUndo() { const failedMessage = t("insights.coach.historyDeleteFailed"); commitRef.current = (id) => commitCoachConversationDelete(queryClient, id, owner, () => - toast.error(failedMessage), + showErrorToast(failedMessage), ); }); @@ -596,7 +597,7 @@ export function useRenameCoachConversation() { }), onError: (_error, _input, snapshot) => { if (snapshot) restoreCoachConversationRename(queryClient, snapshot); - toast.error(t("insights.coach.rename.error")); + showErrorToast(t("insights.coach.rename.error"), { cause: _error }); }, onSettled: (_data, _error, input) => { void invalidateCoachConversationRename(queryClient, input.id); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 3aa6bb8b9..ecb985c3b 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, @@ -195,7 +196,7 @@ export function InsightsEditMode({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.editMode.saveError")); + showErrorToast(t("insights.editMode.saveError"), { cause: err }); }, }); @@ -212,7 +213,8 @@ export function InsightsEditMode({ setDraftState(seedArrangeDraft(saved.sections)); toast.success(t("insights.editMode.resetSuccess")); }, - onError: () => toast.error(t("insights.editMode.saveError")), + onError: (failure) => + showErrorToast(t("insights.editMode.saveError"), { cause: failure }), }); const busy = saveMutation.isPending || resetMutation.isPending; diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 3e8c9f920..7719277d0 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -12,6 +12,7 @@ import { Wrench, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Popover, @@ -724,7 +725,7 @@ function InsightsTabStripImpl({ const decision = regenerateToastKind(regenerateOutcome); if (decision) { if (decision.kind === "error") { - toast.error(t(decision.messageKey)); + showErrorToast(t(decision.messageKey)); } else { toast.success(t(decision.messageKey)); } diff --git a/src/components/insights/pattern-dismiss-action.tsx b/src/components/insights/pattern-dismiss-action.tsx index 83118062c..9b00478ab 100644 --- a/src/components/insights/pattern-dismiss-action.tsx +++ b/src/components/insights/pattern-dismiss-action.tsx @@ -4,6 +4,7 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; import { EyeOff, RotateCcw, X } from "lucide-react"; import { useContext, useReducer, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -138,8 +139,8 @@ export function PatternDismissButton({ { dismissed: true }, ); await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled); - } catch { - toast.error(t("insights.pattern.dismissError")); + } catch (failure) { + showErrorToast(t("insights.pattern.dismissError"), { cause: failure }); } finally { setPending(false); } @@ -184,8 +185,8 @@ export function PatternDismissedNotice({ { dismissed: false }, ); await applyPatternDecisionSuccess(queryClient, onRestored, onSettled); - } catch { - toast.error(t("insights.pattern.restoreError")); + } catch (failure) { + showErrorToast(t("insights.pattern.restoreError"), { cause: failure }); } finally { setPending(false); } diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 18cc9f5a5..8bb1e00ce 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -61,7 +62,8 @@ export function BiomarkerManager() { queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); const hiddenMutation = useMutation({ @@ -75,7 +77,8 @@ export function BiomarkerManager() { ); queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); }, - onError: () => toast.error(t("labs.biomarker.hideError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.hideError"), { cause: failure }), }); function openNew() { diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 9518eb0b8..36573bea5 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -19,6 +19,7 @@ import { Plus, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { DeleteButton } from "@/components/data-list"; @@ -283,7 +284,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); router.push("/labs"); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); // v1.25.1 — the per-marker description leads the page, mirroring the metric diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 109d32de6..efaf0d32e 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -156,8 +157,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { await apiPost("/api/labs/restore", { ids: [id] }); invalidate(); toast.success(t("labs.restoredToast")); - } catch { - toast.error(t("labs.restoreError")); + } catch (failure) { + showErrorToast(t("labs.restoreError"), { cause: failure }); } } @@ -172,7 +173,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }, }); }, - onError: () => toast.error(t("labs.deleteError")), + onError: (failure) => + showErrorToast(t("labs.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 46db94312..df911daac 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -14,6 +14,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -233,21 +234,22 @@ export function OcrReviewDialog({ onSuccess: (data) => { const seeded = seedReviewRows(data.rows, data.reportDate); if (seeded.length === 0) { - toast.error(t("labs.ocr.noRows")); + showErrorToast(t("labs.ocr.noRows")); return; } setRows(seeded); setPageCoverage(data.pageCoverage ?? null); setStage("review"); }, - onError: (err) => toast.error(extractErrorMessage(err, t)), + onError: (err) => + showErrorToast(extractErrorMessage(err, t), { cause: err }), }); } function onSave() { const plan = planSave(rows); if (plan.kind === "nothing-selected") { - toast.error(t("labs.ocr.nothingToSave")); + showErrorToast(t("labs.ocr.nothingToSave")); return; } if (plan.kind === "blocked") { @@ -296,7 +298,8 @@ export function OcrReviewDialog({ onCommitted(); handleClose(false); }, - onError: () => toast.error(t("labs.ocr.saveFailed")), + onError: (failure) => + showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }), }, ); } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 942909da7..e8b8b570b 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -65,6 +65,7 @@ import { useTableSort } from "@/hooks/use-table-sort"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { createPortal } from "react-dom"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { DayLink, DayLinkAt } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -687,8 +688,8 @@ export function MeasurementList({ await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); - } catch { - toast.error(t("measurements.restoreError")); + } catch (failure) { + showErrorToast(t("measurements.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -711,8 +712,8 @@ export function MeasurementList({ // v1.16.4 — a failed row delete used to fail silently (no onError // handler); the edit-dialog path surfaced its own inline error but // the row-level confirm had no feedback at all. - onError: () => { - toast.error(t("measurements.deleteError")); + onError: (failure) => { + showErrorToast(t("measurements.deleteError"), { cause: failure }); }, }); @@ -744,8 +745,8 @@ export function MeasurementList({ }, ); }, - onError: () => { - toast.error(t("measurements.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 9cbc2cc3b..cde93df12 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -14,6 +14,7 @@ import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle"; @@ -434,12 +435,14 @@ function RetargetControl({ await apiPut(`/api/medications/${medicationId}/efficacy/target`, body); onChanged(); if (clear) setValue(""); - } catch { + } catch (failure) { // v1.30 UI audit (M9) — the save previously failed silently: the // buttons re-enabled with no toast, no inline alert, and the // rejection escaped as an unhandled promise. The user could not // tell the override was not saved. - toast.error(t("medications.efficacy.retarget.error")); + showErrorToast(t("medications.efficacy.retarget.error"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 1e81fce04..04789ed39 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -21,6 +21,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -202,8 +203,11 @@ export function LedgerAddDialog({ await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medicationName })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, + ); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 084bf9e2f..e52462fd2 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -61,6 +61,7 @@ import { Unlink, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -307,10 +308,11 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { + } catch (failure) { if (prev) queryClient.setQueryData(queryKey, prev); - toast.error( + showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, ); } finally { setMarking(null); @@ -359,8 +361,10 @@ export function DoseHistoryLedger({ // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -393,8 +397,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -412,8 +418,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 91cd2f779..06e430cb8 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -7,6 +7,7 @@ import { import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -321,7 +322,9 @@ export function Glp1MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index f60d3d623..afe03c1ea 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -135,8 +136,10 @@ function IntakeEditDialogBody({ await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx index 04aa954e6..04d3f960a 100644 --- a/src/components/medications/intake-history-editable.tsx +++ b/src/components/medications/intake-history-editable.tsx @@ -22,6 +22,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -102,8 +103,10 @@ export function IntakeHistoryEditable({ toast.success(t("medications.detail.intake.bulkDelete.toast")); setBulkConfirmOpen(false); clearSelection(); - } catch { - toast.error(t("medications.detail.intake.bulkDelete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.bulkDelete.failed"), { + cause: failure, + }); } finally { setBulkBusy(false); } @@ -126,8 +129,10 @@ export function IntakeHistoryEditable({ next.delete(id); return next; }); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } finally { setRowDeleteBusy(false); } diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 71f4d0162..bcc7d306e 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -195,7 +196,7 @@ export function LogIntakeDialog({ if (slot !== NO_SLOT) { const iso = slotInstantIso(takenAt, slot); if (!iso) { - toast.error(t("medications.logIntake.slotError")); + showErrorToast(t("medications.logIntake.slotError")); return; } scheduledFor = iso; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3a3f37cfc..07e73bbf8 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -7,6 +7,7 @@ import { import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -243,7 +244,9 @@ export function MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index 94d944111..fd6cdefe4 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -9,6 +9,7 @@ import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -55,8 +56,10 @@ function CategoryRow({ try { await update.mutateAsync({ key: category.key, label: trimmed }); setEditing(false); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -66,8 +69,10 @@ function CategoryRow({ key: category.key, isActive: !category.isActive, }); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -221,8 +226,10 @@ export function MedicationCategoriesSheet({ count: String(result.movedCount), }), ); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 73ab6f2e2..82d8ee2be 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -39,6 +39,7 @@ import { Trash2, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -143,8 +144,10 @@ export function ScheduleHistoryTimeline({ queryKey: queryKeys.medicationDetail(medicationId), }); }, - onError: () => { - toast.error(t("medications.detail.zeitplan.history.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.detail.zeitplan.history.deleteError"), { + cause: failure, + }); }, }); @@ -468,11 +471,12 @@ function EraDialog({ reset(); onSaved(); }, - onError: () => { - toast.error( + onError: (failure) => { + showErrorToast( target ? t("medications.detail.zeitplan.history.updateError") : t("medications.detail.zeitplan.history.createError"), + { cause: failure }, ); }, }); diff --git a/src/components/medications/scheduling/schedule-times-editor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx index ce7eef19f..fac183869 100644 --- a/src/components/medications/scheduling/schedule-times-editor.tsx +++ b/src/components/medications/scheduling/schedule-times-editor.tsx @@ -20,6 +20,7 @@ import { useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -170,8 +171,10 @@ export function ScheduleTimesEditor({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.zeitplan.saved")); - } catch { - toast.error(t("medications.detail.zeitplan.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zeitplan.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 617a17ea9..6ce63e5aa 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -19,6 +19,7 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -142,8 +143,10 @@ export function ApiTokensRow({ try { await navigator.clipboard.writeText(text); toast.success(label); - } catch { - toast.error(t("medications.detail.api.copyFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.api.copyFailed"), { + cause: failure, + }); } } @@ -178,10 +181,11 @@ export function ApiTokensRow({ : (status?.activeTokenCount ?? 0) + 1, }); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.api.mintFailed"), + { cause: err }, ); } finally { setBusy(false); diff --git a/src/components/medications/sections/auto-mark-taken-section.tsx b/src/components/medications/sections/auto-mark-taken-section.tsx index dff300198..45f44fa1b 100644 --- a/src/components/medications/sections/auto-mark-taken-section.tsx +++ b/src/components/medications/sections/auto-mark-taken-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function AutoMarkTakenBody({ ? t("medications.autoMarkTaken.enabledToast") : t("medications.autoMarkTaken.disabledToast"), ); - } catch { + } catch (failure) { setLocalOn(previous); - toast.error(t("medications.autoMarkTaken.toggleFailed")); + showErrorToast(t("medications.autoMarkTaken.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx index a60d5959e..98886eff9 100644 --- a/src/components/medications/sections/courses-section.tsx +++ b/src/components/medications/sections/courses-section.tsx @@ -15,6 +15,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -222,8 +223,8 @@ export function CoursesRow({ await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`); await invalidateMedicationReads(queryClient); setDeleting(null); - } catch { - toast.error(t("medications.course.error.generic")); + } catch (failure) { + showErrorToast(t("medications.course.error.generic"), { cause: failure }); } finally { setDeleteBusy(false); } diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 4240c3346..bd3635cc8 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -30,6 +30,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Archive, Loader2, Trash2 } from "lucide-react"; import { @@ -92,9 +93,11 @@ export function LifecycleManageBody({ : t("medications.detail.zone.pause.resumedToast"), ); onAfterAction?.(); - } catch { + } catch (failure) { setPaused(previous); - toast.error(t("medications.detail.zone.pause.failed")); + showErrorToast(t("medications.detail.zone.pause.failed"), { + cause: failure, + }); } finally { setTier1Busy(false); } @@ -110,8 +113,10 @@ export function LifecycleManageBody({ toast.success(t("medications.detail.zone.end.toast")); setEndDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.end.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.end.failed"), { + cause: failure, + }); } finally { setTier2Busy(false); } @@ -246,8 +251,10 @@ export function DangerZoneBody({ toast.success(t("medications.detail.zone.purge.toast")); setPurgeDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.purge.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.purge.failed"), { + cause: failure, + }); } finally { setTier3aBusy(false); } @@ -278,8 +285,10 @@ export function DangerZoneBody({ // analytics / compliance / inline chart). Not awaited: navigation // already happened and a transient refetch must not block it. void invalidateMedicationReads(queryClient); - } catch { - toast.error(t("medications.detail.zone.delete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.delete.failed"), { + cause: failure, + }); setTier3bBusy(false); } } diff --git a/src/components/medications/sections/intake-tracking-section.tsx b/src/components/medications/sections/intake-tracking-section.tsx index 21b86e397..624dba62c 100644 --- a/src/components/medications/sections/intake-tracking-section.tsx +++ b/src/components/medications/sections/intake-tracking-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function IntakeTrackingBody({ ? t("medications.trackIntake.enabledToast") : t("medications.trackIntake.disabledToast"), ); - } catch { + } catch (failure) { setLocalTracked(previous); - toast.error(t("medications.trackIntake.toggleFailed")); + showErrorToast(t("medications.trackIntake.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx index 73939f220..fbbc61941 100644 --- a/src/components/medications/sections/inventory-dialogs.tsx +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -18,6 +18,7 @@ import { useId, useState, type FormEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -145,8 +146,10 @@ export function AddInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.addFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.addFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -399,8 +402,10 @@ export function AdjustInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.adjustSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.adjustFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.adjustFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -573,8 +578,10 @@ export function PackagingDialog({ ]); toast.success(t("medications.detail.bestand.packagingSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.packagingFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.packagingFailed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index f946a6481..7af96d638 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -48,6 +48,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -129,8 +130,10 @@ export function InventorySection({ await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`); await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.deleteSuccess")); - } catch { - toast.error(t("medications.detail.bestand.deleteFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.deleteFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index fa78453b3..a8c0a3aa1 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -23,6 +23,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; @@ -137,9 +138,11 @@ export function NotificationsBody({ ? t("medications.detail.notifications.enabledToast") : t("medications.detail.notifications.disabledToast"), ); - } catch { + } catch (failure) { setLocalEnabled(previous); - toast.error(t("medications.detail.notifications.toggleFailed")); + showErrorToast(t("medications.detail.notifications.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index b40933e85..bb2bed055 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -28,6 +28,7 @@ import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -227,10 +228,11 @@ function PhaseConfigForm({ toast.success(t("medications.phaseSaved")); onClose(); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.phases.saveFailed"), + { cause: err }, ); } finally { setSaving(false); diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 64856df27..ebb127d47 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -124,8 +125,10 @@ export function GraceRow({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.grace.saved")); - } catch { - toast.error(t("medications.detail.settings.grace.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.grace.failed"), { + cause: failure, + }); } finally { setGraceBusy(false); } @@ -207,8 +210,10 @@ export function DrugCodingRow({ }); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.codes.saved")); - } catch { - toast.error(t("medications.detail.settings.codes.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.codes.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 140fb8edb..a2554c2fe 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -9,6 +9,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -215,8 +216,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, // v1.16.4 — a failed delete used to fail silently, leaving the row // in place with no signal that the request was rejected. - onError: () => { - toast.error(t("medications.sideEffects.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.sideEffects.deleteError"), { + cause: failure, + }); }, }); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 6ce7997c8..04bea56f4 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -47,6 +47,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiPost } from "@/lib/api/api-fetch"; import { intakeToastOptions } from "@/components/medications/use-medication-intake"; @@ -286,9 +287,9 @@ export async function runTakeAllDue(deps: { toast.success(message); } } else if (taken > 0) { - toast.error(t("medications.takeAllDue.partialToast", { taken, failed })); + showErrorToast(t("medications.takeAllDue.partialToast", { taken, failed })); } else { - toast.error(t("medications.takeAllDue.failedToast")); + showErrorToast(t("medications.takeAllDue.failedToast")); } return { taken, failed }; diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index bf1abbdaa..6abb424b9 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -4,6 +4,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useState } from "react"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateMedicationReads } from "@/lib/query-keys"; @@ -203,8 +204,11 @@ export async function runRecordIntake(deps: { ); await invalidateMedicationReads(queryClient); onRecorded?.(eventId, skipped); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); } finally { setIntakeLoading(null); } @@ -300,8 +304,11 @@ export async function runLogIntake(deps: { toast.success(message); } return true; - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); return false; } } @@ -321,8 +328,8 @@ export async function runUndoIntake(deps: { await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`); await invalidateMedicationReads(queryClient); toast.success(t("medications.intakeUndone")); - } catch { - toast.error(t("medications.intakeUndoFailed")); + } catch (failure) { + showErrorToast(t("medications.intakeUndoFailed"), { cause: failure }); } } diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 26e0d8cf2..1a79b18ec 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -193,9 +193,11 @@ describe("MoodList — level A on the edit path (v1.37)", () => { describe("MoodList — delete failure toast (v1.11.5)", () => { it("surfaces a toast on a rejected delete instead of failing silently", () => { - expect(listSrc).toContain('import { toast } from "sonner"'); + expect(listSrc).toContain( + 'import { showErrorToast } from "@/lib/errors/error-toast"', + ); expect(listSrc).toMatch( - /deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/, + /deleteMutation = useMutation\(\{[\s\S]*onError: \((\w+)\) => \{[\s\S]*showErrorToast\(t\("mood\.deleteError"\), \{ cause: \1 \}\)/, ); }); }); diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx index 1b095e126..21bef8aa3 100644 --- a/src/components/mood/manage/archived-tags-card.tsx +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ArchiveRestore, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -70,9 +71,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { } catch (err) { rollback(); if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { void invalidateMoodTagCaches(queryClient); @@ -89,7 +93,10 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { toast.success(t("mood.manage.purged")); } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx index bb678c8ac..c2014b3db 100644 --- a/src/components/mood/manage/tag-editor-sheet.tsx +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -4,6 +4,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -141,9 +142,12 @@ export function TagEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 329a2483b..6c87547c0 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -30,6 +30,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -173,7 +174,10 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -354,8 +358,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { ); toast.success(t("mood.manage.groupDeleted")); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, ); } finally { void invalidateMoodTagCaches(queryClient); @@ -554,9 +559,12 @@ function GroupEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.groupLimitReached")); + showErrorToast(t("mood.manage.groupLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index e83e14e2e..9f39d4bae 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -33,6 +33,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -166,7 +167,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -238,7 +242,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } @@ -275,7 +282,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 03602aad7..f9035cd54 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -69,6 +69,7 @@ import { // reads newest-first by default; every other column opens ascending. const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateTime } from "@/lib/format"; import { DayLink } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -384,8 +385,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); - } catch { - toast.error(t("mood.restoreError")); + } catch (failure) { + showErrorToast(t("mood.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -409,8 +410,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { // leaving the row in place with no signal that the request was rejected. // Surface the failure so the user knows to retry. Mirrors the v1.11.3 // medication-card silent-failure fix. - onError: () => { - toast.error(t("mood.deleteError")); + onError: (failure) => { + showErrorToast(t("mood.deleteError"), { cause: failure }); }, }); @@ -439,8 +440,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { }, ); }, - onError: () => { - toast.error(t("mood.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("mood.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index d85f732c3..ba06c061b 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineFields, @@ -234,7 +235,9 @@ export function BaselineForm({ await onConfirmed(); }, (err) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")), + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }), ); } diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 2f8e9160f..fc1012f26 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineForm } from "@/components/onboarding/baseline-form"; import { StepHeading } from "@/components/onboarding/step-heading"; @@ -189,7 +190,9 @@ export function ConfirmScreen({ /** The managed arm's two buttons: nothing above them reports a failure. */ function finishReporting(managedRecordId?: string) { void finish(managedRecordId).catch((err) => { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); }); } diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 02462c2ba..58aab06f1 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, Compass, @@ -171,10 +172,10 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { queryKey: queryKeys.aiConsentReceipt("ai_full"), }); }, - onError: () => { + onError: (failure) => { // The button stays. A failed grant is a failed grant; the screen must // not read as though consent were on file. - toast.error(t("onboarding.ai.offer.error")); + showErrorToast(t("onboarding.ai.offer.error"), { cause: failure }); }, }); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 948ba61c0..86b3f2300 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { MeasurementForm } from "@/components/measurements/measurement-form"; @@ -109,7 +110,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { }); setCompleted(true); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } @@ -118,7 +121,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { await answer.mutateAsync({ step: "first-result", status: "skipped" }); router.push(screenHref("done")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index a151437ee..37577e5ae 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Activity, Brain, @@ -110,7 +111,9 @@ export function QuestionScreen({ const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, step) ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index e4be7672d..1eb517c62 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChoiceChip } from "@/components/onboarding/choice-chip"; import { StepActions } from "@/components/onboarding/step-actions"; @@ -68,7 +69,9 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, "units") ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 736f4b69d..1e62c48ee 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -70,7 +71,9 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { acknowledge.isPending || complete.isPending || restart.isPending; const fail = (err: unknown) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); async function setUp() { if (pending || !acknowledged) return; diff --git a/src/components/outcome/outcome-toast.ts b/src/components/outcome/outcome-toast.ts index 84ca8d157..20a9f71c1 100644 --- a/src/components/outcome/outcome-toast.ts +++ b/src/components/outcome/outcome-toast.ts @@ -1,6 +1,7 @@ "use client"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; @@ -25,7 +26,7 @@ export function toastWrittenOutcome( toast.warning(message); return; case "failed": - toast.error(message); + showErrorToast(message); return; case "empty": toast.info(message); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 7aa6524b0..b3232114f 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -14,6 +14,7 @@ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -256,7 +257,10 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const id = deleting.id; setDeleting(null); remove.mutate(id, { - onError: () => toast.error(t("practitioners.deleteFailed")), + onError: (failure) => + showErrorToast(t("practitioners.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index 0c2639a43..53e5394d5 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -14,6 +14,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field-group"; @@ -107,7 +108,7 @@ export function PractitionerSheet({ const message = err instanceof Error ? err.message : t("practitioners.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 07d77b6dc..5b10ecbde 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -18,6 +18,7 @@ import { import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Toaster } from "@/components/ui/sonner"; import { applyThemeColor } from "@/lib/pwa/theme-color"; import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge"; @@ -370,7 +371,7 @@ function OfflineMutationToaster() { typeof navigator !== "undefined" && navigator.onLine === false ) { - toast.error(t("offlineBanner.saveFailed"), { + showErrorToast(t("offlineBanner.saveFailed"), { id: "offline-mutation-failed", }); } diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 2fa1059bf..a5f334d93 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -30,6 +30,7 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -116,7 +117,7 @@ export function AboutMeNoteManager() { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.ai.aboutMe.saveError")); + showErrorToast(t("settings.ai.aboutMe.saveError"), { cause: err }); }, }); diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 8f0c3e88b..9ad51e11d 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -88,7 +89,7 @@ export function AiProfileInclusionManager() { if (isConflict(error)) { queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() }); } - toast.error(t("records.aiInclusion.saveError")); + showErrorToast(t("records.aiInclusion.saveError"), { cause: error }); }, }); diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index f9efc719b..741d00396 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -25,6 +25,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -98,7 +99,7 @@ export function AllergyFreeTextNote({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("records.allergies.freeText.saveError")); + showErrorToast(t("records.allergies.freeText.saveError"), { cause: err }); }, }); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index e89ce9a56..015931429 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, ShieldAlert } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Badge } from "@/components/ui/badge"; @@ -53,7 +54,8 @@ export function AllergyManager({ toast.success(t("records.allergies.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); }, - onError: () => toast.error(t("records.allergies.deleteError")), + onError: (failure) => + showErrorToast(t("records.allergies.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 681ca000b..9045d4c90 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -24,6 +24,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -89,8 +90,8 @@ export function ConditionsManager() { queryKey: queryKeys.coachAboutMeQuestions(), }); }, - onError: () => { - toast.error(t("records.conditions.saveError")); + onError: (failure) => { + showErrorToast(t("records.conditions.saveError"), { cause: failure }); }, }); diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 7bf147763..c8223954d 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -84,7 +85,8 @@ export function EmergencyProfileManager() { queryClient.setQueryData(queryKeys.emergencyProfile(), data); toastWrittenOutcome("success", t("records.emergency.savedToast")); }, - onError: () => toast.error(t("records.emergency.saveError")), + onError: (failure) => + showErrorToast(t("records.emergency.saveError"), { cause: failure }), }); if (query.isError) { diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 2f84fff10..e9266e439 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, Users } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -43,7 +44,8 @@ export function FamilyHistoryManager() { toast.success(t("records.family.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() }); }, - onError: () => toast.error(t("records.family.deleteError")), + onError: (failure) => + showErrorToast(t("records.family.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 61676506f..4e05393a3 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -177,7 +178,7 @@ export function HealthProfileFactsManager() { if (failed.length === 0) { toastWrittenOutcome("success", t("records.profileFacts.savedToast")); } else { - toast.error( + showErrorToast( t("records.profileFacts.savePartialError", { kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "), }), @@ -228,10 +229,11 @@ export function HealthProfileFactsManager() { queryKey: queryKeys.healthProfileFacts(), }); } - toast.error( + showErrorToast( t("records.profileFacts.removeError", { kind: t(KIND_LABEL[current.kind]), }), + { cause: error }, ); }, }); diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 5904130ed..2e913d33e 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SupportInfoCard } from "@/components/settings/support-info-card"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -260,6 +261,9 @@ export function AboutSection() { )} + {/* v1.43 — the text a bug report needs, put together in the browser. */} + + {/* Sources & docs — every link card was untitled in the v1.4.2 About surface, so the buttons floated alone with no heading telling the user what the section was for. */} diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 934ed319e..d6fc0efc3 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Check, Copy, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"; @@ -406,8 +407,10 @@ function HandoverLink({ t("recordSharing.managed.handover.copied"), ); window.setTimeout(() => setCopied(false), 2_000); - } catch { - toast.error(t("recordSharing.managed.handover.copyError")); + } catch (failure) { + showErrorToast(t("recordSharing.managed.handover.copyError"), { + cause: failure, + }); } } diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 47d19d28d..50e4f6f41 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ListChecks, Loader2 } from "lucide-react"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -37,7 +38,9 @@ export function SetupCard() { await restart.mutateAsync(); router.push("/onboarding/who"); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 236b9fde2..c12d86e85 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode } from "react"; import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; @@ -201,8 +202,8 @@ export function RuntimeActionsRow({ try { await apiPut("/api/insights/settings", { privacyMode: next }); onPrivacyChanged(); - } catch { - toast.error(t("settings.savingError")); + } catch (failure) { + showErrorToast(t("settings.savingError"), { cause: failure }); } } diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 9639edb24..2357a16a5 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -15,6 +15,7 @@ import { type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -229,8 +230,8 @@ function IngestTokenCard({ setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -455,12 +456,12 @@ function ApiTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 3264a1289..20403a5ca 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -56,6 +56,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiDelete } from "@/lib/api/api-fetch"; import { Textarea } from "@/components/ui/textarea"; import { @@ -123,7 +124,7 @@ function FactRow({ const edit = useEditCoachFact({ // The edited words in place are the confirmation. onSuccess: () => setEditing(false), - onError: () => toast.error(t("admin.settingsSaveError")), + onError: () => showErrorToast(t("admin.settingsSaveError")), }); const sourceKey = factSourceKey(fact); const trimmed = draft.trim(); @@ -262,7 +263,7 @@ export function CoachMemorySection({ const forgetOne = useForgetCoachFact({ onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); const forgetAll = useMutation({ @@ -279,8 +280,10 @@ export function CoachMemorySection({ ); queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); }, - onError: () => { - toast.error(t("settings.ai.coachMemory.clearError")); + onError: (failure) => { + showErrorToast(t("settings.ai.coachMemory.clearError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 1c3afecfa..f13ba961e 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { LayoutDashboard, RotateCcw, @@ -335,7 +336,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { toast.message(t("dashboard.layoutConflictReloaded")); return; } - toast.error(t("dashboard.layoutSaveError")); + showErrorToast(t("dashboard.layoutSaveError"), { cause: err }); }, }); diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 2c3ef08c1..0bc0850e3 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -18,6 +18,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -180,7 +181,10 @@ export function EnvironmentSection() { setHomeResults(data.results); setHomeSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const searchTravel = useMutation({ @@ -190,7 +194,10 @@ export function EnvironmentSection() { setTravelResults(data.results); setTravelSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const setHome = useMutation({ @@ -209,7 +216,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.homeSaved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const addTravel = useMutation({ @@ -230,7 +240,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelAdded")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const removeTravel = useMutation({ @@ -240,7 +253,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelRemoved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const setAirQuality = useMutation({ @@ -249,8 +265,10 @@ export function EnvironmentSection() { apiPatch("/api/environment/preferences", { airQualityEnabled }), // The switch itself shows the new state; no toast on success. onSuccess: () => invalidate(), - onError: () => - toast.error(t("settings.sections.environment.airQuality.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.airQuality.saveError"), { + cause: failure, + }), }); const backfill = useMutation({ @@ -264,8 +282,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.backfillQueued")); }, - onError: () => - toast.error(t("settings.sections.environment.backfillError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.backfillError"), { + cause: failure, + }), }); if (!enabled) { diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 7222916a8..aeeb5c209 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react"; import { DndContext, @@ -187,7 +188,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.pillOrder.saveError")); + showErrorToast(t("insights.pillOrder.saveError"), { cause: err }); }, }); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index bc89bb947..e0773c183 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; @@ -246,7 +247,7 @@ export function ConnectionsPanel({ const reasonKey = WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? "settings.withingsOauthError.generic"; - toast.error(t("settings.withingsOauthFailed"), { + showErrorToast(t("settings.withingsOauthFailed"), { description: t(reasonKey), duration: 10_000, }); @@ -285,7 +286,7 @@ export function ConnectionsPanel({ } else { // Known reason tags resolve to a specific message; anything else falls // back to the generic copy (matching the Withings handler). - toast.error(t(`settings.${provider}OauthFailed`), { + showErrorToast(t(`settings.${provider}OauthFailed`), { description: t(oauthReasonKey(provider, oauthOutcome.reason)), duration: 10_000, }); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index a9dfe96a1..fdffc8ad1 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -20,6 +20,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -106,12 +107,16 @@ function McpConnectionsCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.mcp.connectionRevokeFailed")); + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: res, + }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); - } catch { - toast.error(t("settings.mcp.connectionRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: failure, + }); } } @@ -224,7 +229,8 @@ function McpEnableCard() { void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("settings.sections.modules.saved")); }, - onError: () => toast.error(t("settings.sections.modules.error")), + onError: (failure) => + showErrorToast(t("settings.sections.modules.error"), { cause: failure }), }); return ( @@ -334,8 +340,8 @@ function McpTokensCard() { setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -345,12 +351,12 @@ function McpTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 88c2542f1..5987c6574 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -48,6 +48,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -248,7 +249,7 @@ export function ScoreSection() { return; } } - toast.error(t("settings.sections.score.error")); + showErrorToast(t("settings.sections.score.error"), { cause: err }); }, }); @@ -267,8 +268,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); @@ -287,8 +290,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 30dbba232..49f1e845c 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -31,6 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ArrowDown, ArrowUp, @@ -193,7 +194,10 @@ export function SourcesSection() { setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, - onError: () => toast.error(t("settings.sections.sources.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.sources.saveError"), { + cause: failure, + }), }); const resetMutation = useMutation({ diff --git a/src/components/settings/support-info-card.tsx b/src/components/settings/support-info-card.tsx new file mode 100644 index 000000000..7daa22a25 --- /dev/null +++ b/src/components/settings/support-info-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Copy, LifeBuoy } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import type { IntegrationStatusEnvelope } from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { recentErrorIds } from "@/lib/errors/error-id"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { buildSupportInfo } from "@/lib/support/support-info"; + +interface VersionPayload { + version: string; + buildSha: string | null; +} + +/** + * Settings, About: "Copy support info". Puts together what a bug report needs + * from reads the app already makes (build, language, time zone, modules, AI + * state, each connection's health, the last error ids of this session) and + * copies it as plain text. No new route, nothing leaves the browser until the + * person pastes it somewhere. See `src/lib/support/support-info.ts`. + */ +export function SupportInfoCard() { + const { t, locale } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + + async function copy() { + setBusy(true); + try { + const [version, integrations] = await Promise.all([ + queryClient + .fetchQuery({ + queryKey: queryKeys.apiVersion(), + queryFn: async () => apiGet("/api/version"), + staleTime: Infinity, + }) + .catch(() => null), + queryClient + .fetchQuery({ + queryKey: queryKeys.integrationsStatus(), + queryFn: async () => + apiGet("/api/integrations/status"), + }) + .catch(() => null), + ]); + const text = buildSupportInfo({ + version, + me: user + ? { + id: user.id, + username: user.username, + email: user.email, + fullName: user.fullName, + timezone: user.timezone, + modules: user.modules, + ai: user.ai ?? null, + } + : null, + locale, + connections: integrations?.integrations ?? null, + recentErrorIds: recentErrorIds(), + client: { + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : null, + standalone: + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches, + }, + generatedAt: new Date().toISOString(), + }); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + window.prompt(t("settings.about.support.copy"), text); + } + } catch (failure) { + showErrorToast(t("settings.about.support.copyFailed"), { + cause: failure, + }); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + ); +} diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 6e857bc5a..ee3869cb9 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button"; @@ -101,8 +102,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("thresholds.saveError"), + { cause: err }, ), }); @@ -131,7 +133,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) { void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, - onError: () => toast.error(t("thresholds.saveError")), + onError: (failure) => + showErrorToast(t("thresholds.saveError"), { cause: failure }), }); return ( diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index bddd162e0..c0f018f18 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { RotateCcw } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -257,8 +258,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("targets.edit.saveError"), + { cause: err }, ), }); @@ -284,7 +286,8 @@ function TargetEditSheetBody({ toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); }, - onError: () => toast.error(t("targets.edit.saveError")), + onError: (failure) => + showErrorToast(t("targets.edit.saveError"), { cause: failure }), }); const busy = updateMutation.isPending || resetMutation.isPending; diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index cb80e2982..ff7488a06 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -18,6 +18,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { @@ -105,8 +106,9 @@ export function BoosterMintPrompt({ }); toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { - toast.error( + showErrorToast( err instanceof Error ? err.message : t("vaccinations.booster.failed"), + { cause: err }, ); } finally { onClose(); diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx index 929ac6819..7431a0add 100644 --- a/src/components/vaccinations/custom-vaccine-sheet.tsx +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -152,7 +153,7 @@ export function CustomVaccineSheet({ ? t("vaccinations.custom.nameTaken") : t("vaccinations.custom.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -315,8 +316,10 @@ export function CustomVaccineSheet({ ); onOpenChange(false); }, - onError: () => - toast.error(t("vaccinations.custom.saveFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.custom.saveFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 942ba59bc..319d19b0a 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Check, Syringe } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -74,7 +75,10 @@ export function VaccinationDocumentSuggestion({ setLinkedId(candidate.id); toastWrittenOutcome("success", t("vaccinations.suggestion.linked")); }, - onError: () => toast.error(t("vaccinations.suggestion.failed")), + onError: (failure) => + showErrorToast(t("vaccinations.suggestion.failed"), { + cause: failure, + }), }, ); }; diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3cb0e8130..40dbbd401 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -100,7 +101,7 @@ export function VaccinationSheet({ const message = err instanceof Error ? err.message : t("vaccinations.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -188,8 +189,10 @@ export function VaccinationSheet({ onClick={() => { remove.mutate(vaccination.id, { onSuccess: () => onOpenChange(false), - onError: () => - toast.error(t("vaccinations.deleteFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index e23ddc89a..924073df2 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -35,6 +35,7 @@ import { } from "@tanstack/react-query"; import { useCallback } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { @@ -223,10 +224,11 @@ export function useAccountSwitch() { return landOnRecord(destination); }, onError: (error: unknown) => { - toast.error( + showErrorToast( isSharingAccessDenied(error) ? t("recordSharing.switch.noLongerAvailable") : t("recordSharing.switch.failed"), + { cause: error }, ); }, }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 038625e60..fb17c7f50 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; import { adoptRecordFenceState } from "@/lib/api/record-fence"; @@ -646,6 +647,7 @@ export function useLogout() { }, // v1.16.4 — a network-failed logout used to do nothing at all (the // menu closed, the session stayed); a toast names the failure. - onError: () => toast.error(t("common.networkError")), + onError: (failure) => + showErrorToast(t("common.networkError"), { cause: failure }), }); } diff --git a/src/hooks/use-module-toggle.ts b/src/hooks/use-module-toggle.ts index db2b9224b..c54fbce6b 100644 --- a/src/hooks/use-module-toggle.ts +++ b/src/hooks/use-module-toggle.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -106,7 +107,7 @@ export function useModuleToggle(options?: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.sections.modules.error")); + showErrorToast(t("settings.sections.modules.error"), { cause: err }); }, }); } diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 148635b07..e526e6f12 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -405,3 +405,51 @@ describe("verb helpers", () => { ); }); }); + +describe("ApiError.requestId", () => { + it("takes meta.requestId, else the x-request-id header", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "meta-id-0001" } }, + 500, + ), + ); + const fromMeta = await apiGet("/api/x").catch((e: unknown) => e); + expect(fromMeta).toBeInstanceOf(ApiError); + expect((fromMeta as ApiError).requestId).toBe("meta-id-0001"); + + fetchMock.mockResolvedValueOnce( + new Response("Bad gateway", { + status: 502, + headers: { "x-request-id": "header-id-0001" }, + }), + ); + const fromHeader = await apiGet("/api/x").catch((e: unknown) => e); + expect((fromHeader as ApiError).requestId).toBe("header-id-0001"); + + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); + const none = await apiGet("/api/x").catch((e: unknown) => e); + expect((none as ApiError).requestId).toBeNull(); + }); + + it("records a server failure's id for the session's support info", async () => { + const { recentErrorIds, _resetRecentErrorIdsForTests } = + await import("@/lib/errors/error-id"); + _resetRecentErrorIdsForTests(); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "srv-fail-0001" } }, + 503, + ), + ); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "no", meta: { requestId: "client-0001" } }, + 404, + ), + ); + await apiGet("/api/x").catch(() => {}); + await apiGet("/api/x").catch(() => {}); + expect(recentErrorIds()).toEqual(["srv-fail-0001"]); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 5d6dfcc31..95c13f927 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -50,6 +50,7 @@ */ import { randomId } from "@/lib/random-id"; +import { recordErrorId } from "@/lib/errors/error-id"; import { readError } from "./read-error"; import { validateResponseContext, @@ -62,12 +63,28 @@ export class ApiError extends Error { readonly status: number; /** `meta` object of the error envelope, when the body carried one. */ readonly meta: Record | undefined; + /** + * The server's request id for this failure: `meta.requestId`, else the + * `x-request-id` response header, else null (a proxy answered, or an older + * server). Its first characters are the error id a person reads out. + */ + readonly requestId: string | null; - constructor(message: string, status: number, meta?: Record) { + constructor( + message: string, + status: number, + meta?: Record, + requestId?: string | null, + ) { super(message); this.name = "ApiError"; this.status = status; this.meta = meta; + const fromMeta = + typeof meta?.requestId === "string" && meta.requestId.length > 0 + ? meta.requestId + : null; + this.requestId = fromMeta ?? requestId ?? null; } } @@ -87,7 +104,16 @@ async function throwApiError(res: Response): Promise { } catch { /* non-JSON body — readError supplies the fallback message */ } - throw new ApiError(await readError(res), res.status, meta); + const error = new ApiError( + await readError(res), + res.status, + meta, + res.headers.get("x-request-id"), + ); + // A server-side failure is worth an id in the session's support info, + // whether or not the call site ends up showing it. + if (error.status >= 500 && error.requestId) recordErrorId(error.requestId); + throw error; } async function parseEnvelope( diff --git a/src/lib/errors/__tests__/error-id.test.ts b/src/lib/errors/__tests__/error-id.test.ts new file mode 100644 index 000000000..aef107c63 --- /dev/null +++ b/src/lib/errors/__tests__/error-id.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// The node test environment has no `window`; a session storage stand-in is +// all this module touches. +const store = new Map(); +const sessionStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; +vi.stubGlobal("window", { sessionStorage }); +afterAll(() => { + vi.unstubAllGlobals(); +}); + +import { ApiError } from "@/lib/api/api-fetch"; +import { + _resetRecentErrorIdsForTests, + errorIdOf, + recentErrorIds, + recordErrorId, + RECENT_ERROR_IDS_MAX, + shortErrorId, +} from "../error-id"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + _resetRecentErrorIdsForTests(); +}); + +describe("errorIdOf", () => { + it("shows a server failure's request id and keeps a 4xx quiet", () => { + expect(errorIdOf(new ApiError("boom", 500, { requestId: ID }))).toBe(ID); + expect(errorIdOf(new ApiError("no", 404, { requestId: ID }))).toBeNull(); + expect(errorIdOf(new ApiError("boom", 503, undefined, ID))).toBe(ID); + expect(errorIdOf(new ApiError("boom", 500))).toBeNull(); + }); + + it("shows a 4xx too once it reached an error boundary", () => { + expect( + errorIdOf(new ApiError("no", 404, { requestId: ID }), { + unexpected: true, + }), + ).toBe(ID); + }); + + it("prefers meta.requestId over the header", () => { + const error = new ApiError("boom", 500, { requestId: ID }, "header-0001"); + expect(error.requestId).toBe(ID); + }); + + it("reads the header of a raw Response", () => { + const res = new Response("x", { + status: 502, + headers: { "x-request-id": ID }, + }); + expect(errorIdOf(res)).toBe(ID); + expect( + errorIdOf( + new Response("x", { status: 409, headers: { "x-request-id": ID } }), + ), + ).toBeNull(); + }); + + it("falls back to a render digest and to nothing", () => { + const rendered = Object.assign(new Error("render"), { + digest: "123456789", + }); + expect(errorIdOf(rendered)).toBe("123456789"); + expect(errorIdOf(new TypeError("Failed to fetch"))).toBeNull(); + expect(errorIdOf(undefined)).toBeNull(); + expect(errorIdOf("text")).toBeNull(); + }); + + it("shows the first 8 characters", () => { + expect(shortErrorId(ID)).toBe("3f9a2c1b"); + }); +}); + +describe("recent error ids", () => { + it("keeps the newest five, newest first, without repeats", () => { + for (let i = 0; i < 7; i++) recordErrorId(`id-0000-${i}`); + recordErrorId("id-0000-5"); + const ids = recentErrorIds(); + expect(ids).toHaveLength(RECENT_ERROR_IDS_MAX); + expect(ids[0]).toBe("id-0000-5"); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain("id-0000-0"); + }); + + it("survives a reload through session storage", () => { + recordErrorId(ID); + expect( + JSON.parse(sessionStorage.getItem("healthlog-recent-error-ids")!), + ).toEqual([ID]); + }); +}); diff --git a/src/lib/errors/__tests__/error-toast.test.tsx b/src/lib/errors/__tests__/error-toast.test.tsx new file mode 100644 index 000000000..0abc6b7cd --- /dev/null +++ b/src/lib/errors/__tests__/error-toast.test.tsx @@ -0,0 +1,48 @@ +import { isValidElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn(() => "toast-id") } })); + +import { toast } from "sonner"; +import { ApiError } from "@/lib/api/api-fetch"; +import { showErrorToast } from "../error-toast"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + vi.mocked(toast.error).mockClear(); +}); + +describe("showErrorToast", () => { + it("is exactly toast.error without a cause", () => { + showErrorToast("Could not save"); + expect(toast.error).toHaveBeenCalledWith("Could not save"); + showErrorToast("Could not save", { id: "x" }); + expect(toast.error).toHaveBeenLastCalledWith("Could not save", { id: "x" }); + }); + + it("is exactly toast.error for a cause with no id worth showing", () => { + showErrorToast("Not allowed", { + cause: new ApiError("no", 403, { requestId: ID }), + }); + expect(toast.error).toHaveBeenCalledWith("Not allowed"); + }); + + it("adds the error id to a server failure and keeps the toast up longer", () => { + showErrorToast("Could not save", { + cause: new ApiError("boom", 500, { requestId: ID }), + description: "Try again.", + }); + const [message, options] = vi.mocked(toast.error).mock.calls[0] as [ + string, + { description: unknown; duration: number }, + ]; + expect(message).toBe("Could not save"); + expect(options.duration).toBe(10_000); + expect(isValidElement(options.description)).toBe(true); + const props = (options.description as { props: Record }) + .props; + expect(props.id).toBe(ID); + expect(props.description).toBe("Try again."); + }); +}); diff --git a/src/lib/errors/error-id.ts b/src/lib/errors/error-id.ts new file mode 100644 index 000000000..9acc827f3 --- /dev/null +++ b/src/lib/errors/error-id.ts @@ -0,0 +1,129 @@ +/** + * Error ids: the short handle a person reads out when something failed. + * + * Every API error envelope carries the request id (`meta.requestId`, and the + * `x-request-id` header). The web shows its first 8 characters with a copy + * button on a server failure (5xx) or an unexpected one, and the operator + * finds the event with it under Admin, App logs. A render failure that never + * reached the API has no request id; for one the server rendered, Next's + * `digest` is the handle the server log prints beside the stack. + * + * The last few ids of this browser session are kept for the "Copy support + * info" text in Settings, About, so a person filing an issue later still has + * them. Session storage, not local: they belong to this visit. + * + * Every error display goes through this module (toasts through + * `showErrorToast`, boundaries through `ErrorIdLine`); the guard is + * `src/__tests__/error-display-helper-guard.test.ts`. + */ + +/** How many characters of the id are shown. */ +export const ERROR_ID_LENGTH = 8; + +/** How many ids the session keeps for the support info. */ +export const RECENT_ERROR_IDS_MAX = 5; + +const STORAGE_KEY = "healthlog-recent-error-ids"; + +let memory: string[] = []; + +/** The displayed form of an id. */ +export function shortErrorId(id: string): string { + return id.slice(0, ERROR_ID_LENGTH); +} + +function readStored(): string[] { + if (typeof window === "undefined") return memory; + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if ( + Array.isArray(parsed) && + parsed.every((v) => typeof v === "string" && v.length <= 64) + ) { + return parsed.slice(0, RECENT_ERROR_IDS_MAX); + } + } catch { + /* storage blocked or malformed: memory is the fallback */ + } + return memory; +} + +/** Remember an id for this session's support info, newest first. */ +export function recordErrorId(id: string): void { + if (!id) return; + const next = [id, ...readStored().filter((v) => v !== id)].slice( + 0, + RECENT_ERROR_IDS_MAX, + ); + memory = next; + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* the in-memory copy still serves this page */ + } +} + +/** The ids this session saw, newest first, at most `RECENT_ERROR_IDS_MAX`. */ +export function recentErrorIds(): string[] { + return [...readStored()]; +} + +/** Test helper. */ +export function _resetRecentErrorIdsForTests(): void { + memory = []; + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing to clear */ + } +} + +interface ApiLikeError { + status: number; + requestId: string | null; +} + +function isApiLike(error: unknown): error is ApiLikeError { + return ( + typeof error === "object" && + error !== null && + typeof (error as { status?: unknown }).status === "number" && + "requestId" in error + ); +} + +/** + * The id to show for a failure, or null when there is none worth showing. + * + * - An API failure shows its request id when the server failed (5xx). A + * 4xx is an answer the person can act on and its message says how, + * unless it reached an error boundary (`unexpected`), where nothing is + * an expected answer any more. + * - Any other error is unexpected; it shows Next's `digest` when the + * server rendered it, and nothing when there is no id to find it by. + */ +export function errorIdOf( + error: unknown, + options: { unexpected?: boolean } = {}, +): string | null { + // A raw `Response` from `apiFetchRaw`, for the call sites that branch on + // `res.ok` themselves: the id rides the header. + if (typeof Response !== "undefined" && error instanceof Response) { + const id = error.headers.get("x-request-id"); + const shown = options.unexpected === true || error.status >= 500; + return shown && id ? id : null; + } + if (isApiLike(error)) { + // Inside an error boundary every failure is unexpected, a 4xx included. + const shown = options.unexpected === true || error.status >= 500; + return shown && error.requestId ? error.requestId : null; + } + if (typeof error === "object" && error !== null) { + const digest = (error as { digest?: unknown }).digest; + if (typeof digest === "string" && digest.length > 0) return digest; + } + return null; +} diff --git a/src/lib/errors/error-toast.tsx b/src/lib/errors/error-toast.tsx new file mode 100644 index 000000000..41ab12c22 --- /dev/null +++ b/src/lib/errors/error-toast.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { toast, type ExternalToast } from "sonner"; + +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "./error-id"; + +export interface ErrorToastOptions extends ExternalToast { + /** + * The failure behind the toast, usually the caught error. When it is a + * server failure (an `ApiError` with a 5xx status) the toast carries its + * error id with a copy button; otherwise the toast is the plain one. + */ + cause?: unknown; +} + +/** How long a toast that carries an error id stays, so it can be copied. */ +const ERROR_ID_TOAST_DURATION_MS = 10_000; + +/** + * The one error toast of the web app. + * + * Every error toast goes through here so a server failure always shows the id + * a person needs to report it (`src/__tests__/error-display-helper-guard.test.ts` + * holds call sites to it). Without a `cause`, or with one that has no id worth + * showing, the call is exactly `toast.error(message, options)`. + */ +export function showErrorToast( + message: Parameters[0], + options: ErrorToastOptions = {}, +): string | number { + const { cause, ...rest } = options; + const id = errorIdOf(cause); + if (!id) { + return Object.keys(rest).length > 0 + ? toast.error(message, rest) + : toast.error(message); + } + const description = + typeof rest.description === "function" + ? rest.description() + : rest.description; + return toast.error(message, { + ...rest, + duration: rest.duration ?? ERROR_ID_TOAST_DURATION_MS, + description: , + }); +} + +function ErrorToastDescription({ + description, + id, +}: { + description: ReactNode; + id: string; +}) { + return ( + + {description ? {description} : null} + + + ); +} diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts index 5b1251e0e..579bf5ec3 100644 --- a/src/lib/queries/use-documents-layout.ts +++ b/src/lib/queries/use-documents-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -58,9 +59,9 @@ export async function runSetDocumentsLayout(deps: { { version: 1, ...patch }, ); if (saved) queryClient.setQueryData(key, saved); - } catch { + } catch (failure) { queryClient.setQueryData(key, previous); - toast.error(t("documents.layout.saveFailed")); + showErrorToast(t("documents.layout.saveFailed"), { cause: failure }); } } diff --git a/src/lib/queries/use-medication-list-layout.ts b/src/lib/queries/use-medication-list-layout.ts index 6a2ef6815..8b36d2ca6 100644 --- a/src/lib/queries/use-medication-list-layout.ts +++ b/src/lib/queries/use-medication-list-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -74,7 +75,7 @@ export async function runSetMedicationListView(deps: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("medications.viewSaveFailed")); + showErrorToast(t("medications.viewSaveFailed"), { cause: err }); } } @@ -114,7 +115,7 @@ export async function runSaveMedicationListOrder(deps: { toast.message(t("common.conflictReloaded")); return false; } - toast.error(t("medications.reorderSaveFailed")); + showErrorToast(t("medications.reorderSaveFailed"), { cause: err }); return false; } } diff --git a/src/lib/support/__tests__/support-info.test.ts b/src/lib/support/__tests__/support-info.test.ts new file mode 100644 index 000000000..7c05005f6 --- /dev/null +++ b/src/lib/support/__tests__/support-info.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { buildSupportInfo, type SupportInfoInput } from "../support-info"; + +const ERROR_ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +function input(over: Partial = {}): SupportInfoInput { + return { + version: { version: "1.43.0", buildSha: "abc1234" }, + me: { + id: "cmuserabcdefghijklmnopqr", + username: "distinctuser", + email: "distinct.user@example.org", + fullName: "Dora Distinct", + timezone: "Europe/Berlin", + modules: { mood: true, sleep: true, cycle: false }, + ai: { + capabilities: { + coach: { available: false, reason: "not_granted" }, + briefing: { available: true, reason: null }, + }, + provider: { configured: true, managedBy: "server" }, + usage: { level: "STANDARD" }, + }, + }, + locale: "de", + connections: [ + { integration: "polar", connected: false, health: null }, + { + integration: "withings", + connected: true, + health: { + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T08:00:00.000Z", + action: null, + unavailable: [{ item: "spo2", cause: "plan" }], + lastSuccessAt: "2026-10-14T07:00:00.000Z", + lastDataAt: "2026-10-14T06:00:00.000Z", + diagnostics: { + ledgerState: "failing", + failingLegs: ["sleep"], + failingSince: "2026-10-14T08:00:00.000Z", + lastAttemptAt: "2026-10-15T08:00:00.000Z", + verdict: "stale", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T08:00:00.000Z", + errorCode: "503", + }, + ], + }, + }, + }, + ], + recentErrorIds: [ERROR_ID], + client: { + userAgent: + "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + standalone: true, + }, + generatedAt: "2026-10-15T10:00:00.000Z", + ...over, + } as SupportInfoInput; +} + +describe("buildSupportInfo", () => { + it("carries build, account settings, AI state, health and error ids", () => { + const text = buildSupportInfo(input()); + expect(text).toContain("- Server version: 1.43.0"); + expect(text).toContain("- Build SHA: abc1234"); + expect(text).toContain("- Client: web (installed app)"); + expect(text).toContain("Chrome/128.0.0.0"); + expect(text).toContain("- Language: de"); + expect(text).toContain("- Time zone: Europe/Berlin"); + expect(text).toContain("- Enabled modules: mood, sleep"); + expect(text).toContain("- Usage level: STANDARD"); + expect(text).toContain("- coach: unavailable (not_granted)"); + expect(text).toContain( + "- withings: degraded / provider_errors since 2026-10-14T08:00:00.000Z", + ); + expect(text).toContain("Unavailable: spo2 (plan)"); + expect(text).toContain("2026-10-15T08:00:00.000Z sleep: transient (503)"); + expect(text).not.toContain("polar"); + // The person's own error ids survive the id-shaped scrub. + expect(text).toContain(`- ${ERROR_ID}`); + }); + + it("never carries the username, email, name or account id", () => { + const text = buildSupportInfo(input()); + for (const value of [ + "cmuserabcdefghijklmnopqr", + "distinctuser", + "distinct.user@example.org", + "Dora Distinct", + ]) { + expect(text.toLowerCase()).not.toContain(value.toLowerCase()); + } + }); + + it("redacts an account value that turns up in a field it should not", () => { + const text = buildSupportInfo( + input({ + me: { + ...input().me!, + timezone: "distinct.user@example.org", + }, + }), + ); + expect(text).not.toContain("distinct.user@example.org"); + }); + + it("says what it could not load rather than guessing", () => { + const text = buildSupportInfo( + input({ version: null, connections: null, recentErrorIds: [] }), + ); + expect(text).toContain("- Server version: unknown"); + expect(text).toContain("## Connections\n- Not loaded"); + expect(text).toContain("## Recent error ids (this session)\n- None"); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 78b7b7c63..7ab5359ed 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -121,7 +121,8 @@ export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ name: "ip-addresses", apply: (text) => text - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Not after a slash or a dot: `Chrome/128.0.0.0` is a version. + .replace(/(?>; + ai?: { + capabilities?: Record< + string, + { available: boolean; reason: string | null } + >; + provider?: { configured: boolean; managedBy: string | null }; + usage?: { level?: string | null } | null; + } | null; + } | null; + locale: string; + connections: Array<{ + integration: string; + connected?: boolean; + health?: ConnectionHealth | null; + }> | null; + recentErrorIds: readonly string[]; + client: { userAgent: string | null; standalone: boolean }; + generatedAt: string; +} + +/** The support info as plain text, redacted. */ +export function buildSupportInfo(input: SupportInfoInput): string { + const lines: string[] = ["# HealthLog support info"]; + lines.push(`Generated: ${input.generatedAt}`); + + lines.push("", "## App"); + lines.push(`- Server version: ${input.version?.version ?? "unknown"}`); + lines.push(`- Build SHA: ${input.version?.buildSha ?? "unknown"}`); + lines.push( + `- Client: web${input.client.standalone ? " (installed app)" : ""}`, + ); + if (input.client.userAgent) { + lines.push(`- Browser: ${input.client.userAgent}`); + } + + lines.push("", "## Account"); + lines.push(`- Language: ${input.locale}`); + lines.push(`- Time zone: ${input.me?.timezone ?? "unknown"}`); + const modules = Object.entries(input.me?.modules ?? {}) + .filter(([, on]) => on === true) + .map(([key]) => key) + .sort(); + lines.push(`- Enabled modules: ${modules.join(", ") || "none"}`); + + const ai = input.me?.ai ?? null; + lines.push("", "## AI"); + if (!ai) { + lines.push("- State: not reported"); + } else { + if (ai.provider) { + lines.push( + `- Provider configured: ${ai.provider.configured ? "yes" : "no"}${ai.provider.managedBy ? ` (${ai.provider.managedBy})` : ""}`, + ); + } + if (ai.usage?.level) lines.push(`- Usage level: ${ai.usage.level}`); + for (const [key, state] of Object.entries(ai.capabilities ?? {}).sort( + ([a], [b]) => a.localeCompare(b), + )) { + lines.push( + `- ${key}: ${state.available ? "available" : `unavailable (${state.reason ?? "unknown"})`}`, + ); + } + } + + lines.push("", "## Connections"); + const connected = (input.connections ?? []).filter((c) => c.health); + if (input.connections === null) { + lines.push("- Not loaded"); + } else if (connected.length === 0) { + lines.push("- None connected"); + } + for (const c of connected) { + const h = c.health!; + lines.push( + `- ${c.integration}: ${h.state} / ${h.reason}${h.since ? ` since ${h.since}` : ""}${h.action ? `, action ${h.action}` : ""}`, + ); + if (h.lastSuccessAt) lines.push(` - Last success: ${h.lastSuccessAt}`); + if (h.lastDataAt) lines.push(` - Newest data: ${h.lastDataAt}`); + if (h.unavailable.length > 0) { + lines.push( + ` - Unavailable: ${h.unavailable.map((u) => `${u.item} (${u.cause})`).join(", ")}`, + ); + } + const d = h.diagnostics; + lines.push( + ` - Ledger: ${d.ledgerState ?? "none"}, verdict ${d.verdict}${d.failingLegs.length > 0 ? `, failing ${d.failingLegs.join(", ")}` : ""}${d.failingSince ? ` since ${d.failingSince}` : ""}`, + ); + for (const call of d.recentCalls.slice(0, 5)) { + lines.push( + ` - ${call.at} ${call.endpoint}: ${call.status}${call.errorCode ? ` (${call.errorCode})` : ""}`, + ); + } + } + + lines.push("", "## Recent error ids (this session)"); + if (input.recentErrorIds.length === 0) lines.push("- None"); + for (const id of input.recentErrorIds) lines.push(`- ${id}`); + + const knownValues = input.me + ? [ + input.me.id, + input.me.username, + input.me.email, + input.me.fullName, + ].filter((v): v is string => typeof v === "string" && v.length > 0) + : []; + // The error ids are this session's own request ids, which the person is + // meant to hand over; they are put back after the id-shaped scrub. + const placeholders = input.recentErrorIds.map((_, i) => `@@ERRORID${i}@@`); + let text = `${lines.join("\n")}\n`; + input.recentErrorIds.forEach((id, i) => { + text = text.split(`- ${id}\n`).join(`- ${placeholders[i]}\n`); + }); + text = redactSupportText(text, { knownValues }); + input.recentErrorIds.forEach((id, i) => { + text = text.split(placeholders[i]).join(id); + }); + return text; +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts index 8a05ac7a5..94b0e431e 100644 --- a/tests/integration/admin-support-report.test.ts +++ b/tests/integration/admin-support-report.test.ts @@ -57,7 +57,8 @@ async function signIn(userId: string) { } function call() { - return GET(new NextRequest("http://localhost/api/admin/support-report")); + const route = GET as unknown as (request: NextRequest) => Promise; + return route(new NextRequest("http://localhost/api/admin/support-report")); } describe("GET /api/admin/support-report (integration)", () => {