fix(insights): rebuild the recovery page onto the canonical metric structure

The recovery page rendered a grid of compact device-score tiles plus a
redundant cross-link to the recovery-score anatomy at the top. It now
follows the same structure as every other metric page (blood pressure,
pulse, the HealthKit scaffold): each present device-native signal renders
a block carrying a short explainer, the max / median / mean stat strip,
the shared chart with the identical 7 / 30 / 90 / All toggle, and a
per-chart assessment. Multiple charts now carry multiple matching texts.

Register the seven device-native recovery / strain signals (ANS charge,
day / workout strain, cardio load, whole-day average / max heart rate,
energy expenditure) in the generic metric-status registry so each chart
gets a generated assessment, gate them on the recovery module, and give
each its own chart-overlay slot. Drop the duplicate top score-link block
(this surface is reached from the overview already).

Regenerate the OpenAPI contract for the widened metric-status enum.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:35:09 +02:00
1 parent 930c4a75dd
commit bc2ce702f1
14 files changed
+380 -89

No files matched your search

@@ -154,12 +154,13 @@ describe("device-score loading skeletons", () => {
expect(html).not.toContain('data-slot="device-score-tile"');
});
it("paints the shared Skeleton grid for recovery while loading", () => {
it("paints a chart skeleton 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"');
// v1.18.1 — the rebuilt page leads with canonical chart blocks, so the
// loading shell reserves a chart skeleton (not the old tile grid).
expect(html).toContain('data-slot="chart-skeleton"');
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
@@ -208,7 +209,7 @@ describe("<RecoverySection> data-gating", () => {
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
it("renders the strain group and cross-links the recovery score when present", () => {
it("renders a canonical chart block for the present signal", () => {
analyticsMock.mockReturnValue(
analyticsWith({
DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }),
@@ -218,8 +219,10 @@ describe("<RecoverySection> data-gating", () => {
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-group-strain"');
expect(html).toContain('data-metric="DAY_STRAIN"');
expect(html).toContain('data-slot="recovery-score-link"');
expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"');
// B3 — the redundant "Recovery score" cross-link block is gone; this
// surface is reached from the overview already.
expect(html).not.toContain('data-slot="recovery-score-link"');
// The recharge group (ANS charge only) has no data → hidden.
expect(html).not.toContain('data-slot="recovery-group-recharge"');
// Empty note must not show when at least one signal is present.
@@ -0,0 +1,116 @@
"use client";
import type { ComponentType } from "react";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import type { DataSummary } from "@/lib/analytics/trends";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
/**
* v1.18.1 (B1 / B2) — one canonical chart block on `/insights/recovery`.
*
* The rebuilt recovery page reads exactly like every other metric page
* (blood pressure, pulse, the HealthKit scaffold): each present device
* signal renders its own block carrying the four building blocks the
* canonical structure demands — a short explanatory line, the max /
* median / mean stat strip, the same `<HealthChartDynamic>` chart with the
* identical 7 / 30 / 90 / All toggle, and a per-chart `<MetricStatusCard>`
* assessment (B2: multiple charts → multiple matching texts, generated by
* the generic metric-status path rather than hard-coded).
*
* Device-native scores carry no fixed target band, so no `MetricTargetSummary`
* is mounted here; the assessment + the user's own baseline carry the read.
* Each block owns its own `useChartDomainStats` so brushing one chart's range
* never bleeds into a sibling's stat strip.
*/
export interface RecoveryMetricBlockProps {
/** The DB `MeasurementType` the chart + strip read. */
type: string;
/** Per-block chart-overlay slot so the chart-cog popover persists. */
chartKey: ChartOverlayKey;
/** The registry metric id the per-chart assessment keys on. */
statusMetric: MetricStatusMetricId;
/** Localised block title (chart title + stat-strip header). */
title: string;
/** Short explanatory line beneath the title — what the metric refers to. */
explainer: string;
/** Leading glyph, shared across title / strip / chart. */
icon: ComponentType<{ className?: string }>;
/** Single canonical chart line colour. */
color: string;
/** Unit suffix (e.g. `bpm`, `kJ`, `score`). */
unit: string;
/** Analytics summary for `type`; null until the read lands. */
summary: DataSummary | null;
/** Decimal precision for the stat-strip values. Defaults to 1. */
fractionDigits?: number;
}
export function RecoveryMetricBlock({
type,
chartKey,
statusMetric,
title,
explainer,
icon: Icon,
color,
unit,
summary,
fractionDigits,
}: RecoveryMetricBlockProps) {
const { user, isAuthenticated } = useAuth();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { statsByType, onVisibleStats } = useChartDomainStats();
return (
<section data-slot={`recovery-block-${type}`} className="space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Icon
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<h2 className="text-base font-semibold">{title}</h2>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{explainer}
</p>
</div>
<MetricStatStrip
summary={summary}
unit={unit}
fractionDigits={fractionDigits}
seriesLabel={title}
icon={Icon}
windowStats={statsByType?.[type] ?? null}
/>
<HealthChartDynamic
chartKey={chartKey}
types={[type]}
title={title}
titleIcon={Icon}
colors={[color]}
unit={unit}
yAxisUnit={unit}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
/>
<MetricStatusCard
metric={statusMetric}
icon={<Sparkles className="h-5 w-5" />}
enabled
/>
</section>
);
}
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import {
Activity,
Battery,
@@ -15,61 +14,77 @@ 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";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block";
interface TileSpec {
interface BlockSpec {
type: string;
chartKey: ChartOverlayKey;
statusMetric: MetricStatusMetricId;
title: string;
explainer: string;
icon: typeof Activity;
color: string;
unit?: string;
unit: string;
fractionDigits?: number;
}
interface TileGroup {
interface BlockGroup {
key: string;
headingIcon: typeof Activity;
title: string;
subtitle: string;
tiles: TileSpec[];
blocks: BlockSpec[];
}
/**
* v1.17.1 — body of the `/insights/recovery` sub-page.
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
*
* The home for the WHOOP / Polar device-native recovery + strain scores that
* were ingested but had no render host: day strain, workout strain, ANS charge,
* cardio load, plus the day's whole-cycle average / max heart rate and the
* kilojoule energy-expenditure figure. The composite RECOVERY_SCORE keeps its
* own anatomy view at `/insights/scores/recovery`; this page cross-links to it
* rather than duplicating the ring, and surrounds it with the raw device
* signals it never showed.
* The page now reads exactly like every other metric page: each present
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
* whole-day cardiac / energy figures) renders as a canonical block — a
* short explanatory line, the max / median / mean stat strip, the same
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
*
* Each tile is data-gated (`<DeviceScoreTile>` returns null at count 0) and
* each group hides when none of its tiles has data, so a non-wearable account
* sees only the calm empty state below. Reads the shared analytics summaries
* slice (no extra round-trip); server-authoritative throughout.
* B3 — the redundant "Recovery score" link block that used to lead the
* page is gone: this surface is reached from the overview already, so the
* cross-link was duplicate ("doppelt gemoppelt").
*
* Each block is data-gated (a signal with zero readings is dropped) and a
* group with no present block hides entirely, so a non-wearable account
* sees only the calm empty note. Reads the shared analytics summaries slice
* (no extra round-trip); server-authoritative throughout.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
const summaries = data?.summaries;
const unitScore = t("insights.deviceScore.unitScore");
const unitBpm = t("insights.deviceScore.unitBpm");
const unitKj = t("insights.deviceScore.unitKj");
const groups: TileGroup[] = [
const groups: BlockGroup[] = [
{
key: "recharge",
headingIcon: Battery,
title: t("insights.recovery.recharge.title"),
subtitle: t("insights.recovery.recharge.subtitle"),
tiles: [
blocks: [
{
type: "ANS_CHARGE",
chartKey: "ansCharge",
statusMetric: "ANS_CHARGE",
title: t("measurements.typeAnsCharge"),
explainer: t("insights.recovery.block.ansChargeExplainer"),
icon: Battery,
color: "#50fa7b",
unit: t("insights.deviceScore.unitScore"),
unit: unitScore,
fractionDigits: 1,
},
],
@@ -79,29 +94,38 @@ export function RecoverySection() {
headingIcon: Gauge,
title: t("insights.recovery.strain.title"),
subtitle: t("insights.recovery.strain.subtitle"),
tiles: [
blocks: [
{
type: "DAY_STRAIN",
chartKey: "dayStrain",
statusMetric: "DAY_STRAIN",
title: t("measurements.typeDayStrain"),
explainer: t("insights.recovery.block.dayStrainExplainer"),
icon: Gauge,
color: "#ffb86c",
unit: t("insights.deviceScore.unitScore"),
unit: unitScore,
fractionDigits: 1,
},
{
type: "WORKOUT_STRAIN",
chartKey: "workoutStrain",
statusMetric: "WORKOUT_STRAIN",
title: t("measurements.typeWorkoutStrain"),
explainer: t("insights.recovery.block.workoutStrainExplainer"),
icon: Activity,
color: "#ff79c6",
unit: t("insights.deviceScore.unitScore"),
unit: unitScore,
fractionDigits: 1,
},
{
type: "CARDIO_LOAD",
chartKey: "cardioLoad",
statusMetric: "CARDIO_LOAD",
title: t("measurements.typeCardioLoad"),
explainer: t("insights.recovery.block.cardioLoadExplainer"),
icon: TrendingUp,
color: "#bd93f9",
unit: t("insights.deviceScore.unitScore"),
unit: unitScore,
},
],
},
@@ -110,27 +134,36 @@ export function RecoverySection() {
headingIcon: HeartPulse,
title: t("insights.recovery.cardio.title"),
subtitle: t("insights.recovery.cardio.subtitle"),
tiles: [
blocks: [
{
type: "AVERAGE_HEART_RATE",
chartKey: "averageHeartRate",
statusMetric: "AVERAGE_HEART_RATE",
title: t("measurements.typeAverageHeartRate"),
explainer: t("insights.recovery.block.averageHeartRateExplainer"),
icon: Heart,
color: "#8be9fd",
unit: t("insights.deviceScore.unitBpm"),
unit: unitBpm,
},
{
type: "MAX_HEART_RATE",
chartKey: "maxHeartRate",
statusMetric: "MAX_HEART_RATE",
title: t("measurements.typeMaxHeartRate"),
explainer: t("insights.recovery.block.maxHeartRateExplainer"),
icon: HeartPulse,
color: "#ff5555",
unit: t("insights.deviceScore.unitBpm"),
unit: unitBpm,
},
{
type: "ENERGY_EXPENDITURE_KJ",
chartKey: "energyExpenditureKj",
statusMetric: "ENERGY_EXPENDITURE_KJ",
title: t("measurements.typeEnergyExpenditureKj"),
explainer: t("insights.recovery.block.energyExpenditureKjExplainer"),
icon: Flame,
color: "#f1fa8c",
unit: t("insights.deviceScore.unitKj"),
unit: unitKj,
},
],
},
@@ -139,11 +172,9 @@ export function RecoverySection() {
const hasAny =
summaries != null &&
groups.some((group) =>
group.tiles.some((tile) => (summaries[tile.type]?.count ?? 0) > 0),
group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0),
);
const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
@@ -151,12 +182,12 @@ export function RecoverySection() {
icon={Battery}
title={t("insights.recovery.recharge.title")}
/>
<DeviceScoreGridSkeleton count={2} />
<ChartSkeleton />
</div>
);
}
if (!hasAny && !hasRecoveryScore) {
if (!hasAny) {
return (
<EmptyState
data-slot="recovery-empty"
@@ -169,45 +200,16 @@ export function RecoverySection() {
return (
<div className="space-y-8" data-slot="recovery-section">
{hasRecoveryScore ? (
<Link
href="/insights/scores/recovery"
data-slot="recovery-score-link"
className="bg-card border-border hover:bg-accent/40 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors"
>
<span className="flex min-w-0 items-center gap-2">
<Heart
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">
{t("insights.recovery.scoreLink.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("insights.recovery.scoreLink.subtitle")}
</span>
</span>
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("insights.recovery.scoreLink.cta")}
</span>
</Link>
) : null}
{groups.map((group) => {
const present =
summaries == null
? []
: group.tiles.filter(
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
);
const present = group.blocks.filter(
(block) => (summaries[block.type]?.count ?? 0) > 0,
);
if (present.length === 0) return null;
return (
<section
key={group.key}
data-slot={`recovery-group-${group.key}`}
className="space-y-3"
className="space-y-4"
>
<SectionHeading
icon={group.headingIcon}
@@ -218,17 +220,20 @@ export function RecoverySection() {
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries?.[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
fractionDigits={tile.fractionDigits}
<div className="space-y-8">
{present.map((block) => (
<RecoveryMetricBlock
key={block.type}
type={block.type}
chartKey={block.chartKey}
statusMetric={block.statusMetric}
title={block.title}
explainer={block.explainer}
icon={block.icon}
color={block.color}
unit={block.unit}
summary={summaries[block.type] ?? null}
fractionDigits={block.fractionDigits}
/>
))}
</div>