From c87ce161bac5dfc06dd20f104144cf35946ecbe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 06:09:49 +0200 Subject: [PATCH] Never render a failed read as an absence of data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No data yet" is a claim about the user's record. Making it when the read failed states something false about their health history. The mental-wellbeing surface was the one that mattered: the assessment history read dropped isError entirely, so a failure left both instrument cards showing "not taken yet" and the detail sheet showing an empty trend. Someone with months of screening results was told, on a mental-health surface, that the results did not exist. It now tells loading, failed and genuinely empty apart, with a retry on the failure. Also swept: - the workouts insights page read isEmpty but not isError, so a failed list read fell through every branch to null and painted a titled shell with nothing in it; - the micronutrients card (not in the original list) rendered its "no nutrient data" empty state on a failed read, while its sibling hydration card already used the shared error card; - the medication detail page and the custom-metric values list showed an honest error with no way out. Both keep retry: false — right for a deleted row, wrong for a transient failure — and gain a user-initiated retry instead, which cannot storm; - /achievements rendered a blank page on a direct hit with the module disabled. It now bounces to /insights like every other gated page. The achievements query error branch, the custom-metric error branch and the chronotype notice were already correct and are left alone. --- messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- src/app/achievements/page.tsx | 23 ++- src/app/insights/workouts/page-client.tsx | 16 +- src/app/medications/[id]/page.tsx | 24 +-- .../failed-read-never-empty.test.tsx | 174 ++++++++++++++++++ .../custom-metric-values-list.tsx | 12 +- .../nutrients/micronutrients-card.tsx | 14 +- .../mental-health/mental-wellbeing.tsx | 60 ++++-- 13 files changed, 330 insertions(+), 53 deletions(-) create mode 100644 src/components/__tests__/failed-read-never-empty.test.tsx diff --git a/messages/de.json b/messages/de.json index 768603e4d..df47d3c46 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3036,7 +3036,8 @@ "crossTraining": "Cross-Training", "mixedCardio": "Mixed Cardio", "other": "Sonstiges" - } + }, + "loadError": "Deine Workouts konnten nicht geladen werden" }, "bloodGlucose": { "title": "Blutzucker", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Details anzeigen" + "openDetail": "Details anzeigen", + "historyLoadError": "Dein Testverlauf konnte nicht geladen werden", + "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut." }, "records": { "disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)", "rowMetaNoRef": "{days} von {window} Tagen", "footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.", - "emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert" + "emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert", + "loadError": "Deine Nährstoffdaten konnten nicht geladen werden" }, "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme." }, diff --git a/messages/en.json b/messages/en.json index 13cb9346a..bc65088f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3036,7 +3036,8 @@ "crossTraining": "Cross training", "mixedCardio": "Mixed cardio", "other": "Other" - } + }, + "loadError": "Your workouts could not be loaded" }, "bloodGlucose": { "title": "Blood Glucose", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Show details" + "openDetail": "Show details", + "historyLoadError": "Your assessment history could not be loaded", + "historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again." }, "records": { "disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)", "rowMetaNoRef": "{days} of {window} days", "footer": "{tracked} of {total} tracked micronutrients have data in this window.", - "emptyTitle": "No micronutrient data synced yet" + "emptyTitle": "No micronutrient data synced yet", + "loadError": "Your nutrient data could not be loaded" }, "sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake." }, diff --git a/messages/es.json b/messages/es.json index eeea4b250..e2a61d014 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3036,7 +3036,8 @@ "crossTraining": "Entreno cruzado", "mixedCardio": "Cardio mixto", "other": "Otro" - } + }, + "loadError": "No se pudieron cargar tus entrenamientos" }, "bloodGlucose": { "title": "Glucosa en sangre", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Ver detalles" + "openDetail": "Ver detalles", + "historyLoadError": "No se pudo cargar tu historial de evaluaciones", + "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo." }, "records": { "disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} de {window} días", "footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.", - "emptyTitle": "Aún no hay datos de micronutrientes sincronizados" + "emptyTitle": "Aún no hay datos de micronutrientes sincronizados", + "loadError": "No se pudieron cargar tus datos de nutrientes" }, "sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total." }, diff --git a/messages/fr.json b/messages/fr.json index 2f70e8345..d6e429b8a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3036,7 +3036,8 @@ "crossTraining": "Cross-training", "mixedCardio": "Cardio mixte", "other": "Autre" - } + }, + "loadError": "Tes séances n'ont pas pu être chargées" }, "bloodGlucose": { "title": "Glycémie", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Voir les détails" + "openDetail": "Voir les détails", + "historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé", + "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie." }, "records": { "disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} sur {window} jours", "footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.", - "emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant" + "emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant", + "loadError": "Tes données de nutriments n'ont pas pu être chargées" }, "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total." }, diff --git a/messages/it.json b/messages/it.json index 7564df5e8..c519d160a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3036,7 +3036,8 @@ "crossTraining": "Cross-training", "mixedCardio": "Cardio misto", "other": "Altro" - } + }, + "loadError": "Non è stato possibile caricare i tuoi allenamenti" }, "bloodGlucose": { "title": "Glicemia", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Mostra dettagli" + "openDetail": "Mostra dettagli", + "historyLoadError": "Non è stato possibile caricare la cronologia dei test", + "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova." }, "records": { "disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} di {window} giorni", "footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.", - "emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato" + "emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato", + "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti" }, "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale." }, diff --git a/messages/pl.json b/messages/pl.json index 5c0a1f890..bdb33dc86 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3036,7 +3036,8 @@ "crossTraining": "Cross-trening", "mixedCardio": "Cardio mieszane", "other": "Inne" - } + }, + "loadError": "Nie udało się wczytać treningów" }, "bloodGlucose": { "title": "Poziom glukozy", @@ -8279,7 +8280,9 @@ "finally": "Finally …" } }, - "openDetail": "Pokaż szczegóły" + "openDetail": "Pokaż szczegóły", + "historyLoadError": "Nie udało się wczytać historii testów", + "historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie." }, "records": { "disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.", @@ -8736,7 +8739,8 @@ "rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)", "rowMetaNoRef": "{days} z {window} dni", "footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.", - "emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach" + "emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach", + "loadError": "Nie udało się wczytać danych o składnikach odżywczych" }, "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie." }, diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 656fd32e8..abee3ed10 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -34,6 +34,8 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; @@ -260,6 +262,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { export default function AchievementsPage() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); + const router = useRouter(); // v1.18.0 — the achievements module gate. When the account has the // module turned off the whole page disappears: an empty state stands in @@ -276,14 +279,26 @@ export default function AchievementsPage() { enabled: isAuthenticated && achievementsEnabled, }); + useEffect(() => { + if (!authLoading && isAuthenticated && !achievementsEnabled) { + router.push("/insights"); + } + }, [authLoading, isAuthenticated, achievementsEnabled, router]); + if (authLoading || (achievementsEnabled && isLoading)) { return ; } - // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry - // is hidden by the same gate, so a direct hit on /achievements renders - // nothing rather than an orphaned shell. No new i18n string is minted - // (the disabled state has no copy of its own). + // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is + // hidden by the same gate, so this only catches a direct URL hit. It used to + // render `null` and stop there, which left a blank page with no explanation + // and no route anywhere — a dead end rather than a gate. It now bounces to + // /insights, matching every other module-gated page (`useModulePageGuard`); + // the brief `null` is the pre-redirect frame, not the destination. + // + // The shared guard is not used wholesale here because it also redirects an + // unauthenticated visitor to /auth/login, and this page deliberately answers + // that case with a "sign in to see your achievements" header instead. if (isAuthenticated && !achievementsEnabled) { return null; } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index f4feb898b..70d8e03db 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -9,6 +9,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { WorkoutList } from "@/components/insights/workout-list"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; /** * v1.4.32 — `/insights/workouts`. @@ -36,7 +37,13 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function InsightsWorkoutsPageClient() { const { t } = useTranslations(); const { ready } = useModulePageGuard("workouts"); - const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 }); + // `isError` / `refetch` were already on the hook and simply not read here, so + // a failed list read fell through every branch to `null` and painted a titled + // shell with nothing in it — no message, no way back. Not the empty state, + // but just as opaque. + const { data, isLoading, isEmpty, isError, refetch } = useWorkouts({ + limit: 100, + }); // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. if (!ready) { @@ -57,7 +64,12 @@ export default function InsightsWorkoutsPageClient() { {/* No `` here: workouts are session records, not a MeasurementType series, so the period-over-period range read has nothing to aggregate. */} - {isLoading ? ( + {isError ? ( + refetch()} + /> + ) : isLoading ? (
diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 2eb9488a8..b0f76eea2 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -22,7 +22,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { BackLink } from "@/components/ui/back-link"; -import { Card } from "@/components/ui/card"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { MedicationDetailTabs, type MedicationDetailSnapshot, @@ -49,6 +49,7 @@ export default function MedicationDetailPage({ data: medication, isLoading, isError, + refetch, } = useQuery({ queryKey: queryKeys.medicationDetail(id), queryFn: async () => { @@ -71,20 +72,21 @@ export default function MedicationDetailPage({ return ; } + // `retry: false` above stays: a deleted medication 404s here and automatic + // backoff against a row that no longer exists is pure waste. But that flag + // also stranded the page on a single transient failure, with no way forward + // short of navigating away and back. A user-initiated retry is the right + // granularity — it costs one request per click and cannot storm. if (isError || !medication) { return (
- -

- {t("medications.detail.shell.loadFailed")} -

-
+
+ void refetch()} + /> +
); } diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx new file mode 100644 index 000000000..9a109e71a --- /dev/null +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import en from "../../../messages/en.json"; + +/** + * A failed read must never render as an absence of data. + * + * "No data yet" is a factual claim about the user's record. Making it when the + * read failed is a false statement about their health history — worst of all on + * the mental-wellbeing surface, where a user with months of screening results + * was told, in a mental-health context, that they had never taken an + * assessment. + * + * Each surface must tell three states apart: loading, failed, and genuinely + * empty. These tests pin the failed/empty split; the loading branch is asserted + * where it is SSR-observable. + * + * SSR-only convention (`renderToStaticMarkup`, no `@testing-library/react`), so + * the error state is produced by seeding the query cache with a settled failure + * rather than by driving a real fetch. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiPost: () => new Promise(() => {}), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { gender: "FEMALE", modules: {} }, + isAuthenticated: true, + isLoading: false, + }), +})); + +import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; +import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; + +function makeClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + // An errored query would otherwise refetch on mount, and the observer + // reports that intent as an optimistic pending state during the server + // render — which would hide the very branch under test. + retryOnMount: false, + staleTime: Infinity, + }, + }, + }); +} + +/** + * Drive `queryKey` to a settled failure through the public prefetch path, so + * the SSR render below observes exactly the state a real failed read leaves in + * the cache. + */ +async function seedFailure( + client: QueryClient, + queryKey: readonly unknown[], +): Promise { + await client.prefetchQuery({ + queryKey: queryKey as unknown[], + queryFn: () => Promise.reject(new Error("read failed")), + retry: false, + }); +} + +function render(node: React.ReactNode, client: QueryClient): string { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("mental wellbeing — screening history", () => { + const mh = en.mentalHealth; + + it("renders the error state with a retry when the history read fails", async () => { + const client = makeClient(); + await seedFailure(client, queryKeys.mentalHealthAssessments()); + const html = render(, client); + + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain(mh.historyLoadError); + expect(html).toContain(en.common.retry); + // The load-bearing assertion: the instrument cards' "not taken yet" copy + // must NOT appear. That sentence is a claim about the user's record. + expect(html).not.toContain(mh.noResultYet); + }); + + it("still renders the instrument cards when the record is genuinely empty", () => { + const client = makeClient(); + client.setQueryData(queryKeys.mentalHealthAssessments(), { + assessments: [], + }); + const html = render(, client); + + expect(html).not.toContain('data-slot="query-error-card"'); + // An honest empty record: the cards render and invite a first check-in. + expect(html).toContain(mh.noResultYet); + }); + + it("shows neither the empty copy nor an error while the read is in flight", () => { + const html = render(, makeClient()); + expect(html).not.toContain('data-slot="query-error-card"'); + expect(html).not.toContain(mh.noResultYet); + }); +}); + +describe("micronutrients card", () => { + const mn = en.nutrients.micronutrients; + + it("renders the error state with a retry when the nutrient read fails", async () => { + const client = makeClient(); + await seedFailure(client, queryKeys.nutrientIntake(30)); + const html = render(, client); + + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain(mn.loadError); + expect(html).toContain(en.common.retry); + expect(html).not.toContain(mn.emptyTitle); + }); + + it("still renders the empty state when the record is genuinely empty", () => { + const client = makeClient(); + client.setQueryData(queryKeys.nutrientIntake(30), { nutrients: [] }); + const html = render(, client); + + expect(html).not.toContain('data-slot="query-error-card"'); + expect(html).toContain(mn.emptyTitle); + }); +}); + +/** + * The remaining surfaces in the sweep are pages whose render depends on the + * router / module gate, which the SSR-only convention cannot drive. Pin their + * contract structurally instead: each must read `isError` from its query and + * hand a retry to the shared error card, so a later edit cannot quietly drop + * the branch and fall back through to the empty state. + */ +describe("read-failure branch is wired on every swept surface", () => { + const surfaces = [ + "src/components/mental-health/mental-wellbeing.tsx", + "src/app/insights/workouts/page-client.tsx", + "src/app/medications/[id]/page.tsx", + "src/components/custom-metrics/custom-metric-values-list.tsx", + "src/app/achievements/page.tsx", + "src/components/insights/nutrients/micronutrients-card.tsx", + "src/components/insights/sleep/chronotype-section.tsx", + ] as const; + + for (const rel of surfaces) { + it(`${rel} reads isError and offers a retry`, () => { + const source = readFileSync(join(process.cwd(), rel), "utf8"); + expect(source).toMatch(/isError/); + // chronotype-section is the one surface that legitimately renders an + // inline notice rather than the card (it sits inside a chart section), + // so it is exempt from the retry affordance but not from the branch. + if (!rel.endsWith("chronotype-section.tsx")) { + expect(source).toMatch(/onRetry=/); + expect(source).toMatch(/refetch/); + } + }); + } +}); diff --git a/src/components/custom-metrics/custom-metric-values-list.tsx b/src/components/custom-metrics/custom-metric-values-list.tsx index 37edf8428..5650f46a6 100644 --- a/src/components/custom-metrics/custom-metric-values-list.tsx +++ b/src/components/custom-metrics/custom-metric-values-list.tsx @@ -6,6 +6,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -41,6 +42,7 @@ export function CustomMetricValuesList({ data: list, isLoading, isError, + refetch, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -68,11 +70,15 @@ export function CustomMetricValuesList({ ); const total = list?.pages[0]?.meta.total ?? 0; + // The error state was honest but a dead end: one red sentence, no way to try + // again short of navigating away and back. QueryErrorCard carries the same + // message with a retry, matching the sibling custom-metric list. if (isError) { return ( -

- {t("customMetrics.loadError")} -

+ void refetch()} + /> ); } diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx index 5919d438c..7822d1811 100644 --- a/src/components/insights/nutrients/micronutrients-card.tsx +++ b/src/components/insights/nutrients/micronutrients-card.tsx @@ -5,6 +5,7 @@ import { Leaf } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import { useAuth } from "@/hooks/use-auth"; @@ -53,7 +54,11 @@ export function MicronutrientsCard() { const sex = user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; - const { data, isLoading } = useQuery({ + // A failed read left `data` undefined, `rows` empty, and the card rendered + // its "no micronutrient data" EmptyState — the same output as an account that + // genuinely logs no nutrients. The sibling hydration card already routes + // through QueryErrorCard; this one now matches it. + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.nutrientIntake(WINDOW_DAYS), queryFn: () => apiGet(`/api/nutrients?days=${WINDOW_DAYS}`), @@ -72,7 +77,12 @@ export function MicronutrientsCard() { - {isLoading ? ( + {isError ? ( + void refetch()} + /> + ) : isLoading ? (