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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:49:46 +02:00
1 parent 60c5db8f60
commit d0d0f49385
9 files changed
+148 -2

No files matched your search

+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -142,6 +142,48 @@ describe("<HealthKitMetricPage> 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(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
);
// 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(
<HealthKitMetricPage {...baseProps} statusMetric="HEART_RATE_VARIABILITY" />,
);
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
@@ -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 (
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
statStrip={<StatStripSkeleton />}
>
<ChartSkeleton />
</SubPageShell>
);
}
// 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 `<VitalsDashboard>`
// pattern.
if (error) {
return (
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<div
data-slot="healthkit-metric-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.subPage.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="healthkit-metric-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
</SubPageShell>
);
}
if (isEmpty) {
const ctaNode =
emptyStateCtaType != null ? (
@@ -284,3 +344,32 @@ export function HealthKitMetricPage({
</SubPageShell>
);
}
/**
* v1.12.7 — layout-stable loading shell for the stat strip slot. Mirrors
* the loaded `<MetricStatStrip>` 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 (
<Card
data-slot="metric-stat-strip-skeleton"
aria-hidden="true"
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-3">
<Skeleton className="h-5 w-32" />
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="min-h-[44px] space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
+9
View File
@@ -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();
},
};
}