diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f06755f3a..5424d8089 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1569,6 +1569,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD - in: query name: from schema: @@ -1743,6 +1745,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD value: type: number measuredAt: @@ -4372,6 +4376,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD 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." @@ -8831,6 +8837,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD value: type: number unit: @@ -9260,6 +9268,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD value: type: number unit: diff --git a/messages/de.json b/messages/de.json index a00c1da74..a92b68857 100644 --- a/messages/de.json +++ b/messages/de.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Durchschnittlicher Puls", "typeMaxHeartRate": "Maximaler Puls", "typeSleepDisturbanceCount": "Schlafstörungen", + "typeAnsCharge": "ANS-Ladung", + "typeCardioLoad": "Kardio-Belastung", "filterBySource": "Nach Quelle filtern", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", diff --git a/messages/en.json b/messages/en.json index 982cde664..d84442b10 100644 --- a/messages/en.json +++ b/messages/en.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Average heart rate", "typeMaxHeartRate": "Maximum heart rate", "typeSleepDisturbanceCount": "Sleep disturbances", + "typeAnsCharge": "ANS charge", + "typeCardioLoad": "Cardio load", "filterBySource": "Filter by source", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", diff --git a/messages/es.json b/messages/es.json index d8a572cc4..47cf19601 100644 --- a/messages/es.json +++ b/messages/es.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frecuencia cardíaca media", "typeMaxHeartRate": "Frecuencia cardíaca máxima", "typeSleepDisturbanceCount": "Interrupciones del sueño", + "typeAnsCharge": "Carga del SNA", + "typeCardioLoad": "Carga cardio", "filterBySource": "Filtrar por fuente", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", diff --git a/messages/fr.json b/messages/fr.json index 52f5b9d53..b0ae9da61 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Fréquence cardiaque moyenne", "typeMaxHeartRate": "Fréquence cardiaque maximale", "typeSleepDisturbanceCount": "Perturbations du sommeil", + "typeAnsCharge": "Charge SNA", + "typeCardioLoad": "Charge cardio", "filterBySource": "Filtrer par source", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", diff --git a/messages/it.json b/messages/it.json index 7c50a8d29..3c3a8d1e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frequenza cardiaca media", "typeMaxHeartRate": "Frequenza cardiaca massima", "typeSleepDisturbanceCount": "Disturbi del sonno", + "typeAnsCharge": "Carica SNA", + "typeCardioLoad": "Carico cardio", "filterBySource": "Filtra per fonte", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", diff --git a/messages/pl.json b/messages/pl.json index f96c44bb7..cfcbb80cb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Średnie tętno", "typeMaxHeartRate": "Maksymalne tętno", "typeSleepDisturbanceCount": "Zakłócenia snu", + "typeAnsCharge": "Ładunek ANS", + "typeCardioLoad": "Obciążenie kardio", "filterBySource": "Filtruj według źródła", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", diff --git a/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql b/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql new file mode 100644 index 000000000..96681435c --- /dev/null +++ b/prisma/migrations/0166_v1171_polar_recovery_strain/migration.sql @@ -0,0 +1,20 @@ +-- v1.17.1 — Polar pull-completeness: two native Polar signals get their own +-- MeasurementType so they stop being read off the wire and dropped at the mapper. +-- +-- 1. `ANS_CHARGE` — Polar Nightly Recharge `ans_charge`, the HRV-based +-- autonomic-nervous-system charge. The primary differentiated recovery +-- component, distinct from the 1–6 recovery band already mapped to +-- RECOVERY_SCORE. +-- 2. `CARDIO_LOAD` — Polar Training Load Pro `cardio_load`, the device-native +-- cardiovascular-strain figure (Polar's WHOOP-day-strain analogue). Kept +-- distinct from DAY_STRAIN (WHOOP 0–21) and the COMPUTED STRAIN_SCORE so +-- each native value keeps its own bucket and scale. +-- +-- Purely additive: two enum-extensions on `measurement_type`. No backfill, no +-- column change, no existing row touched. Idempotent (`IF NOT EXISTS`); reruns +-- are safe. Forward-only — Postgres cannot remove an enum value, and with no +-- rows carrying them before the app ships they are inert until the Polar sync +-- writes them. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ANS_CHARGE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_LOAD'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c95adf984..7cb48fae8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -774,6 +774,17 @@ enum MeasurementType { 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. + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ── + // Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based + // autonomic-nervous-system charge component of Nightly Recharge — Polar's most + // differentiated recovery signal, distinct from the 1–6 recovery band that + // already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro + // cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct + // from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so + // each device-native value keeps its own bucket and native scale. + ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment. + CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket. + @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index b171fd450..4fd724030 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate", MAX_HEART_RATE: "measurements.typeMaxHeartRate", SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + ANS_CHARGE: "measurements.typeAnsCharge", + CARDIO_LOAD: "measurements.typeCardioLoad", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -225,6 +228,11 @@ export const MEASUREMENT_TYPE_ICONS: Record = { AVERAGE_HEART_RATE: HeartPulse, MAX_HEART_RATE: Heart, SLEEP_DISTURBANCE_COUNT: Waves, + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // HeartPulse reads as the autonomic-charge signal; Gauge carries the + // cardio-load strain figure (same family as DAY_STRAIN). + ANS_CHARGE: HeartPulse, + CARDIO_LOAD: Gauge, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -326,4 +334,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // chart-1 (strain/score family) for both, matching DAY_STRAIN. + ANS_CHARGE: "bg-chart-1/20 text-chart-1", + CARDIO_LOAD: "bg-chart-1/20 text-chart-1", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index dfeef8cac..39ca25522 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -90,10 +90,13 @@ const EXPECTED_TYPES = [ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + "ANS_CHARGE", + "CARDIO_LOAD", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 63 canonical measurement types", () => { + it("exposes the 65 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -215,6 +218,13 @@ describe("measurementTypeEnum coverage", () => { "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Polar Nightly Recharge + Training Load Pro components. ANS + // charge and Cardio Load are device-derived recovery / strain composites, + // not measured clinical vitals; they surface on their own Insights cluster + // with a "descriptive, not clinical" framing and never belong in the + // clinical vitals PDF. See doctor-report-pdf-core.ts. + "ANS_CHARGE", + "CARDIO_LOAD", ]); 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 5c1e104cf..11028f734 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -115,6 +115,11 @@ export const ALLOWED_CHART_TOKENS = [ "metric:AVERAGE_HEART_RATE", "metric:MAX_HEART_RATE", "metric:SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Polar-native recovery / strain components. Continuous daily + // series, so each carries a `metric:` token and renders through the + // generic chart renderer alongside the WHOOP-native score classes. + "metric:ANS_CHARGE", + "metric:CARDIO_LOAD", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 2687f37b9..90203c390 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -60,6 +60,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Polar Nightly Recharge + Training Load Pro components. These + // ingest server-side from the Polar AccessLink API (source = POLAR), never + // from HealthKit: Apple ships no identifier for Polar's autonomic charge or + // its device-native cardio-load strain figure. No HK mapping by design. + "ANS_CHARGE", + "CARDIO_LOAD", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index df0db5f29..5124c2646 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -188,6 +188,12 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["AVERAGE_HEART_RATE", "cardiovascular"], ["MAX_HEART_RATE", "cardiovascular"], ["SLEEP_DISTURBANCE_COUNT", "sleep"], + + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // ANS charge + Cardio Load are device-native recovery / strain composites, + // so they join the scores cluster alongside DAY_STRAIN. + ["ANS_CHARGE", "scores"], + ["CARDIO_LOAD", "scores"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 91d2f82fa..00aae80c2 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -176,6 +176,13 @@ export function getPRDirection( // WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction. case "AVERAGE_HEART_RATE": case "MAX_HEART_RATE": + // v1.17.1 — Polar-native recovery / strain components. ANS_CHARGE is a + // baseline-relative autonomic deviation (homeostatic, not a goal axis) and + // CARDIO_LOAD is a device-derived cardiovascular-load composite — neither a + // higher nor a lower value is an achievement, so they stay null like the + // other device-derived scores (HRV_RMSSD / DAY_STRAIN). + case "ANS_CHARGE": + case "CARDIO_LOAD": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a10432006..011d85787 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -100,6 +100,13 @@ export const measurementTypeEnum = z.enum([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Polar Nightly Recharge + Training Load Pro components ingested + // server-side as `source = POLAR`. ANS_CHARGE is the HRV-based autonomic + // charge (distinct from the 1–6 recovery band that maps to RECOVERY_SCORE); + // CARDIO_LOAD is Polar's device-native cardiovascular-strain figure (distinct + // from WHOOP DAY_STRAIN and the COMPUTED STRAIN_SCORE). + "ANS_CHARGE", + "CARDIO_LOAD", ]); /** @@ -312,6 +319,9 @@ const unitMap: Record = { MAX_HEART_RATE: "bpm", // Per-night sleep disturbance tally — a plain integer count. SLEEP_DISTURBANCE_COUNT: "count", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + ANS_CHARGE: "score", + CARDIO_LOAD: "score", }; export function getUnitForType(type: string): string { @@ -487,6 +497,13 @@ const VALUE_RANGES: Record = { // 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 }, + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + // ANS charge is a baseline-relative autonomic deviation that can run + // negative; a generous symmetric ±100 band covers any plausible reading. + // Cardio Load is a non-negative cumulative-strain figure; 1000 is a generous + // ceiling over any plausible single-day training load. + ANS_CHARGE: { min: -100, max: 100 }, + CARDIO_LOAD: { min: 0, max: 1000 }, }; export function validateMeasurementRange(