From bc2ce702f1efaf3bc5f25b857a0fd6cca5702755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:35:09 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 7 + messages/de.json | 9 + messages/en.json | 9 + messages/es.json | 9 + messages/fr.json | 9 + messages/it.json | 9 + messages/pl.json | 9 + src/app/api/insights/metric-status/route.ts | 9 + src/app/insights/recovery/page.tsx | 11 +- .../__tests__/device-score-surfaces.test.tsx | 15 +- .../recovery/recovery-metric-block.tsx | 116 +++++++++++++ .../insights/recovery/recovery-section.tsx | 163 +++++++++--------- src/lib/dashboard-layout.ts | 9 + src/lib/insights/metric-status-registry.ts | 85 ++++++++- 14 files changed, 380 insertions(+), 89 deletions(-) create mode 100644 src/components/insights/recovery/recovery-metric-block.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e55644f1..cb1029861 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 0a4f06172..c7befd31d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index a9fcbab85..32385a1a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index e7e8e81e2..3d0c9e8a5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 1320cc4c2..fa30711b1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index a643e7671..11959a6f4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 0b1792274..ad476cd02 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index e6b62286e..e7d0076d3 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -51,6 +51,15 @@ const METRIC_MODULE: Partial> = { 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 diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx index df6a268b7..7db6abea0 100644 --- a/src/app/insights/recovery/page.tsx +++ b/src/app/insights/recovery/page.tsx @@ -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 ``, 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() { diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 537537f3d..73ae8e968 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -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(); 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(" 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(" data-gating", () => { ); const html = render(); 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. diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx new file mode 100644 index 000000000..07c419343 --- /dev/null +++ b/src/components/insights/recovery/recovery-metric-block.tsx @@ -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 `` chart with the + * identical 7 / 30 / 90 / All toggle, and a per-chart `` + * 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 ( +
+
+
+
+

+ {explainer} +

+
+ + + + + + } + enabled + /> +
+ ); +} diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 02d6f0a2e..f0b1a7dca 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -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 + * `` chart with the identical 7 / 30 / 90 / All toggle, + * and a per-chart AI assessment (``). The composite + * RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`. * - * Each tile is data-gated (`` 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 (
@@ -151,12 +182,12 @@ export function RecoverySection() { icon={Battery} title={t("insights.recovery.recharge.title")} /> - +
); } - if (!hasAny && !hasRecoveryScore) { + if (!hasAny) { return ( - {hasRecoveryScore ? ( - - - - - {t("insights.recovery.scoreLink.cta")} - - - ) : 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 (
} /> -
- {present.map((tile) => ( - + {present.map((block) => ( + ))}
diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 3c58e3cf7..f5f0833d8 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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]; diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index 4ce666921..e374895fd 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -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 = { 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). */