diff --git a/messages/de.json b/messages/de.json index 1d1ba962d..ff332a5f1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3a8b08b12..2f0bc9511 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 210c603ad..92e2f8707 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e73f56d89..73dbb3706 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 5e20abdb5..1c3001bfc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f62178eb5..f4bbf1655 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index e861f6bc1..537537f3d 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -80,6 +80,16 @@ function analyticsWith(summaries: Record) { }; } +function analyticsLoading() { + return { + data: undefined, + isLoading: true, + isEmpty: false, + error: null, + refetch: () => {}, + }; +} + beforeEach(() => { analyticsMock.mockReset(); }); @@ -133,6 +143,27 @@ describe(" gating", () => { }); }); +describe("device-score loading skeletons", () => { + it("paints the shared Skeleton grid for sleep-quality while loading", () => { + analyticsMock.mockReturnValue(analyticsLoading()); + const html = render(); + 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(); + 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(" data-gating", () => { it("collapses to nothing when no sleep-quality metric has data", () => { analyticsMock.mockReturnValue( @@ -171,6 +202,9 @@ describe(" data-gating", () => { ); const html = render(); expect(html).toContain('data-slot="recovery-empty"'); + // Routed through the shared (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"'); }); diff --git a/src/components/insights/device-score-tile-skeleton.tsx b/src/components/insights/device-score-tile-skeleton.tsx new file mode 100644 index 000000000..6ad95042c --- /dev/null +++ b/src/components/insights/device-score-tile-skeleton.tsx @@ -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 ( + + ); +} + +/** + * 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 ( +
+ {Array.from({ length: count }, (_, i) => ( + + ))} +
+ ); +} diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 69af38d43..8c01db054 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -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({ + + + + {latest != null ? ( + + {fmt(latest)} + {unit ? ( + + {unit} + + ) : null} + + ) : null} + -
-
-
- {latest != null ? ( - - {fmt(latest)} - {unit ? ( - - {unit} - - ) : null} - - ) : null} -
- {isLearning ? ( 0; - if (!isLoading && summaries != null && !hasAny && !hasRecoveryScore) { + if (isLoading || summaries == null) { return ( -
- {t("insights.recovery.empty")} +
+ +
); } + if (!hasAny && !hasRecoveryScore) { + return ( + } + title={t("insights.recovery.emptyTitle")} + description={t("insights.recovery.empty")} + /> + ); + } + return (
{hasRecoveryScore ? ( diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx index 0b6a817ed..7813c07d2 100644 --- a/src/components/insights/sleep/sleep-quality-section.tsx +++ b/src/components/insights/sleep/sleep-quality-section.tsx @@ -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 ). + if (isLoading || !summaries) { + return ( +
+ + +
+ ); + } const tiles: Array<{ type: string; diff --git a/src/components/labs/__tests__/lab-list-loading.test.tsx b/src/components/labs/__tests__/lab-list-loading.test.tsx new file mode 100644 index 000000000..d01a62c43 --- /dev/null +++ b/src/components/labs/__tests__/lab-list-loading.test.tsx @@ -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 `` (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( + + {node} + , + ); +} + +describe(" loading", () => { + it("paints the shared Skeleton stack while loading", () => { + const html = render(); + expect(html).toContain('data-slot="lab-list-loading"'); + expect(html).toContain('data-slot="skeleton"'); + // No bespoke spinner anymore. + expect(html).not.toContain("animate-spin"); + }); +}); diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 0b67f2e71..ab1a0cd5c 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -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 ( -
- +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))}
); } @@ -137,49 +154,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
{groups.map((group) => ( - -
-
- {group.analyte} - {group.panel ? ( - - {group.panel} - - ) : null} - -
-
- - {formatValue(group.latest.value)} {group.latest.unit} + + + {group.analyte} + {group.panel ? ( + + {group.panel} - {group.latest.referenceLow !== null || - group.latest.referenceHigh !== null ? ( - - {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)}`} - - ) : null} - - {formatDate(group.latest.takenAt)} - - {group.readings.length > 1 ? ( - - {t("labs.readingsCount", { - count: group.readings.length, - })} - - ) : null} -
-
-
- r.value)} - /> + ) : null} + + + + r.value)} /> + + + +
+ + {formatValue(group.latest.value)} {group.latest.unit} + + {group.latest.referenceLow !== null || + group.latest.referenceHigh !== null ? ( + + {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)}`} + + ) : null} + + {formatDate(group.latest.takenAt)} + + {group.readings.length > 1 ? ( + + {t("labs.readingsCount", { + count: group.readings.length, + })} + + ) : null}
diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx new file mode 100644 index 000000000..66c83a991 --- /dev/null +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -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 + * `` 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( + {node}, + ); +} + +beforeEach(() => { + remindersMock.mockReset(); +}); + +describe(" loading + empty", () => { + it("paints the shared Skeleton stack while loading", () => { + remindersMock.mockReturnValue({ data: undefined, isLoading: true }); + const html = render(); + 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(); + 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(); + expect(html).toContain("Annual blood panel"); + expect(html).not.toContain('data-slot="empty-state"'); + expect(html).not.toContain('data-slot="vorsorge-loading"'); + }); +}); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index b86900975..162de30bb 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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 && ( - +