mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
a27247f93c
commit
dad0b89a8c
11 files changed
+156
-34
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;
|
||||
|
||||
Reference in new issue
Block a user