mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge: standardize insight metric pages — canonical recovery page, per-signal charts + assessments
This commit is contained in:
commit
81e176bdb9
14 files changed
+380
-89
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in new issue
Block a user