merge: standardize insight metric pages — canonical recovery page, per-signal charts + assessments

This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:42:23 +02:00
commit 81e176bdb9
14 files changed
+380 -89

No files matched your search

+7
View File
@@ -4927,6 +4927,13 @@ paths:
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- ANS_CHARGE
- DAY_STRAIN
- WORKOUT_STRAIN
- CARDIO_LOAD
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- ENERGY_EXPENDITURE_KJ
description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s.
The seven specialised metrics are served by their own routes and are not accepted here."
required: true
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Erholungs-Score",
"subtitle": "Sieh dir an, wie sich der nächtliche Score zusammensetzt",
"cta": "Ansehen"
},
"block": {
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
"dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.",
"workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war – die kardiovaskuläre Last dieser Einheit.",
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
}
},
"bodyTempDeviation": {
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Recovery score",
"subtitle": "See how the nightly score breaks down",
"cta": "View"
},
"block": {
"ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.",
"dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.",
"workoutStrainExplainer": "How demanding a single workout was on the same strain scale — the cardiovascular cost of that session.",
"cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
}
},
"bodyTempDeviation": {
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Puntuación de recuperación",
"subtitle": "Mira cómo se descompone la puntuación nocturna",
"cta": "Ver"
},
"block": {
"ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.",
"dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.",
"workoutStrainExplainer": "Cuán exigente fue un entrenamiento en la misma escala de esfuerzo — el coste cardiovascular de esa sesión.",
"cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
}
},
"bodyTempDeviation": {
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Score de récupération",
"subtitle": "Voyez comment se décompose le score nocturne",
"cta": "Voir"
},
"block": {
"ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.",
"dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.",
"workoutStrainExplainer": "À quel point une séance a été exigeante sur la même échelle d'effort — le coût cardiovasculaire de cette séance.",
"cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
}
},
"bodyTempDeviation": {
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Punteggio di recupero",
"subtitle": "Vedi come si scompone il punteggio notturno",
"cta": "Apri"
},
"block": {
"ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.",
"dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.",
"workoutStrainExplainer": "Quanto è stato impegnativo un singolo allenamento sulla stessa scala di sforzo — il costo cardiovascolare di quella sessione.",
"cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
}
},
"bodyTempDeviation": {
+9
View File
@@ -3638,6 +3638,15 @@
"title": "Wynik regeneracji",
"subtitle": "Zobacz, jak rozkłada się nocny wynik",
"cta": "Zobacz"
},
"block": {
"ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.",
"dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.",
"workoutStrainExplainer": "Jak wymagający był pojedynczy trening na tej samej skali wysiłku — koszt sercowo-naczyniowy tej sesji.",
"cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
}
},
"bodyTempDeviation": {
@@ -51,6 +51,15 @@ const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
BREATHING_DISTURBANCES: "sleep",
BLOOD_GLUCOSE: "glucose",
CARDIO_RECOVERY: "recovery",
// v1.18.1 — device-native recovery / strain signals gate on the
// recovery module like CARDIO_RECOVERY.
ANS_CHARGE: "recovery",
DAY_STRAIN: "recovery",
WORKOUT_STRAIN: "recovery",
CARDIO_LOAD: "recovery",
AVERAGE_HEART_RATE: "recovery",
MAX_HEART_RATE: "recovery",
ENERGY_EXPENDITURE_KJ: "recovery",
};
// Closed enum derived from the registry so the route + registry cannot
+8 -3
View File
@@ -14,11 +14,16 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section
* that were stored end-to-end but had no page: day strain, workout strain, ANS
* charge, cardio load, and the day's whole-cycle average / max heart rate and
* kilojoule energy expenditure. The composite RECOVERY_SCORE keeps its anatomy
* detail at `/insights/scores/recovery`; this page cross-links to it and
* surrounds it with the raw signals it never surfaced.
* detail at `/insights/scores/recovery`.
*
* v1.18.1 (B1–B3) — the page now follows the canonical per-metric structure:
* each present signal renders a block carrying a short explainer, the
* max / median / mean stat strip, the same chart with the 7 / 30 / 90 / All
* toggle, and a per-chart assessment. The redundant top "Recovery score"
* cross-link is removed (reached from the overview already).
*
* Every block is data-gated inside `<RecoverySection>`, so an account without a
* strap / ring sees only the calm empty note. Server-authoritative: the tiles
* strap / ring sees only the calm empty note. Server-authoritative: the charts
* render stored values, never a recompute.
*/
export default function InsightsRecoveryPage() {
@@ -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>
+9
View File
@@ -266,6 +266,15 @@ export const CHART_OVERLAY_KEYS = [
"stairAscentSpeed",
"stairDescentSpeed",
"breathingDisturbances",
// v1.18.1 — chart-overlay slots for the rebuilt `/insights/recovery`
// page's device-native signal charts. One per chart surface.
"ansCharge",
"dayStrain",
"workoutStrain",
"cardioLoad",
"averageHeartRate",
"maxHeartRate",
"energyExpenditureKj",
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+84 -1
View File
@@ -117,7 +117,17 @@ export type MetricStatusMetricId =
| "SIX_MINUTE_WALK_DISTANCE"
| "STAIR_ASCENT_SPEED"
| "STAIR_DESCENT_SPEED"
| "BREATHING_DISTURBANCES";
| "BREATHING_DISTURBANCES"
// v1.18.1 — device-native recovery / strain signals (WHOOP / Polar /
// Oura). Each carries a generic assessment so the rebuilt
// `/insights/recovery` page reads one matching text per chart.
| "ANS_CHARGE"
| "DAY_STRAIN"
| "WORKOUT_STRAIN"
| "CARDIO_LOAD"
| "AVERAGE_HEART_RATE"
| "MAX_HEART_RATE"
| "ENERGY_EXPENDITURE_KJ";
/**
* The registry. Keyed by the HealthKit metric id. Each entry's
@@ -495,6 +505,79 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
direction: "lower-better",
archetype: "sleep",
},
// ── v1.18.1 device-native recovery / strain signals ──
// ANS charge — autonomic-nervous-system recharge (Polar Nightly
// Recharge / ring readiness lineage). Higher = better recovered. No
// fixed band: the device scales differ, so the user's own baseline
// leads (target-band defers wholly to it).
ANS_CHARGE: {
id: "ANS_CHARGE",
measurementType: "ANS_CHARGE",
displayName: "ANS charge",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
},
// Day strain — WHOOP's 0–21 cardiovascular-load scale. Neither extreme
// is a goal on its own (high strain with poor recovery is the risk);
// the read is descriptive against the user's own pattern.
DAY_STRAIN: {
id: "DAY_STRAIN",
measurementType: "DAY_STRAIN",
displayName: "Day strain",
unit: "score",
direction: "target-band",
archetype: "activity-fitness",
},
// Workout strain — per-workout strain on the same 0–21 scale.
WORKOUT_STRAIN: {
id: "WORKOUT_STRAIN",
measurementType: "WORKOUT_STRAIN",
displayName: "Workout strain",
unit: "score",
direction: "target-band",
archetype: "activity-fitness",
},
// Cardio load — training-load proxy (acute cardiovascular load).
// Descriptive against the personal baseline.
CARDIO_LOAD: {
id: "CARDIO_LOAD",
measurementType: "CARDIO_LOAD",
displayName: "Cardio load",
unit: "score",
direction: "target-band",
archetype: "activity-fitness",
},
// Whole-day average heart rate (WHOOP cycle average). Lower at rest is
// the fitter signal, but this is a whole-cycle figure, so it is read as
// a target-band against the user's own days rather than a flat lower.
AVERAGE_HEART_RATE: {
id: "AVERAGE_HEART_RATE",
measurementType: "AVERAGE_HEART_RATE",
displayName: "Average heart rate",
unit: "bpm",
direction: "target-band",
archetype: "physiological-vital",
},
// Whole-day peak heart rate (WHOOP cycle max).
MAX_HEART_RATE: {
id: "MAX_HEART_RATE",
measurementType: "MAX_HEART_RATE",
displayName: "Max heart rate",
unit: "bpm",
direction: "target-band",
archetype: "physiological-vital",
},
// Day energy expenditure in kilojoules (WHOOP reports kJ natively).
// Higher reflects a more active day; no fixed band.
ENERGY_EXPENDITURE_KJ: {
id: "ENERGY_EXPENDITURE_KJ",
measurementType: "ENERGY_EXPENDITURE_KJ",
displayName: "Energy expenditure",
unit: "kJ",
direction: "higher-better",
archetype: "activity-fitness",
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */