mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(insights): unify device-score/labs/vorsorge loading, empty, and tile primitives
This commit is contained in:
commit
7637657fa2
15 files changed
+390
-106
No files matched your search
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Erholung",
|
||||
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
|
||||
"emptyTitle": "Noch keine Erholungsdaten",
|
||||
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
|
||||
"recharge": {
|
||||
"title": "Recharge",
|
||||
|
||||
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Recovery",
|
||||
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
|
||||
"emptyTitle": "No recovery data yet",
|
||||
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
|
||||
"recharge": {
|
||||
"title": "Recharge",
|
||||
|
||||
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Recuperación",
|
||||
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
|
||||
"emptyTitle": "Aún no hay datos de recuperación",
|
||||
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
|
||||
"recharge": {
|
||||
"title": "Recarga",
|
||||
|
||||
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Récupération",
|
||||
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
|
||||
"emptyTitle": "Pas encore de données de récupération",
|
||||
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
|
||||
"recharge": {
|
||||
"title": "Recharge",
|
||||
|
||||
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Recupero",
|
||||
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
|
||||
"emptyTitle": "Ancora nessun dato di recupero",
|
||||
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
|
||||
"recharge": {
|
||||
"title": "Ricarica",
|
||||
|
||||
@@ -3602,6 +3602,7 @@
|
||||
"recovery": {
|
||||
"title": "Regeneracja",
|
||||
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
|
||||
"emptyTitle": "Brak danych o regeneracji",
|
||||
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
|
||||
"recharge": {
|
||||
"title": "Recharge",
|
||||
|
||||
@@ -80,6 +80,16 @@ function analyticsWith(summaries: Record<string, DataSummary>) {
|
||||
};
|
||||
}
|
||||
|
||||
function analyticsLoading() {
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isEmpty: false,
|
||||
error: null,
|
||||
refetch: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
analyticsMock.mockReset();
|
||||
});
|
||||
@@ -133,6 +143,27 @@ describe("<DeviceScoreTile> gating", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-score loading skeletons", () => {
|
||||
it("paints the shared Skeleton grid for sleep-quality while loading", () => {
|
||||
analyticsMock.mockReturnValue(analyticsLoading());
|
||||
const html = render(<SleepQualitySection enabled />);
|
||||
expect(html).toContain('data-slot="sleep-quality-loading"');
|
||||
expect(html).toContain('data-slot="device-score-grid-skeleton"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
// No real tiles while the slice is still loading.
|
||||
expect(html).not.toContain('data-slot="device-score-tile"');
|
||||
});
|
||||
|
||||
it("paints the shared Skeleton grid for recovery while loading", () => {
|
||||
analyticsMock.mockReturnValue(analyticsLoading());
|
||||
const html = render(<RecoverySection />);
|
||||
expect(html).toContain('data-slot="recovery-loading"');
|
||||
expect(html).toContain('data-slot="device-score-grid-skeleton"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
expect(html).not.toContain('data-slot="recovery-empty"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SleepQualitySection> data-gating", () => {
|
||||
it("collapses to nothing when no sleep-quality metric has data", () => {
|
||||
analyticsMock.mockReturnValue(
|
||||
@@ -171,6 +202,9 @@ describe("<RecoverySection> data-gating", () => {
|
||||
);
|
||||
const html = render(<RecoverySection />);
|
||||
expect(html).toContain('data-slot="recovery-empty"');
|
||||
// Routed through the shared <EmptyState> (dashed bordered card), not the
|
||||
// old hand-rolled div — the empty title comes from the unified primitive.
|
||||
expect(html).toContain("border-dashed");
|
||||
expect(html).not.toContain('data-slot="recovery-group-strain"');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* v1.17.1 — loading placeholder that mirrors {@link DeviceScoreTile}'s
|
||||
* layout so the device-score grids (recovery + sleep-quality) and the lab
|
||||
* list paint the same tile shape while their analytics slice loads, instead
|
||||
* of popping in or showing a bespoke centred spinner. One header row (title +
|
||||
* latest readout) over a sparkline block, all via the shared `Skeleton`
|
||||
* primitive (which carries `motion-reduce:animate-none` for free).
|
||||
*/
|
||||
export function DeviceScoreTileSkeleton() {
|
||||
return (
|
||||
<Card data-slot="device-score-tile-skeleton" aria-hidden="true">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<CardAction>
|
||||
<Skeleton className="h-5 w-12" />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-[120px] w-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `count`-wide grid of {@link DeviceScoreTileSkeleton}s, matching the
|
||||
* `grid gap-4 sm:grid-cols-2` the device-score sections render their tiles in.
|
||||
*/
|
||||
export function DeviceScoreGridSkeleton({ count = 2 }: { count?: number }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="device-score-grid-skeleton"
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<DeviceScoreTileSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,13 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
@@ -92,32 +97,31 @@ export function DeviceScoreTile({
|
||||
<Card
|
||||
data-slot="device-score-tile"
|
||||
data-metric={type}
|
||||
className={cn("gap-2 py-3 md:py-4", className)}
|
||||
className={className}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{title}</span>
|
||||
</CardTitle>
|
||||
{latest != null ? (
|
||||
<CardAction
|
||||
data-slot="device-score-latest"
|
||||
className="text-foreground self-baseline text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</CardAction>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
</div>
|
||||
{latest != null ? (
|
||||
<span
|
||||
data-slot="device-score-latest"
|
||||
className="text-foreground shrink-0 text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLearning ? (
|
||||
<LearningGate
|
||||
compact
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
|
||||
interface TileSpec {
|
||||
type: string;
|
||||
@@ -142,17 +144,29 @@ export function RecoverySection() {
|
||||
|
||||
const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
|
||||
|
||||
if (!isLoading && summaries != null && !hasAny && !hasRecoveryScore) {
|
||||
if (isLoading || summaries == null) {
|
||||
return (
|
||||
<div
|
||||
data-slot="recovery-empty"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.recovery.empty")}
|
||||
<div className="space-y-3" data-slot="recovery-loading">
|
||||
<SectionHeading
|
||||
icon={Battery}
|
||||
title={t("insights.recovery.recharge.title")}
|
||||
/>
|
||||
<DeviceScoreGridSkeleton count={2} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAny && !hasRecoveryScore) {
|
||||
return (
|
||||
<EmptyState
|
||||
data-slot="recovery-empty"
|
||||
icon={<Heart className="size-6" />}
|
||||
title={t("insights.recovery.emptyTitle")}
|
||||
description={t("insights.recovery.empty")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8" data-slot="recovery-section">
|
||||
{hasRecoveryScore ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
|
||||
/**
|
||||
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
|
||||
@@ -23,12 +24,26 @@ import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
*/
|
||||
export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const summaries = data?.summaries;
|
||||
if (!summaries) return null;
|
||||
|
||||
// While the shared slice loads, paint the tile-shaped Skeleton grid (same
|
||||
// primitive the recovery + labs surfaces use) rather than nothing, so the
|
||||
// section does not pop in. Once data lands the section either renders its
|
||||
// present tiles or collapses to null — an in-page sub-section never shows a
|
||||
// standalone empty card (the page-level empties on /insights/recovery and
|
||||
// /labs own that treatment via <EmptyState>).
|
||||
if (isLoading || !summaries) {
|
||||
return (
|
||||
<section data-slot="sleep-quality-loading" className="space-y-3">
|
||||
<SectionHeading icon={Moon} title={t("insights.sleepQuality.title")} />
|
||||
<DeviceScoreGridSkeleton count={2} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const tiles: Array<{
|
||||
type: string;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.17.1 — the lab list's loading affordance.
|
||||
*
|
||||
* Audit 02-H2/L2: the list used a bespoke centred `<Loader2>` (a different
|
||||
* spinner size than the page above it). The fix paints a tile-shaped
|
||||
* `Skeleton` stack via the shared primitive so labs / recovery / sleep-quality
|
||||
* share one loading dialect. On first synchronous render the query is pending,
|
||||
* so the skeleton branch shows.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
// Never resolves within the synchronous SSR render → query stays loading.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
import { LabList } from "../lab-list";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabList> loading", () => {
|
||||
it("paints the shared Skeleton stack while loading", () => {
|
||||
const html = render(<LabList />);
|
||||
expect(html).toContain('data-slot="lab-list-loading"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
// No bespoke spinner anymore.
|
||||
expect(html).not.toContain("animate-spin");
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Loader2, Trash2 } from "lucide-react";
|
||||
import { FlaskConical, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -104,8 +111,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<div className="space-y-3" data-slot="lab-list-loading">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i} aria-hidden="true">
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24 self-end sm:self-center" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,49 +154,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.analyte.toLowerCase()}>
|
||||
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{group.panel}
|
||||
</span>
|
||||
) : null}
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatValue(group.latest.value)} {group.latest.unit}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{group.panel}
|
||||
</span>
|
||||
{group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{group.latest.referenceLow !== null &&
|
||||
group.latest.referenceHigh !== null
|
||||
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
|
||||
: group.latest.referenceHigh !== null
|
||||
? `≤ ${formatValue(group.latest.referenceHigh)}`
|
||||
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs">
|
||||
{formatDate(group.latest.takenAt)}
|
||||
</span>
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
count: group.readings.length,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 self-end sm:self-center">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
) : null}
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</CardTitle>
|
||||
<CardAction className="flex items-center gap-3">
|
||||
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -190,6 +176,35 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{formatValue(group.latest.value)} {group.latest.unit}
|
||||
</span>
|
||||
{group.latest.referenceLow !== null ||
|
||||
group.latest.referenceHigh !== null ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{group.latest.referenceLow !== null &&
|
||||
group.latest.referenceHigh !== null
|
||||
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
|
||||
: group.latest.referenceHigh !== null
|
||||
? `≤ ${formatValue(group.latest.referenceHigh)}`
|
||||
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs">
|
||||
{formatDate(group.latest.takenAt)}
|
||||
</span>
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
count: group.readings.length,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
|
||||
/**
|
||||
* v1.17.1 — the Vorsorge section's loading + empty affordances.
|
||||
*
|
||||
* Audit 02-H1/H2 + 11-H1: the list read `isLoading` but rendered nothing while
|
||||
* fetching (a header over blank space). The fix paints a tile-shaped `Skeleton`
|
||||
* stack while loading and routes the no-data case through the shared
|
||||
* `<EmptyState>` with an add action. These tests pin both.
|
||||
*/
|
||||
|
||||
const remindersMock = vi.fn();
|
||||
vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
useMeasurementReminders: () => remindersMock(),
|
||||
useMeasurementReminderMutations: () => ({
|
||||
create: { mutate: vi.fn(), isPending: false },
|
||||
remove: { mutate: vi.fn(), isPending: false },
|
||||
satisfy: { mutate: vi.fn(), isPending: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
import { VorsorgeSection } from "../vorsorge-section";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
remindersMock.mockReset();
|
||||
});
|
||||
|
||||
describe("<VorsorgeSection> loading + empty", () => {
|
||||
it("paints the shared Skeleton stack while loading", () => {
|
||||
remindersMock.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain('data-slot="vorsorge-loading"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
// No empty-state while still loading.
|
||||
expect(html).not.toContain('data-slot="empty-state"');
|
||||
});
|
||||
|
||||
it("routes the no-data case through the shared EmptyState with an action", () => {
|
||||
remindersMock.mockReturnValue({ data: [], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain('data-slot="empty-state"');
|
||||
expect(html).toContain("border-dashed");
|
||||
expect(html).not.toContain('data-slot="vorsorge-loading"');
|
||||
});
|
||||
|
||||
it("renders reminder cards once data lands", () => {
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "r1",
|
||||
label: "Annual blood panel",
|
||||
measurementType: null,
|
||||
intervalDays: 365,
|
||||
rrule: null,
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
enabled: true,
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Annual blood panel");
|
||||
expect(html).not.toContain('data-slot="empty-state"');
|
||||
expect(html).not.toContain('data-slot="vorsorge-loading"');
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,18 @@ import { CheckCircle2, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
@@ -137,7 +144,7 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vorsorge-label">
|
||||
{t("measurementReminders.form.label")}
|
||||
@@ -263,10 +270,32 @@ export function VorsorgeSection({ enabled = true }: { enabled?: boolean }) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !showForm && (
|
||||
<div className="space-y-3" data-slot="vorsorge-loading">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Card key={i} aria-hidden="true">
|
||||
<CardContent className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24 shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (reminders?.length ?? 0) === 0 && !showForm && (
|
||||
<EmptyState
|
||||
icon={<Plus className="size-6" />}
|
||||
title={t("measurementReminders.empty.title")}
|
||||
description={t("measurementReminders.empty.description")}
|
||||
action={
|
||||
<Button type="button" onClick={() => setShowForm(true)}>
|
||||
{t("measurementReminders.addButton")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -315,31 +344,16 @@ function VorsorgeCard({
|
||||
{/* NEUTRAL card — no status-driven colour. The due state reads only
|
||||
through the discreet badge below. */}
|
||||
<Card>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-6">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate font-medium">{reminder.label}</p>
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="truncate font-medium">{reminder.label}</span>
|
||||
{!reminder.enabled && (
|
||||
<Badge variant="outline">
|
||||
{t("measurementReminders.disabledBadge")}
|
||||
</Badge>
|
||||
{cadence}
|
||||
</p>
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
</CardTitle>
|
||||
<CardAction className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -361,7 +375,22 @@ function VorsorgeCard({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{t(`measurementReminders.${due.key}`, { days: due.days })}
|
||||
</Badge>
|
||||
{cadence}
|
||||
</p>
|
||||
{reminder.location && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("measurementReminders.location.prefix", {
|
||||
location: reminder.location,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
|
||||
Reference in new issue
Block a user