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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:22:43 +02:00
1 parent a27247f93c
commit dad0b89a8c
11 files changed
+156 -34

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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>
);
}
+30 -26
View File
@@ -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;