mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
60c5db8f60
commit
d0d0f49385
9 files changed
+148
-2
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user