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()}