From d0d0f4938531c00ad3801b06828114d1cca1315e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 13:48:46 +0200 Subject: [PATCH] feat(insights): add loading skeleton and error+retry to HealthKit subpages The HealthKit metric pages consumed only `{data, isEmpty}` and ignored the hook's `isLoading`/`error`, so they painted nothing in flight and fell through silently on failure. Branch on `isLoading` to a stat-strip + chart skeleton, and on `error` to a message plus a Retry that re-issues the analytics query (mirrors the vitals-dashboard pattern). Expose `refetch` from the analytics hook and add a `subPage.loadError` string in all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../healthkit-metric-page-status.test.tsx | 42 +++++++++ .../insights/healthkit-metric-page.tsx | 93 ++++++++++++++++++- src/hooks/use-insights-analytics.ts | 9 ++ 9 files changed, 148 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2d7779d34..f2b5eb18d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.", "pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.", diff --git a/messages/en.json b/messages/en.json index f7ca2aa09..afd44dcf2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Could not load this metric right now.", "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.", "pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/es.json b/messages/es.json index 2fb005280..64787b5a4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "No se pudo cargar esta métrica ahora mismo.", "blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.", "gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/fr.json b/messages/fr.json index fcc7105dc..afd77ad1f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Impossible de charger cette mesure pour le moment.", "blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.", "gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/it.json b/messages/it.json index 0483242c2..f5b7b8531 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Impossibile caricare questa metrica al momento.", "blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.", "gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/messages/pl.json b/messages/pl.json index 73c9d03df..61f3e396f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1984,6 +1984,7 @@ } }, "subPage": { + "loadError": "Nie udało się teraz wczytać tej metryki.", "blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.", "gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.", "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index d03212622..0e5996a25 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -142,6 +142,48 @@ describe(" status mount", () => { expect(metricStatusMock).not.toHaveBeenCalled(); }); + it("renders the loading skeleton while the analytics read is in flight", () => { + analyticsMock.mockReturnValue({ + data: undefined, + isEmpty: false, + isLoading: true, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render( + , + ); + + // Stat-strip skeleton + chart skeleton paint; the assessment card never + // mounts in flight. + expect(html).toContain('data-slot="metric-stat-strip-skeleton"'); + expect(html).toContain('data-slot="chart-skeleton"'); + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + + it("renders an error message + retry control when the analytics read fails", () => { + const refetch = vi.fn(); + analyticsMock.mockReturnValue({ + data: undefined, + isEmpty: false, + isLoading: false, + error: new Error("boom"), + refetch, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render( + , + ); + + expect(html).toContain('data-slot="healthkit-metric-error"'); + expect(html).toContain('data-slot="healthkit-metric-retry"'); + expect(html).toContain('role="alert"'); + // The error branch wins over the data / empty branches, so the + // assessment fetch never fires. + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => { // v1.8.7.1 (Design H1) — the active-energy page formerly passed // `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 872791862..608590570 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; -import { Sparkles } from "lucide-react"; +import { RefreshCw, Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -14,6 +14,9 @@ import type { InsightMetric } from "@/lib/insights/metric-availability"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card"; @@ -164,7 +167,13 @@ export function HealthKitMetricPage({ const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + const { + data: analytics, + isEmpty, + isLoading, + error, + refetch, + } = useInsightsAnalytics(insightMetric); // v1.12.7 — shared brushed-window state. The chart reports the per-type // Min / Max / Median / Mean for the selected domain; the stat strip reads @@ -195,6 +204,57 @@ export function HealthKitMetricPage({ const title = t(`${i18nPrefix}.title`); const description = t(`${i18nPrefix}.description`); + // v1.12.7 — in-flight skeleton. The page consumed only `{data, isEmpty}` + // before, so the ~30 HealthKit sub-pages painted nothing until the + // analytics read landed, then popped the content in. Branch on the hook's + // `isLoading` to reserve the stat-strip + chart height with the same + // skeletons the rest of the surface uses, so the layout holds. + if (isLoading) { + return ( + } + > + + + ); + } + + // v1.12.7 — error + retry. A failed analytics read used to fall through to + // the empty-state (or a blank surface); surface a compact message + a + // Retry that re-issues the query, mirroring the `` + // pattern. + if (error) { + return ( + +
+ {t("insights.subPage.loadError")} + +
+
+ ); + } + if (isEmpty) { const ctaNode = emptyStateCtaType != null ? ( @@ -284,3 +344,32 @@ export function HealthKitMetricPage({ ); } + +/** + * v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors + * the loaded `` card chrome (denser `py-3` rhythm, one + * header row + a four-up grid) so the page does not jump when the analytics + * read lands. Decorative — hidden from assistive tech; the chart skeleton + * below it carries the `aria-busy` announcement. + */ +function StatStripSkeleton() { + return ( + + ); +} diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts index 540f30a55..aa4bcc20a 100644 --- a/src/hooks/use-insights-analytics.ts +++ b/src/hooks/use-insights-analytics.ts @@ -46,6 +46,12 @@ export interface UseInsightsAnalyticsResult { */ isEmpty: boolean; error: Error | null; + /** + * Re-issue the underlying `/api/analytics?slice=summaries` query. Wired + * to the error+retry control on the HealthKit sub-pages so a failed load + * recovers without a full reload. + */ + refetch: () => void; } export function useInsightsAnalytics( @@ -78,5 +84,8 @@ export function useInsightsAnalytics( isLoading: query.isLoading, isEmpty, error: query.error as Error | null, + refetch: () => { + void query.refetch(); + }, }; }