From a1c5d78f725b29f99735fe540fe16d3767b28e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 23:09:55 +0200 Subject: [PATCH] fix(nutrients): stop the /insights/nutrients overview swallowing a failed read as absence The page's overview query shares queryKeys.nutrientIntake(30) with MicronutrientsCard, which already renders its own QueryErrorCard on a failed read. But the page's empty-state check ran first and treated undefined data (the failed-read state) the same as a genuinely empty window, returning "no nutrient data yet" and short-circuiting before any of the three cards - including the already-correct one - ever mounted. A self-hoster with HealthKit permissions granted and a read that actually failed saw the same blank invitation as someone who never logged anything. Add the isError branch above the empty-state check so a failed read surfaces its own QueryErrorCard with a retry, and cover it with a test that renders the actual page (not just the card in isolation) so a regression here can't hide behind a passing unit test again. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/insights/nutrients/page.tsx | 25 +++++++++++ .../failed-read-never-empty.test.tsx | 45 ++++++++++++++++++- 8 files changed, 81 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index bff89c5ed..ad16401f4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.", "moduleOffCta": "Nährstoffe aktivieren", "emptyTitle": "Noch keine Nährstoffdaten", - "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag." + "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag.", + "loadError": "Deine Nährstoffdaten konnten nicht geladen werden" }, "hydration": { "referenceMeta": "Richtwert {value} {unit}/Tag · EFSA", diff --git a/messages/en.json b/messages/en.json index 727acb4e2..fa2d42cc3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.", "moduleOffCta": "Turn on nutrients", "emptyTitle": "No nutrient data yet", - "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry." + "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry.", + "loadError": "Your nutrient data could not be loaded" }, "hydration": { "referenceMeta": "Reference intake {value} {unit}/day · EFSA", diff --git a/messages/es.json b/messages/es.json index b2f6e063a..251f651ca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.", "moduleOffCta": "Activar nutrientes", "emptyTitle": "Aún no hay datos de nutrientes", - "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua." + "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua.", + "loadError": "No se pudieron cargar tus datos de nutrientes" }, "hydration": { "referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA", diff --git a/messages/fr.json b/messages/fr.json index 90abcf387..a67466eed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.", "moduleOffCta": "Activer les nutriments", "emptyTitle": "Pas encore de données nutritionnelles", - "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau." + "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau.", + "loadError": "Tes données de nutriments n'ont pas pu être chargées" }, "hydration": { "referenceMeta": "Apport de référence {value} {unit}/jour · EFSA", diff --git a/messages/it.json b/messages/it.json index 27f980c31..eb3baad20 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.", "moduleOffCta": "Attiva nutrienti", "emptyTitle": "Ancora nessun dato sui nutrienti", - "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente." + "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente.", + "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti" }, "hydration": { "referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA", diff --git a/messages/pl.json b/messages/pl.json index 0517f9e04..2cdb2cd35 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9087,7 +9087,8 @@ "moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.", "moduleOffCta": "Włącz składniki odżywcze", "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", - "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody." + "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody.", + "loadError": "Nie udało się wczytać danych o składnikach odżywczych" }, "hydration": { "referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA", diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index 5f123e74c..0922ce0ed 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; @@ -33,6 +34,16 @@ const WINDOW_DAYS = 30; * on the device is not visible consent to a server / Coach holding * a supplement pattern); this page just makes the toggle * discoverable in context instead of buried in Settings. + * - module on, the overview read failed → a `QueryErrorCard` with a + * retry. This branch has to sit ABOVE the empty-state check below: + * `overview.data` is `undefined` on a failed read exactly like it is + * before the first response lands, so without this the page told a + * self-hoster who granted every HealthKit permission "no nutrient + * data yet" when the read had actually failed — a guess dressed up + * as an honest absence. `` reads this SAME + * `queryKeys.nutrientIntake(WINDOW_DAYS)` query and already renders + * its own error card, but this page's old empty-state branch + * short-circuited before that card ever mounted. * - module on, zero rows anywhere in the window → one EmptyState * explaining where data comes from (phone health-app sync or * manual water entry) instead of three near-empty cards. @@ -83,6 +94,20 @@ export default function InsightsNutrientsPage() { ); } + if (overview.isError) { + return ( + + void overview.refetch()} + /> + + ); + } + const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0; if (!overview.isLoading && !hasAnyData) { return ( diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx index 8b4eaf6ff..0b008e8e3 100644 --- a/src/components/__tests__/failed-read-never-empty.test.tsx +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -32,8 +32,11 @@ vi.mock("@/lib/api/api-fetch", () => ({ })); vi.mock("@/hooks/use-auth", () => ({ + // `modules.nutrients: true` so the `/insights/nutrients` page tests below + // reach their query instead of short-circuiting on the module-off branch; + // no other surface in this file reads `user.modules`. useAuth: () => ({ - user: { gender: "FEMALE", modules: {} }, + user: { gender: "FEMALE", modules: { nutrients: true } }, isAuthenticated: true, isLoading: false, }), @@ -41,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; +import InsightsNutrientsPage from "@/app/insights/nutrients/page"; function makeClient() { return new QueryClient({ @@ -140,6 +144,40 @@ describe("micronutrients card", () => { }); }); +describe("/insights/nutrients page — overview read shares the micronutrients queryKey", () => { + const np = en.nutrients.page; + + // The page's overview query and `` read the exact same + // `queryKeys.nutrientIntake(30)` cache entry. Before this branch existed, a + // failed read left `overview.data` undefined — indistinguishable from the + // pre-response state — so the page's empty-state check fired first and + // returned "no nutrient data yet" without ever mounting the three cards + // below (including ``'s own, already-correct error + // card). + it("renders the page-level error card, not the empty state, when the overview 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(np.loadError); + expect(html).toContain(en.common.retry); + expect(html).not.toContain(np.emptyTitle); + // The three-card spine must not mount alongside the error notice. + expect(html).not.toContain('data-slot="nutrients-hydration-card"'); + expect(html).not.toContain('data-slot="nutrients-micronutrients-card"'); + }); + + it("still renders the honest empty state when the window genuinely has no rows", () => { + 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(np.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 @@ -155,6 +193,11 @@ describe("read-failure branch is wired on every swept surface", () => { "src/components/custom-metrics/custom-metric-values-list.tsx", "src/app/achievements/page.tsx", "src/components/insights/nutrients/micronutrients-card.tsx", + // The page shares `MicronutrientsCard`'s exact queryKey and used to + // short-circuit to the "no nutrient data yet" EmptyState on a failed + // read, before that card's own (already-correct) error branch ever + // got a chance to mount. + "src/app/insights/nutrients/page.tsx", // A failed comprehensive read used to fall through to "you haven't added // any medications yet" — with a CTA inviting someone on a full daily // regimen to re-enter what the app already holds.