feat(web): show the error id on server failures and add the support info and report surfaces

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:31:51 +02:00
1 parent 800bebd45c
commit f7386742b4
138 files changed
+1982 -318

No files matched your search

+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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": "업적",
+24 -4
View File
@@ -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",
@@ -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<Record<string, string>> = {
// 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 !(
/<ErrorDetails\b/.test(source) ||
/<ErrorIdLine\b/.test(source) ||
/\berrorIdOf\s*\(/.test(source)
);
});
expect(missing).toEqual([]);
for (const rel of Object.keys(BOUNDARY_EXEMPT)) {
expect(boundaries, `${rel} is exempt but no longer exists`).toContain(
rel,
);
}
});
it("shows the id in the shared boundary panel", () => {
const source = read("components/error-details.tsx");
expect(source).toMatch(/<ErrorIdLine\b/);
expect(source).toMatch(/\berrorIdOf\s*\(/);
});
});
+3
View File
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
import { SupportReportCard } from "@/components/admin/support-report-card";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
@@ -69,6 +70,8 @@ export function AdminSectionRenderer({
>
<SectionFrame>
<SystemStatusSection />
{/* v1.43 — the redacted report for a bug report. */}
<SupportReportCard />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
+51
View File
@@ -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"}
</pre>
{errorId && (
<p
data-slot="error-id"
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "#d0d0d0",
marginBottom: 16,
}}
>
<span>Fehler-ID / Error ID</span>
<code style={{ color: "#f8f8f2" }}>{shortErrorId(errorId)}</code>
<button
type="button"
onClick={handleCopyId}
aria-label="Copy error ID"
style={{
background: "transparent",
color: "#f8f8f2",
border: "1px solid #44475a",
borderRadius: 6,
minHeight: 44,
padding: "6px 12px",
cursor: "pointer",
fontSize: 13,
}}
>
Copy
</button>
</p>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
onClick={reset}
+3 -1
View File
@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { StatTile } from "@/components/admin/_stat-tile";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
@@ -297,10 +298,11 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
},
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, KeyRound, Loader2 } 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";
@@ -89,8 +90,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
toast.error(
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
},
});
+3 -1
View File
@@ -3,6 +3,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -104,10 +105,11 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
+13 -6
View File
@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
@@ -864,10 +865,11 @@ export function BackupsSection() {
}, 2000);
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
{ cause: err },
);
},
});
@@ -923,10 +925,11 @@ export function BackupsSection() {
},
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
{ cause: err },
);
},
onSettled: () => {
@@ -983,7 +986,7 @@ export function BackupsSection() {
void queryClient.invalidateQueries();
} else if (job.status === "failed") {
setRefusedSections(missingSectionsOf(job.failure));
toast.error(t("admin.section.backups.restoreFailed"));
showErrorToast(t("admin.section.backups.restoreFailed"));
}
}
}
@@ -1061,17 +1064,20 @@ export function BackupsSection() {
err instanceof ApiError &&
err.meta?.errorCode === "backup.restore.active"
) {
toast.error(t("admin.section.backups.restoreActive"));
showErrorToast(t("admin.section.backups.restoreActive"), {
cause: err,
});
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
return;
}
setRefusedSections([]);
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
{ cause: err },
);
},
});
@@ -1109,10 +1115,11 @@ export function BackupsSection() {
toast.success(t("admin.section.backups.downloadStarted"));
} catch (err) {
if (err instanceof ReproofCancelledError) return;
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
setDownloadingId(null);
@@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
@@ -75,8 +76,9 @@ export function CentralCodexSection() {
intervalSeconds: json.data.intervalSeconds,
});
} catch (err) {
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
setDevicePolling(false);
}
@@ -110,8 +112,9 @@ export function CentralCodexSection() {
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
setDeviceCode(null);
setDevicePolling(false);
@@ -142,8 +145,9 @@ export function CentralCodexSection() {
});
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
} finally {
setDisconnecting(false);
+5 -1
View File
@@ -23,6 +23,7 @@ import {
ShieldCheck,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
@@ -125,7 +126,10 @@ export function EncryptionSection() {
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
showErrorToast(
err.message || t("admin.section.encryption.rotateFailed"),
{ cause: err },
);
},
});
+3 -1
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, 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";
@@ -111,10 +112,11 @@ export function GlitchtipSection() {
});
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
@@ -30,6 +30,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
@@ -221,8 +222,8 @@ export function InviteTokensSection() {
toast.success(t("admin.invites.createdToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.createError"));
onError: (failure) => {
showErrorToast(t("admin.invites.createError"), { cause: failure });
},
});
@@ -234,8 +235,8 @@ export function InviteTokensSection() {
toast.success(t("admin.invites.revokedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.revokeError"));
onError: (failure) => {
showErrorToast(t("admin.invites.revokeError"), { cause: failure });
},
});
@@ -245,8 +246,8 @@ export function InviteTokensSection() {
setCopied(true);
toast.success(t("admin.invites.copiedToast"));
window.setTimeout(() => setCopied(false), 2_000);
} catch {
toast.error(t("admin.invites.copyError"));
} catch (failure) {
showErrorToast(t("admin.invites.copyError"), { cause: failure });
}
}
+8 -3
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -106,11 +107,13 @@ export function KeyBackupCard() {
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
toast.error(t("admin.keyBackup.stale"));
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
await invalidate();
return;
}
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
cause: err,
});
},
});
@@ -133,7 +136,9 @@ export function KeyBackupCard() {
},
onError: (err: Error) => {
setCheckResult(null);
toast.error(err.message || t("admin.keyBackup.checkFailed"));
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
cause: err,
});
},
});
@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
Check,
CheckCircle2,
@@ -112,8 +113,8 @@ export function LoginOverviewSection() {
window.setTimeout(() => {
setCopiedIp((current) => (current === id ? null : current));
}, 2_000);
} catch {
toast.error(t("admin.copyFailed"));
} catch (failure) {
showErrorToast(t("admin.copyFailed"), { cause: failure });
}
}
@@ -14,6 +14,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Wrench } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { StatTile } from "@/components/admin/_stat-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -92,7 +93,10 @@ export function MeasurementMaintenanceCard() {
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.maintenance.startFailed"));
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
{ cause: err },
);
},
});
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ToggleLeft } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -74,10 +75,11 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
+6 -3
View File
@@ -11,6 +11,7 @@ import {
XCircle,
} 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";
@@ -144,7 +145,7 @@ export function RemindersSection() {
onSuccess: (data) => {
const failures = describeChannelTestFailures(data?.results ?? [], t);
if (failures.length > 0) {
toast.error(data?.message ?? t("admin.notificationTestFailed"), {
showErrorToast(data?.message ?? t("admin.notificationTestFailed"), {
description: (
<ul className="space-y-0.5">
{failures.map((line) => (
@@ -158,10 +159,11 @@ export function RemindersSection() {
}
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
{ cause: error },
);
},
});
@@ -196,8 +198,9 @@ export function RemindersSection() {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
{ cause: error },
);
},
});
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Copy, Download, LifeBuoy, Loader2 } 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 { apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface SupportReport {
generatedAt: string;
text: string;
}
/**
* Admin, System status: the redacted support report, with copy and download.
*
* The report is made on request, not on page load: it reads every account's
* connection health, which is work an operator only wants when filing a bug.
* Once made it is shown in full above the actions, so the operator reads what
* they are about to share.
*/
export function SupportReportCard() {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
const report = useQuery({
queryKey: queryKeys.adminSupportReport(),
queryFn: async () => apiGet<SupportReport>("/api/admin/support-report"),
enabled: false,
staleTime: 0,
gcTime: 5 * 60_000,
});
async function load(): Promise<SupportReport | null> {
const result = await report.refetch();
if (result.error || !result.data) {
showErrorToast(t("admin.supportReport.loadError"), {
cause: result.error,
});
return null;
}
return result.data;
}
async function copy() {
const data = await load();
if (!data) return;
try {
await navigator.clipboard.writeText(data.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
window.prompt(t("admin.supportReport.copy"), data.text);
}
}
async function download() {
const data = await load();
if (!data) return;
const blob = new Blob([data.text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `healthlog-support-report-${data.generatedAt.slice(0, 10)}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const busy = report.isFetching;
return (
<SettingsCard>
<SettingsCardHeader
icon={LifeBuoy}
title={t("admin.supportReport.title")}
description={t("admin.supportReport.description")}
/>
<p className="text-sm">{t("admin.supportReport.body")}</p>
{report.data && (
<pre
data-slot="support-report-text"
className="bg-muted/50 max-h-80 overflow-auto rounded-lg border p-3 font-mono text-xs whitespace-pre-wrap"
>
{report.data.text}
</pre>
)}
<SettingsCardActions>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void copy()}
disabled={busy}
aria-live="polite"
>
{busy ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Copy className="size-4" aria-hidden="true" />
)}
{copied
? t("admin.supportReport.copied")
: t("admin.supportReport.copy")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void download()}
disabled={busy}
>
<Download className="size-4" aria-hidden="true" />
{t("admin.supportReport.download")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
+3 -1
View File
@@ -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 },
);
},
});
@@ -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 },
);
},
});
@@ -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);
}
@@ -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()
@@ -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({
+9 -4
View File
@@ -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 }),
});
}
@@ -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 }),
},
);
};
@@ -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 }),
},
)
}
@@ -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()]);
},
+16 -7
View File
@@ -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;
@@ -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,
}),
});
}}
>
+7 -1
View File
@@ -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({
<div className="bg-muted/50 text-muted-foreground rounded-md border p-3 font-mono text-xs break-all">
{error.message || t("common.unknownError")}
</div>
{errorId && <ErrorIdLine id={errorId} />}
<div className="flex flex-wrap items-center gap-2">
{reset && (
<Button onClick={reset} variant="outline">
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { recordErrorId, shortErrorId } from "@/lib/errors/error-id";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* "Error ID 3f9a2c1b" with a copy button: the one way the web shows the id of
* a failure, in toasts (`showErrorToast`) and in error boundaries. The first 8
* characters are shown; the button copies the whole id, which the operator's
* log search matches as well as the short one.
*
* Showing an id also records it for the session's support info.
*/
export function ErrorIdLine({
id,
className,
}: {
id: string;
className?: string;
}) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
useEffect(() => {
recordErrorId(id);
}, [id]);
async function copy() {
try {
await navigator.clipboard.writeText(id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
window.prompt(t("common.errorIdCopy"), id);
}
}
return (
<span
data-slot="error-id"
className={cn("inline-flex items-center gap-1.5 text-xs", className)}
>
<span className="text-muted-foreground">{t("common.errorIdLabel")}</span>
<code className="text-foreground font-mono">{shortErrorId(id)}</code>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-8"
onClick={(event) => {
// Inside a toast, a click must not count as a dismiss swipe.
event.stopPropagation();
void copy();
}}
aria-label={
copied ? t("common.errorIdCopied") : t("common.errorIdCopy")
}
title={copied ? t("common.errorIdCopied") : t("common.errorIdCopy")}
>
{copied ? (
<Check className="size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</Button>
</span>
);
}
+3 -1
View File
@@ -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 }),
});
},
},
@@ -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;
@@ -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],
@@ -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 =
@@ -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,
});
},
});
@@ -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 });
},
});
@@ -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 }),
});
}
@@ -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) {
@@ -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);
@@ -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;
@@ -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));
}
@@ -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);
}
+5 -2
View File
@@ -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() {
+3 -1
View File
@@ -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
+5 -3
View File
@@ -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({
+7 -4
View File
@@ -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 }),
},
);
}
@@ -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 });
},
});
@@ -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);
}
@@ -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);
}
@@ -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,
});
}
}
@@ -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);
@@ -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);
}
@@ -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);
}
@@ -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;
@@ -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);
@@ -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,
});
}
}
@@ -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 },
);
},
});
@@ -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);
}
@@ -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);
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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,
});
}
}
@@ -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);
}
@@ -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);
@@ -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);
}
@@ -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,
});
},
});
+3 -2
View File
@@ -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 };
@@ -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 });
}
}
@@ -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 \}\)/,
);
});
});
@@ -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);
}
@@ -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);
+12 -4
View File
@@ -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);
@@ -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);
}
+7 -6
View File
@@ -69,6 +69,7 @@ import {
// reads newest-first by default; every other column opens ascending.
const MOOD_DESC_COLUMNS: ReadonlySet<string> = 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 });
},
});
+4 -1
View File
@@ -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,
}),
);
}
+4 -1
View File
@@ -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,
});
});
}
+3 -2
View File
@@ -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 });
},
});
@@ -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,
});
}
}
@@ -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,
});
}
}
+4 -1
View File
@@ -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,
});
}
}
+4 -1
View File
@@ -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;
+2 -1
View File
@@ -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);
@@ -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,
}),
});
}}
>
@@ -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 });
}
};
+2 -1
View File
@@ -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",
});
}
@@ -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 });
},
});
@@ -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 });
},
});
@@ -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 });
},
});
+3 -1
View File
@@ -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() {
@@ -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 });
},
});
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more