From ef188e0503ac0028dc51d222e7509e0437b6cac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:45:58 +0200 Subject: [PATCH 01/10] feat(measurements): add average/max heart rate + sleep disturbance types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three MeasurementType values for WHOOP fields a coverage audit found uncaptured: AVERAGE_HEART_RATE and MAX_HEART_RATE (the cycle score's whole-day aggregates, kept distinct from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE) and SLEEP_DISTURBANCE_COUNT (the per-night disturbance tally). Units: bpm / bpm / count. Wiring mirrors the existing per-type surfaces, traced from VO2_MAX and the v1.11.0 WHOOP-native score classes: - canonical enum + unit + plausibility range in src/lib/validations/measurement.ts (every ingest / analytics / rollup path derives from measurementTypeEnum.options, so the mean-based rollup tier picks the new types up automatically). - Prisma enum + migration 0125 (ALTER TYPE ADD VALUE IF NOT EXISTS, non-transactional, idempotent — same pattern as 0111). - category overlay (cardiovascular for the HR pair, sleep for the count). - list-view label key / Lucide icon / chart-colour. - i18n label key in all six locales. - chart-token allowlist + orphan-enum stripper. - PersonalRecord direction (HR pair null — no clean goal axis, like WALKING_HEART_RATE_AVERAGE; disturbance count MIN, like BREATHING_DISTURBANCES). - enum-coverage drift guard (count 60 -> 63, doctor-PDF exclusions for the device-derived signals) + apple-health HK-counterpart exclusion (these are WHOOP-only; HealthKit ships no first-class daily-aggregate HR quantity nor a disturbance tally). The types render on the measurement list + Insights chart surfaces; like the v1.11.0 WHOOP-native scores they get no dedicated Insights sub-page (the sub-page map stays curated) and no LOINC / FHIR mapping (not clinical-grade). Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 5 +++- messages/en.json | 5 +++- messages/es.json | 5 +++- messages/fr.json | 5 +++- messages/it.json | 5 +++- messages/pl.json | 5 +++- .../migration.sql | 22 ++++++++++++++++ prisma/schema.prisma | 10 ++++++++ .../measurements/measurement-list-meta.ts | 18 +++++++++++++ .../measurement-type-enum-coverage.test.ts | 15 ++++++++++- src/lib/insights/chart-tokens.ts | 11 ++++++++ .../__tests__/apple-health-mapping.test.ts | 8 ++++++ src/lib/measurements/categories.ts | 8 ++++++ src/lib/personal-records/pr-direction.ts | 10 ++++++++ src/lib/validations/measurement.ts | 25 +++++++++++++++++++ 15 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql diff --git a/messages/de.json b/messages/de.json index b8e95477c..bf9eb7544 100644 --- a/messages/de.json +++ b/messages/de.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Schlafeffizienz", "typeSleepConsistency": "Schlafkonsistenz", "typeSleepNeed": "Schlafbedarf", - "typeEnergyExpenditureKj": "Energieverbrauch" + "typeEnergyExpenditureKj": "Energieverbrauch", + "typeAverageHeartRate": "Durchschnittlicher Puls", + "typeMaxHeartRate": "Maximaler Puls", + "typeSleepDisturbanceCount": "Schlafstörungen" }, "mood": { "title": "Stimmung", diff --git a/messages/en.json b/messages/en.json index 82405daa4..12615cafd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Sleep efficiency", "typeSleepConsistency": "Sleep consistency", "typeSleepNeed": "Sleep need", - "typeEnergyExpenditureKj": "Energy expenditure" + "typeEnergyExpenditureKj": "Energy expenditure", + "typeAverageHeartRate": "Average heart rate", + "typeMaxHeartRate": "Maximum heart rate", + "typeSleepDisturbanceCount": "Sleep disturbances" }, "mood": { "title": "Mood", diff --git a/messages/es.json b/messages/es.json index 5cff9817f..037b7afba 100644 --- a/messages/es.json +++ b/messages/es.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Eficiencia del sueño", "typeSleepConsistency": "Consistencia del sueño", "typeSleepNeed": "Necesidad de sueño", - "typeEnergyExpenditureKj": "Gasto energético" + "typeEnergyExpenditureKj": "Gasto energético", + "typeAverageHeartRate": "Frecuencia cardíaca media", + "typeMaxHeartRate": "Frecuencia cardíaca máxima", + "typeSleepDisturbanceCount": "Interrupciones del sueño" }, "mood": { "title": "Estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index e855a5951..2503a8966 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Efficacité du sommeil", "typeSleepConsistency": "Régularité du sommeil", "typeSleepNeed": "Besoin de sommeil", - "typeEnergyExpenditureKj": "Dépense énergétique" + "typeEnergyExpenditureKj": "Dépense énergétique", + "typeAverageHeartRate": "Fréquence cardiaque moyenne", + "typeMaxHeartRate": "Fréquence cardiaque maximale", + "typeSleepDisturbanceCount": "Perturbations du sommeil" }, "mood": { "title": "Humeur", diff --git a/messages/it.json b/messages/it.json index 328707c98..edeec0b39 100644 --- a/messages/it.json +++ b/messages/it.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Efficienza del sonno", "typeSleepConsistency": "Costanza del sonno", "typeSleepNeed": "Fabbisogno di sonno", - "typeEnergyExpenditureKj": "Dispendio energetico" + "typeEnergyExpenditureKj": "Dispendio energetico", + "typeAverageHeartRate": "Frequenza cardiaca media", + "typeMaxHeartRate": "Frequenza cardiaca massima", + "typeSleepDisturbanceCount": "Disturbi del sonno" }, "mood": { "title": "Umore", diff --git a/messages/pl.json b/messages/pl.json index 8727a1bbf..42c8a52bb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -473,7 +473,10 @@ "typeSleepEfficiency": "Efektywność snu", "typeSleepConsistency": "Regularność snu", "typeSleepNeed": "Zapotrzebowanie na sen", - "typeEnergyExpenditureKj": "Wydatek energetyczny" + "typeEnergyExpenditureKj": "Wydatek energetyczny", + "typeAverageHeartRate": "Średnie tętno", + "typeMaxHeartRate": "Maksymalne tętno", + "typeSleepDisturbanceCount": "Zakłócenia snu" }, "mood": { "title": "Nastrój", diff --git a/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql new file mode 100644 index 000000000..58c713efe --- /dev/null +++ b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql @@ -0,0 +1,22 @@ +-- v1.12.8 — WHOOP cycle + sleep coverage completion (additive). +-- +-- Purely-additive: three new `measurement_type` enum members for WHOOP fields +-- a coverage audit found were read-and-dropped (cycle average / max heart +-- rate) or never fetched (per-night sleep disturbance count). No backfill, no +-- existing row touched. +-- +-- AVERAGE_HEART_RATE / MAX_HEART_RATE land the WHOOP `cycle.score` +-- average_heart_rate / max_heart_rate (previously typed but never emitted by +-- `mapCycle`). They stay distinct from spot PULSE, RESTING_HEART_RATE, and +-- WALKING_HEART_RATE_AVERAGE — these are the day's whole-cycle aggregates. +-- +-- SLEEP_DISTURBANCE_COUNT lands the WHOOP `sleep.score.stage_summary` +-- disturbance_count (per-night tally). +-- +-- Postgres enum-add is non-transactional; the `IF NOT EXISTS` guards make a +-- rerun safe. Forward-only — Postgres cannot remove an enum value, so the +-- three new members stay; with no rows carrying them they are inert. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AVERAGE_HEART_RATE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MAX_HEART_RATE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_DISTURBANCE_COUNT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e07cb488e..0fd25f1a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -650,6 +650,16 @@ enum MeasurementType { SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night. ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips). + // ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ── + // Three previously-uncaptured WHOOP fields. AVERAGE_HEART_RATE / MAX_HEART_RATE + // ride the cycle score (already fetched, previously dropped); kept distinct + // from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE because + // they are the day's whole-cycle aggregates. SLEEP_DISTURBANCE_COUNT is the + // per-night `stage_summary.disturbance_count`. + AVERAGE_HEART_RATE // bpm — WHOOP `cycle.score.average_heart_rate`. The day's whole-cycle average heart rate. + MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate. + SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances. + @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index ad4d1ab39..b171fd450 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -34,6 +34,7 @@ import { Headphones, Sun, PersonStanding, + Waves, type LucideIcon, } from "lucide-react"; @@ -109,6 +110,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { SLEEP_CONSISTENCY: "measurements.typeSleepConsistency", SLEEP_NEED: "measurements.typeSleepNeed", ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate", + MAX_HEART_RATE: "measurements.typeMaxHeartRate", + SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -213,6 +218,13 @@ export const MEASUREMENT_TYPE_ICONS: Record = { SLEEP_CONSISTENCY: Moon, SLEEP_NEED: Moon, ENERGY_EXPENDITURE_KJ: Flame, + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // HeartPulse + Heart carry the daily-aggregate cardiac pair (same family + // as the other heart-rate signals); Waves reads as the per-night + // sleep-disturbance signal. + AVERAGE_HEART_RATE: HeartPulse, + MAX_HEART_RATE: Heart, + SLEEP_DISTURBANCE_COUNT: Waves, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -308,4 +320,10 @@ export const MEASUREMENT_TYPE_COLORS: Record = { SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2", SLEEP_NEED: "bg-chart-2/20 text-chart-2", ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // chart-3 (cardio family) for the daily-aggregate heart-rate pair; + // chart-2 (sleep/activity family) for the per-night disturbance count. + AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3", + MAX_HEART_RATE: "bg-chart-3/20 text-chart-3", + SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 020e05f6f..dfeef8cac 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -86,10 +86,14 @@ const EXPECTED_TYPES = [ "SLEEP_CONSISTENCY", "SLEEP_NEED", "ENERGY_EXPENDITURE_KJ", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "SLEEP_DISTURBANCE_COUNT", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 60 canonical measurement types", () => { + it("exposes the 63 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -202,6 +206,15 @@ describe("measurementTypeEnum coverage", () => { "SLEEP_CONSISTENCY", "SLEEP_NEED", "ENERGY_EXPENDITURE_KJ", + // v1.12.8 — WHOOP cycle + sleep coverage completion. The daily-aggregate + // heart-rate pair and the per-night disturbance count are device-derived + // WHOOP signals, not measured clinical vitals; they surface on the + // measurement list + Insights chart surfaces with a "descriptive, not + // clinical" framing and never belong in the clinical vitals PDF. See + // doctor-report-pdf-core.ts for the matching exclusion rationale. + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "SLEEP_DISTURBANCE_COUNT", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 1518857b5..960d28924 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -109,6 +109,12 @@ export const ALLOWED_CHART_TOKENS = [ "metric:SLEEP_CONSISTENCY", "metric:SLEEP_NEED", "metric:ENERGY_EXPENDITURE_KJ", + // v1.12.8 — WHOOP cycle + sleep coverage completion. Continuous daily + // series, so each carries a `metric:` token and renders through the + // generic chart renderer. + "metric:AVERAGE_HEART_RATE", + "metric:MAX_HEART_RATE", + "metric:SLEEP_DISTURBANCE_COUNT", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -219,6 +225,11 @@ const ORPHAN_ENUMS = [ "SLEEP_CONSISTENCY", "SLEEP_NEED", "ENERGY_EXPENDITURE_KJ", + // v1.12.8 — WHOOP cycle + sleep coverage completion, same shape: strip the + // bare enum name if the model drops it into prose. + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "SLEEP_DISTURBANCE_COUNT", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 602f1cdee..d178f693c 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -52,6 +52,14 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "SLEEP_CONSISTENCY", "SLEEP_NEED", "ENERGY_EXPENDITURE_KJ", + // v1.12.8 — WHOOP cycle + sleep coverage completion. These ingest + // server-side from the WHOOP API (source = WHOOP), never from HealthKit: + // Apple ships no first-class daily-aggregate average / max heart-rate + // quantity, and the per-night disturbance count is WHOOP-specific (Apple + // ships sleep stages, not a disturbance tally). No HK mapping by design. + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "SLEEP_DISTURBANCE_COUNT", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 4377904fd..df0db5f29 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -180,6 +180,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // Day energy expenditure is a cumulative activity metric (kJ analogue of // ACTIVE_ENERGY_BURNED). ["ENERGY_EXPENDITURE_KJ", "activity"], + + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate join the derived cardiac surface + // alongside RESTING_HEART_RATE + HRV (whole-cycle aggregates, not spot + // readings). The per-night disturbance count sits in the sleep cluster. + ["AVERAGE_HEART_RATE", "cardiovascular"], + ["MAX_HEART_RATE", "cardiovascular"], + ["SLEEP_DISTURBANCE_COUNT", "sleep"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 33cfb7432..91d2f82fa 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -98,6 +98,10 @@ export function getPRDirection( // events, and fewer is unambiguously the goal. case "FALL_COUNT": case "BREATHING_DISTURBANCES": + // v1.12.8 — the per-night sleep disturbance count reads MIN: the + // "record" is the night with the fewest disturbances, and fewer is + // unambiguously the goal (mirrors BREATHING_DISTURBANCES). + case "SLEEP_DISTURBANCE_COUNT": return PersonalRecordDirection.MIN; // Explicitly no PersonalRecord — see comment block above. @@ -166,6 +170,12 @@ export function getPRDirection( case "SLEEP_CONSISTENCY": case "SLEEP_NEED": case "ENERGY_EXPENDITURE_KJ": + // v1.12.8 — the daily average / max heart rate are whole-cycle + // aggregates without a clean goal axis: a higher value can mean either + // a harder-effort day or an ailing one, exactly like the + // WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction. + case "AVERAGE_HEART_RATE": + case "MAX_HEART_RATE": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 595826da8..0b843fc5b 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -90,6 +90,15 @@ export const measurementTypeEnum = z.enum([ "SLEEP_CONSISTENCY", "SLEEP_NEED", "ENERGY_EXPENDITURE_KJ", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ── + // Daily average / max heart rate ride WHOOP's cycle score (previously + // fetched but dropped). Kept distinct from spot PULSE / RESTING_HEART_RATE / + // WALKING_HEART_RATE_AVERAGE: these are the day's whole-cycle aggregates, + // not a point-in-time or context-specific reading. Sleep disturbance count + // is the per-night WHOOP `stage_summary.disturbance_count`. + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "SLEEP_DISTURBANCE_COUNT", ]); /** @@ -283,6 +292,13 @@ const unitMap: Record = { // Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the // device value round-trips rather than being converted to kcal). ENERGY_EXPENDITURE_KJ: "kJ", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate bpm — whole-cycle aggregates, distinct + // from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE. + AVERAGE_HEART_RATE: "bpm", + MAX_HEART_RATE: "bpm", + // Per-night sleep disturbance tally — a plain integer count. + SLEEP_DISTURBANCE_COUNT: "count", }; export function getUnitForType(type: string): string { @@ -449,6 +465,15 @@ const VALUE_RANGES: Record = { // Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous // ceiling over any plausible ultra-endurance day. ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 }, + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate bpm — same plausibility band as the + // spot PULSE: endurance athletes touch the 30s at rest, severe + // tachycardia caps below 300. + AVERAGE_HEART_RATE: { min: 20, max: 300 }, + MAX_HEART_RATE: { min: 20, max: 300 }, + // Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a + // generous ceiling over any plausible severely-fragmented night. + SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 }, }; export function validateMeasurementRange( From dabedd2edc563abd23d4305b7b05bb96db8f3674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:46:05 +0200 Subject: [PATCH 02/10] feat(whoop): map cycle average/max HR and sleep disturbance count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapCycle now emits AVERAGE_HEART_RATE and MAX_HEART_RATE from the cycle score's average_heart_rate / max_heart_rate — both were already on the typed WhoopCycle interface but dropped on the floor. mapSleep now reads stage_summary.disturbance_count (added to the WhoopSleep interface) and emits SLEEP_DISTURBANCE_COUNT. All three guard nulls / unscored records and carry distinct field tags (avg_hr / max_hr / disturbances) so they don't collide with the existing strain / energy / stage rows under the shared resource externalId. Update mapping.md to document the three and note the cycle HR pair were previously fetched-but-dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/whoop/client.ts | 44 +++++++++++++++++++++++++++++++++++++--- src/lib/whoop/mapping.md | 7 ++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 51489db66..7eb91358e 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -190,6 +190,9 @@ export interface WhoopSleep { total_light_sleep_time_milli: number; total_slow_wave_sleep_time_milli: number; total_rem_sleep_time_milli: number; + // v1.12.8 — per-night sleep disturbance tally. Optional: older / still- + // scoring records may omit it. + disturbance_count?: number; }; sleep_needed: { baseline_milli: number; @@ -581,18 +584,29 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] { }); } + if (typeof stages.disturbance_count === "number") { + out.push({ + type: "SLEEP_DISTURBANCE_COUNT", + value: stages.disturbance_count, + unit: "count", + measuredAt, + fieldTag: "disturbances", + }); + } + return out; } /** * Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED - * `STRAIN_SCORE`) and `ENERGY_EXPENDITURE_KJ` (kept in native kJ). Energy is - * NOT converted to kcal here — that conversion is for the workout path only. + * `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's + * whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted + * to kcal here — that conversion is for the workout path only. */ export function mapCycle(c: WhoopCycle): MappedMeasurement[] { if (!c.score) return []; const measuredAt = new Date(c.start); - return [ + const out: MappedMeasurement[] = [ { type: "DAY_STRAIN", value: round2(c.score.strain), @@ -608,6 +622,30 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] { fieldTag: "energy_kj", }, ]; + + // v1.12.8 — the cycle score's average / max heart rate were fetched but + // dropped before this release. Distinct fieldTags keep them from colliding + // with the strain / energy rows under the shared cycle `externalId`. + if (typeof c.score.average_heart_rate === "number") { + out.push({ + type: "AVERAGE_HEART_RATE", + value: Math.round(c.score.average_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "avg_hr", + }); + } + if (typeof c.score.max_heart_rate === "number") { + out.push({ + type: "MAX_HEART_RATE", + value: Math.round(c.score.max_heart_rate), + unit: "bpm", + measuredAt, + fieldTag: "max_hr", + }); + } + + return out; } /** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */ diff --git a/src/lib/whoop/mapping.md b/src/lib/whoop/mapping.md index 0e4026743..bd9648bdd 100644 --- a/src/lib/whoop/mapping.md +++ b/src/lib/whoop/mapping.md @@ -35,9 +35,12 @@ A recovery record with `score === null` (WHOOP still scoring) maps to nothing. | `sleep_efficiency_percentage` | `SLEEP_EFFICIENCY` | % | `sleep_eff` | — | | `sleep_consistency_percentage` | `SLEEP_CONSISTENCY` | % | `sleep_consistency` | — | | `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | — | +| `stage_summary.disturbance_count` | `SLEEP_DISTURBANCE_COUNT` | count | `disturbances` | — | Stage durations are ms→minutes (`÷ 60000`). One row per stage per night -(same pattern as Apple Health). Percentage / respiratory fields are optional. +(same pattern as Apple Health). Percentage / respiratory / disturbance-count +fields are optional. `disturbance_count` (v1.12.8) is a per-night tally — a +plain integer, stored as-is. ## Cycle (`/v2/cycle`) — record `start` is `measuredAt` @@ -45,6 +48,8 @@ Stage durations are ms→minutes (`÷ 60000`). One row per stage per night |---|---|---|---|---| | `cycle.score.strain` | `DAY_STRAIN` | score | `day_strain` | 0–21 WHOOP scale. Distinct from the COMPUTED `STRAIN_SCORE` (0–100 TRIMP proxy). | | `cycle.score.kilojoule` | `ENERGY_EXPENDITURE_KJ` | kJ | `energy_kj` | Native kJ — NOT converted to kcal (the workout path converts). | +| `cycle.score.average_heart_rate` | `AVERAGE_HEART_RATE` | bpm | `avg_hr` | v1.12.8 — the day's whole-cycle average HR. Fetched but dropped before v1.12.8; now mapped. Distinct from spot `PULSE` / `RESTING_HEART_RATE` / `WALKING_HEART_RATE_AVERAGE`. | +| `cycle.score.max_heart_rate` | `MAX_HEART_RATE` | bpm | `max_hr` | v1.12.8 — the day's whole-cycle peak HR. Fetched but dropped before v1.12.8; now mapped. | ## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement` From 47255a93b567a6c202035ac1145e096df9277041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:46:10 +0200 Subject: [PATCH 03/10] fix(ai): align provider default model fallbacks The BYOK Anthropic client fell back to the retired claude-3-5-sonnet-latest; match the chain-runner default (claude-sonnet-4-6). The admin ai-settings GET displayed a gpt-4o-mini fallback that disagreed with the actual provider default (gpt-4o); align the display string so the surfaced model matches what the provider would use. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/admin/ai-settings/route.ts | 2 +- src/lib/ai/provider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/admin/ai-settings/route.ts b/src/app/api/admin/ai-settings/route.ts index 0883f5e5d..90f406b88 100644 --- a/src/app/api/admin/ai-settings/route.ts +++ b/src/app/api/admin/ai-settings/route.ts @@ -18,7 +18,7 @@ export const GET = apiHandler(async () => { }); const hasKey = Boolean(settings?.adminAiKeyEncrypted); - const model = settings?.adminAiModel ?? "gpt-4o-mini"; + const model = settings?.adminAiModel ?? "gpt-4o"; const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1"; return apiSuccess({ diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index e695f3058..e71b923c4 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -52,7 +52,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null { // the SDK default is correct. return new AnthropicClient({ apiKey: decrypt(row.aiAnthropicKeyEncrypted), - model: row.aiModel ?? "claude-3-5-sonnet-latest", + model: row.aiModel ?? "claude-sonnet-4-6", }); } case "LOCAL": { From de732ba73206ec69d6ff3923de684cdaeafedbeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:46:14 +0200 Subject: [PATCH 04/10] docs(api): regenerate OpenAPI for new MeasurementType values Pick up AVERAGE_HEART_RATE / MAX_HEART_RATE / SLEEP_DISTURBANCE_COUNT in the MeasurementType enum the iOS client consumes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b6615a691..87da5c00e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -738,6 +738,9 @@ paths: - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT - in: query name: from schema: @@ -894,6 +897,9 @@ paths: - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT value: type: number measuredAt: @@ -2722,6 +2728,9 @@ paths: - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -4796,6 +4805,9 @@ components: - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT value: type: number unit: @@ -5112,6 +5124,9 @@ components: - SLEEP_CONSISTENCY - SLEEP_NEED - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT value: type: number unit: From a3692a62f8adb42d6053e60b5f2eb4bc8a6d6ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:47:49 +0200 Subject: [PATCH 05/10] ai: align the remaining Anthropic default to the current Sonnet --- src/lib/ai/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index e71b923c4..90eda9d1d 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -456,7 +456,7 @@ export async function resolveProviderForTest( // shared column. return new AnthropicClient({ apiKey, - model: model || "claude-3-5-sonnet-latest", + model: model || "claude-sonnet-4-6", }); } case "LOCAL": { From 278007ce366af4f318e9221b3bfd1f1592912fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:48:41 +0200 Subject: [PATCH 06/10] feat(insights): give wellness dials a white ring on a gradient tile Echo the hero card on the wellness-scores strip: each ring tile now rides a saturated purple-pink gradient (.score-tile-gradient) and the dial paints a white arc over a translucent-white track with rounded ends. The gradient mixes over the fixed dark --dracula-bg rather than the theme --card, so it stays dark in both themes and white copy clears WCAG AA either way (white on the purple endpoint ~5.0:1, pink endpoint ~6.1:1). ScoreRing gains a variant prop: onGradient (white arc, white number) for the tile, band (band-coloured arc) by default so the score-anatomy detail view stays legible on its plain card. The band semantic is never lost to the white arc - it still rides data-band, the aria-label, and the band-word label under the ring. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/globals.css | 24 +++++++++ .../derived/__tests__/score-ring.test.tsx | 11 +++++ .../__tests__/wellness-scores.test.tsx | 11 ++--- .../insights/derived/score-ring.tsx | 49 +++++++++++++++++-- .../insights/derived/wellness-scores.tsx | 34 +++++++++---- 5 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a10abb382..7a88a0c7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -381,6 +381,30 @@ 0 18px 48px -28px color-mix(in srgb, var(--dracula-purple) 50%, transparent); } +/* + * `.score-tile-gradient` — the saturated purple→pink wellness-tile + * background that echoes the hero band while staying dark enough for a + * WHITE score ring + white centre number to clear WCAG AA in BOTH themes. + * + * Unlike `.hero-gradient` (which mixes over the theme-dependent + * `var(--card)` — near-white in Alucard light, so a white ring would + * vanish), this variant mixes purple/pink over the *fixed* dark + * `var(--dracula-bg)` (#282a36). That keeps the surface identical and + * dark in light + dark mode, so white text/ring read ≥ 4.5:1 either way: + * white on the purple endpoint ≈ 5.0:1, on the pink endpoint ≈ 6.1:1. + * A subtler glow than the hero's keeps a row of five tiles from + * overwhelming the page. + */ +.score-tile-gradient { + background: linear-gradient( + 140deg, + color-mix(in srgb, var(--dracula-purple) 55%, var(--dracula-bg)) 0%, + color-mix(in srgb, var(--dracula-pink) 45%, var(--dracula-bg)) 100% + ); + border: 1px solid color-mix(in srgb, var(--dracula-purple) 28%, transparent); + box-shadow: 0 12px 32px -24px color-mix(in srgb, var(--dracula-purple) 45%, transparent); +} + /* * `pulseDot` keyframes — the "live, refreshed N min ago" green dot in * the hero meta row. Used as `.pulse-dot { animation: pulseDot ... }`. diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx index dffccf055..5afbaa253 100644 --- a/src/components/insights/derived/__tests__/score-ring.test.tsx +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -52,4 +52,15 @@ describe("", () => { expect(html).toContain('role="img"'); expect(html).toContain("aria-label"); }); + + it("keeps the band semantic on the white-arc onGradient variant", () => { + // The white arc drops the band colour, so the band must still ride the + // data attribute + aria-label for the colour-blind/non-visual read. + const html = render( + , + ); + expect(html).toContain('data-band="green"'); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + }); }); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx index 54e66eb52..0e3ae67ac 100644 --- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -66,7 +66,7 @@ describe("", () => { expect(html).toBe(""); }); - it("renders a ring tile with a canonical TileHeader icon + heading", () => { + it("renders a ring tile with an icon + heading and a gradient surface", () => { const html = render( ", () => { ); expect(html).toContain('data-slot="wellness-scores"'); expect(html).toContain('data-metric="READINESS"'); - // Each tile leads with the shared TileHeader (icon + heading row), with - // the metric name in the foreground-colour CardTitle. - expect(html).toContain('data-slot="tile-header"'); - expect(html).toContain('data-slot="card-title"'); + // The tile carries the metric heading and rides the saturated + // purple→pink gradient that echoes the hero card. expect(html).toContain("Readiness"); + expect(html).toContain("score-tile-gradient"); // The ring carries its band on the arc + data-attribute (not the number). expect(html).toContain('data-band="green"'); - // The band word renders under the ring. + // The band word renders under the ring (semantic kept off the white arc). expect(html).toContain('data-slot="wellness-score-band-word"'); }); diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index ccd2e647c..63b01bd27 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -37,6 +37,16 @@ import { * a11y: `role="img"` + an aria-label restating the number and band, so the * ring is never colour-only. `prefers-reduced-motion` disables the sweep * (Recharts `isAnimationActive`) and the count-up. + * + * `variant` picks the arc palette. `"band"` (default) paints the filled + * arc in the score's band token (green/yellow/red) over a muted track — + * the legible treatment on a plain `bg-card` surface (the score-anatomy + * detail view). `"onGradient"` paints a WHITE arc over a translucent-white + * track for use on the saturated `.score-tile-gradient` wellness cards, + * where a band tint would muddy against the purple/pink and white reads at + * high contrast. The band semantic is never lost to the white arc: it + * still rides the `data-band` attribute, the aria-label, and the band-word + * label the wellness tile renders beneath the ring. */ const SIZE: Record< @@ -59,6 +69,14 @@ export interface ScoreRingProps { size?: "sm" | "md" | "lg"; /** Disable the sweep + count-up (e.g. when already animated by a parent). */ animate?: boolean; + /** + * Arc palette. `"band"` (default) tints the arc by score band over a + * muted track — legible on a plain card. `"onGradient"` paints a white + * arc over a translucent-white track, for the saturated gradient + * wellness tiles. See the component doc for why the band semantic is not + * lost to the white arc. + */ + variant?: "band" | "onGradient"; className?: string; } @@ -68,15 +86,21 @@ export function ScoreRing({ label, size = "md", animate = true, + variant = "band", className, }: ScoreRingProps) { const { t } = useTranslations(); const dims = SIZE[size]; + const onGradient = variant === "onGradient"; const hasScore = score != null && Number.isFinite(score); const clamped = hasScore ? clampScore(score) : 0; const resolvedBand: ScoreBand = band ?? bandForScore(clamped); - const fill = hasScore ? BAND_VAR[resolvedBand] : "var(--muted-foreground)"; + const fill = onGradient + ? "#ffffff" + : hasScore + ? BAND_VAR[resolvedBand] + : "var(--muted-foreground)"; // Count-up only feeds the centred number; the ring arc reads the final // value (Recharts owns its own sweep via isAnimationActive). @@ -122,7 +146,11 @@ export function ScoreRing({ > {hasScore ? displayedRounded : "—"} @@ -174,7 +210,12 @@ export function ScoreRing({ x={cx} y={cy + (size === "lg" ? 30 : size === "md" ? 22 : 16)} className={cn( - "fill-muted-foreground", + // On the gradient tile a translucent white reads + // cleanly; on a plain card the muted-foreground + // token keeps the sub-label quiet. + onGradient + ? "fill-white/80" + : "fill-muted-foreground", dims.labelClass, )} > diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index f99f0cf49..53628480d 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -67,27 +67,41 @@ function RingTile({ metricSlot: string; icon: ComponentType<{ className?: string }>; }) { + const Icon = icon; return ( - {/* v1.12.6 — every tile leads with the canonical icon + heading - (foreground colour, matching the Einschätzung reference) so the - wellness strip reads as the same card language as the rest of - Insights. The metric name no longer rides an under-ring caption. */} - +
+
{/* The ring keeps just the number centred; the metric name lives in - the TileHeader above (a long localised title would overflow the + the header above (a long localised title would overflow the small ring's centred SVG text and clip under recharts' - `overflow:hidden`). */} - + `overflow:hidden`). The white-arc `onGradient` variant reads + cleanly on the gradient; the band stays conveyed by the word + label below + the ring's `data-band` + aria-label. */} + {bandWord} From 465c8b8e333ae5ba53f4f883c74b0af3d325e354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:51:28 +0200 Subject: [PATCH 07/10] feat(insights): drive metric stat strip from the chart range tab; add chart tile header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the draggable chart brush. The Min/Max/Median/Mittelwert strip now always reflects the data visible under the chart's active range tab (7/30/90/ all): the chart reports per-type stats for its range-sliced series and the sub-page threads them into the strip. No drag, no "selected range" pill — the range tab is the single selector for both chart and strip. Blood pressure recomputes both systolic and diastolic. Give each metric chart card the canonical TileHeader (icon + title, same size and spacing as the assessment/target/stat tiles) via an optional titleIcon; the dashboard and mini mounts keep the compact heading. Drop the now-unused charts.selectRange and insights.subPage.stats.selectedRange keys from all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/insights/blood-pressure/page.tsx | 12 +- src/app/insights/weight/page.tsx | 8 +- src/components/charts/health-chart.tsx | 175 +++++------------- .../__tests__/metric-stat-strip.test.tsx | 10 +- .../insights/healthkit-metric-page.tsx | 13 +- src/components/insights/metric-stat-strip.tsx | 26 +-- src/components/insights/sub-page-shell.tsx | 10 +- src/hooks/use-chart-domain-stats.ts | 53 +++--- 14 files changed, 121 insertions(+), 222 deletions(-) diff --git a/messages/de.json b/messages/de.json index bf9eb7544..ef739e2c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1497,8 +1497,7 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich", - "selectRange": "Zeitraum auswählen" + "rangeBand": "Bereich" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mittelwert", - "selectedRange": "Ausgewählter Bereich" + "mean": "Mittelwert" }, "showAllValues": "Alle Werte anzeigen", "valuesPageTitle": "{metric} — alle Werte", diff --git a/messages/en.json b/messages/en.json index 12615cafd..0113f3ab5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1497,8 +1497,7 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range", - "selectRange": "Select a range" + "rangeBand": "Range" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Median", - "mean": "Mean", - "selectedRange": "Selected range" + "mean": "Mean" }, "showAllValues": "Show all readings", "valuesPageTitle": "{metric} — all readings", diff --git a/messages/es.json b/messages/es.json index 037b7afba..85772d59c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1497,8 +1497,7 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango", - "selectRange": "Seleccionar un intervalo" + "rangeBand": "Rango" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Mín", "max": "Máx", "median": "Mediana", - "mean": "Media", - "selectedRange": "Intervalo seleccionado" + "mean": "Media" }, "showAllValues": "Mostrar todas las mediciones", "valuesPageTitle": "{metric} — todas las mediciones", diff --git a/messages/fr.json b/messages/fr.json index 2503a8966..76a80ca9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1497,8 +1497,7 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage", - "selectRange": "Sélectionner une plage" + "rangeBand": "Plage" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Médiane", - "mean": "Moyenne", - "selectedRange": "Plage sélectionnée" + "mean": "Moyenne" }, "showAllValues": "Afficher toutes les mesures", "valuesPageTitle": "{metric} — toutes les mesures", diff --git a/messages/it.json b/messages/it.json index edeec0b39..75d2f312a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1497,8 +1497,7 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo", - "selectRange": "Seleziona un intervallo" + "rangeBand": "Intervallo" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Max", "median": "Mediana", - "mean": "Media", - "selectedRange": "Intervallo selezionato" + "mean": "Media" }, "showAllValues": "Mostra tutte le misurazioni", "valuesPageTitle": "{metric} — tutte le misurazioni", diff --git a/messages/pl.json b/messages/pl.json index 42c8a52bb..4ce4a7ddc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1497,8 +1497,7 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres", - "selectRange": "Wybierz zakres" + "rangeBand": "Zakres" }, "chart": { "overlay": { @@ -2112,8 +2111,7 @@ "min": "Min", "max": "Maks", "median": "Mediana", - "mean": "Średnia", - "selectedRange": "Wybrany zakres" + "mean": "Średnia" }, "showAllValues": "Pokaż wszystkie pomiary", "valuesPageTitle": "{metric} — wszystkie pomiary", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 7b0b87828..7cb330f84 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -51,10 +51,10 @@ export default function InsightsBlutdruckPage() { const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null; const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null; - // v1.12.7 — chart-reactive metric statistics. Blood pressure brushes BOTH - // series at once: the single chart reports per-type windowed stats and each - // strip reads its own half. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — chart-reactive metric statistics. Blood pressure tracks BOTH + // series at once: the single chart reports per-type visible-range stats and + // each strip column reads its own half. + const { statsByType, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -155,14 +155,14 @@ export default function InsightsBlutdruckPage() { chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("charts.bloodPressure")} + titleIcon={HeartPulse} colors={["#ff79c6", "#8be9fd"]} unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} compareBaseline={compareBaseline} userTimezone={user?.timezone} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> {/* v1.12.4 — target card sits between the chart and the assessment on diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 6ef57921d..b31c56ede 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -40,8 +40,8 @@ export default function InsightsGewichtPage() { const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; - // v1.12.7 — brushed-window stats shared between the chart and the strip. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — visible-range stats shared between the chart and the strip. + const { statsByType, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -102,13 +102,13 @@ export default function InsightsGewichtPage() { chartKey="weight" types={["WEIGHT"]} title={t("charts.weight")} + titleIcon={Scale} colors={["#bd93f9"]} unit="kg" valueBands={weightBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2035eaf77..0ea841468 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -16,12 +16,12 @@ import { ReferenceLine, ReferenceArea, ReferenceDot, - Brush, } from "recharts"; import { Loader2 } from "lucide-react"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, type ComponentType } from "react"; import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip"; import { ChartEmptyState } from "./chart-empty-state"; +import { TileHeader } from "@/components/insights/tile-header"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -194,26 +194,27 @@ interface HealthChartProps { */ valueScale?: number; /** - * v1.12.7 — chart-reactive metric statistics (opt-in). + * v1.12.8 — chart-reactive metric statistics. When supplied, the chart + * reports the per-type Min / Max / Median / Mean of the data currently + * visible under the active range tab (the 7 / 30 / 90 / All selector), + * keyed by `MeasurementType`. It fires on mount and whenever the range tab + * (or the underlying series) changes, so the `` a sub-page + * lifts the callback into always reflects the same window the chart paints. * - * When `true`, the chart mounts a Recharts `` beneath the plot so - * the user can drag a window over the visible range. The dashboard and the - * mini / rationale mounts do NOT pass it, so they render byte-identical to - * the pre-v1.12.7 chart — the feature is fully isolated behind this flag. - * - * Mini mode ignores the flag (the rationale card pins its own window and - * has no room for a brush). + * Mini mode never reports — the rationale card pins its own window and has + * no stat strip. The dashboard / ad-hoc mounts simply omit the callback. */ - selectableDomain?: boolean; + onVisibleStats?: (stats: Record | null) => void; /** - * v1.12.7 — companion to `selectableDomain`. Receives the per-type - * Min / Max / Median / Mean computed over the brushed window, keyed by - * `MeasurementType`, or `null` when the selection spans the whole range - * (or collapses to under one point). A metric sub-page lifts this into a - * shared state the `` reads, so the strip recomputes for - * the visible domain while the default stays the cheap full-range summary. + * v1.12.8 — optional leading glyph for the chart card's ``. + * When supplied, the chart header renders the canonical icon + title row + * (same size + spacing as every other Insights tile) instead of the bare + * `

`. Omitted on the dashboard / mini mounts, which keep the compact + * heading. Accepts any component that takes a `className` (the contract + * `` and the stat strip's `icon` already use), so a page can + * thread the same glyph it hands the stat strip. */ - onDomainStats?: (stats: Record | null) => void; + titleIcon?: ComponentType<{ className?: string }>; } interface ChartDataPoint { @@ -515,8 +516,8 @@ export function HealthChart({ verticalMarkers, userTimezone = "Europe/Berlin", valueScale = 1, - selectableDomain = false, - onDomainStats, + onVisibleStats, + titleIcon, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -544,18 +545,6 @@ export function HealthChart({ : 30; const [rangePoints, setRangePoints] = useState(initialRangePoints); - // v1.12.7 — chart-reactive metric statistics. The brush reports a - // `[startIndex, endIndex]` window into the rendered `chartData`; `null` - // means the full range is selected. Gated behind `selectableDomain` and - // suppressed in mini mode, so the dashboard + rationale charts never mount - // the brush and stay byte-identical. The range-tab change below clears the - // selection so the brush window can't outlive the data it indexed into. - const brushEnabled = selectableDomain && !mini; - const [brushWindow, setBrushWindow] = useState<{ - startIndex: number; - endIndex: number; - } | null>(null); - // v1.4.18 — three overlay toggles (showTrendIndicator / showTrendArrow // / showTargetRange) are persisted per chart via the new // `useChartOverlayPrefs` hook. When the chart isn't bound to a @@ -940,59 +929,36 @@ export function HealthChart({ return enriched; }, [data, rangePoints, showMA, showTrend, types, tzFmt]); - // v1.12.7 — chart-reactive metric statistics. + // v1.12.8 — chart-reactive metric statistics. // - // When a brush window is active, compute the per-type Min / Max / Median / - // Mean over just the selected slice of `chartData` (the array the chart - // already holds — no re-fetch). A selection that spans the whole range, or - // collapses to under one point, reports `null` so the strip falls back to - // the cheap full-range summary. Only runs when `brushEnabled`; the - // dashboard / mini mounts compute nothing. - const windowStatsByType = useMemo | null>( + // Compute the per-type Min / Max / Median / Mean over the data currently + // visible under the active range tab — i.e. the `rangePoints`-sliced, + // bucketed `chartData` the chart already holds (no re-fetch). The result + // recomputes whenever the range tab changes the visible slice, so the + // `` a sub-page lifts the callback into always reflects the + // same window the chart paints. Skipped in mini mode (no stat strip). + const visibleStatsByType = useMemo | null>( () => { - if (!brushEnabled || !brushWindow || !chartData?.length) return null; - const last = chartData.length - 1; - const start = Math.max(0, Math.min(brushWindow.startIndex, last)); - const end = Math.max(0, Math.min(brushWindow.endIndex, last)); - const lo = Math.min(start, end); - const hi = Math.max(start, end); - // A full-range selection is the default; reporting `null` keeps the - // strip on its precomputed summary rather than a redundant recompute. - if (lo <= 0 && hi >= last) return null; - const slice = chartData.slice(lo, hi + 1); - // Fewer than one data point in the window is not a meaningful summary; - // fall back to the full range. - if (slice.length < 1) return null; + if (mini || !chartData?.length) return null; const out: Record = {}; for (const type of types) { const stats = computeWindowStats( - slice.map((point) => point[type] as number | undefined), + chartData.map((point) => point[type] as number | undefined), ); if (stats.count > 0) out[type] = stats; } return Object.keys(out).length > 0 ? out : null; }, - [brushEnabled, brushWindow, chartData, types], + [mini, chartData, types], ); - // Report the windowed stats up to the sub-page so the shared + // Report the visible-range stats up to the sub-page so the shared // `` can read them. Effect (not render-time call) so the // parent state update never fires during this component's render. useEffect(() => { - if (!brushEnabled) return; - onDomainStats?.(windowStatsByType); - }, [brushEnabled, windowStatsByType, onDomainStats]); - - // Clamp a stale brush window in render (never via setState-in-effect) so the - // controlled `` never points past the end of `chartData` after a - // refetch shrinks the series. A window whose end falls past the new last - // index is treated as "no selection" — the stats memo above already ignores - // it, and the brush re-seeds to the full range. - const chartLength = chartData?.length ?? 0; - const effectiveBrushWindow = - brushWindow && brushWindow.endIndex <= Math.max(0, chartLength - 1) - ? brushWindow - : null; + if (mini) return; + onVisibleStats?.(visibleStatsByType); + }, [mini, visibleStatsByType, onVisibleStats]); // v1.4.16 phase B8 — comparison overlay. // @@ -1388,7 +1354,17 @@ export function HealthChart({ ≥sm: original side-by-side layout, all chips visible. */
-

{title}

+ {/* v1.12.8 — when a `titleIcon` is supplied (every Insights + metric subpage), the chart card leads with the canonical + `` (icon + title at `text-base`, foreground + colour, h-5 w-5) so it matches the assessment / target / + stat tiles. The dashboard / ad-hoc mounts omit the icon and + keep the compact `

`. */} + {titleIcon ? ( + + ) : ( +

{title}

+ )} {activeBucket !== "day" && ( {t( @@ -1441,11 +1417,9 @@ export function HealthChart({ size="sm" className="min-h-11 px-2 text-xs sm:px-3" onClick={() => { - // v1.12.7 — a range-tab change re-slices `chartData`, so - // any active brush window would now index into a different - // array. Clear it so the stat strip falls back to the new - // full range rather than a stale selection. - setBrushWindow(null); + // v1.12.8 — a range-tab change re-slices `chartData`; the + // visible-range stats memo recomputes off the new slice and + // the stat strip follows automatically. setRangePoints(r.points); }} title={t(r.titleKey)} @@ -1989,55 +1963,6 @@ export function HealthChart({ data-slot={`chart-compare-line-${type}`} /> ))} - {/* v1.12.7 — chart-reactive metric statistics. The brush - lets the user drag a window over the visible range; the - `onChange` handler stores the `[startIndex, endIndex]` - pair, and the windowed-stats memo above recomputes the - stat strip's Min / Max / Median / Mean from the brushed - slice. Only mounts when `brushEnabled` (sub-pages opt in - via `selectableDomain`); the dashboard / mini mounts skip - it entirely so their render stays unchanged. `gap={1}` - reports every index so a one-day drag still resolves; the - travellers carry an `ariaLabel` so the brush announces - itself to assistive tech. */} - {brushEnabled ? ( - { - const range = next as { - startIndex?: number; - endIndex?: number; - }; - if ( - typeof range.startIndex !== "number" || - typeof range.endIndex !== "number" - ) { - return; - } - setBrushWindow({ - startIndex: range.startIndex, - endIndex: range.endIndex, - }); - }} - tickFormatter={(value) => - tzFmt.date( - new Date( - chartData?.[Math.round(Number(value))]?.timestamp ?? - Date.now(), - ), - ) - } - /> - ) : null}

diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 4cbddc0eb..d52e11c9c 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -136,7 +136,7 @@ describe("", () => { expect(html).toBe(""); }); - it("pins the 'selected range' pill on a brushed series", () => { + it("reads the chart's visible-range stats over the full-range summary, with no pill", () => { const html = render( ", () => { ]} />, ); - // The brushed sys block flags windowed + carries the pill; dia does not. + // The sys block flags windowed (visible-range stats present); dia does not. expect(html).toContain('data-series="sys" data-windowed="true"'); - expect(html).toContain('data-slot="metric-stat-window-badge"'); - // Windowed numbers win over the full-range summary for sys. + // v1.12.8 — no "selected range" pill: the chart's range tab is the + // single, visible selector for both chart and strip. + expect(html).not.toContain('data-slot="metric-stat-window-badge"'); + // Visible-range numbers win over the full-range summary for sys. expect(html).toContain("120"); }); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 608590570..fc426f673 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -175,11 +175,10 @@ export function HealthKitMetricPage({ refetch, } = useInsightsAnalytics(insightMetric); - // v1.12.7 — shared brushed-window state. The chart reports the per-type - // Min / Max / Median / Mean for the selected domain; the stat strip reads - // it back for this page's single series. No selection → null → the strip - // keeps the full-range summary. - const { statsByType, onDomainStats } = useChartDomainStats(); + // v1.12.8 — shared visible-range state. The chart reports the per-type + // Min / Max / Median / Mean for the data under its active range tab; the + // stat strip reads it back for this page's single series. + const { statsByType, onVisibleStats } = useChartDomainStats(); const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored @@ -309,6 +308,7 @@ export function HealthKitMetricPage({ chartKey={chartKey} types={[measurementType]} title={t(`${i18nPrefix}.chartTitle`)} + titleIcon={statIcon} colors={[color]} unit={unit} yAxisUnit={yAxisUnit} @@ -316,8 +316,7 @@ export function HealthKitMetricPage({ compareBaseline={compareBaseline} userTimezone={user?.timezone} valueScale={valueScale} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> {targetSummarySlug ? ( diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index f3a4318eb..3c8e08b3e 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -134,12 +134,15 @@ function SeriesBlock({ const fmt = useFormatters(); // Nothing to show until the summary lands or for a metric with no - // readings. The gate rides the full-range summary even when a window is - // active, so a brush never resurrects a series that has no data at all. + // readings. The gate rides the full-range summary even when the chart + // reports a narrower visible range, so a range change never resurrects a + // series that has no data at all. if (!summary || summary.count <= 0) return null; - // A window selection with at least one reading wins over the full-range - // summary; otherwise the block reads the precomputed numbers. + // v1.12.8 — the chart reports stats for the data under its active range tab; + // when present those visible-range numbers win over the full-range summary + // so the strip always reads the same window the chart paints. No pill — the + // chart's range tab is the single, visible selector for both. const windowed = windowStats != null && windowStats.count > 0; const source = windowed ? windowStats : summary; @@ -169,20 +172,7 @@ function SeriesBlock({ className="space-y-2" > {seriesLabel ? ( - - {t("insights.subPage.stats.selectedRange")} - - ) : undefined - } - /> + ) : null}
{cells.map((cell) => ( diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index c4dc0a8d1..32bfaac3a 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -262,11 +262,11 @@ export function SubPageShell({ brand-new-metric paths. Numbers-first is the Apple-Health / Withings / Oura detail-screen convention. - v1.12.7 — the stats are chart-reactive: when the user brushes a - window in the chart below, the page lifts the chart's per-type - windowed Min / Max / Median / Mean and threads it back into the - strip, which swaps the header to a "selected range" pill. No - selection falls back to the cheap full-range summary. */} + v1.12.8 — the stats are chart-reactive: the chart reports the + per-type Min / Max / Median / Mean for the data under its active + range tab (7 / 30 / 90 / All), and the page threads it back into the + strip so the numbers always reflect the range the chart paints. No + drag, no pill — the range tab is the single selector. */} {statStrip} {/* v1.12.6 — the canonical spine body: intro (header) → stat strip (above) → chart → target card → assessment. The page renders diff --git a/src/hooks/use-chart-domain-stats.ts b/src/hooks/use-chart-domain-stats.ts index 90f06361d..a243d0b65 100644 --- a/src/hooks/use-chart-domain-stats.ts +++ b/src/hooks/use-chart-domain-stats.ts @@ -5,38 +5,34 @@ import { useCallback, useRef, useState } from "react"; import type { MetricWindowStats } from "@/lib/charts/window-stats"; /** - * v1.12.7 — chart-reactive metric statistics. + * v1.12.8 — chart-reactive metric statistics. * - * A metric sub-page lifts this hook so its `` - * (which mounts a Recharts ``) and its `` read the - * same brushed window. The chart reports the per-type Min / Max / Median / - * Mean for the selected domain through `onDomainStats`; the strip reads - * `statsByType[type]` and, when present, renders the windowed numbers in - * place of the full-range summary. + * A metric sub-page lifts this hook so its `` and its + * `` read the same window. The chart reports the per-type + * Min / Max / Median / Mean for the data currently visible under its active + * range tab (the 7 / 30 / 90 / All selector) through `onVisibleStats`; the + * strip reads `statsByType[type]` and renders those windowed numbers. * - * `null` (no selection, or a selection that collapsed to under one point) - * means "show the full range" — the strip falls back to the precomputed - * summary, so the default is unchanged and costs no extra work. The chart - * owns the maths (it already holds the bucketed series), so the hook only - * stores what it is handed and exposes a stable callback. + * `null` (no data, or mini mode) means "show the full range" — the strip + * falls back to the precomputed summary. The chart owns the maths (it already + * holds the bucketed series), so the hook only stores what it is handed and + * exposes a stable callback. * * The callback is referentially stable across renders so it can sit in the - * chart's effect dependency list without re-firing. A shallow guard skips - * the state write when the chart reports the same window twice in a row, - * keeping a drag that lands on the same indices from thrashing the strip. + * chart's effect dependency list without re-firing. A shallow guard skips the + * state write when the chart reports the same window twice in a row, keeping a + * re-render that recomputes the same slice from thrashing the strip. */ export interface ChartDomainStats { /** - * Per-type windowed stats, keyed by `MeasurementType`. Null means no - * active selection (full range). A type may be absent from the map when - * its series has no readings inside the brushed window — the strip falls - * back to the full-range summary for that series. + * Per-type windowed stats, keyed by `MeasurementType`. Null means the chart + * has not reported a slice yet (or has no data). A type may be absent from + * the map when its series has no readings in the visible range — the strip + * falls back to the full-range summary for that series. */ statsByType: Record | null; - /** True while a sub-range is selected (drives the "selected range" label). */ - isWindowed: boolean; - /** Stable callback handed to ``. */ - onDomainStats: (stats: Record | null) => void; + /** Stable callback handed to ``. */ + onVisibleStats: (stats: Record | null) => void; } export function useChartDomainStats(): ChartDomainStats { @@ -46,11 +42,11 @@ export function useChartDomainStats(): ChartDomainStats { > | null>(null); const lastRef = useRef("null"); - const onDomainStats = useCallback( + const onVisibleStats = useCallback( (stats: Record | null) => { - // Cheap shallow signature so an identical brush window (a drag that - // settles on the same indices, or a re-render that recomputes the - // same slice) doesn't re-set state and re-render the strip. + // Cheap shallow signature so an identical window (a re-render that + // recomputes the same slice) doesn't re-set state and re-render the + // strip. const signature = stats === null ? "null" : JSON.stringify(stats); if (signature === lastRef.current) return; lastRef.current = signature; @@ -61,7 +57,6 @@ export function useChartDomainStats(): ChartDomainStats { return { statsByType, - isWindowed: statsByType !== null, - onDomainStats, + onVisibleStats, }; } From cde88278708abea7047e174f20eeddab0ae57e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 14:51:35 +0200 Subject: [PATCH 08/10] feat(insights): order pulse assessment above cardio-fitness link; harmonize its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the pulse Einschätzung above the VO₂ max cardio-fitness cross-link so the plain-language read leads and the cross-link trails. Lead the cross-link card with the canonical TileHeader so its Gauge glyph matches every other tile header (foreground colour, same size and position) instead of the muted, offset placement. Allow the documented trailing CTA in the spine-order guard for pulse while keeping the target-before-assessment check. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/assessment-spine-order.test.ts | 58 ++++++++++++------- src/app/insights/pulse/page.tsx | 50 ++++++++-------- 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index acde8bac2..91347efb0 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -33,17 +33,29 @@ const PAGES_DIR = join(process.cwd(), "src", "app", "insights"); // `useInsightStatus`-backed pages render ``; the // medications page reads the richer `summary`-shaped route inline, so it // renders `` directly. -const BESPOKE_PAGES: ReadonlyArray<{ slug: string; assessmentTag: string }> = [ +const BESPOKE_PAGES: ReadonlyArray<{ + slug: string; + assessmentTag: string; + /** + * v1.12.8 — pulse intentionally trails its assessment with the + * cardio-fitness CTA (the operator-requested "Einschätzung above the VO₂ + * max cross-link" order). The CTA is a navigation affordance, not a data + * card, so it is the one sanctioned block that may render after the + * assessment; the guard skips the last-block check for these slugs but + * still enforces that the target card precedes the assessment. + */ + assessmentMayTrail?: boolean; +}> = [ { slug: "weight", assessmentTag: " { - for (const { slug, assessmentTag } of BESPOKE_PAGES) { + for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) { it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => { const source = readFileSync( join(PAGES_DIR, slug, "page.tsx"), @@ -68,25 +80,29 @@ describe("bespoke metric-detail spine — assessment is the last block", () => { `${slug}: assessment card ${assessmentTag} not found inside the shell`, ).toBeGreaterThan(-1); - // All six bespoke pages render the assessment as a self-closing - // element. Find the `/>` that closes it, then assert nothing but - // whitespace separates that close from ``. The icon - // passed as a prop (``) self-closes BEFORE the element's own - // `/>`, so the first `/>` after the assessment open is the icon's and - // the second is the element's — scan for the last `/>` in the body, - // which belongs to the trailing assessment element by construction. - const selfClose = body.lastIndexOf("/>"); - expect( - selfClose, - `${slug}: assessment card is not self-closing as expected`, - ).toBeGreaterThan(assessmentIndex); + // Pages where the assessment is the last block (the default) must have + // nothing but whitespace after the assessment element's `/>`. Pages that + // intentionally trail the assessment with a navigation CTA + // (`assessmentMayTrail`, today only pulse) skip the last-block check — + // the target-before-assessment guard below still applies. + if (!assessmentMayTrail) { + // The assessment renders as a self-closing element. The icon passed + // as a prop (``) self-closes BEFORE the element's own `/>`, + // so the last `/>` in the body belongs to the trailing assessment + // element by construction. + const selfClose = body.lastIndexOf("/>"); + expect( + selfClose, + `${slug}: assessment card is not self-closing as expected`, + ).toBeGreaterThan(assessmentIndex); - const trailing = body.slice(selfClose + 2).trim(); - expect( - trailing, - `${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` + - `the assessment must be the last block on the canonical spine`, - ).toBe(""); + const trailing = body.slice(selfClose + 2).trim(); + expect( + trailing, + `${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` + + `the assessment must be the last block on the canonical spine`, + ).toBe(""); + } // v1.12.4 — the target card precedes the assessment when present. const targetIndex = body.indexOf(" 0; const pulseSummary = analytics?.summaries?.PULSE ?? null; - // v1.12.7 — brushed-window stats shared between the pulse chart and the + // v1.12.8 — visible-range stats shared between the pulse chart and the // strip (the VO2 chart-row below keeps its own full-range read). - const { statsByType, onDomainStats } = useChartDomainStats(); + const { statsByType, onVisibleStats } = useChartDomainStats(); // v1.4.27 F17 — gate the sub-page on at least one pulse observation. // Brand-new accounts (no manual logs, no Apple-Health upload yet) @@ -139,41 +140,44 @@ export default function InsightsPulsPage() { chartKey="pulse" types={["PULSE"]} title={t("charts.pulse")} + titleIcon={Heart} colors={["#50fa7b"]} unit="bpm" valueBands={pulseBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} - selectableDomain - onDomainStats={onDomainStats} + onVisibleStats={onVisibleStats} /> + {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off + the overview onto its metric page). */} + + + {/* v1.12.8 — the Einschätzung (assessment) sits ABOVE the cardio-fitness + CTA so the plain-language read of the pulse data leads, and the + cross-link to the dedicated VO₂ max page trails it. */} + } /> + {/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it under "Heart"), so the pulse page links across to its dedicated `/insights/cardio-fitness` page — the single surface that carries the full chart plus the plain-language assessment — rather than - duplicating a second, divergent VO₂ max view here. */} + duplicating a second, divergent VO₂ max view here. + + v1.12.8 — the card now leads with the canonical `` so the + `Gauge` glyph reads in the foreground colour at the same size and + position as every other tile header, with the body + CTA beneath. */} {hasVo2 ? ( - -