diff --git a/messages/de.json b/messages/de.json index db74e5a99..dfbda8953 100644 --- a/messages/de.json +++ b/messages/de.json @@ -55,7 +55,9 @@ "errorIdLabel": "Fehler-ID", "errorIdCopy": "Fehler-ID kopieren", "errorIdCopied": "Fehler-ID kopiert", - "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal." + "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal.", + "sessionEnded": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um weiterzumachen.", + "signInAgain": "Anmelden" }, "apiErrors": { "measurement": { diff --git a/messages/en.json b/messages/en.json index bc012e734..db1048e6e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -55,7 +55,9 @@ "errorIdLabel": "Error ID", "errorIdCopy": "Copy error ID", "errorIdCopied": "Error ID copied", - "readThrottled": "Too many requests just now. Try again later." + "readThrottled": "Too many requests just now. Try again later.", + "sessionEnded": "Your session has ended. Sign in again to continue.", + "signInAgain": "Sign in" }, "apiErrors": { "measurement": { diff --git a/messages/es.json b/messages/es.json index f3270594d..0172de547 100644 --- a/messages/es.json +++ b/messages/es.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID de error", "errorIdCopy": "Copiar ID de error", "errorIdCopied": "ID de error copiado", - "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde." + "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde.", + "sessionEnded": "Tu sesión ha caducado. Inicia sesión de nuevo para continuar.", + "signInAgain": "Iniciar sesión" }, "apiErrors": { "measurement": { diff --git a/messages/fr.json b/messages/fr.json index cb5eab05e..96ca2aa61 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID d'erreur", "errorIdCopy": "Copier l'ID d'erreur", "errorIdCopied": "ID d'erreur copié", - "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard." + "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard.", + "sessionEnded": "Ta session a expiré. Reconnecte-toi pour continuer.", + "signInAgain": "Se connecter" }, "apiErrors": { "measurement": { diff --git a/messages/it.json b/messages/it.json index ffb452c8c..60f79a6a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID errore", "errorIdCopy": "Copia ID errore", "errorIdCopied": "ID errore copiato", - "readThrottled": "Troppe richieste in questo momento. Riprova più tardi." + "readThrottled": "Troppe richieste in questo momento. Riprova più tardi.", + "sessionEnded": "La tua sessione è scaduta. Accedi di nuovo per continuare.", + "signInAgain": "Accedi" }, "apiErrors": { "measurement": { diff --git a/messages/ko.json b/messages/ko.json index fabcc9460..8a49a3d3a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -55,7 +55,9 @@ "errorIdLabel": "오류 ID", "errorIdCopy": "오류 ID 복사", "errorIdCopied": "오류 ID 복사됨", - "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요." + "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요.", + "sessionEnded": "세션이 만료되었어요. 계속하려면 다시 로그인해 주세요.", + "signInAgain": "로그인" }, "apiErrors": { "measurement": { diff --git a/messages/pl.json b/messages/pl.json index 512d0de85..ddd745519 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -55,7 +55,9 @@ "errorIdLabel": "ID błędu", "errorIdCopy": "Kopiuj ID błędu", "errorIdCopied": "Skopiowano ID błędu", - "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później." + "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później.", + "sessionEnded": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować.", + "signInAgain": "Zaloguj się" }, "apiErrors": { "measurement": { diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts new file mode 100644 index 000000000..0a61c87f1 --- /dev/null +++ b/src/__tests__/query-error-error-prop-guard.test.ts @@ -0,0 +1,82 @@ +/** + * Every read-failure primitive is handed the error it reports. + * + * `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from + * the error itself: nothing (or the surface's refused state) for a 403, a + * note without a retry for a 429, sign-in for a 401, a retry otherwise. A + * call site that renders one without the error falls back to "failed" for + * every status, which is how a refused share showed an error card and a spent + * budget offered a retry that could only be refused again. + * + * The prop is required in the type, so the compiler already catches a missing + * one. What it cannot catch is a literal that defeats it (`error={null}`, + * `error={undefined}`), so this guard reads every opening tag under `src/` + * and refuses both. The matcher is whitespace-tolerant (tags span lines) and + * the match count is asserted, so a matcher that stops finding the tags fails + * rather than passing over nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(__dirname, ".."); + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "__tests__" || entry.name === "generated") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path, out); + else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test.")) + out.push(path); + } + return out; +} + +/** The opening tag's attribute text, from the name to its closing `>`. */ +function openingTags(source: string): string[] { + const tags: string[] = []; + const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g; + for (let m = open.exec(source); m; m = open.exec(source)) { + let depth = 0; + let i = m.index + m[0].length; + for (; i < source.length; i++) { + const ch = source[i]; + if (ch === "{") depth++; + else if (ch === "}") depth--; + else if (ch === ">" && depth === 0) break; + } + tags.push(source.slice(m.index, i + 1)); + } + return tags; +} + +/** Comments mention the primitives by name (``); skip them. */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +const sites = walk(SRC).flatMap((file) => + openingTags(stripComments(readFileSync(file, "utf8"))).map((tag) => ({ + file: relative(SRC, file), + tag, + })), +); + +describe("read-failure primitives carry their error", () => { + it("finds the call sites it guards", () => { + // About 120 at the time of writing. A drop to a handful means the + // matcher broke, not that the sites went away. + expect(sites.length).toBeGreaterThan(100); + }); + + it("passes the query's error at every site", () => { + const offenders = sites + .filter( + ({ tag }) => + !/\serror=\{/.test(tag) || + /\serror=\{\s*(null|undefined)\s*\}/.test(tag), + ) + .map(({ file, tag }) => `${file}: ${tag.split("\n")[0]}`); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 6c0fe99b0..512ac2331 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -293,7 +293,7 @@ export default function AchievementsPage() { // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading, isError, refetch } = useAchievementsQuery({ + const { data, isLoading, isError, error, refetch } = useAchievementsQuery({ enabled: isAuthenticated && achievementsEnabled, }); @@ -326,7 +326,7 @@ export default function AchievementsPage() { title={t("achievements.title")} description={t("achievements.subtitle")} /> - refetch()} /> + refetch()} /> ); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5b6e2f7f1..cb2e9567c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -24,8 +24,11 @@ export default function AdminOverviewPage() { // Many sub-sections share `useAdminSettings()` and render defaults // silently on error — the banner makes the failure visible so the // admin knows the toggles below aren't reflecting real state. - const { isError: settingsError, refetch: refetchSettings } = - useAdminSettings(); + const { + isError: settingsError, + error: settingsFailure, + refetch: refetchSettings, + } = useAdminSettings(); if (!user || user.role !== "ADMIN") return null; @@ -36,6 +39,7 @@ export default function AdminOverviewPage() { now (its own grid row, so the nav lines up with the first card). */} {settingsError && ( void refetchSettings()} /> diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index fdadea8ad..2b6aab91e 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -69,6 +69,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { conversations, isLoading, isError, + error, refetch, hasNextPage, isFetchingNextPage, @@ -182,7 +183,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { // A load failure must not masquerade as "no conversations yet" — // that reads as a data-loss scare and offers no way back. Mirror // /coach/plans and surface a retry. - refetch()} /> + refetch()} /> ) : groups.length === 0 ? (
{query.isError ? ( - query.refetch()} /> + query.refetch()} /> ) : query.isLoading ? (
void } + { + isLoading: boolean; + isError: boolean; + error: unknown; + retry: () => void; + } > = { analytics: { isLoading: analyticsQuery.isLoading, isError: analyticsQuery.isError, + error: analyticsQuery.error, retry: () => void analyticsQuery.refetch(), }, comprehensive: { isLoading: comprehensiveQuery.isLoading, isError: comprehensiveQuery.isError, + error: comprehensiveQuery.error, retry: () => void comprehensiveQuery.refetch(), }, workouts: { isLoading: workoutsProbe.isLoading, isError: workoutsProbe.isError, + error: workoutsProbe.error, retry: () => workoutsProbe.refetch(), }, nutrients: { isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading, isError: nutrientsModuleEnabled && nutrientsProbe.isError, + error: nutrientsProbe.error, retry: () => void nutrientsProbe.refetch(), }, ecg: { isLoading: ecgQuery.isLoading, isError: ecgQuery.isError, + error: ecgQuery.error, retry: () => void ecgQuery.refetch(), }, }; @@ -358,6 +368,7 @@ export default function MetricCatalogPage() { {failed.length > 0 ? ( failed.forEach((id) => probes[id].retry())} diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index 110be6dbe..174687e2b 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -39,7 +39,7 @@ export default function InsightsEcgPage() { // Reuse the SAME query cell `` reads (`insightsEcgList`) so the // page and the section share one cache entry. The page only needs the // `hasRecordings` flag to choose between the empty state and the section. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.insightsEcgList(), queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), enabled: isAuthenticated, @@ -55,7 +55,7 @@ export default function InsightsEcgPage() { title={t("insights.ecg.sectionTitle")} description={t("insights.subPage.ecgDescription")} > - refetch()} /> + refetch()} /> ); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 8a7327dee..b8e703978 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -106,6 +106,7 @@ export default function InsightsMedikamentePage() { data: comprehensive, isLoading: isComprehensiveLoading, isError: isComprehensiveError, + error: comprehensiveError, refetch: refetchComprehensive, } = useQuery({ queryKey: queryKeys.insightsComprehensive(), @@ -179,6 +180,7 @@ export default function InsightsMedikamentePage() { explainerMetric="medications" > refetchComprehensive()} /> diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index cd48d2837..e64c2b046 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -99,6 +99,7 @@ export default function InsightsNutrientsPage() { description={t("nutrients.page.description")} > void overview.refetch()} /> diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 305f32c63..3fc4782ad 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -45,9 +45,11 @@ export default function InsightsSchlafPage() { // to paint the same sentence three times, none of them offering a way out. // The page owns the shared state and renders one notice with a retry; the // cards no longer carry their own copy of it. - const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm( - Boolean(user) && !isEmpty, - ); + const { + isError: rhythmError, + error: rhythmFailure, + refetch: refetchRhythm, + } = useSleepRhythm(Boolean(user) && !isEmpty); if (isEmpty) { return ( @@ -127,6 +129,7 @@ export default function InsightsSchlafPage() { */} {rhythmError ? ( void refetchRhythm()} /> diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 338d55b20..473db5d2e 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -99,7 +99,7 @@ export default function InsightsWorkoutDetailPage({
) : error ? ( // A failed query must never read as "no data" (UI-STANDARDS §6). - refetch()} /> + refetch()} /> ) : !data ? (

refetch()} /> diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index b0f76eea2..ef0df0823 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -49,6 +49,7 @@ export default function MedicationDetailPage({ data: medication, isLoading, isError, + error, refetch, } = useQuery({ queryKey: queryKeys.medicationDetail(id), @@ -83,6 +84,7 @@ export default function MedicationDetailPage({

void refetch()} /> diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index aa1593d01..2cb33b7bd 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -333,6 +333,7 @@ export default function MedicationsPageClient() { data: medications, isLoading, isError, + error, refetch: refetchMedications, } = useQuery({ queryKey: queryKeys.medications(), @@ -656,6 +657,7 @@ export default function MedicationsPageClient() {
) : isError ? ( void refetchMedications()} diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f84bc2fbd..2fc4c7585 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -48,7 +48,7 @@ export default function NotificationsPage() { const { t } = useTranslations(); const queryClient = useQueryClient(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.notificationsPreferences(), queryFn: async () => { return apiGet("/api/notifications/preferences"); @@ -186,6 +186,7 @@ export default function NotificationsPage() { description={t("notifications.subtitle")} /> refetch()} /> diff --git a/src/app/onboarding/handover/page.tsx b/src/app/onboarding/handover/page.tsx index 7d29c8e9f..a658f0ce8 100644 --- a/src/app/onboarding/handover/page.tsx +++ b/src/app/onboarding/handover/page.tsx @@ -58,6 +58,7 @@ export default function HandoverDecisionPage() { {decision.isError ? ( void decision.refetch()} /> diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 9fae5214a..b3fa06daf 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -998,7 +998,10 @@ export default function DashboardPageClient({ ) : !mounted || digestQuery.isLoading ? ( ) : digestQuery.isError ? ( - digestQuery.refetch()} /> + digestQuery.refetch()} + /> ) : null } diff --git a/src/components/__tests__/design-consistency-guards.test.tsx b/src/components/__tests__/design-consistency-guards.test.tsx index 3187b2754..4e346c01b 100644 --- a/src/components/__tests__/design-consistency-guards.test.tsx +++ b/src/components/__tests__/design-consistency-guards.test.tsx @@ -75,7 +75,9 @@ describe(" — one canonical H1", () => { describe(" — retry affordance", () => { it("renders a retry button when onRetry is provided", () => { - const html = render( {}} />); + const html = render( + {}} />, + ); expect(html).toContain('data-slot="query-error-card"'); expect(html).toContain('role="alert"'); // The retry affordance is a real
) : query.isError ? ( void query.refetch()} /> diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index d1047e4f0..2f097e29b 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -847,7 +847,7 @@ export function BackupsSection() { const fmt = useFormatters(); const queryClient = useQueryClient(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); @@ -1243,6 +1243,7 @@ export function BackupsSection() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 9780cdcf2..dff4dafbe 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -99,6 +99,7 @@ export function CoachFeedbackSection() { ) : query.isError ? ( void query.refetch()} /> diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 041d6c762..cca03537f 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -144,7 +144,7 @@ export function HostMetricsChart() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { return apiGet("/api/admin/host-metrics?since=2h"); @@ -198,6 +198,7 @@ export function HostMetricsChart() { } /> void refetch()} /> diff --git a/src/components/admin/key-backup-card.tsx b/src/components/admin/key-backup-card.tsx index 100c41965..2b894cf6a 100644 --- a/src/components/admin/key-backup-card.tsx +++ b/src/components/admin/key-backup-card.tsx @@ -155,6 +155,7 @@ export function KeyBackupCard() { /> {status.isError ? ( void status.refetch()} /> diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 78996fa95..6109f5a89 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() { if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index eb0a17ae7..2339a0353 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -141,7 +141,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { export function OffhostBackupsSection() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => apiGet("/api/admin/backups"), }); @@ -170,6 +170,7 @@ export function OffhostBackupsSection() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/pulse-day-mean-card.tsx b/src/components/admin/pulse-day-mean-card.tsx index 9a78004f4..705922bac 100644 --- a/src/components/admin/pulse-day-mean-card.tsx +++ b/src/components/admin/pulse-day-mean-card.tsx @@ -125,6 +125,7 @@ export function PulseDayMeanCard() { if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index fef83bcc1..e505ac556 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -38,7 +38,7 @@ interface AuditLogResponse { export function RecentAuditPreview() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { return apiGet( @@ -84,6 +84,7 @@ export function RecentAuditPreview() { ) : isError ? ( void refetch()} /> diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index b2811261e..ee67f9999 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -128,7 +128,7 @@ export function FailingJobsCard({ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data: status, isError, refetch } = useSystemStatus(); + const { data: status, isError, error, refetch } = useSystemStatus(); return (
@@ -277,6 +277,7 @@ export function SystemStatusSection() { // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. void refetch()} retryLabel={t("admin.systemStatusRetry")} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index c2088e662..cdd053dfa 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -31,7 +31,7 @@ import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); - const { data: status, isError, refetch } = useSystemStatus(); + const { data: status, isError, error, refetch } = useSystemStatus(); const { data: keyBackup } = useKeyBackupStatus(); return ( @@ -110,6 +110,7 @@ export function SystemStatusSummary() {
) : isError ? ( void refetch()} /> diff --git a/src/components/body-sites/body-sites-section.tsx b/src/components/body-sites/body-sites-section.tsx index 5dc1228a7..dbd6b7b35 100644 --- a/src/components/body-sites/body-sites-section.tsx +++ b/src/components/body-sites/body-sites-section.tsx @@ -93,6 +93,7 @@ export function BodySitesSection({ {list.isError ? ( void list.refetch()} /> diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index c31201cf9..bf7c4c2ad 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -87,6 +87,7 @@ export function CustomMetricDetail({ const { data: metric, isError: metricError, + error: metricFailure, refetch: refetchMetric, } = useQuery({ queryKey: queryKeys.customMetricDetail(customMetricId), @@ -98,6 +99,7 @@ export function CustomMetricDetail({ data: list, isLoading, isError: listError, + error: listFailure, refetch: refetchList, } = useInfiniteQuery({ queryKey: queryKeys.customMetricEntries({ @@ -179,6 +181,7 @@ export function CustomMetricDetail({ description={description} /> { if (metricError) void refetchMetric(); diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 73d0cdacf..232326b3d 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -45,7 +45,7 @@ export function CustomMetricList() { const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), queryFn: () => apiGet("/api/custom-metrics"), }); @@ -93,6 +93,7 @@ export function CustomMetricList() { ) : isError ? ( refetch()} /> diff --git a/src/components/custom-metrics/custom-metric-values-list.tsx b/src/components/custom-metrics/custom-metric-values-list.tsx index 5650f46a6..2607996b1 100644 --- a/src/components/custom-metrics/custom-metric-values-list.tsx +++ b/src/components/custom-metrics/custom-metric-values-list.tsx @@ -42,6 +42,7 @@ export function CustomMetricValuesList({ data: list, isLoading, isError, + error, refetch, fetchNextPage, hasNextPage, @@ -76,6 +77,7 @@ export function CustomMetricValuesList({ if (isError) { return ( void refetch()} /> diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 4483a177f..f68773888 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; @@ -71,7 +71,7 @@ export function CycleRingTile({ className }: { className?: string }) { if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) { return ( - void calendar.refetch()} className="border-0 bg-transparent p-0" @@ -405,6 +406,7 @@ export function CycleView() { {calendarError ? ( void calendar.refetch()} /> @@ -435,6 +437,7 @@ export function CycleView() { {insights.isError ? ( void insights.refetch()} /> @@ -454,6 +457,7 @@ export function CycleView() { {profileQuery.isError ? ( void profileQuery.refetch()} /> diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d1857122e..6e485130d 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -24,7 +24,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; -import { readFailureKind } from "@/components/ui/read-failure-row"; +import { readFailureKind } from "@/lib/api/read-failure"; import { cycleDependentKeys, @@ -158,7 +158,7 @@ export function useCycleRingDial(enabled: boolean): { enabled && calendar.isError && !calendar.data && - readFailureKind(calendar.error) !== "denied", + readFailureKind(calendar.error) !== "refused", }; } diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index ec0f87625..bdfa74ef4 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -63,7 +63,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); const timeZone = useDisplayTimezone(); - const { data, isError, refetch } = useWorkouts({ limit: 3 }); + const { data, isError, error, refetch } = useWorkouts({ limit: 3 }); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the // optimistic mount fetch on the client's very first render and cannot on the @@ -116,6 +116,7 @@ export function RecentWorkoutsTile() { ) : isError ? ( refetch()} className="border-0 bg-transparent shadow-none" /> diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index e4eac0401..b65ab18de 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -274,6 +274,7 @@ export function DayView({ {day.isError ? ( void day.refetch()} slot="day-error" diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 54887ad6b..c799199b3 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -18,7 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import type { EncounterDTO } from "@/lib/encounters/dto"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -195,7 +195,7 @@ export function SinceLastVisit({ if (window.isError && rows.length === 0) { return ( - ) : list.isError ? ( list.refetch()} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index a80281ef9..93b4c4509 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -287,7 +287,7 @@ export function DocumentRecordLinks({ title={t("documents.detail.vaccinationsLabel")} slot="document-vaccination-links" pending={vaccinations.isPending || !seedFresh} - error={vaccinations.isError || seedError} + error={vaccinations.error || seedError} errorLabel={t("links.picker.loadError")} onRetry={() => { if (vaccinations.isError) void vaccinations.refetch(); diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index b313056cc..5bae07a45 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -1076,6 +1076,7 @@ export function DocumentsView() { ) ) : list.isError ? ( void list.refetch()} /> diff --git a/src/components/documents/episode-documents-card.tsx b/src/components/documents/episode-documents-card.tsx index 14bfa981b..cbe8235cf 100644 --- a/src/components/documents/episode-documents-card.tsx +++ b/src/components/documents/episode-documents-card.tsx @@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) { if (list.isError) { return ( void list.refetch()} /> diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 5649c5733..1ae4fc11d 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -552,6 +552,7 @@ export function DocumentSourcePicker({ ) : search.isError ? ( void search.refetch()} slot="document-source-error" diff --git a/src/components/encounters/procedures-section.tsx b/src/components/encounters/procedures-section.tsx index d555b59ed..89fccc7a3 100644 --- a/src/components/encounters/procedures-section.tsx +++ b/src/components/encounters/procedures-section.tsx @@ -96,6 +96,7 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) { {list.isError ? ( void list.refetch()} /> diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 7544c3539..dde6fea21 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { {list.isError ? ( void list.refetch()} /> diff --git a/src/components/illness/episode-visits-card.tsx b/src/components/illness/episode-visits-card.tsx index 087660712..ad50bd99b 100644 --- a/src/components/illness/episode-visits-card.tsx +++ b/src/components/illness/episode-visits-card.tsx @@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) { if (list.isError) { return ( void list.refetch()} /> diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx index bad38802b..63a89e0e3 100644 --- a/src/components/illness/illness-episode-detail.tsx +++ b/src/components/illness/illness-episode-detail.tsx @@ -58,6 +58,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { data: episode, isLoading, isError, + error, refetch, } = useIllnessEpisode(episodeId); const resolve = useResolveEpisode(); @@ -69,6 +70,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { if (isError) { return ( void refetch()} /> diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 68f774bc1..a1a813172 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -370,6 +370,7 @@ export function IllnessView() { data: episodes, isLoading, isError, + error, refetch, } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); @@ -460,6 +461,7 @@ export function IllnessView() { // A read failure is NOT an empty journal — surface the error + Retry so // an outage never reads as "no episodes yet". void refetch()} /> diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index 2565c9bb0..49a537b78 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -205,7 +205,7 @@ export function CoachResults({ const expected = hasLive ? countResultsInSection(liveWithProvenance(live ?? [], metas), section) : countResultsInSection(metas, section); - const { ref, results, isError, refetch } = useCoachMessageResults({ + const { ref, results, isError, error, refetch } = useCoachMessageResults({ conversationId, messageId, enabled: !hasLive && expected > 0, @@ -222,6 +222,7 @@ export function CoachResults({ > {!hasLive && isError ? ( @@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) { if (isError || !data) { return ( void refetch()} slot="coach-conversations-transcript-error" diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index cedd31787..bbd356714 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -270,6 +270,7 @@ export function useCoachConversationHistory( conversations, isLoading: query.isLoading, isError: query.isError, + error: query.error, hasNextPage: query.hasNextPage, isFetchingNextPage: query.isFetchingNextPage, fetchNextPage: query.fetchNextPage, diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index 6d8926da7..3cdf69ec5 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -173,10 +173,8 @@ export function CoincidentDeviationCard({ className, }: CoincidentDeviationCardProps) { const { t } = useTranslations(); - const { data, isError, refetch } = useDerivedMetric( - COINCIDENT_METRIC, - { enabled }, - ); + const { data, isError, error, refetch } = + useDerivedMetric(COINCIDENT_METRIC, { enabled }); // A failed read says so with a retry. Without this branch the card sat on // its skeleton forever, which read as "still loading" rather than failed. @@ -192,6 +190,7 @@ export function CoincidentDeviationCard({ title={t("insights.derived.coincident.cardTitle")} /> void refetch()} /> diff --git a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx index ab5660e09..aa7560ee5 100644 --- a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx +++ b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx @@ -3,6 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { ApiError } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; /** * The strip sits above the chart on every metric sub-page. While its read is @@ -37,4 +39,57 @@ describe("CoachReadStrip — placeholder while the read loads", () => { expect(markup).toContain('data-state="loading"'); expect(markup).toContain('aria-busy="true"'); }); + + function renderFailed(error: unknown) { + // `retryOnMount: false` keeps the seeded failure from being reset to a + // fresh load on render. + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, enabled: false, retryOnMount: false }, + }, + }); + client + .getQueryCache() + .build(client, { queryKey: queryKeys.insightsCoachRead("WEIGHT", "en") }) + .setState({ status: "error", error, fetchStatus: "idle" }); + return renderToStaticMarkup( + + + + + , + ); + } + + /** The class list of the strip body, the element after the header. */ + function bodyClass(markup: string): string | undefined { + return /data-slot="card-content" class="([^"]*)"/.exec(markup)?.[1]; + } + + it("holds the same frame and body height when the read fails", () => { + const loading = renderToStaticMarkup( + + + + + , + ); + const failed = renderFailed(new ApiError("Boom", 500)); + expect(failed).toContain('data-slot="coach-read-strip"'); + expect(failed).toContain('data-state="error"'); + expect(failed).toContain('data-slot="coach-read-strip-error"'); + expect(bodyClass(failed)).toBeDefined(); + expect(bodyClass(failed)).toBe(bodyClass(loading)); + expect(bodyClass(failed)).toContain("min-h-8"); + }); + + it("draws nothing for a refused read", () => { + expect(renderFailed(new ApiError("Forbidden", 403))).toBe(""); + }); }); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 0519c58a8..0b4f7dd9f 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -10,8 +10,9 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; +import { readFailureKind } from "@/lib/api/read-failure"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -60,6 +61,13 @@ export interface CoachReadStripProps { valueScale?: number; } +/** + * The strip's body holds the height of the error row's Retry button (32 px, + * 44 px on a touch screen) in every state, so the placeholder, a failure and + * a one-line read all stand the same height and the chart below never moves. + */ +const STRIP_BODY = "min-h-8 pointer-coarse:min-h-11"; + export function CoachReadStrip({ metricType, unit, @@ -97,26 +105,19 @@ export function CoachReadStrip({ // baseline line degrades to the "learning" copy), so the placeholder only // ever gives way to a card of its own height. A failed read gives way to a // notice with a retry: dropping the strip read as "nothing to say about - // today", which is not what happened. The route is a manage-level read, so - // a viewer on a read-only share is refused it; that draws nothing, and a - // spent analytics budget says so without offering a retry. + // today", which is not what happened. The notice sits inside the same card + // shell as the placeholder, so a failure does not move the chart either. + // The route is a manage-level read, so a viewer on a read-only share is + // refused it; that draws nothing, and a spent analytics budget says so + // without offering a retry. if (!mounted || !data) { if (!isAuthenticated) return null; - if (isError) { - return ( - void refetch()} - /> - ); - } + if (isError && readFailureKind(error) === "refused") return null; return ( @@ -126,8 +127,18 @@ export function CoachReadStrip({ titleAs="h2" /> - - + + {isError ? ( + void refetch()} + className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0" + /> + ) : ( + + )} ); @@ -241,7 +252,7 @@ export function CoachReadStrip({ titleAs="h2" /> - +
{baselineLine ? (

diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 87dfa9113..1abb147d5 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({ return (

void query.refetch()} /> diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 1201bb12f..265c99814 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { subtitle={t("insights.derived.vitals.sectionSubtitle")} /> { try { @@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { // alert is content, not muted meta — it also has to be recoverable. return ( refetch()} /> diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index d3eb28880..c7c7ac737 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -392,6 +392,7 @@ export function HealthKitMetricPage({ explainerParams={explainerParams} > apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), @@ -255,6 +255,7 @@ export function IntradayPulseChart({ ) : isError ? ( refetch()} className="border-0 bg-transparent shadow-none" /> diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 94a111766..f1d49ed7c 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -132,7 +132,8 @@ export function MoodInsightsSections({ } = {}) { const { t } = useTranslations(); - const { data, isLoading, isPending, isError, refetch } = useMoodInsights(); + const { data, isLoading, isPending, isError, error, refetch } = + useMoodInsights(); // `isPending`, not `isLoading`: before the session resolves the read is // disabled rather than loading, and the frame must already be there. @@ -161,7 +162,7 @@ export function MoodInsightsSections({ // "rest" region (mirroring the loading convention above), rather than // stacking an identical row in all three regions down the page. return region === "rest" ? ( - refetch()} /> + refetch()} /> ) : null; } diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index e6acef61f..b3e213a0e 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -32,7 +32,7 @@ import { Compass } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/date-format"; @@ -71,7 +71,7 @@ export function MoodPrognosisCard() { if (isError && !data) { return ( - { // Correlations are statistics, never gated on AI; a rejection is a @@ -81,6 +81,7 @@ export function MoodWhatStandsOut({ ) : null} {isError ? ( void refetch()} diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index 7d74d06bc..38b6126a0 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Coffee } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -43,7 +43,7 @@ export function CaffeineCard() { if (isError && !data) { return ( - apiGet( @@ -39,7 +39,7 @@ export function HydrationCard() { }); if (isError) { - return ; + return ; } const todayTotal = data?.days.at(-1)?.amount ?? 0; diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 9ddd76809..8518a3993 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -159,7 +159,7 @@ export function MicronutrientsCard() { // its "no micronutrient data" EmptyState — the same output as an account that // genuinely logs no nutrients. The sibling hydration card already routes // through QueryErrorCard; this one now matches it. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.nutrientIntake(WINDOW_DAYS), queryFn: () => apiGet(`/api/nutrients?days=${WINDOW_DAYS}`), @@ -180,6 +180,7 @@ export function MicronutrientsCard() { {isError ? ( void refetch()} /> diff --git a/src/components/insights/overview-section-boundary.tsx b/src/components/insights/overview-section-boundary.tsx index 12ad846c4..8dea5c752 100644 --- a/src/components/insights/overview-section-boundary.tsx +++ b/src/components/insights/overview-section-boundary.tsx @@ -30,13 +30,14 @@ interface FenceProps { interface FenceState { failed: boolean; + error?: unknown; } class SectionFence extends Component { state: FenceState = { failed: false }; - static getDerivedStateFromError(): FenceState { - return { failed: true }; + static getDerivedStateFromError(error: unknown): FenceState { + return { failed: true, error }; } componentDidCatch(error: Error): void { @@ -58,9 +59,10 @@ class SectionFence extends Component { data-section={this.props.sectionId} > this.setState({ failed: false })} + onRetry={() => this.setState({ failed: false, error: undefined })} /> ); diff --git a/src/components/insights/rhythm-events-card.tsx b/src/components/insights/rhythm-events-card.tsx index 670d21a7f..6c953a550 100644 --- a/src/components/insights/rhythm-events-card.tsx +++ b/src/components/insights/rhythm-events-card.tsx @@ -91,7 +91,7 @@ export function RhythmEventsCard({ const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.insightsRhythmEvents(), queryFn: async () => { try { @@ -119,6 +119,7 @@ export function RhythmEventsCard({ title={t("insights.rhythmEvents.sectionTitle")} /> void refetch()} diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 1b69d184b..5286b8ee4 100644 --- a/src/components/insights/workout-detail/day-context.tsx +++ b/src/components/insights/workout-detail/day-context.tsx @@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({ {sleep.isError ? ( // A failed read is not an absent night. @@ -169,6 +170,7 @@ export function WorkoutDetailDayContext({

{mood.isError ? ( diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 2e44759e0..6cd1065dd 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -57,7 +57,7 @@ export function BiomarkerManager() { const canMerge = canManageDomain("labs"); const [mergeOpen, setMergeOpen] = useState(false); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), }); @@ -195,6 +195,7 @@ export function BiomarkerManager() {
) : isError ? ( void refetch()} /> diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 24fdd8c7f..557d32a4c 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -191,6 +191,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { data: list, isLoading, isError: listError, + error: listFailure, refetch: refetchList, } = useInfiniteQuery({ queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }), @@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { description={description} /> { if (markerError) void refetchMarker(); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index e98e04ffa..12413ab3d 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -146,7 +146,7 @@ export function LabList({ // (beneath the heading); the overview rows no longer carry it. // One entry per biomarker over the whole history, grouped server-side: a // capped page of raw rows dropped every marker measured only long ago. - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.labResultsOverview(), queryFn: () => apiGet("/api/labs/overview"), }); @@ -203,6 +203,7 @@ export function LabList({ // outage never reads as "you have no lab results". return ( void refetch()} /> diff --git a/src/components/labs/lab-values-list.tsx b/src/components/labs/lab-values-list.tsx index 0040e896d..560e1427b 100644 --- a/src/components/labs/lab-values-list.tsx +++ b/src/components/labs/lab-values-list.tsx @@ -34,6 +34,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) { data: list, isLoading, isError, + error, refetch, fetchNextPage, hasNextPage, @@ -63,6 +64,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) { if (isError) { return ( void refetch()} /> diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx index 8c438ed02..3c532b649 100644 --- a/src/components/links/entity-link-picker.tsx +++ b/src/components/links/entity-link-picker.tsx @@ -243,7 +243,7 @@ export function EntityLinkPicker({ onChange, searchPlaceholder, emptyLabel, - error = false, + error = null, errorLabel, onRetry, extraAction, @@ -263,9 +263,11 @@ export function EntityLinkPicker({ /** * The option read failed. Renders an error row with Retry instead of the * empty label: a failed read must never pass for "nothing to link", which - * is exactly how a refused page size once hid a whole vault. + * is exactly how a refused page size once hid a whole vault. Pass the + * failed read's error (or any truthy value for a failure without one); the + * row reads its status. */ - error?: boolean; + error?: unknown; errorLabel?: string; onRetry?: () => void; /** @@ -369,6 +371,7 @@ export function EntityLinkPicker({ ) : error ? ( void list.refetch()} selected={selected} @@ -403,7 +403,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) { title={t("links.related.pick.vaccination")} slot="related-link-vaccination" pending={doses.isPending} - error={doses.isError} + error={doses.error} errorLabel={t("links.picker.loadError")} onRetry={() => void doses.refetch()} selected={selected} diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts index cb5807b57..f95fc279e 100644 --- a/src/components/links/use-link-picker-pages.ts +++ b/src/components/links/use-link-picker-pages.ts @@ -199,9 +199,11 @@ export function useLinkPickerPages({ return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []); }, [searching, search.data, newer.data, older.data]); - const activeError = searching - ? search.isError - : older.isError || (newerRequested && newer.isError); + // The failed read's error itself, not a flag: the picker's error row reads + // the status to tell a refusal or a spent budget from a real failure. + const activeError: unknown = searching + ? search.error + : (older.error ?? (newerRequested ? newer.error : null)); return { items, diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index d8e75b1b5..a586e60c3 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -81,7 +81,12 @@ export function VorsorgeDashboardCard() { // tell that this row had never been asked. const { canManageDomain } = useRecordCapabilities(); const canManageMeasurements = canManageDomain("measurements"); - const { data: reminders, isError, refetch } = useMeasurementReminders(); + const { + data: reminders, + isError, + error, + refetch, + } = useMeasurementReminders(); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the // optimistic mount fetch on the client's very first render and cannot on the @@ -161,7 +166,7 @@ export function VorsorgeDashboardCard() { ) : isError ? ( // A read failure must not read as "no upcoming reminders" — a missed // checkup is the harm behind that fall-through (§6). - refetch()} /> + refetch()} /> ) : upcoming.length === 0 ? (

diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index e51027d23..2a07aadd0 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -243,6 +243,7 @@ export function VorsorgeSection({ data: reminders, isLoading, isError, + error, refetch, } = useMeasurementReminders(enabled); const { create, update, remove, satisfy, skip, snooze } = @@ -736,6 +737,7 @@ export function VorsorgeSection({ {!isLoading && isError && ( void refetch()} /> diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 36fe4fe3d..b910129e8 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { ListRow } from "@/components/ui/list-row"; import { @@ -606,7 +606,7 @@ export function MeasurementList({ ? "groupBy=day" : "raw"; - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.measurementsList({ type: typeFilter === "ALL" ? undefined : typeFilter, sourceEq, @@ -1034,7 +1034,7 @@ export function MeasurementList({ ) : isError ? ( // A read failure is NOT an empty list — surface the error + Retry so // an outage never reads as "you have no measurements". - void refetch()} /> + void refetch()} /> ) : !data?.measurements?.length ? ( <> {/* v1.4.15 phase-C5: replaces the bare-text empty rectangle. @@ -1924,7 +1924,7 @@ function DayDrillDown({ } if (error) { return ( - apiGet( @@ -128,7 +128,7 @@ export function EfficacyTab({ title={t("medications.efficacy.title")} dataSlot="medication-wirkung-section" > - refetch()} /> + refetch()} /> ); } diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 5f5efb97a..ebfc211dc 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -201,28 +201,29 @@ export function IntakeHistoryListV2({ const offset = page * pageSize; - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: queryKeys.medicationIntakeList(medicationId, { - sortBy, - sortDir, - limit: pageSize, - offset, - status: STATUS_FILTER, - }), - queryFn: async () => { - const params = new URLSearchParams({ + const { data, isLoading, isError, error, refetch } = + useQuery({ + queryKey: queryKeys.medicationIntakeList(medicationId, { sortBy, sortDir, - limit: String(pageSize), - offset: String(offset), + limit: pageSize, + offset, status: STATUS_FILTER, - }); - return apiGet( - `/api/medications/${medicationId}/intake?${params.toString()}`, - ); - }, - enabled: isAuthenticated, - }); + }), + queryFn: async () => { + const params = new URLSearchParams({ + sortBy, + sortDir, + limit: String(pageSize), + offset: String(offset), + status: STATUS_FILTER, + }); + return apiGet( + `/api/medications/${medicationId}/intake?${params.toString()}`, + ); + }, + enabled: isAuthenticated, + }); const sourceLabels: Record = useMemo( () => @@ -274,6 +275,7 @@ export function IntakeHistoryListV2({

) : isError ? ( void refetch()} /> diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index fd6cdefe4..9127141fb 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -242,7 +242,10 @@ export function MedicationCategoriesSheet({ >
{categories.isError ? ( - void categories.refetch()} /> + void categories.refetch()} + /> ) : ( (categories.data ?? []).map((category) => ( void refetch()} /> + void refetch()} /> )} {!isLoading && !isError && items.length === 0 && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index ea9e079e3..c47885781 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -75,6 +75,7 @@ export function MentalWellbeing() { data: history, isPending: historyPending, isError: historyError, + error: historyFailure, refetch: refetchHistory, } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), @@ -166,6 +167,7 @@ export function MentalWellbeing() {

{t("mentalHealth.choosePrompt")}

{historyError ? ( void refetchHistory()} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index f9035cd54..4987f1814 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -340,7 +340,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -714,6 +714,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { // A read failure is NOT an empty list — surface the error + Retry so // an outage never reads as "you have no mood entries". void refetch()} /> diff --git a/src/components/mood/mood-quick-tags.tsx b/src/components/mood/mood-quick-tags.tsx index c9fae8e23..d2a5468ac 100644 --- a/src/components/mood/mood-quick-tags.tsx +++ b/src/components/mood/mood-quick-tags.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { ReadFailureRow } from "@/components/ui/read-failure-row"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -75,7 +75,7 @@ export function MoodQuickTags({ } if (isError && !data) { return ( - void list.refetch()} /> diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index a5f334d93..70eb80a24 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -184,6 +184,7 @@ export function AboutMeNoteManager() { {query.isError && ( query.refetch()} /> diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index ba657df58..961faf4aa 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -110,6 +110,7 @@ export function AiProfileInclusionManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index 062e45761..c974bdbef 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -133,6 +133,7 @@ export function AllergyFreeTextNote({ {query.isError ? ( <> query.refetch()} /> diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index 015931429..a461cbe5d 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -43,7 +43,7 @@ export function AllergyManager({ const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.allergyList(true), queryFn: () => apiGet("/api/allergies"), }); @@ -154,6 +154,7 @@ export function AllergyManager({
) : isError ? ( void refetch()} /> diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index aa25fa41a..42b2f2cdb 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -125,6 +125,7 @@ export function ConditionsManager() {
{query.isError ? ( void query.refetch()} /> diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 8d7716467..920fe4b40 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -91,6 +91,7 @@ export function EmergencyProfileManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index e9266e439..c34c73703 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -33,7 +33,7 @@ export function FamilyHistoryManager() { const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.familyHistoryList(), queryFn: () => apiGet("/api/family-history"), }); @@ -116,6 +116,7 @@ export function FamilyHistoryManager() {
) : isError ? ( void refetch()} /> diff --git a/src/components/records/life-circumstances-manager.tsx b/src/components/records/life-circumstances-manager.tsx index cb340a77f..56db109be 100644 --- a/src/components/records/life-circumstances-manager.tsx +++ b/src/components/records/life-circumstances-manager.tsx @@ -962,6 +962,7 @@ export function LifeCircumstancesManager() { if (query.isError) { return ( void query.refetch()} /> diff --git a/src/components/records/profile-summary.tsx b/src/components/records/profile-summary.tsx index e6e33f8d1..226e02eee 100644 --- a/src/components/records/profile-summary.tsx +++ b/src/components/records/profile-summary.tsx @@ -140,7 +140,10 @@ export function ProfileSummaryPage() {

) : null} {summary.isError ? ( - void summary.refetch()} /> + void summary.refetch()} + /> ) : null} {summary.data ? : null}
diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index c27ffee1b..f7e6f5e49 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -40,7 +40,7 @@ import { export function GrantsGivenCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useAccountGrants(); + const { data, isLoading, isError, error, refetch } = useAccountGrants(); const revoke = useRevokeGrant(); const given = data?.given ?? []; @@ -62,6 +62,7 @@ export function GrantsGivenCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index f5b3bf2be..9d75f9ba3 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -54,7 +54,7 @@ import { */ export function GrantsReceivedCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useAccountGrants(); + const { data, isLoading, isError, error, refetch } = useAccountGrants(); const accept = useAcceptGrant(); const renounce = useRenounceGrant(); const switchAccount = useAccountSwitch(); @@ -80,6 +80,7 @@ export function GrantsReceivedCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/access/managed-profile-card.tsx b/src/components/settings/access/managed-profile-card.tsx index 60520a9f7..03dddf3f0 100644 --- a/src/components/settings/access/managed-profile-card.tsx +++ b/src/components/settings/access/managed-profile-card.tsx @@ -229,6 +229,7 @@ function ManagedProfileRow({ shows no destructive control for it. */} {guardians.isError && ( void guardians.refetch()} /> diff --git a/src/components/settings/access/managed-profile-edit-form.tsx b/src/components/settings/access/managed-profile-edit-form.tsx index 76db29edb..a4f4b4ffb 100644 --- a/src/components/settings/access/managed-profile-edit-form.tsx +++ b/src/components/settings/access/managed-profile-edit-form.tsx @@ -81,6 +81,7 @@ export function ManagedProfileEditForm({ if (profile.isError) { return ( void profile.refetch()} /> diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 204064ac2..1c27cbf82 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -88,6 +88,7 @@ export function ManagedProfileHandover({ if (status.isError) { return ( void status.refetch()} /> diff --git a/src/components/settings/access/record-activity-card.tsx b/src/components/settings/access/record-activity-card.tsx index 7d958fd28..06fe54112 100644 --- a/src/components/settings/access/record-activity-card.tsx +++ b/src/components/settings/access/record-activity-card.tsx @@ -39,7 +39,7 @@ import { */ export function RecordActivityCard() { const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useRecordActivity(); + const { data, isLoading, isError, error, refetch } = useRecordActivity(); const entries = data?.entries ?? []; @@ -88,6 +88,7 @@ export function RecordActivityCard() { )} {isError && ( void refetch()} /> diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 1a0e68ec0..0a1c7897a 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -26,7 +26,7 @@ import { NativeSelect } from "@/components/ui/native-select"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -71,15 +71,11 @@ export function AiInsightsCard({ data: insightsSettings, isPending: settingsPending, isError: settingsError, + error: settingsFailure, refetch: refetchSettings, } = useQuery({ queryKey: queryKeys.insightsSettings(), - queryFn: async () => { - const res = await apiFetchRaw("/api/insights/settings"); - if (!res.ok) throw new Error("insights settings read failed"); - const json = await res.json(); - return json.data as InsightsSettings; - }, + queryFn: () => apiGet("/api/insights/settings"), enabled: isAuthenticated, }); @@ -87,15 +83,11 @@ export function AiInsightsCard({ data: userProvider, isPending: providerPending, isError: providerError, + error: providerFailure, refetch: refetchProvider, } = useQuery({ queryKey: queryKeys.userAiProvider(), - queryFn: async () => { - const res = await apiFetchRaw("/api/user/ai-provider"); - if (!res.ok) throw new Error("user AI provider read failed"); - const json = await res.json(); - return json.data as UserAIProvider; - }, + queryFn: () => apiGet("/api/user/ai-provider"), enabled: isAuthenticated, }); @@ -103,15 +95,11 @@ export function AiInsightsCard({ data: chainData, isPending: chainPending, isError: chainError, + error: chainFailure, refetch: refetchChain, } = useQuery({ queryKey: queryKeys.insightsProviderChain(), - queryFn: async () => { - const res = await apiFetchRaw("/api/insights/provider-chain"); - if (!res.ok) throw new Error("insights provider chain read failed"); - const json = await res.json(); - return json.data as ProviderChainData; - }, + queryFn: () => apiGet("/api/insights/provider-chain"), enabled: isAuthenticated, }); @@ -185,7 +173,10 @@ export function AiInsightsCard({ } /> {hasReadError ? ( - + ) : readsPending ? ( ) : ( diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 2357a16a5..737b043c0 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -440,6 +440,7 @@ function ApiTokensCard() { const { data: tokens, isError, + error, isLoading, refetch, } = useQuery({ @@ -511,7 +512,7 @@ function ApiTokensCard() { {isError ? ( // A read failure must not read as "no active tokens" on this // security surface — the §6 fall-through the token list must avoid. - refetch()} /> + refetch()} /> ) : isLoading ? (
diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index f2e36d724..aeacc80c0 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -79,7 +79,10 @@ export function CoachConversationsMemoryCard({ description={t("settings.ai.coachConversations.description")} /> {history.isError ? ( - void history.refetch()} /> + void history.refetch()} + /> ) : visible.length === 0 ? (

{history.isLoading diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 20403a5ca..bb3b2f94e 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -326,6 +326,7 @@ export function CoachMemorySection({ {query.isError && ( query.refetch()} /> diff --git a/src/components/settings/coach-reminders-section.tsx b/src/components/settings/coach-reminders-section.tsx index 4156eac5c..e0758ada2 100644 --- a/src/components/settings/coach-reminders-section.tsx +++ b/src/components/settings/coach-reminders-section.tsx @@ -188,6 +188,7 @@ export function CoachRemindersSection({ {query.isError && ( query.refetch()} /> diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index db3736c07..22f5de00e 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -73,7 +73,12 @@ export function ManagedIntegrationStatus() { }); if (statusQuery.isError) { - return void statusQuery.refetch()} />; + return ( + void statusQuery.refetch()} + /> + ); } const loading = statusQuery.isLoading || !statusQuery.data; diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 0b9fbeb5e..4b153bf2e 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -1115,7 +1115,12 @@ export function ManagedRecordSettingsSection({ }); if (query.isError) { - return void query.refetch()} />; + return ( + void query.refetch()} + /> + ); } return ( diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index fdffc8ad1..40b90787d 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -93,6 +93,7 @@ function McpConnectionsCard() { const { data: connections, isError, + error, isLoading, refetch, } = useQuery({ @@ -134,7 +135,7 @@ function McpConnectionsCard() {

{isError ? ( - refetch()} /> + refetch()} /> ) : isLoading ? ( ) : list.length === 0 ? ( @@ -285,6 +286,7 @@ function McpTokensCard() { const { data: tokens, isError, + error, isLoading, refetch, } = useQuery({ @@ -457,7 +459,7 @@ function McpTokensCard() { {t("settings.activeTokensTitle")}

{isError ? ( - refetch()} /> + refetch()} /> ) : isLoading ? ( ) : activeTokens.length === 0 ? ( diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 5987c6574..985e771ff 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -354,6 +354,7 @@ export function ScoreSection() { {configQuery.isError ? ( void configQuery.refetch()} /> @@ -444,6 +445,7 @@ export function ScoreSection() { {analyticsQuery.isError ? ( void analyticsQuery.refetch()} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 24bbd937b..58d17011e 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -68,7 +68,7 @@ export function SecurityActivityCard({ const [open, setOpen] = useState(false); const regionId = useId(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.securityActivity(), queryFn: () => apiGet<{ events: ActivityRow[] }>("/api/auth/me/security-activity"), @@ -110,6 +110,7 @@ export function SecurityActivityCard({ {isError && ( refetch()} /> diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index 319a209d8..d2059b6db 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -76,7 +76,7 @@ export function SecuritySessionsCard({ const [keptGrants, setKeptGrants] = useState([]); const revokeGrant = useRevokeGrant(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.sessions(), queryFn: () => apiGet<{ sessions: SessionRow[] }>("/api/auth/me/sessions"), enabled: isAuthenticated, @@ -165,6 +165,7 @@ export function SecuritySessionsCard({ {isError && ( refetch()} /> diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index 70509f8d5..a00495378 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -154,6 +154,7 @@ export function ShareDocumentPicker({
) : list.isError ? ( list.refetch()} /> diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 825a2604a..7b3574d06 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -111,6 +111,7 @@ function ShareLinksCard() { const { data: links, isError, + error, isLoading, refetch, } = useQuery({ @@ -168,7 +169,7 @@ function ShareLinksCard() { {isError ? ( // A read failure must not read as "no active links" on a clinical // sharing surface — that is the §6 fall-through this card must avoid. - refetch()} /> + refetch()} /> ) : isLoading ? (
diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 113a3f7fb..093f0de21 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -40,7 +40,7 @@ export function TrustedDevicesCard({ const queryClient = useQueryClient(); const [status, setStatus] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.trustedDevices(), queryFn: () => apiGet<{ devices: TrustedDeviceRow[] }>("/api/auth/me/trusted-devices"), @@ -105,6 +105,7 @@ export function TrustedDevicesCard({ {isError && ( refetch()} /> diff --git a/src/components/symptoms/episode-symptoms-card.tsx b/src/components/symptoms/episode-symptoms-card.tsx index b81d081f6..aa62c8988 100644 --- a/src/components/symptoms/episode-symptoms-card.tsx +++ b/src/components/symptoms/episode-symptoms-card.tsx @@ -61,6 +61,7 @@ export function EpisodeSymptomsCard({ episode }: { episode: { id: string } }) { if (definitions.isError || filed.isError) { return ( { void definitions.refetch(); diff --git a/src/components/symptoms/symptom-entry-form.tsx b/src/components/symptoms/symptom-entry-form.tsx index 3b8296bf7..1d534caf8 100644 --- a/src/components/symptoms/symptom-entry-form.tsx +++ b/src/components/symptoms/symptom-entry-form.tsx @@ -222,6 +222,7 @@ export function SymptomEntryForm({ // A failed read is not an empty list: the empty hint would invite // the person to define a symptom they may already have. void definitions.refetch()} /> diff --git a/src/components/symptoms/symptoms-section.tsx b/src/components/symptoms/symptoms-section.tsx index 2f11d5be1..4c64ee22a 100644 --- a/src/components/symptoms/symptoms-section.tsx +++ b/src/components/symptoms/symptoms-section.tsx @@ -390,6 +390,7 @@ export function SymptomsSection() { if (definitions.isError || events.isError) { return ( { void definitions.refetch(); diff --git a/src/components/timeline/readiness-sheet.tsx b/src/components/timeline/readiness-sheet.tsx index 7a7a51071..a63469e9f 100644 --- a/src/components/timeline/readiness-sheet.tsx +++ b/src/components/timeline/readiness-sheet.tsx @@ -76,6 +76,7 @@ export function TimelineReadinessSheet({
) : readiness.isError ? ( void readiness.refetch()} /> diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index feeb92d60..0f6925265 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -464,6 +464,7 @@ export function TimelineView() { ) : timeline.isError ? ( void timeline.refetch()} /> diff --git a/src/components/ui/__tests__/query-error-primitives.test.tsx b/src/components/ui/__tests__/query-error-primitives.test.tsx new file mode 100644 index 000000000..3edb66fa3 --- /dev/null +++ b/src/components/ui/__tests__/query-error-primitives.test.tsx @@ -0,0 +1,104 @@ +/** + * A failed read is reported by what the failure means, and both read-failure + * shapes decide it the same way from the query's error: + * + * - 403: the viewer may not read this. Nothing a retry could fix; the surface + * draws its refused state, or nothing. + * - 429: the read budget is spent. A note, never a retry that would only be + * refused again. + * - 401: the session ended. Never a silent blank: the reader is offered the + * way back to sign-in. + * - anything else: a real failure, with a retry. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { readFailureKind } from "@/lib/api/read-failure"; +import { I18nProvider } from "@/lib/i18n/context"; +import { QueryErrorCard } from "../query-error-card"; +import { QueryErrorRow } from "../query-error-row"; + +function ssr(node: ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const row = (error: unknown, refused?: ReactNode) => + ssr( + {}} + slot="probe" + />, + ); + +const card = (error: unknown, refused?: ReactNode) => + ssr( + {}} + />, + ); + +describe("readFailureKind", () => { + it("names an ended session, a refusal, a spent budget and the rest", () => { + expect(readFailureKind(new ApiError("x", 401))).toBe("unauthenticated"); + expect(readFailureKind(new ApiError("x", 403))).toBe("refused"); + expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); + expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); + expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); + expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); + expect(readFailureKind(null)).toBe("failed"); + }); +}); + +describe.each([ + ["", row], + ["", card], +])("%s", (_name, render) => { + it("draws nothing for a refused read, or the surface's refused state", () => { + expect(render(new ApiError("Forbidden", 403))).toBe(""); + expect( + render( + new ApiError("Forbidden", 403), +

Not shared with you.

, + ), + ).toBe('

Not shared with you.

'); + }); + + it("says a spent budget without offering a retry", () => { + const html = render(new ApiError("Too many", 429)); + expect(html).toContain("Too many requests just now."); + expect(html).not.toContain("retry"); + expect(html).not.toContain("Could not load"); + expect(html).not.toContain("Something on our side."); + }); + + it("offers sign-in for an ended session, never a blank", () => { + const html = render(new ApiError("Unauthorized", 401)); + expect(html).not.toBe(""); + expect(html).toContain("Your session has ended."); + expect(html).toContain('data-slot="query-error-sign-in"'); + expect(html).not.toContain("retry"); + }); + + it("offers a retry for a real failure", () => { + for (const error of [ + new ApiError("Boom", 500), + new TypeError("Failed to fetch"), + ]) { + const html = render(error); + expect(html).toContain("Could not load the"); + expect(html).toMatch(/data-slot="query-error(-row)?-retry"/); + expect(html).not.toContain("query-error-sign-in"); + } + }); +}); diff --git a/src/components/ui/__tests__/query-error-row.test.tsx b/src/components/ui/__tests__/query-error-row.test.tsx index 6e30b88dc..723f9ef33 100644 --- a/src/components/ui/__tests__/query-error-row.test.tsx +++ b/src/components/ui/__tests__/query-error-row.test.tsx @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { ApiError } from "@/lib/api/api-fetch"; import { I18nProvider } from "@/lib/i18n/context"; import { QueryErrorRow } from "../query-error-row"; +/** A real read failure; the status-specific shapes live in query-error-primitives. */ +const failed = new ApiError("Boom", 500); + function ssr(node: React.ReactNode) { return renderToStaticMarkup( {node}, @@ -12,7 +16,9 @@ function ssr(node: React.ReactNode) { describe("", () => { it("renders the compact alert row with foreground message text", () => { - const html = ssr(); + const html = ssr( + , + ); expect(html).toContain('role="alert"'); expect(html).toContain('data-slot="query-error-row"'); expect(html).toContain("Could not load vitals"); @@ -25,13 +31,17 @@ describe("", () => { }); it("falls back to the generic load-failed copy", () => { - expect(ssr()).toContain("Could not load"); + expect(ssr()).toContain("Could not load"); }); it("only renders the retry affordance when a handler is wired", () => { - expect(ssr()).not.toContain(")).not.toContain( + " {}} />); + const withRetry = ssr( + {}} />, + ); expect(withRetry).toContain("", () => { it("honours the slot overrides the converted surfaces depend on", () => { const html = ssr( {}} slot="healthkit-metric-error" @@ -54,7 +65,12 @@ describe("", () => { it("takes a custom retry label", () => { expect( ssr( - {}} retryLabel="Again" />, + {}} + retryLabel="Again" + />, ), ).toContain("Again"); }); diff --git a/src/components/ui/__tests__/read-failure-row.test.tsx b/src/components/ui/__tests__/read-failure-row.test.tsx deleted file mode 100644 index f24470030..000000000 --- a/src/components/ui/__tests__/read-failure-row.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * A supplementary read that fails is reported by what the failure means. - * - * Several strips and tiles answer a failed read with a retry row. Two - * failures are not worth one: a refusal (a read-only share reaching a - * manage-level read, a grant without the section) cannot be retried into - * success and must draw nothing, and a spent read budget would only be - * refused again by an immediate retry. - */ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { ApiError } from "@/lib/api/api-fetch"; -import { I18nProvider } from "@/lib/i18n/context"; -import { ReadFailureRow, readFailureKind } from "../read-failure-row"; - -function ssr(error: unknown) { - return renderToStaticMarkup( - - {}} - slot="probe" - /> - , - ); -} - -describe("readFailureKind", () => { - it("names refusals, a spent budget and everything else", () => { - expect(readFailureKind(new ApiError("x", 401))).toBe("denied"); - expect(readFailureKind(new ApiError("x", 403))).toBe("denied"); - expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); - expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); - expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); - expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); - }); -}); - -describe("", () => { - it("draws nothing for a refused read", () => { - expect(ssr(new ApiError("Forbidden", 403))).toBe(""); - expect(ssr(new ApiError("Unauthorized", 401))).toBe(""); - }); - - it("says a spent budget without offering a retry", () => { - const html = ssr(new ApiError("Too many", 429)); - expect(html).toContain('data-slot="probe"'); - expect(html).toContain("Too many requests just now."); - expect(html).not.toContain("query-error-row-retry"); - }); - - it("offers a retry for a real failure", () => { - const html = ssr(new ApiError("Boom", 500)); - expect(html).toContain("Could not load the strip."); - expect(html).toContain('data-slot="query-error-row-retry"'); - expect(ssr(new TypeError("Failed to fetch"))).toContain( - 'data-slot="query-error-row-retry"', - ); - }); -}); diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx index 0104b260b..8177e297c 100644 --- a/src/components/ui/query-error-card.tsx +++ b/src/components/ui/query-error-card.tsx @@ -5,7 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { useTranslations } from "@/lib/i18n/context"; +import { useReadFailure } from "@/hooks/use-read-failure"; import { cn } from "@/lib/utils"; /** @@ -18,21 +18,37 @@ import { cn } from "@/lib/utils"; * the generic `common.*` strings so a caller can drop it in with just * `onRetry`; a surface with its own "couldn't load X" string passes it through * `title` / `description`. + * + * The card reads the failure itself (`error`), exactly as `QueryErrorRow` + * does: a refusal draws `refused` (nothing by default), a spent budget says + * so without a retry, an ended session offers sign-in, and only a real + * failure shows `title` / `description` with `onRetry`. */ export function QueryErrorCard({ + error, + refused = null, title, description, onRetry, retryLabel, className, }: { + /** The query's error. It decides what the card offers. */ + error: unknown; + /** What a refused (403) read draws instead of the card. */ + refused?: ReactNode; title?: ReactNode; description?: ReactNode; onRetry?: () => void; retryLabel?: string; className?: string; }) { - const { t } = useTranslations(); + const failure = useReadFailure(error, { message: title, onRetry }); + if (!failure) return <>{refused}; + const { action } = failure; + // The description belongs to a real failure; a spent budget or an ended + // session replaces the title, and the surface's own detail would not fit. + const detail = failure.kind === "failed" ? description : null; return ( diff --git a/src/components/ui/query-error-row.tsx b/src/components/ui/query-error-row.tsx index d278c6c25..38521dfd4 100644 --- a/src/components/ui/query-error-row.tsx +++ b/src/components/ui/query-error-row.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { AlertTriangle, RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/lib/i18n/context"; +import { useReadFailure } from "@/hooks/use-read-failure"; import { cn } from "@/lib/utils"; /** @@ -23,11 +23,16 @@ import { cn } from "@/lib/utils"; * `QueryErrorRow` * - chart body → `ChartErrorState` * - * One deliberate change from the copies it replaces: the message renders in - * `text-foreground`. An alert IS the content of a failed surface, and §3 - * reserves muted for meta — both copies had it muted. + * The row reads the failure itself (`error`): a refusal draws `refused` + * (nothing by default), a spent budget says so without a retry, an ended + * session offers sign-in, and only a real failure offers `onRetry`. + * + * The message renders in `text-foreground`: an alert IS the content of a + * failed surface, and §3 reserves muted for meta. */ export function QueryErrorRow({ + error, + refused = null, message, onRetry, retryLabel, @@ -35,7 +40,11 @@ export function QueryErrorRow({ retrySlot, className, }: { - /** The failure line. Defaults to the generic `common.loadFailed`. */ + /** The query's error. It decides what the row offers. */ + error: unknown; + /** What a refused (403) read draws instead of the row. */ + refused?: ReactNode; + /** The line for a real failure. Defaults to `common.loadFailed`. */ message?: ReactNode; onRetry?: () => void; retryLabel?: string; @@ -49,7 +58,9 @@ export function QueryErrorRow({ retrySlot?: string; className?: string; }) { - const { t } = useTranslations(); + const failure = useReadFailure(error, { message, onRetry }); + if (!failure) return <>{refused}; + const { action } = failure; return ( diff --git a/src/components/ui/read-failure-row.tsx b/src/components/ui/read-failure-row.tsx deleted file mode 100644 index 7d6ab945d..000000000 --- a/src/components/ui/read-failure-row.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { ApiError } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; - -/** - * What a failed read means for the surface that made it. - * - * - `denied`: 401 / 403. The viewer may not read this, typically a shared - * or delegated record whose grant leaves the section out. Nothing failed - * that a retry could fix, and the surface is simply not theirs to see. - * - `throttled`: 429. The read budget is spent for now; an immediate retry - * would only be refused again. - * - `failed`: everything else, a real read failure worth a retry. - */ -export type ReadFailureKind = "denied" | "throttled" | "failed"; - -export function readFailureKind(error: unknown): ReadFailureKind { - if (error instanceof ApiError) { - if (error.status === 401 || error.status === 403) return "denied"; - if (error.status === 429) return "throttled"; - } - return "failed"; -} - -/** - * The compact failure row for a supplementary read, by what the failure - * means: nothing for a refusal, a notice without a retry for a spent budget, - * and `QueryErrorRow` with a retry for a real failure. - * - * For a surface the page can stand without (a strip, a tile, a block under a - * card). A primary list keeps `QueryErrorCard`. - */ -export function ReadFailureRow({ - error, - message, - onRetry, - slot, - className, -}: { - error: unknown; - message?: string; - onRetry: () => void; - slot?: string; - className?: string; -}) { - const { t } = useTranslations(); - const kind = readFailureKind(error); - if (kind === "denied") return null; - if (kind === "throttled") { - return ( - - ); - } - return ( - - ); -} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index af9c4ced8..bf0900b48 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -48,7 +48,7 @@ export function VaccinationsView() { // measurements section rather than this one. A grant that opened only the // health background is refused there, so the offer is not made. const canPlanBooster = canWriteDomain("measurements"); - const { data, isLoading, isError, refetch } = useVaccinations(); + const { data, isLoading, isError, error, refetch } = useVaccinations(); const records = data?.vaccinations ?? []; // v1.42 (#1005) — the record's own definitions, listed below the log once // there is one. A failed read leaves the section out rather than claiming @@ -139,6 +139,7 @@ export function VaccinationsView() { // A read failure is not an empty Impfpass — surface the error + Retry so // an outage never reads as "nothing logged yet". void refetch()} /> diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts index 16ab0532d..d6a42ed7d 100644 --- a/src/hooks/use-coach-message-results.ts +++ b/src/hooks/use-coach-message-results.ts @@ -26,6 +26,8 @@ export function useCoachMessageResults(args: { results: CoachResultEntry[] | undefined; isLoading: boolean; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; /** Read again after a failure. */ refetch: () => void; } { @@ -72,6 +74,7 @@ export function useCoachMessageResults(args: { results: query.data, isLoading: query.isLoading, isError: query.isError, + error: query.error, refetch: () => void query.refetch(), }; } diff --git a/src/hooks/use-mood-day.ts b/src/hooks/use-mood-day.ts index f43c19e74..8106fa5d1 100644 --- a/src/hooks/use-mood-day.ts +++ b/src/hooks/use-mood-day.ts @@ -34,6 +34,8 @@ export interface UseMoodDayResult { * carried no entry. */ entries: MoodDayEntry[] | undefined; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; refetch: () => void; } @@ -48,7 +50,7 @@ export function useMoodDay( date: string | undefined, enabled: boolean, ): UseMoodDayResult { - const { data, isError, refetch } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.moodEntriesDay(date ?? ""), queryFn: () => { const params = new URLSearchParams({ @@ -66,6 +68,7 @@ export function useMoodDay( return { entries: data?.entries, isError, + error, refetch: () => void refetch(), }; } diff --git a/src/hooks/use-read-failure.ts b/src/hooks/use-read-failure.ts new file mode 100644 index 000000000..57800b410 --- /dev/null +++ b/src/hooks/use-read-failure.ts @@ -0,0 +1,62 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { + readFailureKind, + signInHref, + type ReadFailureKind, +} from "@/lib/api/read-failure"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * What a read-failure primitive shows for one failed read: `null` for a + * refusal (the caller's refused state stands in), otherwise the line and the + * one action that can help. A real failure offers the caller's retry, an + * ended session offers sign-in, a spent budget offers nothing. + * + * Shared by `QueryErrorRow` and `QueryErrorCard` so both shapes branch on + * the failure the same way; a call site never decides it. + */ +export function useReadFailure( + error: unknown, + { message, onRetry }: { message?: ReactNode; onRetry?: () => void }, +): { + kind: Exclude; + message: ReactNode; + action: { + label: string; + onClick: () => void; + kind: "retry" | "signIn"; + } | null; +} | null { + const { t } = useTranslations(); + switch (readFailureKind(error)) { + case "refused": + return null; + case "throttled": + return { + kind: "throttled", + message: t("common.readThrottled"), + action: null, + }; + case "unauthenticated": + return { + kind: "unauthenticated", + message: t("common.sessionEnded"), + action: { + label: t("common.signInAgain"), + onClick: () => window.location.assign(signInHref()), + kind: "signIn", + }, + }; + case "failed": + return { + kind: "failed", + message: message ?? t("common.loadFailed"), + action: onRetry + ? { label: t("common.retry"), onClick: onRetry, kind: "retry" } + : null, + }; + } +} diff --git a/src/hooks/use-sleep-night.ts b/src/hooks/use-sleep-night.ts index cf5837aa0..f32b3d6ab 100644 --- a/src/hooks/use-sleep-night.ts +++ b/src/hooks/use-sleep-night.ts @@ -52,6 +52,8 @@ export interface UseSleepNightResult { * night that carried nothing. */ data: SleepNightPayload | undefined; isError: boolean; + /** The failed read's error, for the error row to read. */ + error: unknown; refetch: () => void; } @@ -65,7 +67,7 @@ export function useSleepNight( date: string | undefined, enabled: boolean, ): UseSleepNightResult { - const { data, isError, refetch } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.sleepNight(date), queryFn: () => apiGet( @@ -76,5 +78,5 @@ export function useSleepNight( refetchOnWindowFocus: false, }); - return { data, isError, refetch: () => void refetch() }; + return { data, isError, error, refetch: () => void refetch() }; } diff --git a/src/lib/api/read-failure.ts b/src/lib/api/read-failure.ts new file mode 100644 index 000000000..0a6c8b388 --- /dev/null +++ b/src/lib/api/read-failure.ts @@ -0,0 +1,33 @@ +import { ApiError } from "./api-fetch"; + +/** + * What a failed read means for the surface that made it. + * + * - `unauthenticated`: 401. The session has ended. The surface says so and + * offers the way back to sign-in; drawing nothing would leave a page that + * looks empty for a reason the reader cannot see. + * - `refused`: 403. The viewer may not read this, typically a shared or + * delegated record whose grant leaves the section out. Nothing failed that + * a retry could fix, so the surface draws its refused state, or nothing. + * - `throttled`: 429. The read budget is spent for now; an immediate retry + * would only be refused again. + * - `failed`: everything else, a real read failure worth a retry. + */ +export type ReadFailureKind = + "unauthenticated" | "refused" | "throttled" | "failed"; + +export function readFailureKind(error: unknown): ReadFailureKind { + if (error instanceof ApiError) { + if (error.status === 401) return "unauthenticated"; + if (error.status === 403) return "refused"; + if (error.status === 429) return "throttled"; + } + return "failed"; +} + +/** The sign-in page, returning to where the reader is now. */ +export function signInHref(): string { + if (typeof window === "undefined") return "/auth/login"; + const here = `${window.location.pathname}${window.location.search}`; + return `/auth/login?next=${encodeURIComponent(here)}`; +}