From 923249dbf64801f1ed5e157dfe0ecfc8a95eeb92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 10:10:49 +0200 Subject: [PATCH] fix(ui): draw every failed read through a read-failure primitive Around thirty surfaces drew a failed read their own way: a bare destructive paragraph with no retry, a muted line saying the data was still being learned, a skeleton that never ended, an error card nested in a tile, or nothing at all. None of them told an ended session from a refusal. They now use QueryErrorRow or QueryErrorCard with the query's error and a retry, inset where a card already frames them, and keep their card and title where the page would otherwise jump. Hydration and caffeine now fail the same way. The recovery page no longer points to Integrations while Oura's first resilience level is simply on its way. A guard reads every read hook's isError branch and refuses hand-rolled markup there, and refuses a framed error row inside a card. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/ko.json | 6 +- messages/nl.json | 6 +- messages/pl.json | 6 +- .../read-failure-markup-guard.test.ts | 182 ++++++++++++++++++ src/app/insights/catalog/page.tsx | 1 + src/app/insights/page-client.tsx | 1 + src/components/admin/ai-quality-section.tsx | 1 + src/components/admin/backups-section.tsx | 1 + .../admin/coach-feedback-section.tsx | 1 + src/components/admin/encryption-section.tsx | 13 +- src/components/admin/host-metrics-chart.tsx | 1 + .../admin/invite-tokens-section.tsx | 18 +- src/components/admin/key-backup-card.tsx | 1 + .../admin/offhost-backups-section.tsx | 1 + .../admin/provider-health-section.tsx | 13 +- src/components/admin/recent-audit-preview.tsx | 1 + .../admin/system-status-section.tsx | 1 + .../admin/system-status-summary.tsx | 1 + .../cycle-insight-summary-card.test.tsx | 16 +- .../cycle/cycle-insight-summary-card.tsx | 27 ++- src/components/cycle/cycle-view.tsx | 2 +- .../__tests__/recent-workouts-tile.test.tsx | 6 +- .../dashboard/recent-workouts-tile.tsx | 9 +- .../documents/document-detail-sheet.tsx | 11 +- .../illness/illness-correlation-card.tsx | 14 +- .../illness/illness-day-timeline.tsx | 16 +- .../illness/illness-insights-card.tsx | 21 +- .../__tests__/recovery-empty-state.test.tsx | 21 +- .../coach-panel/attachment-picker.tsx | 11 +- .../coach-panel/coach-model-picker.tsx | 25 +-- .../insights/coach-panel/history-rail.tsx | 20 +- .../insights/derived/coach-read-strip.tsx | 3 +- .../insights/derived/wellness-scores.tsx | 39 ++-- .../insights/intraday-pulse-chart.tsx | 11 +- .../insights/mood/mood-what-stands-out.tsx | 1 + .../insights/nutrients/caffeine-card.tsx | 22 ++- .../insights/nutrients/hydration-card.tsx | 23 ++- .../insights/period-narrative-card.tsx | 33 +++- .../insights/recovery/recovery-section.tsx | 29 ++- .../insights/workout-detail/day-context.tsx | 2 + .../vorsorge-dashboard-card.tsx | 2 +- .../medications/dose-history-ledger.tsx | 14 +- .../scheduling/schedule-history-timeline.tsx | 15 +- .../sections/phase-config-sheet.tsx | 16 +- .../assessment-item-breakdown.tsx | 17 +- .../coach-conversations-memory-card.tsx | 1 + .../settings/coach-memory-section.tsx | 1 + .../settings/coach-reminders-section.tsx | 1 + .../integrations/apple-health-card.tsx | 12 +- .../settings/integrations/dawarich-card.tsx | 11 +- src/components/settings/privacy-section.tsx | 13 +- src/components/settings/score-section.tsx | 1 + .../settings/security-activity-card.tsx | 1 + .../settings/security-sessions-card.tsx | 1 + .../settings/trusted-devices-card.tsx | 1 + 60 files changed, 579 insertions(+), 175 deletions(-) create mode 100644 src/__tests__/read-failure-markup-guard.test.ts diff --git a/messages/de.json b/messages/de.json index b2667a7de..72b690df6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet." - } + }, + "loadError": "Deine Erholungswerte konnten nicht geladen werden." }, "bodyTempDeviation": { "title": "Körpertemperatur-Abweichung", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)" + "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)", + "loadError": "Wasser konnte nicht geladen werden." }, "caffeine": { "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)", diff --git a/messages/en.json b/messages/en.json index 3e30108aa..da940b4e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.", "maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.", "energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively." - } + }, + "loadError": "Your recovery readings could not be loaded." }, "bodyTempDeviation": { "title": "Body temperature deviation", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Reference intake {value} {unit}/day (EFSA)" + "referenceMeta": "Reference intake {value} {unit}/day (EFSA)", + "loadError": "Could not load water." }, "caffeine": { "ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)", diff --git a/messages/es.json b/messages/es.json index a7e92f85e..aeab27a15 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.", "maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.", "energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa." - } + }, + "loadError": "No se pudieron cargar tus valores de recuperación." }, "bodyTempDeviation": { "title": "Desviación de la temperatura corporal", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)" + "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)", + "loadError": "No se pudo cargar el agua." }, "caffeine": { "ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)", diff --git a/messages/fr.json b/messages/fr.json index 2626bfb40..92a8ae59b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.", "maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.", "energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement." - } + }, + "loadError": "Vos valeurs de récupération n'ont pas pu être chargées." }, "bodyTempDeviation": { "title": "Écart de température corporelle", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)" + "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)", + "loadError": "Impossible de charger l'eau." }, "caffeine": { "ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)", diff --git a/messages/it.json b/messages/it.json index 9f6082888..4ca892e15 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", "maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", "energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo." - } + }, + "loadError": "Non è stato possibile caricare i tuoi valori di recupero." }, "bodyTempDeviation": { "title": "Deviazione della temperatura corporea", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)" + "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)", + "loadError": "Impossibile caricare l'acqua." }, "caffeine": { "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)", diff --git a/messages/ko.json b/messages/ko.json index 291d55291..7d8c1316b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.", "maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.", "energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)." - } + }, + "loadError": "회복 값을 불러오지 못했어요." }, "bodyTempDeviation": { "title": "체온 편차", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)" + "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)", + "loadError": "수분을 불러오지 못했어요." }, "caffeine": { "ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)", diff --git a/messages/nl.json b/messages/nl.json index 21d713639..8028f2dc4 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4678,7 +4678,8 @@ }, "emptyAction": "Integraties openen", "emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.", - "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe." + "emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.", + "loadError": "Je herstelwaarden konden niet worden geladen." }, "bodyTempDeviation": { "title": "Afwijking lichaamstemperatuur", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)" + "referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)", + "loadError": "Water kon niet worden geladen." }, "caffeine": { "ceilingMeta": "Veilige bovengrens {value} {unit}/dag (EFSA)", diff --git a/messages/pl.json b/messages/pl.json index 4735fd572..3aa3bd00e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4678,7 +4678,8 @@ "averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", "maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", "energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia." - } + }, + "loadError": "Nie udało się załadować Twoich wartości regeneracji." }, "bodyTempDeviation": { "title": "Odchylenie temperatury ciała", @@ -11906,7 +11907,8 @@ } }, "hydration": { - "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)" + "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)", + "loadError": "Nie udało się załadować wody." }, "caffeine": { "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)", diff --git a/src/__tests__/read-failure-markup-guard.test.ts b/src/__tests__/read-failure-markup-guard.test.ts new file mode 100644 index 000000000..b8c46a4c6 --- /dev/null +++ b/src/__tests__/read-failure-markup-guard.test.ts @@ -0,0 +1,182 @@ +/** + * A failed read draws a read-failure primitive, and draws it the right way. + * + * Two shapes kept coming back beside the primitives: + * + * - A read's `isError` branch rendering its own markup: a bare + * `text-destructive` paragraph with no retry, a muted line that reads as + * "still learning", a hand-rolled row. None of them knows an ended session + * from a refusal, which is the decision `QueryErrorRow`, `QueryErrorCard` + * and `ChartErrorState` make from the error. + * - A `QueryErrorRow` with its own frame inside a card that already draws + * one, a card inside a card. Inside a card body the row is `inset`. + * + * Both readers are lexical. A read is a hook call (`const x = useFoo(` or a + * destructure with `isError`) that is not `useMutation` and whose result is + * never `.mutate`d; a mutation's error is inline `role="alert"` text by the + * standards, so it is left alone. The branch is the text after `isError … ?` + * or `&&` up to the next `) : `. Nesting is the count of card tags opened and + * not closed before the row. Each reader is checked against a fixture of the + * shape it refuses, and the tree must yield a floor of matches, so a matcher + * that stops finding anything fails. + */ +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; +} + +function escape(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const PRIMITIVE = /QueryErrorRow|QueryErrorCard|ChartErrorState/; + +/** Every read `isError` branch: its line and whether it hand-rolls markup. */ +function readErrorBranches( + source: string, +): Array<{ line: number; handRolled: boolean }> { + const reads = new Set(); + for (const m of source.matchAll( + /const\s+(\w+)\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g, + )) { + if (m[2] === "useMutation") continue; + if (new RegExp(`${escape(m[1])}\\.mutate(?:Async)?\\(`).test(source)) + continue; + reads.add(m[1]); + } + const alternatives = [...reads].map((v) => `${escape(v)}\\.isError`); + const destructured = [ + ...source.matchAll( + /const\s*\{[^}]*\bisError\b[^}]*\}\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g, + ), + ].some((m) => m[1] !== "useMutation"); + if (destructured) alternatives.push("(? = []; + for (const m of source.matchAll(branch)) { + if (source[m.index - 1] === "!") continue; + const body = source.slice( + m.index + m[0].length, + m.index + m[0].length + 500, + ); + const arm = body.split(") : ")[0]; + found.push({ + line: source.slice(0, m.index).split("\n").length, + handRolled: + !PRIMITIVE.test(arm) && /<(p|div|span)\b/.test(arm.slice(0, 250)), + }); + } + return found; +} + +const CARD_TAGS = ["SettingsCard", "Card", "CardContent", "InsightSectionCard"]; + +/** Lines of `QueryErrorRow`s without `inset` inside an open card tag. */ +function framedRowsInCards(source: string): number[] { + const lines: number[] = []; + for (const m of source.matchAll(//g)) { + if (/\binset\b/.test(m[1])) continue; + const before = source.slice(0, m.index); + let depth = 0; + for (const tag of CARD_TAGS) { + depth += (before.match(new RegExp(`<${tag}(?=[\\s>])`, "g")) ?? []) + .length; + depth -= (before.match(new RegExp(``, "g")) ?? []).length; + depth -= (before.match(new RegExp(`<${tag}(?=\\s)[^>]*?/>`, "g")) ?? []) + .length; + } + if (depth > 0) lines.push(before.split("\n").length); + } + return lines; +} + +const files = walk(SRC).map((file) => ({ + file: relative(SRC, file), + source: readFileSync(file, "utf8"), +})); + +describe("read failures render through a primitive", () => { + it("recognises a hand-rolled read failure", () => { + const rolled = ` + const { data, isError } = useQuery({ queryKey: k, queryFn: f }); + return isError ? ( +

{t("x.loadError")}

+ ) : null;`; + expect(readErrorBranches(rolled)).toEqual([{ line: 3, handRolled: true }]); + const mutation = ` + const save = useSave(); + save.mutate(body); + return save.isError ?

{t("x.saveError")}

: null;`; + expect(readErrorBranches(mutation)).toEqual([]); + const primitive = ` + const list = useList(); + return list.isError ? ( + + ) : null;`; + expect(readErrorBranches(primitive)).toEqual([ + { line: 3, handRolled: false }, + ]); + }); + + it("finds the read branches it guards", () => { + const branches = files.flatMap(({ source }) => readErrorBranches(source)); + // About 100 at the time of writing. + expect(branches.length).toBeGreaterThan(80); + }); + + it("never hand-rolls one beside a read", () => { + const offenders = files.flatMap(({ file, source }) => + readErrorBranches(source) + .filter((b) => b.handRolled) + .map((b) => `${file}:${b.line}`), + ); + expect(offenders).toEqual([]); + }); +}); + +describe("an error row inside a card draws no frame of its own", () => { + it("recognises a framed row inside a card", () => { + expect( + framedRowsInCards( + `\n\n r()} />\n\n`, + ), + ).toEqual([3]); + expect( + framedRowsInCards( + `\n\n r()} />`, + ), + ).toEqual([]); + }); + + it("finds inset rows in the tree, so the reader is live", () => { + // About 40 files at the time of writing. + const inset = files.filter(({ source }) => + / { + const offenders = files.flatMap(({ file, source }) => + framedRowsInCards(source).map((line) => `${file}:${line}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 722a0c42b..075abd613 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -368,6 +368,7 @@ export default function MetricCatalogPage() { {failed.length > 0 ? ( ) : query.isError ? ( void query.refetch()} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 6adca7386..16b6e5ed4 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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 6c07d5854..d62a4f8ba 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/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 0783af6a5..78230e0cf 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -52,6 +52,7 @@ import { } from "@/components/settings/security-section/use-recent-proof"; import { KeyBackupCard } from "./key-backup-card"; import { useKeyBackupStatus } from "./use-key-backup-status"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface ColumnScan { model: string; @@ -182,9 +183,15 @@ export function EncryptionSection() { {t("admin.section.encryption.coverageDetail")}

-

- {t("admin.section.encryption.loadError")} -

+ {statusQuery.isError ? ( + void statusQuery.refetch()} + /> + ) : null} ); diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index cca03537f..20987aa64 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -198,6 +198,7 @@ export function HostMetricsChart() { } /> void refetch()} diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx index 0eca4c66a..107fdca41 100644 --- a/src/components/admin/invite-tokens-section.tsx +++ b/src/components/admin/invite-tokens-section.tsx @@ -75,6 +75,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface InviteRedemptionEntry { id: string; @@ -192,7 +193,12 @@ export function InviteTokensSection() { const [copied, setCopied] = useState(false); const [revokeTarget, setRevokeTarget] = useState(null); - const { data: invites, isError } = useQuery({ + const { + data: invites, + isError, + error, + refetch, + } = useQuery({ queryKey: queryKeys.adminInvites(), queryFn: async () => { return apiGet("/api/admin/invites"); @@ -313,9 +319,13 @@ export function InviteTokensSection() { /> {isError && ( -

- {t("admin.invites.loadError")} -

+ void refetch()} + /> )} {invites && invites.length === 0 && ( diff --git a/src/components/admin/key-backup-card.tsx b/src/components/admin/key-backup-card.tsx index 2b894cf6a..5b06d2b1e 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/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 2339a0353..99ffb6af2 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -170,6 +170,7 @@ export function OffhostBackupsSection() { ) : isError ? ( void refetch()} diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 4b4c8c185..3032ee8ef 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.37.31 — the delivery-health readout the `provider_health` ledger never @@ -66,7 +67,7 @@ export function ProviderHealthSection() { const groupDigits = (value: number): string => new Intl.NumberFormat(locale).format(value); - const { data, isPending, isError } = useQuery({ + const { data, isPending, isError, error, refetch } = useQuery({ queryKey: queryKeys.adminProviderHealth(), queryFn: () => apiGet<{ @@ -134,9 +135,13 @@ export function ProviderHealthSection() { {t("admin.providerHealth.loading")}

) : isError ? ( -

- {t("admin.providerHealth.loadError")} -

+ void refetch()} + /> ) : providers.length === 0 ? (

{t("admin.providerHealth.empty")} diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 4d4fa4e13..cab8589b7 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -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 6eaa8aecd..6282cb243 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -277,6 +277,7 @@ export function SystemStatusSection() { // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. void refetch()} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 62c6c6961..7c412113c 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -110,6 +110,7 @@ export function SystemStatusSummary() { ) : isError ? ( void refetch()} diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index 1dc11f247..7252741d0 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -61,9 +61,15 @@ vi.mock("next/link", () => ({ const calendarState = vi.hoisted(() => ({ current: { - data: undefined as CalendarResponse | undefined, + data: undefined, isLoading: false, isError: false, + } as { + data: CalendarResponse | undefined; + isLoading: boolean; + isError: boolean; + error?: unknown; + refetch?: () => unknown; }, })); const insightsState = vi.hoisted(() => ({ @@ -223,13 +229,17 @@ describe("", () => { expect(html).toBe(""); }); - it("renders nothing on a hard calendar read error", () => { + it("says a hard calendar read error, with a retry", () => { + // Drawing nothing would read as "no cycle tracked". calendarState.current = { data: undefined, isLoading: false, isError: true, + error: new Error("boom"), + refetch: () => undefined, }; const html = render(); - expect(html).toBe(""); + expect(html).toContain('data-slot="cycle-insight-summary-error"'); + expect(html).toContain('data-slot="query-error-row-retry"'); }); }); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index 673b82af3..ee1b2e0c1 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -9,6 +9,7 @@ import { SectionHeading } from "@/components/ui/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.15.2 — the gated cycle-insights summary card for the main Insights page. @@ -56,11 +57,29 @@ export function CycleInsightSummaryCard() { // The server's resolved verdict — the teaser reports it, never recomputes it. const verdict = calendar.data?.verdict; - // Stay silent until the calendar read resolves, and on a hard error: the - // overview must never show a half-painted or error-framed cycle teaser. The - // full cycle page owns the retry affordance. + // Stay silent until the calendar read resolves: the overview must never + // show a half-painted teaser. A failed read is said with a retry, since + // drawing nothing would read as "no cycle tracked". if (calendar.isLoading && !calendar.data) return null; - if (calendar.isError && !calendar.data) return null; + if (calendar.isError && !calendar.data) { + return ( +

+ + void calendar.refetch()} + /> +
+ ); + } const phase = verdict?.phase ?? null; const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL; diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 4742525eb..85d19e5be 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -362,10 +362,10 @@ export function CycleView() { // Inside the calendar card already — a borderless row keeps // it in line with the plain loading line above it (§3/§6). void calendar.refetch()} - className="border-0 bg-transparent p-0" /> ) : ( <> diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 170ee2639..6cac8c733 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -91,10 +91,12 @@ describe("", () => { expect(html).toContain('data-slot="skeleton"'); }); - it("renders a retry card instead of the empty state on a failed fetch", () => { + it("renders a retry row instead of the empty state on a failed fetch", () => { mockResult.isError = true; const html = render(); - expect(html).toContain('data-slot="query-error-card"'); + // A row without a frame of its own: the tile is already the card. + expect(html).toContain('data-slot="recent-workouts-error"'); + expect(html).not.toContain('data-slot="query-error-card"'); // A failed fetch must never read as the honest "no workouts yet" // empty state — that would misreport an outage as an empty account. expect(html).not.toContain('data-slot="recent-workouts-empty"'); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index a9770c5b4..c1fb81f46 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { iconForSport } from "@/lib/workouts/sport-icons"; import { translateOrNull } from "@/lib/i18n/translate-code"; @@ -105,10 +105,11 @@ export function RecentWorkoutsTile() { ))} ) : isError ? ( - refetch()} - className="border-0 bg-transparent shadow-none" + slot="recent-workouts-error" + onRetry={() => void refetch()} /> ) : workouts.length === 0 ? (
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 303a41ce4..c5594528f 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -103,6 +103,7 @@ import { useSuggestDetails, } from "./use-document-assist"; import { formatBytes } from "./vault-utils"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { title?: string | null; @@ -895,9 +896,13 @@ export function DocumentDetailSheet({ ) : null} {detail.isError ? ( -

- {t("documents.detail.loadError")} -

+ void detail.refetch()} + /> ) : null} {doc ? ( diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 58c196889..fb2091bfb 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useIllnessCorrelation } from "./use-illness"; import type { IllnessCorrelationValue, IllnessVitalDeviation } from "./types"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** Translate a MeasurementType to its illness-surface label. */ function useVitalLabel() { @@ -168,7 +169,8 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) { const { t } = useTranslations(); - const { data, isLoading, isError } = useIllnessCorrelation(episodeId); + const { data, isLoading, isError, error, refetch } = + useIllnessCorrelation(episodeId); return ( @@ -186,7 +188,15 @@ export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
- ) : isError || !data || data.status !== "ok" || !data.value ? ( + ) : isError && !data ? ( + // A failed read is not "still learning": it says so and can retry. + void refetch()} + /> + ) : !data || data.status !== "ok" || !data.value ? (

{t("illness.correlation.learning")}

diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index fada9076b..dbf60b7b6 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -25,6 +25,7 @@ import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLogList } from "./use-illness"; import type { IllnessDayLogDTO } from "./types"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; function symptomLabelKey(key: string): string | null { return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null; @@ -106,7 +107,10 @@ export function IllnessDayTimeline({ // v1.36.x — logging a day is not a delegated verb. const { canManageDomain } = useRecordCapabilities(); const canManageIllness = canManageDomain("illness"); - const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc"); + const { data, isLoading, isError, error, refetch } = useIllnessDayLogList( + episodeId, + "desc", + ); const dayLogs = data?.dayLogs ?? []; @@ -122,9 +126,13 @@ export function IllnessDayTimeline({ ) : isError ? ( -

- {t("illness.timeline.error")} -

+ void refetch()} + /> ) : dayLogs.length === 0 ? (

diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index 64f84232b..b15c14335 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -20,6 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { useIllnessInsights } from "./use-illness"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; export function IllnessInsightsCard({ windowDays = 365, @@ -55,6 +56,13 @@ export function IllnessInsightsCard({

+ ) : counts.isError && !data ? ( + void counts.refetch()} + /> ) : !hasEpisodes ? (

{t("illness.insights.none")} @@ -83,11 +91,14 @@ export function IllnessInsightsCard({

- ) : gap.isError || !gap.data ? ( -

- {t("illness.insights.typicalGapLearning")} -

- ) : gap.data.typicalRecoveryGapDays === null ? ( + ) : gap.isError && !gap.data ? ( + void gap.refetch()} + /> + ) : !gap.data || gap.data.typicalRecoveryGapDays === null ? (

{t("illness.insights.typicalGapLearning")}

diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index 57036b5f0..fdc4547b8 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -54,12 +54,12 @@ const OURA_SUMMARIES = { HRV_RMSSD: summary(30), }; -function render(recoveryEmpty: string | null) { +function render(recoveryEmpty: string | null, error: unknown = null) { analyticsMock.mockReturnValue({ - data: { summaries: OURA_SUMMARIES, recoveryEmpty }, + data: error ? undefined : { summaries: OURA_SUMMARIES, recoveryEmpty }, isLoading: false, isEmpty: false, - error: null, + error, refetch: () => {}, }); const queryClient = new QueryClient({ @@ -81,7 +81,6 @@ beforeEach(() => { describe(" empty note", () => { it.each([ ["oura_permission", "heart-health access"], - ["oura_no_resilience", "as soon as Oura provides one"], ["general", "WHOOP or Polar"], ])("renders the server's %s reason with the one action", (reason, text) => { const html = render(reason); @@ -90,6 +89,20 @@ describe(" empty note", () => { expect(html).toContain('href="/settings/integrations"'); }); + it("offers nothing to open while Oura's first level is on its way", () => { + // The connection is fine; there is nothing under Integrations to do. + const html = render("oura_no_resilience"); + expect(html).toContain('data-empty-case="oura_no_resilience"'); + expect(html).toContain("as soon as Oura provides one"); + expect(html).not.toContain('href="/settings/integrations"'); + }); + + it("says a failed read instead of a skeleton that never ends", () => { + const html = render(null, new Error("boom")); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).not.toContain('data-slot="recovery-loading"'); + }); + it("shows no note when the server says the page has data", () => { const html = render(null); expect(html).not.toContain("data-empty-case"); diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index 732754dda..e230593c4 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault @@ -181,9 +182,13 @@ export function AttachmentPicker({ ))} ) : list.isError ? ( -

- {t("documents.list.loadError")} -

+ void list.refetch()} + /> ) : documents.length === 0 ? (

{t("insights.coach.attach.pickerEmpty")} diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index 3c6a3d543..7ff77cef3 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -24,6 +24,7 @@ import { invalidateKeys, queryKeys, } from "@/lib/query-keys"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * The quick model choice in the Coach's settings overlay: which provider @@ -195,21 +196,15 @@ export function CoachModelPicker() { if (chainQuery.isError || providerQuery.isError) { return ( -

-

- {t("common.loadFailed")} -

- -
+ { + void chainQuery.refetch(); + void providerQuery.refetch(); + }} + /> ); } diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 3ecaa120f..a1b8f3178 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -7,7 +7,6 @@ import { MoreHorizontal, Paperclip, Pencil, - RotateCw, Trash2, } from "lucide-react"; import { toast } from "sonner"; @@ -30,6 +29,7 @@ import { useCoachConversationHistory, useDeleteCoachConversationWithUndo, } from "./use-coach"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * The Coach's conversation list: the full history grouped by recency @@ -88,6 +88,7 @@ export function HistoryRail({ conversations, isLoading, isError, + error, refetch, hasNextPage, isFetchingNextPage, @@ -160,16 +161,13 @@ export function HistoryRail({

) : isError && visible.length === 0 ? ( // A load failure must not read as "no conversations yet". -
-

{t("common.loadFailed")}

- -
+ void refetch()} + className="px-2 py-3" + /> ) : groups.length === 0 ? (

{isError ? ( void refetch()} - className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0" + className="flex-row items-center justify-between" /> ) : ( diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 2ed39c544..848773990 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -3,16 +3,9 @@ import { useState, type ComponentType } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { - Activity, - Flame, - Gauge, - HeartPulse, - Moon, - RefreshCw, -} from "lucide-react"; +import { Activity, Flame, Gauge, HeartPulse, Moon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/ui/section-heading"; @@ -65,6 +58,8 @@ interface ScoreStripProps { * Vitals slot). */ isError?: boolean; + /** The batch query's error, which decides what the error row offers. */ + error?: unknown; /** Refetch the one shared batch query. Recovers both strips at once. */ refetch?: () => void; /** @@ -273,6 +268,7 @@ export function WellnessScores({ read, isLoading, isError = false, + error = null, refetch, extraTile, hideStrain = false, @@ -351,24 +347,13 @@ export function WellnessScores({ title={t("insights.derived.scores.sectionTitle")} subtitle={t("insights.derived.scores.sectionSubtitle")} /> -

- {t("insights.derived.scores.loadError")} - -
+ refetch?.()} + /> ); } diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index deff1ae99..da0cabb36 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -23,7 +23,7 @@ import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { DayLink } from "@/components/day/day-link"; import { Button } from "@/components/ui/button"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { axisUnitSuffix } from "@/lib/charts/axis-unit"; import { @@ -77,7 +77,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440]; * coarser hourly grain instead of an empty chart — `resolution` on the DTO * tells the caption which, and the tension read is always absent on an * hourly day (it needs per-sample resolution to stay honest). A failed fetch - * renders `` with a retry action instead of falling through + * renders `` with a retry action instead of falling through * to the empty-day copy. * * `initialDateKey` pins the first day shown, for callers that already know @@ -254,10 +254,11 @@ export function IntradayPulseChart({ {isLoading ? ( ) : isError ? ( - refetch()} - className="border-0 bg-transparent shadow-none" + slot="intraday-pulse-error" + onRetry={() => void refetch()} /> ) : (data?.series?.length ?? 0) < MIN_BUCKETS_FOR_COURSE ? ( // A lone reading or two on a 24-hour axis drew a dot in an empty diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx index 46148152d..c16adb3a7 100644 --- a/src/components/insights/mood/mood-what-stands-out.tsx +++ b/src/components/insights/mood/mood-what-stands-out.tsx @@ -81,6 +81,7 @@ export function MoodWhatStandsOut({ ) : null} {isError ? ( void refetch()} - /> + + + + + + void refetch()} + /> + + ); } diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx index 2f722cba9..5d61aa219 100644 --- a/src/components/insights/nutrients/hydration-card.tsx +++ b/src/components/insights/nutrients/hydration-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { GlassWater } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; @@ -38,8 +38,25 @@ export function HydrationCard() { ), }); - if (isError) { - return ; + // The card keeps its frame and title on a failed read, like the caffeine + // card beside it, so the page does not change shape under the reader. + if (isError && !data) { + return ( + + + + + + void refetch()} + /> + + + ); } const todayTotal = data?.days.at(-1)?.amount ?? 0; diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 2495101b1..94249a5a1 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -18,6 +18,7 @@ import { AskCoachIconButton } from "@/components/insights/ask-coach-action"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { apiGet } from "@/lib/api/api-fetch"; import { InsightSectionCard } from "./insight-section-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; /** * v1.11.0 — period-narrative card (Pillar P1). @@ -121,13 +122,33 @@ export function PeriodNarrativeCard({ ); } - // The route never errors the page; on a fetch error or whenever there is no - // real generated narrative yet (empty account, or a first warm still in - // flight) the card un-mounts itself rather than showing an error, a - // "preparing" husk, or the methodology framing. The query stays mounted and - // polls in the background, so the card appears the moment a narrative lands. + // A failed read is said, with a retry: hiding it would look exactly like + // "no narrative yet". Whenever there is no real generated narrative yet + // (empty account, the capability off, or a first warm still in flight) the + // card un-mounts itself rather than showing a "preparing" husk or the + // methodology framing. The query stays mounted and polls in the background, + // so the card appears the moment a narrative lands. + if (query.isError && !query.data) { + return ( +
+ + void query.refetch()} + /> +
+ ); + } const narrative = query.data?.narrative ?? null; - if (query.isError || !narrative) { + if (!narrative) { return null; } diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 060b067ab..f21fa05de 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -21,6 +21,7 @@ import type { RecoveryEmptyReason, } from "@/lib/insights/recovery-empty"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/score/types"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; @@ -79,7 +80,8 @@ const EMPTY_COPY = { */ export function RecoverySection() { const { t } = useTranslations(); - const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION"); + const { data, isLoading, error, refetch } = + useInsightsAnalytics("SLEEP_DURATION"); // v1.18.1 — Rest Mode parity. The annotation rides the thick analytics // payload's `healthScore` block (server-authoritative; iOS mirrors it). We // read it off the same `["analytics"]` cache the insights mother page @@ -190,6 +192,17 @@ export function RecoverySection() { // answer (`recoveryEmpty`), so web and iOS read one decision. const emptyReason = data?.recoveryEmpty ?? null; + // A failed read is said, never left as a skeleton that loads forever. + if (error && summaries == null) { + return ( + + ); + } + if (isLoading || summaries == null) { return (
@@ -207,11 +220,15 @@ export function RecoverySection() { title={t("insights.recovery.emptyTitle")} description={t(EMPTY_COPY[emptyReason])} action={ - + // Waiting for a first resilience level has nothing to open: the + // connection is fine, the level simply has not arrived. + emptyReason === "oura_no_resilience" ? undefined : ( + + ) } /> ); diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx index 5286b8ee4..d262f13d3 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. {mood.isError ? ( refetch()} /> + refetch()} /> ) : upcoming.length === 0 ? (

diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index e52462fd2..0ff9aa79a 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -114,6 +114,7 @@ import { } from "@/components/medications/dose-history-ledger-compute"; import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -210,7 +211,7 @@ export function DoseHistoryLedger({ // `from` travels: the server's `to` defaults to its own now. const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey, queryFn: async () => { const from = new Date(Date.now() - windowDays * DAY_MS).toISOString(); @@ -465,9 +466,14 @@ export function DoseHistoryLedger({ )} {isError && ( -

- {t("medications.detail.verlauf.loadError")} -

+ void refetch()} + className="py-6" + /> )} {data && !isLoading && groups.length === 0 && ( diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 82d8ee2be..628da2c17 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -60,6 +60,7 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface RevisionEntrySummary { timesOfDay: string[]; @@ -116,7 +117,7 @@ export function ScheduleHistoryTimeline({ null, ); - const { data, isError } = useQuery({ + const { data, isError, error, refetch } = useQuery({ queryKey: queryKeys.medicationScheduleRevisions(medicationId), queryFn: async () => apiGet( @@ -151,11 +152,15 @@ export function ScheduleHistoryTimeline({ }, }); - if (isError) { + if (isError && !data) { return ( -

- {t("medications.detail.zeitplan.history.loadError")} -

+ void refetch()} + /> ); } if (!data) return null; diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index bb2bed055..5c00ea61f 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -49,6 +49,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; type PhaseMode = "MINUTES" | "PERCENT"; @@ -137,7 +138,7 @@ export function PhaseConfigSheet({ }: PhaseConfigSheetProps) { const { t } = useTranslations(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.medicationPhaseConfig(medicationId), queryFn: async () => { return fromApi( @@ -170,13 +171,12 @@ export function PhaseConfigSheet({ />
) : isError ? ( -

- {t("common.loadFailed")} -

+ void refetch()} + /> ) : data ? ( apiGet<{ assessment: AssessmentDetail }>( @@ -62,13 +63,13 @@ export function AssessmentItemBreakdown({ if (isError || !data) { return ( -

- {t("mentalHealth.error")} -

+ void refetch()} + /> ); } diff --git a/src/components/settings/coach-conversations-memory-card.tsx b/src/components/settings/coach-conversations-memory-card.tsx index aeacc80c0..1fde00d09 100644 --- a/src/components/settings/coach-conversations-memory-card.tsx +++ b/src/components/settings/coach-conversations-memory-card.tsx @@ -80,6 +80,7 @@ export function CoachConversationsMemoryCard({ /> {history.isError ? ( void history.refetch()} /> diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index bb3b2f94e..c5e34a9e2 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 b340500a5..3c040e3ba 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/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index cbcb16491..4ba5a3912 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -16,6 +16,7 @@ import { HeartPulse, Upload } from "lucide-react"; import { formatRelative } from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; @@ -303,6 +304,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { data: status, isLoading, isError, + error, + refetch, } = useQuery({ queryKey: queryKeys.healthKitStatus(), queryFn: () => apiGet("/api/integrations/healthkit"), @@ -384,9 +387,12 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { } > {isError ? ( -

- {t("settings.appleHealth.status.unavailable")} -

+ void refetch()} + /> ) : null} ); diff --git a/src/components/settings/integrations/dawarich-card.tsx b/src/components/settings/integrations/dawarich-card.tsx index 4fd606aad..a0ca2cf70 100644 --- a/src/components/settings/integrations/dawarich-card.tsx +++ b/src/components/settings/integrations/dawarich-card.tsx @@ -47,6 +47,7 @@ import { integrationDocsHref, } from "./setup-guide-link"; import type { IntegrationStatusViewModel } from "./shared"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; interface DawarichStatus { connected: boolean; @@ -298,9 +299,13 @@ function DawarichHome() { {t("settings.dawarichHomePlace")} {places.isError ? ( -

- {t(errorKeyOf(places.error))} -

+ void places.refetch()} + /> ) : ( apiGet("/api/settings/privacy-summary"), @@ -141,7 +144,15 @@ export function PrivacySection() {
- ) : summaryError || !summary ? ( + ) : summaryError && !summary ? ( + void refetchSummary()} + /> + ) : !summary ? (

{t("settings.privacy.retention.unavailable")}

diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 985e771ff..cab9665b6 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -445,6 +445,7 @@ export function ScoreSection() { {analyticsQuery.isError ? ( refetch()} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index d2059b6db..69373a1f5 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -165,6 +165,7 @@ export function SecuritySessionsCard({ {isError && ( refetch()} diff --git a/src/components/settings/trusted-devices-card.tsx b/src/components/settings/trusted-devices-card.tsx index 093f0de21..20fa7b45f 100644 --- a/src/components/settings/trusted-devices-card.tsx +++ b/src/components/settings/trusted-devices-card.tsx @@ -105,6 +105,7 @@ export function TrustedDevicesCard({ {isError && ( refetch()}