From 5ae7aa7570f144dcc7a704f8b30c491cd7b486a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 08:47:04 +0200 Subject: [PATCH] feat(insights): add load, error, and empty states to the vitals dashboard Render a skeleton row matching the resolved tile height while the derived batch loads so the grid reserves its final footprint with no layout shift. Hide the section heading until there is content (or a skeleton placeholder) under it, removing the stranded-heading layout shift on empty data. Surface an inline error message plus a Retry that refetches the shared batch query when the batch fails, instead of the whole surface silently vanishing. Mark the tiles container aria-busy while loading and aria-live polite, matching the insights a11y pattern. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../__tests__/vitals-dashboard.test.tsx | 39 +++- .../insights/derived/vitals-dashboard.tsx | 193 +++++++++++++++--- 8 files changed, 214 insertions(+), 30 deletions(-) diff --git a/messages/de.json b/messages/de.json index 78bf46e5b..39cb35622 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "Deine Vitalwerte", + "loadingLabel": "Vitalwerte werden geladen", + "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", "building": "Typischer Bereich entsteht — {count} von {target} Tagen", "typicalRange": "Typisch für dich: {low}–{high}", "fitnessYounger": "Ausdauer wie ~{years} J. jünger", diff --git a/messages/en.json b/messages/en.json index 054d61a83..e0b030cfd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "Your vitals", + "loadingLabel": "Loading your vitals", + "loadError": "Could not load your vitals right now.", "building": "Building your typical range — {count} of {target} days", "typicalRange": "Typical for you: {low}–{high}", "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", diff --git a/messages/es.json b/messages/es.json index b16140457..80d51e25d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "Tus constantes", + "loadingLabel": "Cargando tus constantes", + "loadError": "No se pudieron cargar tus constantes en este momento.", "building": "Creando tu rango habitual — {count} de {target} días", "typicalRange": "Habitual para ti: {low}–{high}", "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", diff --git a/messages/fr.json b/messages/fr.json index 6479aa6df..98d3ff977 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "Vos constantes", + "loadingLabel": "Chargement de vos constantes", + "loadError": "Impossible de charger vos constantes pour le moment.", "building": "Construction de votre plage habituelle — {count} sur {target} jours", "typicalRange": "Habituel pour vous : {low}–{high}", "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", diff --git a/messages/it.json b/messages/it.json index e65233fa0..cd63d35b7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "I tuoi parametri vitali", + "loadingLabel": "Caricamento dei tuoi parametri vitali", + "loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.", "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", "typicalRange": "Abituale per te: {low}–{high}", "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", diff --git a/messages/pl.json b/messages/pl.json index c70c1c764..3b1a05642 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2526,6 +2526,8 @@ }, "vitals": { "sectionTitle": "Twoje parametry życiowe", + "loadingLabel": "Wczytywanie parametrów życiowych", + "loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.", "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", "typicalRange": "Typowe dla Ciebie: {low}–{high}", "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index e30240d7e..b87746fdb 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -64,10 +64,12 @@ function insufficient(reason: string, historyDays = 0): Resp { */ function mockBatch( resolve: (token: DerivedBatchToken) => Resp, - isLoading = false, + extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {}, ) { useDerivedBatch.mockReturnValue({ - isLoading, + isLoading: extra.isLoading ?? false, + isError: extra.isError ?? false, + refetch: extra.refetch ?? (() => {}), read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, }); } @@ -90,13 +92,38 @@ describe("", () => { expect(html).toContain("Typical for you"); }); - it("hides an absent vital entirely (no tile)", () => { + it("hides the whole section (heading + grid) when no vital has content", () => { mockBatch(() => insufficient("no_readings_in_window")); const html = render(); - // The grid + heading still render, but no value tile. - expect(html).toContain('data-slot="vitals-dashboard-grid"'); + // No content under the heading → the heading must not strand over blank + // space. The whole section un-mounts. + expect(html).not.toContain('data-slot="vitals-dashboard"'); + expect(html).not.toContain('data-slot="vitals-dashboard-grid"'); + expect(html).not.toContain("Your vitals"); expect(html).not.toContain('data-state="ok"'); - expect(html).not.toContain('data-metric="WEIGHT"'); + }); + + it("renders a skeleton row (no tiles, no heading strand) while loading", () => { + mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); + const html = render(); + // Heading + grid present so the section reserves its final height, with a + // busy/live region and skeleton placeholders — but no real tiles yet. + expect(html).toContain('data-slot="vitals-dashboard"'); + expect(html).toContain('aria-busy="true"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain('data-slot="vitals-tile-skeleton"'); + expect(html).not.toContain('data-slot="vitals-tile"'); + }); + + it("shows an inline error + retry when the batch fails", () => { + mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); + const html = render(); + // The surface must not silently vanish — a compact error + a Retry button. + expect(html).toContain('data-slot="vitals-dashboard-error"'); + expect(html).toContain('role="alert"'); + expect(html).toContain('data-slot="vitals-dashboard-retry"'); + expect(html).toContain("Could not load your vitals"); + expect(html).toContain("Retry"); }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index c73c5656a..8f16c0b2d 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -1,8 +1,10 @@ "use client"; import { useMemo } from "react"; -import { Gauge, HeartPulse, Scale } from "lucide-react"; +import { Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; @@ -409,6 +411,85 @@ function CoincidentDeviationTile({ read, isLoading }: TileProps) { ); } +/** + * A loading placeholder occupying the resolved tile footprint so the grid + * reserves its final height and the real tiles drop in without a layout + * shift. Mirrors the SparklineDeltaTile geometry (card + label row + value + * row + framing line) with `Skeleton` blocks. Decorative — `aria-hidden`. + */ +function VitalsTileSkeleton() { + return ( + + ); +} + +/** + * Whether the resolved batch holds at least one vital tile worth painting. + * Mirrors the per-tile gates so the section heading is only rendered once + * there is content under it (an empty heading stranded over blank space was + * the CLS the load-state QoL pass closes). Kept in sync with the tile gates + * above — if a new tile gate changes, reflect it here. + */ +function hasRenderableVital(read: DerivedBatchRead): boolean { + const coincident = read({ + metric: "COINCIDENT_DEVIATION", + }); + if ( + coincident?.status === "ok" && + coincident.value?.fired && + coincident.value.contributing.length > 0 + ) { + return true; + } + + const fitness = read({ metric: "FITNESS_AGE" }); + if (fitness?.status === "ok" && fitness.value) return true; + + const vascular = read({ metric: "VASCULAR_AGE_DELTA" }); + if (vascular?.status === "ok" && vascular.value) return true; + + const bmi = read({ metric: "BMI" }); + if (bmi?.status === "ok" && bmi.value) return true; + + const hrv = read({ metric: "HRV_BALANCE" }); + if ( + hrv && + !(hrv.status === "insufficient" && hrv.reason === "no_readings_in_window") + ) { + return true; + } + + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + const baseline = read({ + metric: "VITALS_BASELINE", + type, + }); + if ( + baseline && + !( + baseline.status === "insufficient" && + baseline.reason === "no_readings_in_window" + ) + ) { + return true; + } + } + + return false; +} + /** * The full set of tokens the dashboard reads in one batch — the five * wellness scores + the four derived re-frames + the coincident-deviation @@ -445,35 +526,99 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { }); const read = batch.read; const isLoading = batch.isLoading; + const isError = batch.isError; + const refetch = batch.refetch; const tileProps: TileProps = { read, isLoading }; + // A failed batch (retry:0 + the 8 s client ceiling) must read as an error, + // not as "no data" — otherwise the whole Vitals + Wellness surface silently + // vanishes. Surface a compact message + a Retry that refetches the one + // batch query both strips share. + if (isError) { + return ( +
+
+

+ {t("insights.derived.vitals.sectionTitle")} +

+
+ {t("insights.derived.vitals.loadError")} + +
+
+
+ ); + } + + // The heading only renders once there is content under it (or while the + // skeleton row reserves the final height). When the batch resolves to zero + // vitals the heading would otherwise strand over blank space, then content + // pops in below it — the CLS this pass removes. + const showSection = isLoading || hasRenderableVital(read); + return (
-
-

- {t("insights.derived.vitals.sectionTitle")} -

-
- - - - - - {vitals - .filter((type) => type !== "HEART_RATE_VARIABILITY") - .map((type) => ( - - ))} -
-
+

+ {t("insights.derived.vitals.sectionTitle")} +

+
+ {isLoading ? ( + Array.from({ length: 6 }).map((_, i) => ( + + )) + ) : ( + <> + + + + + + {vitals + .filter((type) => type !== "HEART_RATE_VARIABILITY") + .map((type) => ( + + ))} + + )} +
+ + )}
); }