mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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.
This commit is contained in:
1 parent
786454ee2f
commit
5ae7aa7570
8 files changed
+214
-30
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
|
||||
});
|
||||
}
|
||||
@@ -90,13 +92,38 @@ describe("<VitalsDashboard>", () => {
|
||||
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(<VitalsDashboard />);
|
||||
// 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(<VitalsDashboard />);
|
||||
// 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(<VitalsDashboard />);
|
||||
// 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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="vitals-tile-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CoincidentDeviationValue>({
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
});
|
||||
if (
|
||||
coincident?.status === "ok" &&
|
||||
coincident.value?.fired &&
|
||||
coincident.value.contributing.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fitness = read<FitnessAgeValue>({ metric: "FITNESS_AGE" });
|
||||
if (fitness?.status === "ok" && fitness.value) return true;
|
||||
|
||||
const vascular = read<VascularAgeDeltaValue>({ metric: "VASCULAR_AGE_DELTA" });
|
||||
if (vascular?.status === "ok" && vascular.value) return true;
|
||||
|
||||
const bmi = read<BmiValue>({ metric: "BMI" });
|
||||
if (bmi?.status === "ok" && bmi.value) return true;
|
||||
|
||||
const hrv = read<HrvBalanceValue>({ 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<VitalsBaselineValue>({
|
||||
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 (
|
||||
<div
|
||||
data-slot="vitals-dashboard-wrap"
|
||||
className={cn("space-y-6", className)}
|
||||
>
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-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.derived.vitals.loadError")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
data-slot="vitals-dashboard-retry"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t("common.retry")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
|
||||
<WellnessScores read={read} isLoading={isLoading} />
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
{showSection && (
|
||||
<section
|
||||
data-slot="vitals-dashboard"
|
||||
aria-label={t("insights.derived.vitals.sectionTitle")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.derived.vitals.sectionTitle")}
|
||||
</h2>
|
||||
<div
|
||||
data-slot="vitals-dashboard-grid"
|
||||
aria-busy={isLoading}
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
isLoading ? t("insights.derived.vitals.loadingLabel") : undefined
|
||||
}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 6 }).map((_, i) => (
|
||||
<VitalsTileSkeleton key={`vitals-skeleton-${i}`} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<CoincidentDeviationTile {...tileProps} />
|
||||
<FitnessAgeTile {...tileProps} />
|
||||
<VascularAgeTile {...tileProps} />
|
||||
<HrvBalanceTile {...tileProps} />
|
||||
<BmiTile {...tileProps} />
|
||||
{vitals
|
||||
.filter((type) => type !== "HEART_RATE_VARIABILITY")
|
||||
.map((type) => (
|
||||
<BaselineTile key={type} type={type} {...tileProps} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user