From dad0b89a8c709b0f86c5fd8f88c1b6b8698435d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 01:22:43 +0200 Subject: [PATCH] fix(insights): unify device-score tiles onto the shared card and skeleton primitives Route the recovery and sleep-quality device-score surfaces through the canonical card and loading primitives so they read as one family: - DeviceScoreTile titles itself with CardHeader/CardTitle + CardAction and drops its bespoke header row and padding override, so it matches the sibling sleep-debt and chronotype cards on the unified p-4 md:p-6 token. - Add DeviceScoreTileSkeleton / DeviceScoreGridSkeleton, a shared tile-shaped placeholder that mirrors the loaded layout. Recovery and sleep-quality paint it while their analytics slice loads instead of popping in. - Recovery's no-data branch now renders the shared EmptyState with an icon rather than a hand-rolled div; the sleep-quality sub-section keeps its calm collapse-to-null once data lands. - New insights.recovery.emptyTitle key across all six locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../__tests__/device-score-surfaces.test.tsx | 34 +++++++++++ .../insights/device-score-tile-skeleton.tsx | 49 ++++++++++++++++ src/components/insights/device-score-tile.tsx | 56 ++++++++++--------- .../insights/recovery/recovery-section.tsx | 26 +++++++-- .../insights/sleep/sleep-quality-section.tsx | 19 ++++++- 11 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 src/components/insights/device-score-tile-skeleton.tsx 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;