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) => ( + + ))} + + )} +
+ + )}
); }