From 6dac56b6595e6a3d2487006c5ed6a9cfb77fdabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:01:34 +0200 Subject: [PATCH 01/49] feat(insights): add the derived-metrics compute foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce src/lib/insights/derived/ — the shared, compute-once layer for derived wellness metrics. Core pieces: - types.ts: the Derived discriminated union (ok carries value + coverage + confidence + provenance; insufficient carries coverage + provenance + reason). Client-safe — pure types, no server imports. - coverage.ts: deriveCoverage() + the buildOk/buildInsufficient terminal constructors. Confidence blends the history fraction and the input-presence fraction so it drops monotonically as inputs go missing. - registry.ts: the closed DERIVED_METRIC_ID enum + per-metric metadata (inputs, archetype, min-history / min-inputs floors, implemented flag). - norms.ts: age/sex reference-range tables (VO2max FRIEND registry, resting-HR + respiratory-rate by age) behind lookupNormalRange(). Age + sex only — no ancestry/region ranges. - baseline.ts: the flagship engine — computeVitalsBaseline() returns the personal typical-range band (median +/- k*MAD) for one vital. Center reads the composable DAY rollup mean; the spread is DAY-native only (the sd/slope/r2-don't-compose invariant), with a per-type bounded live-SQL fallback on a coverage miss. - dispatch.ts: maps a DERIVED_METRIC_ID to its compute function; stubbed metrics return an insufficient with reason not_implemented. - index.ts: the append-only public barrel. Unit tests cover the coverage model, the Derived ok/insufficient branching, the norms lookup, the baseline ok / insufficient / coverage-miss-fallback paths + the composed-vs-raw-DAY band parity, and the dispatch stub guard. --- .../derived/__tests__/baseline.test.ts | 205 ++++++++++++ .../derived/__tests__/coverage.test.ts | 179 ++++++++++ .../derived/__tests__/dispatch.test.ts | 88 +++++ .../insights/derived/__tests__/norms.test.ts | 61 ++++ src/lib/insights/derived/baseline.ts | 308 ++++++++++++++++++ src/lib/insights/derived/coverage.ts | 144 ++++++++ src/lib/insights/derived/dispatch.ts | 107 ++++++ src/lib/insights/derived/index.ts | 69 ++++ src/lib/insights/derived/norms.ts | 155 +++++++++ src/lib/insights/derived/registry.ts | 178 ++++++++++ src/lib/insights/derived/types.ts | 98 ++++++ 11 files changed, 1592 insertions(+) create mode 100644 src/lib/insights/derived/__tests__/baseline.test.ts create mode 100644 src/lib/insights/derived/__tests__/coverage.test.ts create mode 100644 src/lib/insights/derived/__tests__/dispatch.test.ts create mode 100644 src/lib/insights/derived/__tests__/norms.test.ts create mode 100644 src/lib/insights/derived/baseline.ts create mode 100644 src/lib/insights/derived/coverage.ts create mode 100644 src/lib/insights/derived/dispatch.ts create mode 100644 src/lib/insights/derived/index.ts create mode 100644 src/lib/insights/derived/norms.ts create mode 100644 src/lib/insights/derived/registry.ts create mode 100644 src/lib/insights/derived/types.ts diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts new file mode 100644 index 000000000..98f1c95c3 --- /dev/null +++ b/src/lib/insights/derived/__tests__/baseline.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn(), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn(), +})); + +import { + median, + medianAbsoluteDeviation, + buildBaselineBand, + computeVitalsBaseline, +} from "../baseline"; +import { prisma } from "@/lib/db"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); + +function dayRow(day: string, mean: number) { + return { + bucketStart: new Date(`${day}T00:00:00Z`), + count: 1, + mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: mean, + maxValue: mean, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("pure statistics", () => { + it("median handles odd + even lengths", () => { + expect(median([3, 1, 2])).toBe(2); + expect(median([4, 1, 3, 2])).toBe(2.5); + }); + + it("MAD is the median of absolute deviations about the median", () => { + // values 1,2,3,4,5 → median 3 → |dev| 2,1,0,1,2 → MAD = 1 + expect(medianAbsoluteDeviation([1, 2, 3, 4, 5])).toBe(1); + }); + + it("buildBaselineBand returns a symmetric MAD band", () => { + const band = buildBaselineBand([50, 52, 54, 56, 58], 3); + expect(band).not.toBeNull(); + expect(band!.center).toBe(54); + expect(band!.low).toBeLessThan(band!.center); + expect(band!.high).toBeGreaterThan(band!.center); + expect(band!.sampleDays).toBe(5); + expect(band!.k).toBe(3); + }); + + it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => { + // Same per-day means whether they arrive as raw rows or DAY rollups. + const perDayMeans = [60, 61, 62, 59, 63, 58, 64]; + const fromRaw = buildBaselineBand(perDayMeans, 3); + const fromRollup = buildBaselineBand([...perDayMeans], 3); + expect(fromRollup).toEqual(fromRaw); + }); +}); + +describe("computeVitalsBaseline — ok path (rollup tier)", () => { + it("returns an ok band from DAY rollups with provenance source DAY", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + const rows = [ + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + "2026-05-24", + "2026-05-25", + "2026-05-26", + "2026-05-27", + ].map((d, i) => dayRow(d, 55 + i)); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.type).toBe("RESTING_HEART_RATE"); + expect(result.provenance.source).toBe("DAY"); + expect(result.coverage.historyDays).toBe(8); + expect(result.confidence.score).toBeGreaterThan(0); + } + // rollup-covered path must not touch raw SQL + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("computeVitalsBaseline — coverage-miss live fallback", () => { + it("falls back to raw rows and reports provenance source live", async () => { + // coverage says no buckets for this type → live read + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", false]]), + ); + const raw = [ + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + "2026-05-24", + "2026-05-25", + "2026-05-26", + ].map((d, i) => ({ + value: 60 + i, + measuredAt: new Date(`${d}T08:00:00Z`), + })); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.provenance.source).toBe("live"); + expect(result.coverage.historyDays).toBe(7); + } + expect(readBestGranularityRollups).not.toHaveBeenCalled(); + expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1); + }); + + it("rollup-covered but window resolves coarser → live fallback keeps spread DAY-native", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + // resolved at MONTH (coarse) — engine must not band from it + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "MONTH", + rows: [dayRow("2026-04-01", 55)], + }); + const raw = Array.from({ length: 7 }, (_, i) => ({ + value: 58 + i, + measuredAt: new Date(`2026-05-2${i}T08:00:00Z`), + })); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.provenance.source).toBe("live"); + }); +}); + +describe("computeVitalsBaseline — insufficient paths", () => { + it("no data at all → insufficient, source none", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue(new Map()); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_readings_in_window"); + expect(result.provenance.source).toBe("none"); + expect(result.coverage.historyDays).toBe(0); + } + }); + + it("below the 7-day floor → insufficient, value-less, but coverage shows progress", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows: [dayRow("2026-05-25", 55), dayRow("2026-05-26", 56), dayRow("2026-05-27", 57)], + }); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_history_for_band"); + expect(result.coverage.historyDays).toBe(3); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/coverage.test.ts b/src/lib/insights/derived/__tests__/coverage.test.ts new file mode 100644 index 000000000..9fb352008 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coverage.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { + deriveCoverage, + buildOk, + buildInsufficient, + scoreToBand, + nowProvenanceTimestamp, +} from "../coverage"; +import { isDerivedOk, type Derived } from "../types"; + +const PROV = { + inputs: ["RESTING_HEART_RATE"], + source: "DAY" as const, + windowDays: 30, + computedAt: "2026-06-02T07:00:00+02:00", +}; + +describe("scoreToBand", () => { + it("maps score ranges onto the four bands", () => { + expect(scoreToBand(90)).toBe("high"); + expect(scoreToBand(75)).toBe("high"); + expect(scoreToBand(60)).toBe("medium"); + expect(scoreToBand(50)).toBe("medium"); + expect(scoreToBand(30)).toBe("low"); + expect(scoreToBand(25)).toBe("low"); + expect(scoreToBand(10)).toBe("draft"); + }); +}); + +describe("deriveCoverage", () => { + it("clamps presentInputs to requiredInputs and copies missing[]", () => { + const missing = ["HEART_RATE_VARIABILITY"]; + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 5, + historyDays: 14, + missing, + fullHistoryDays: 30, + }); + expect(coverage.requiredInputs).toBe(2); + expect(coverage.presentInputs).toBe(2); + expect(coverage.historyDays).toBe(14); + expect(coverage.missing).toEqual(missing); + // defensive copy, not the same reference + expect(coverage.missing).not.toBe(missing); + }); + + it("scores a fully-covered, full-history metric high", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }); + expect(confidence.score).toBe(100); + expect(confidence.band).toBe("high"); + }); + + it("drops confidence monotonically as inputs go missing", () => { + const full = deriveCoverage({ + requiredInputs: 4, + presentInputs: 4, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + const partial = deriveCoverage({ + requiredInputs: 4, + presentInputs: 2, + historyDays: 30, + missing: ["a", "b"], + fullHistoryDays: 30, + }).confidence.score; + const sparse = deriveCoverage({ + requiredInputs: 4, + presentInputs: 1, + historyDays: 30, + missing: ["a", "b", "c"], + fullHistoryDays: 30, + }).confidence.score; + expect(full).toBeGreaterThan(partial); + expect(partial).toBeGreaterThan(sparse); + }); + + it("drops confidence as history shrinks", () => { + const long = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + const short = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 7, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + expect(long).toBeGreaterThan(short); + }); + + it("floors a present-but-sparse value at 1 rather than 0", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 100, + presentInputs: 1, + historyDays: 1, + missing: [], + fullHistoryDays: 365, + }); + expect(confidence.score).toBeGreaterThanOrEqual(1); + }); + + it("scores zero when nothing is present", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: ["a", "b"], + }); + expect(confidence.score).toBe(0); + expect(confidence.band).toBe("draft"); + }); +}); + +describe("buildOk / buildInsufficient — Derived branching", () => { + it("buildOk produces the ok arm with all four facets", () => { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }); + const d: Derived<{ center: number }> = buildOk({ + value: { center: 55 }, + coverage, + confidence, + provenance: PROV, + }); + expect(d.status).toBe("ok"); + expect(isDerivedOk(d)).toBe(true); + if (isDerivedOk(d)) { + expect(d.value.center).toBe(55); + expect(d.confidence.band).toBe("high"); + expect(d.provenance.source).toBe("DAY"); + } + }); + + it("buildInsufficient produces the gated arm with coverage + reason, no value", () => { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: ["RESTING_HEART_RATE"], + }); + const d: Derived<{ center: number }> = buildInsufficient({ + coverage, + provenance: { ...PROV, source: "none", windowDays: 0 }, + reason: "no_readings_in_window", + }); + expect(d.status).toBe("insufficient"); + expect(isDerivedOk(d)).toBe(false); + if (d.status === "insufficient") { + expect(d.reason).toBe("no_readings_in_window"); + expect(d.coverage.missing).toContain("RESTING_HEART_RATE"); + // the gated arm carries no `value` / `confidence` keys + expect("value" in d).toBe(false); + expect("confidence" in d).toBe(false); + } + }); +}); + +describe("nowProvenanceTimestamp", () => { + it("emits an ISO-8601 string", () => { + const ts = nowProvenanceTimestamp(new Date("2026-06-02T05:00:00Z")); + expect(ts).toBe("2026-06-02T05:00:00.000Z"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts new file mode 100644 index 000000000..416a400d3 --- /dev/null +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) } }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); + +import { computeDerivedMetric } from "../dispatch"; +import { + DERIVED_METRIC_IDS, + getDerivedMetricMeta, + isDerivedMetricId, + isVitalsBaselineType, +} from "../registry"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); + +beforeEach(() => vi.clearAllMocks()); + +describe("registry", () => { + it("exposes the flagship as implemented and the rest as stubs", () => { + expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); + }); + + it("isDerivedMetricId rejects unknown ids", () => { + expect(isDerivedMetricId("VITALS_BASELINE")).toBe(true); + expect(isDerivedMetricId("NOPE")).toBe(false); + }); + + it("isVitalsBaselineType gates the supported set", () => { + expect(isVitalsBaselineType("RESTING_HEART_RATE")).toBe(true); + expect(isVitalsBaselineType("STEPS")).toBe(false); + }); + + it("DERIVED_METRIC_IDS is the full closed enum", () => { + expect(DERIVED_METRIC_IDS).toContain("VITALS_BASELINE"); + expect(DERIVED_METRIC_IDS).toContain("READINESS"); + expect(DERIVED_METRIC_IDS.length).toBe(6); + }); +}); + +describe("computeDerivedMetric dispatch", () => { + it("routes VITALS_BASELINE to the engine (no data → insufficient, not a throw)", async () => { + const result = await computeDerivedMetric({ + metric: "VITALS_BASELINE", + userId: "u1", + profile: PROFILE, + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + }); + + it("returns not_implemented for a stubbed metric (never a 500)", async () => { + const result = await computeDerivedMetric({ + metric: "READINESS", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("not_implemented"); + } + }); + + it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => { + const result = await computeDerivedMetric({ + metric: "VITALS_BASELINE", + userId: "u1", + profile: PROFILE, + type: "STEPS", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("unsupported_baseline_type"); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts new file mode 100644 index 000000000..ed08b0ea1 --- /dev/null +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { lookupNormalRange, hasSharpenedNorm } from "../norms"; + +describe("lookupNormalRange — the age/sex reference-range enabler", () => { + it("returns a sex-specific VO2max band for a registered metric", () => { + const male = lookupNormalRange("VO2_MAX", 35, "MALE"); + const female = lookupNormalRange("VO2_MAX", 35, "FEMALE"); + expect(male).not.toBeNull(); + expect(female).not.toBeNull(); + // male bands sit higher than female at the same decade + expect(male!.low).toBeGreaterThan(female!.low); + expect(male!.high).toBeGreaterThan(female!.high); + }); + + it("narrows resting-HR by age (sex-agnostic)", () => { + const child = lookupNormalRange("RESTING_HEART_RATE", 4, null); + const adult = lookupNormalRange("RESTING_HEART_RATE", 40, null); + expect(child).not.toBeNull(); + expect(adult).not.toBeNull(); + // children run higher than adults + expect(child!.high).toBeGreaterThan(adult!.high); + }); + + it("returns null when age is absent (falls back to flat anchor)", () => { + expect(lookupNormalRange("VO2_MAX", null, "MALE")).toBeNull(); + expect(lookupNormalRange("VO2_MAX", undefined, "MALE")).toBeNull(); + }); + + it("returns null for a metric with no norm table", () => { + expect(lookupNormalRange("OXYGEN_SATURATION", 40, "MALE")).toBeNull(); + expect(lookupNormalRange("STEPS", 40, "FEMALE")).toBeNull(); + }); + + it("returns null for a sex-specific-only table when profile sex is absent", () => { + // VO2max is sex-specific only; without a sex we cannot pick one band. + expect(lookupNormalRange("VO2_MAX", 35, null)).toBeNull(); + }); + + it("prefers a sex-agnostic row when present and sex is absent", () => { + // resting-HR rows are sex-agnostic, so an absent sex still resolves. + expect(lookupNormalRange("RESTING_HEART_RATE", 40, null)).not.toBeNull(); + }); + + it("clamps to the oldest bracket for very high ages", () => { + const old = lookupNormalRange("VO2_MAX", 95, "FEMALE"); + expect(old).not.toBeNull(); + }); + + it("rejects negative / non-finite ages", () => { + expect(lookupNormalRange("RESTING_HEART_RATE", -5, null)).toBeNull(); + expect(lookupNormalRange("RESTING_HEART_RATE", Number.NaN, null)).toBeNull(); + }); +}); + +describe("hasSharpenedNorm", () => { + it("mirrors lookupNormalRange truthiness", () => { + expect(hasSharpenedNorm("VO2_MAX", 35, "MALE")).toBe(true); + expect(hasSharpenedNorm("VO2_MAX", 35, null)).toBe(false); + expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false); + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts new file mode 100644 index 000000000..585b7388b --- /dev/null +++ b/src/lib/insights/derived/baseline.ts @@ -0,0 +1,308 @@ +/** + * v1.10.0 — the FLAGSHIP shared baseline engine (catalogue metric #1: + * personal typical-range / vitals baseline). + * + * `computeVitalsBaseline(userId, profile, opts)` is the one real metric + * Wave 1 ships end-to-end. It returns the band of values that is "normal + * for you" for a single vital, computed as a rolling personal baseline: + * + * - **center** = median of the per-day means over the window. The DAY + * means come from the rollup tier (`readBestGranularityRollups` at + * DAY granularity — `mean` composes linearly across DAY buckets, the + * `measurement-read-wmy.ts:29-39` contract) with a per-type bounded + * live-SQL fallback on a coverage miss. + * - **spread** = median ± k·MAD (median absolute deviation, k≈3), an + * outlier-robust ≈3σ-equivalent band. MAD is computed from the same + * DAY-native per-day series — NEVER from a recomposed WEEK/MONTH `sd`. + * This is the hard invariant: `sd`/`slope`/`r2` do not compose, so + * the spread is derived at native (DAY) granularity only. + * + * Method/standard: robust-statistics anomaly detection — Median Absolute + * Deviation (Hampel 1974, JASA 69(346):383–393; Leys et al. 2013, J. Exp. + * Soc. Psychol. 49(4):764–766: "do not use standard deviation around the + * mean, use the median absolute deviation around the median"). Framing: + * Apple Health "Vitals" typical-range — establish the band after ≥7 days, + * then today's reading is "in range" or "outside". + * + * Server-only — reads the rollup tier + raw rows via Prisma. The pure + * statistics helpers below are exported so the unit test can assert the + * composed-bucket baseline matches the raw-DAY baseline within tolerance. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */ +const DEFAULT_MAD_K = 3; +/** 1.4826 makes MAD a consistent estimator of σ under normality. */ +const MAD_SIGMA_SCALE = 1.4826; +/** Default trailing window (days). Apple establishes the band after ≥7. */ +const DEFAULT_WINDOW_DAYS = 30; + +/** The successful `value` payload for a vitals baseline. */ +export interface VitalsBaselineValue { + /** The vital this band describes. */ + type: MeasurementType; + /** Robust center (median of the per-day means). */ + center: number; + /** Band lower edge (center − k·MAD·scale). */ + low: number; + /** Band upper edge (center + k·MAD·scale). */ + high: number; + /** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */ + spread: number; + /** Distinct days that contributed to the baseline. */ + sampleDays: number; + /** k used for the band (echoed for transparency). */ + k: number; +} + +/** Caller-supplied profile (read once per request, never re-fetched here). */ +export interface BaselineProfile { + ageYears: number | null; + sex: "MALE" | "FEMALE" | null; +} + +export interface VitalsBaselineOpts { + /** Which vital to baseline. */ + type: MeasurementType; + /** Trailing window in days. Defaults to 30. */ + windowDays?: number; + /** k for the MAD band. Defaults to 3. */ + k?: number; + /** Compute time (injected for deterministic tests). */ + now?: Date; + /** + * Pre-probed coverage map (one probe per request, shared across + * metrics — the pool-contention mitigation). When omitted the engine + * probes itself. + */ + coverage?: RollupCoverageMap; +} + +/** A per-day mean point used to build the baseline. */ +export interface DayMeanPoint { + day: string; + mean: number; +} + +// ── pure statistics (exported for the parity test) ─────────────────── + +/** Median of a numeric array (does not mutate the input). */ +export function median(values: number[]): number { + if (values.length === 0) return NaN; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Median absolute deviation about the median (raw, unscaled). */ +export function medianAbsoluteDeviation(values: number[]): number { + if (values.length === 0) return NaN; + const center = median(values); + const deviations = values.map((v) => Math.abs(v - center)); + return median(deviations); +} + +/** + * Build the median ± k·MAD band from a per-day mean series. Pure — the + * caller supplies the already-resolved DAY-native series so this can be + * unit-tested against both a raw-DAY series and a rollup-composed series + * (they must agree: DAY rollup `mean` equals the per-day raw mean). + */ +export function buildBaselineBand( + dayMeans: number[], + k: number = DEFAULT_MAD_K, +): Omit | null { + if (dayMeans.length === 0) return null; + const center = median(dayMeans); + const mad = medianAbsoluteDeviation(dayMeans); + // Scale MAD to a σ-equivalent so the band reads like a robust ±kσ. + const spread = k * mad * MAD_SIGMA_SCALE; + return { + center, + low: center - spread, + high: center + spread, + spread, + sampleDays: dayMeans.length, + k, + }; +} + +// ── reads ──────────────────────────────────────────────────────────── + +/** + * Resolve the per-day mean series for `(userId, type)` over the window. + * Rollup tier first (DAY-native, `mean` composes); per-type bounded + * live-SQL fallback on a coverage miss. Returns the series plus the + * provenance source the read resolved against. + */ +async function readDayMeanSeries( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, + coverage: RollupCoverageMap, +): Promise<{ points: DayMeanPoint[]; source: DerivedProvenanceSource }> { + const hasBuckets = coverage.get(type) === true; + + if (hasBuckets) { + // DAY granularity only — the spread invariant forbids composing a + // band from WEEK/MONTH `sd`, and the center reads the DAY `mean` + // which composes exactly. `readBestGranularityRollups` with a + // window < 91 days resolves to DAY by construction. + const resolved = await readBestGranularityRollups(userId, type, windowDays); + if (resolved && resolved.granularity === "DAY" && resolved.rows.length > 0) { + const points = resolved.rows.map((row) => ({ + day: row.bucketStart.toISOString().slice(0, 10), + mean: row.mean, + })); + return { points, source: "DAY" }; + } + // Coverage probe said "has buckets" but the window resolved to a + // coarser tier or zero DAY rows → fall through to the live read so + // the spread is still DAY-native. + } + + // Per-type live fallback — bounded raw read, grouped into per-day + // means. Honest provenance: source = "live". + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) { + return { points: [], source: "none" }; + } + const byDay = new Map(); + for (const row of rows) { + const day = row.measuredAt.toISOString().slice(0, 10); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += row.value; + acc.count += 1; + byDay.set(day, acc); + } + const points: DayMeanPoint[] = [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, acc]) => ({ day, mean: acc.sum / acc.count })); + return { points, source: "live" }; +} + +/** + * FLAGSHIP — the one real Wave 1 metric. Pure `(userId, profile, opts) => + * Promise>`: rolling personal baseline + * (median ± k·MAD) for a single vital, reading the rollup tier with a + * per-type live fallback on a coverage miss. Below the min-history floor + * it returns `insufficient` with coverage + provenance (never a + * fabricated band). + */ +export async function computeVitalsBaseline( + userId: string, + _profile: BaselineProfile, + opts: VitalsBaselineOpts, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const k = opts.k ?? DEFAULT_MAD_K; + const now = opts.now ?? new Date(); + const type = opts.type; + const minHistoryDays = 7; + + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + const { points, source } = await readDayMeanSeries( + userId, + type, + windowDays, + now, + coverage, + ); + + const historyDays = points.length; + const computedAt = nowProvenanceTimestamp(now); + + // No data at all — insufficient, source "none". + if (historyDays === 0) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [String(type)], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source: "none", windowDays, computedAt }, + reason: "no_readings_in_window", + }); + } + + // Below the band floor — value exists but not enough history for a + // robust band. Insufficient, but honest coverage + provenance so the + // card shows "building your typical range — N of 7 days". + if (historyDays < minHistoryDays) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + reason: "insufficient_history_for_band", + }); + } + + const band = buildBaselineBand( + points.map((p) => p.mean), + k, + ); + if (!band) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + reason: "band_computation_failed", + }); + } + + const { coverage: cov, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { type, ...band }, + coverage: cov, + confidence, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + }); +} diff --git a/src/lib/insights/derived/coverage.ts b/src/lib/insights/derived/coverage.ts new file mode 100644 index 000000000..648f5b9e3 --- /dev/null +++ b/src/lib/insights/derived/coverage.ts @@ -0,0 +1,144 @@ +/** + * v1.10.0 — the coverage / confidence model + the `Derived` builders. + * + * `deriveCoverage` turns the raw "what does this metric want vs what is + * present, and how much history backs it" facts into a `DerivedCoverage` + * + a `DerivedConfidence` band. `buildOk` / `buildInsufficient` are the + * two terminal constructors every pure compute function returns — they + * attach coverage + confidence + provenance at compute time, inside the + * pure function, so no surface ever has to bolt them on per-screen. + * + * Confidence model (carried from the metric catalogue, §2.1): + * - history scales the band width — < `minHistoryDays` nights → no band + * (the caller returns `insufficient`); 7d → low, 14d → medium, + * 30d+ → high. + * - for composites, the present/required input ratio drops the score + * monotonically as `missing` grows. + * Confidence is a blend of the history fraction and the input-presence + * fraction so that a fully-covered short-history metric and a + * long-history partially-covered composite both land honestly. + * + * Client-safe — pure functions, no server imports. + */ +import type { + Derived, + DerivedConfidence, + DerivedConfidenceBand, + DerivedCoverage, + DerivedProvenance, +} from "./types"; + +/** Clamp helper. */ +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +/** + * Map a 0..100 score onto the confidence band the shared meter renders. + * `draft` is reserved for a value computed below its minimum-history + * floor but still surfaced (value-only, no band) — callers that gate + * hard return `insufficient` instead and never reach this. + */ +export function scoreToBand(score: number): DerivedConfidenceBand { + if (score >= 75) return "high"; + if (score >= 50) return "medium"; + if (score >= 25) return "low"; + return "draft"; +} + +export interface DeriveCoverageArgs { + /** Inputs the metric wants (its full input set; ≥ 1). */ + requiredInputs: number; + /** Inputs actually present. */ + presentInputs: number; + /** Days of history backing the value. */ + historyDays: number; + /** Named inputs still missing. */ + missing: string[]; + /** + * History-day count at which the band reaches full width / full + * history confidence. Defaults to 30 (the typical-range engine's + * 30-day window); composites pass their own. + */ + fullHistoryDays?: number; +} + +/** + * Build the `DerivedCoverage` + `DerivedConfidence` pair from the raw + * coverage facts. Confidence = the product of the history fraction + * (capped at `fullHistoryDays`) and the input-presence fraction, scaled + * to 0..100 and floored so a present-but-sparse value never reports 0. + */ +export function deriveCoverage(args: DeriveCoverageArgs): { + coverage: DerivedCoverage; + confidence: DerivedConfidence; +} { + const requiredInputs = Math.max(1, args.requiredInputs); + const presentInputs = clamp(args.presentInputs, 0, requiredInputs); + const historyDays = Math.max(0, args.historyDays); + const fullHistoryDays = Math.max(1, args.fullHistoryDays ?? 30); + + const historyFraction = clamp(historyDays / fullHistoryDays, 0, 1); + const inputFraction = presentInputs / requiredInputs; + + // Geometric-ish blend: both axes must be healthy for a high score, but + // a fully-covered short-history metric still earns a non-trivial score + // and a long-history partially-covered composite is penalised by its + // missing inputs. Floor at 1 so a real (non-insufficient) value never + // reads as 0 confidence. + const rawScore = historyFraction * inputFraction * 100; + const score = presentInputs > 0 && historyDays > 0 + ? Math.round(clamp(rawScore, 1, 100)) + : 0; + + return { + coverage: { + requiredInputs, + presentInputs, + historyDays, + missing: [...args.missing], + }, + confidence: { score, band: scoreToBand(score) }, + }; +} + +/** + * Terminal constructor — a successful derived value. Attaches coverage + + * confidence + provenance computed by the caller (via `deriveCoverage`). + */ +export function buildOk(args: { + value: T; + coverage: DerivedCoverage; + confidence: DerivedConfidence; + provenance: DerivedProvenance; +}): Derived { + return { + status: "ok", + value: args.value, + coverage: args.coverage, + confidence: args.confidence, + provenance: args.provenance, + }; +} + +/** + * Terminal constructor — a gated derived value. Carries coverage + + * provenance so the surface renders the shared "track N more days" state. + */ +export function buildInsufficient(args: { + coverage: DerivedCoverage; + provenance: DerivedProvenance; + reason: string; +}): Derived { + return { + status: "insufficient", + coverage: args.coverage, + provenance: args.provenance, + reason: args.reason, + }; +} + +/** ISO 8601 timestamp with offset — the provenance `computedAt` source. */ +export function nowProvenanceTimestamp(now: Date = new Date()): string { + return now.toISOString(); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts new file mode 100644 index 000000000..ab88f8ad0 --- /dev/null +++ b/src/lib/insights/derived/dispatch.ts @@ -0,0 +1,107 @@ +/** + * v1.10.0 — the route-side dispatcher: `DerivedMetricId` → its pure + * compute function → a wire-flat `Derived`. + * + * The generic `/api/insights/derived` route validates `metric` against + * the closed registry enum, then calls `computeDerivedMetric`. Wave 1 + * implements `VITALS_BASELINE` end-to-end; every other registered id is + * a metadata stub whose compute lands in W2/W3. A stub routed before its + * compute lands returns `insufficient` with `reason: "not_implemented"` + * — never a fabricated value, never a 500. + * + * Server-only — imports the server-only compute engines. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildInsufficient, nowProvenanceTimestamp } from "./coverage"; +import { + getDerivedMetricMeta, + isVitalsBaselineType, + type DerivedMetricId, +} from "./registry"; +import { + computeVitalsBaseline, + type BaselineProfile, +} from "./baseline"; +import type { Derived } from "./types"; + +export interface DerivedComputeArgs { + metric: DerivedMetricId; + userId: string; + profile: BaselineProfile; + /** + * Optional sub-target for metrics that baseline a single chosen type + * (`VITALS_BASELINE`). Validated against the engine's supported set. + */ + type?: string | null; + /** Trailing window override (days). */ + windowDays?: number; + now?: Date; +} + +/** A `not_implemented` insufficient — the stub guard for W2/W3 metrics. */ +function notImplemented( + inputs: string[], + now: Date, +): Derived { + const computedAt = nowProvenanceTimestamp(now); + return buildInsufficient({ + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: inputs }, + provenance: { inputs, source: "none", windowDays: 0, computedAt }, + reason: "not_implemented", + }); +} + +/** + * Dispatch a derived-metric id to its compute function. Returns the flat + * `Derived` the route wraps in the standard envelope. The caller + * has already Zod-validated `metric` against the registry enum, so an + * unknown id never reaches here. + */ +export async function computeDerivedMetric( + args: DerivedComputeArgs, +): Promise> { + const now = args.now ?? new Date(); + const meta = getDerivedMetricMeta(args.metric); + if (!meta) { + // Defensive — the route validates against the enum first. + return notImplemented([], now); + } + + if (!meta.implemented) { + return notImplemented(meta.inputs.map(String), now); + } + + switch (args.metric) { + case "VITALS_BASELINE": { + // The baseline engine needs a single chosen vital; default to + // RESTING_HEART_RATE when the caller omits one (the catalogue's + // canonical example). + const requested = args.type ?? "RESTING_HEART_RATE"; + if (!isVitalsBaselineType(requested)) { + return buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [requested], + }, + provenance: { + inputs: [requested], + source: "none", + windowDays: 0, + computedAt: nowProvenanceTimestamp(now), + }, + reason: "unsupported_baseline_type", + }); + } + return computeVitalsBaseline(args.userId, args.profile, { + type: requested as MeasurementType, + windowDays: args.windowDays, + now, + }) as Promise>; + } + default: + // Registered + implemented but no dispatch arm — treat as a stub. + return notImplemented(meta.inputs.map(String), now); + } +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts new file mode 100644 index 000000000..1ecd03325 --- /dev/null +++ b/src/lib/insights/derived/index.ts @@ -0,0 +1,69 @@ +/** + * v1.10.0 — public barrel for the derived-metrics layer. + * + * The one module every consumer imports from. Append-only per wave — + * Wave 1 exports the foundation (`Derived` + coverage builders + + * registry + norms + the flagship baseline engine + the route + * dispatcher); W2/W3 append their per-metric engines (`fitness-age`, + * `vascular-age`, `sleep-score`, `readiness`) here. + * + * Re-exports the client-safe types/coverage builders and the + * server-only compute engines from one place; consumers import only what + * they need. (A `"use client"` component must value-import only from + * `./types` + `./coverage` + `./registry` — those are server-import-free. + * The route + server consumers may import the engines below.) + */ + +// ── client-safe contract (types + pure builders + metadata) ────────── +export type { + Derived, + DerivedOk, + DerivedInsufficient, + DerivedCoverage, + DerivedConfidence, + DerivedConfidenceBand, + DerivedProvenance, + DerivedProvenanceSource, +} from "./types"; +export { isDerivedOk } from "./types"; + +export { + deriveCoverage, + buildOk, + buildInsufficient, + scoreToBand, + nowProvenanceTimestamp, +} from "./coverage"; +export type { DeriveCoverageArgs } from "./coverage"; + +export { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, + isDerivedMetricId, + getDerivedMetricMeta, + isVitalsBaselineType, +} from "./registry"; +export type { + DerivedMetricId, + DerivedMetricMeta, + DerivedArchetype, +} from "./registry"; + +export { lookupNormalRange, hasSharpenedNorm } from "./norms"; +export type { NormRange, NormSex } from "./norms"; + +// ── server-only compute engines (do NOT value-import from a client component) ── +export { + computeVitalsBaseline, + buildBaselineBand, + median, + medianAbsoluteDeviation, +} from "./baseline"; +export type { + VitalsBaselineValue, + VitalsBaselineOpts, + BaselineProfile, +} from "./baseline"; + +export { computeDerivedMetric } from "./dispatch"; +export type { DerivedComputeArgs } from "./dispatch"; diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts new file mode 100644 index 000000000..358023f7b --- /dev/null +++ b/src/lib/insights/derived/norms.ts @@ -0,0 +1,155 @@ +/** + * v1.10.0 — age/sex reference-range tables (the F1 enabler). + * + * The generic assessment registry (`metric-status-registry.ts`) already + * threads a coarse, demographic-flat `normalRange` per metric and already + * passes `age` + `sex` into the prompt. This module SHARPENS that slot: + * `lookupNormalRange(metricId, ageYears, sex)` returns an age/sex-adjusted + * band when a cited standard supports one, falling back to `null` so the + * caller keeps the existing flat anchor (additive, behind the existing + * behaviour — never a regression). + * + * Strictly age + sex. NO ancestry / ethnicity / region-of-birth ranges — + * that is medically contentious and a data category HealthLog does not + * collect (the registry already states this stance). + * + * Cited standards (one per table): + * - VO₂max percentile norms — Cooper Institute / FRIEND registry: + * Kaminsky et al. 2015, "Reference Standards for Cardiorespiratory + * Fitness Measured With Cardiopulmonary Exercise Testing", Mayo Clinic + * Proceedings 90(11):1515–1523. (The 50th-percentile age×sex band.) + * - Resting heart rate by age — standard physiology / population + * reference (adult resting-HR narrows with the AHA 60–100 bpm anchor; + * children/adolescents run higher — Fleming et al. 2011, Lancet + * 377(9770):1011–1018, normal-range HR centiles by age). + * - Respiratory rate by age — clinical reference (WHO/PALS adult vs + * paediatric ranges). + * + * Client-safe — pure data + a pure lookup, no server imports. + */ +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; + +/** Profile sex as stored on `User.gender` ("MALE" | "FEMALE" | null). */ +export type NormSex = "MALE" | "FEMALE" | null | undefined; + +/** A reference band — same shape as `MetricNormalRange`. */ +export interface NormRange { + low: number; + high: number; +} + +/** One age-bracketed row, optionally sex-specific. */ +interface NormRow { + /** Inclusive lower age bound (years). */ + minAge: number; + /** Inclusive upper age bound (years). */ + maxAge: number; + /** Sex this row applies to; `null` = applies to either. */ + sex: "MALE" | "FEMALE" | null; + range: NormRange; +} + +/** + * VO₂max 50th-percentile bands (mL/(kg·min)) by decade × sex — derived + * from the FRIEND registry (Kaminsky et al. 2015). The band brackets the + * "Good" placement for the age×sex cell; the assessment leans on the + * user's own trend, this only sharpens the population anchor. + */ +const VO2_MAX_NORMS: NormRow[] = [ + { minAge: 20, maxAge: 29, sex: "MALE", range: { low: 42, high: 53 } }, + { minAge: 30, maxAge: 39, sex: "MALE", range: { low: 39, high: 49 } }, + { minAge: 40, maxAge: 49, sex: "MALE", range: { low: 35, high: 45 } }, + { minAge: 50, maxAge: 59, sex: "MALE", range: { low: 31, high: 41 } }, + { minAge: 60, maxAge: 120, sex: "MALE", range: { low: 27, high: 37 } }, + { minAge: 20, maxAge: 29, sex: "FEMALE", range: { low: 36, high: 46 } }, + { minAge: 30, maxAge: 39, sex: "FEMALE", range: { low: 33, high: 43 } }, + { minAge: 40, maxAge: 49, sex: "FEMALE", range: { low: 30, high: 39 } }, + { minAge: 50, maxAge: 59, sex: "FEMALE", range: { low: 26, high: 35 } }, + { minAge: 60, maxAge: 120, sex: "FEMALE", range: { low: 22, high: 31 } }, +]; + +/** + * Resting-HR normal bands by age (bpm). Sex-agnostic — the adult band is + * the AHA 60–100 anchor; younger ages run higher (Fleming et al. 2011 + * normal-range centiles). Direction is `lower-better` within the band. + */ +const RESTING_HEART_RATE_NORMS: NormRow[] = [ + { minAge: 0, maxAge: 1, sex: null, range: { low: 100, high: 160 } }, + { minAge: 2, maxAge: 5, sex: null, range: { low: 80, high: 120 } }, + { minAge: 6, maxAge: 12, sex: null, range: { low: 70, high: 110 } }, + { minAge: 13, maxAge: 17, sex: null, range: { low: 60, high: 100 } }, + { minAge: 18, maxAge: 120, sex: null, range: { low: 50, high: 90 } }, +]; + +/** + * Respiratory-rate normal bands by age (breaths/min). Sex-agnostic — + * clinical reference (WHO/PALS): adult 12–20, paediatric ranges run + * higher. + */ +const RESPIRATORY_RATE_NORMS: NormRow[] = [ + { minAge: 0, maxAge: 1, sex: null, range: { low: 30, high: 60 } }, + { minAge: 2, maxAge: 5, sex: null, range: { low: 20, high: 30 } }, + { minAge: 6, maxAge: 12, sex: null, range: { low: 18, high: 25 } }, + { minAge: 13, maxAge: 17, sex: null, range: { low: 12, high: 20 } }, + { minAge: 18, maxAge: 120, sex: null, range: { low: 12, high: 18 } }, +]; + +/** + * The norm tables, keyed by the assessment-registry metric id. A metric + * absent from this map has no age/sex sharpening — the caller keeps the + * flat registry anchor. Append a table here to lift another metric. + */ +const NORM_TABLES: Partial> = { + VO2_MAX: VO2_MAX_NORMS, + RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS, + RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS, +}; + +/** + * Resolve the age/sex-adjusted reference band for a metric, or `null` + * when no sharper band applies (unsupported metric, or demographics + * absent). On `null` the caller keeps the existing flat `normalRange` + * anchor — the enabler is strictly additive. + * + * Sex matching: a sex-specific table prefers the row matching the + * profile sex; when the profile sex is absent it falls back to the + * row's `null`-sex variant if present, else the first matching-age row. + */ +export function lookupNormalRange( + metricId: MetricStatusMetricId, + ageYears: number | null | undefined, + sex: NormSex, +): NormRange | null { + if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) { + return null; + } + const table = NORM_TABLES[metricId]; + if (!table) return null; + + const ageRows = table.filter( + (row) => ageYears >= row.minAge && ageYears <= row.maxAge, + ); + if (ageRows.length === 0) return null; + + // Prefer an exact sex match; then a sex-agnostic row; then any row in + // the age bracket (so a sex-specific-only table still yields a band for + // a profile with no sex by averaging is avoided — we pick a defined + // band rather than fabricate). + const exact = sex ? ageRows.find((row) => row.sex === sex) : undefined; + if (exact) return { ...exact.range }; + + const agnostic = ageRows.find((row) => row.sex === null); + if (agnostic) return { ...agnostic.range }; + + // Sex-specific-only table but no profile sex: no honest single band. + return null; +} + +/** `true` when an age/sex-sharpened band exists for the metric+profile. */ +export function hasSharpenedNorm( + metricId: MetricStatusMetricId, + ageYears: number | null | undefined, + sex: NormSex, +): boolean { + return lookupNormalRange(metricId, ageYears, sex) !== null; +} diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts new file mode 100644 index 000000000..632131306 --- /dev/null +++ b/src/lib/insights/derived/registry.ts @@ -0,0 +1,178 @@ +/** + * v1.10.0 — the closed `DERIVED_METRIC_ID` enum + per-metric metadata. + * + * One single source the generic `/api/insights/derived` route, the + * OpenAPI registration, and the QA inventory all share. An id added here + * is accepted by the route automatically; an unknown id 422s against the + * closed enum (the v1.8.7.1 `metric-status` pattern). + * + * Wave 1 implements ONE metric end-to-end as the reference: the personal + * typical-range vitals baseline (`VITALS_BASELINE`). The other catalogue + * metrics are registered here as metadata STUBS — their `archetype` + * documents the shape later waves plug in, but their compute functions + * land in W2/W3 (`fitness-age.ts`, `vascular-age.ts`, `sleep-score.ts`, + * `readiness.ts`, the coincident-deviation flag). A stubbed metric routed + * before its compute lands returns `insufficient` via the dispatcher's + * `not-implemented` guard, never a fabricated value. + * + * Metadata only — no compute, no server imports — so this stays + * client-safe and the route enum / OpenAPI can import it freely. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * The closed set of derived-metric ids. The single vocabulary the route + * query param, the OpenAPI enum, and the cache scope all speak. + */ +export type DerivedMetricId = + /** FLAGSHIP (W1): personal typical-range band for one vital. */ + | "VITALS_BASELINE" + /** W2: VO₂max → age/sex band + chronological-age delta (passthrough re-frame). */ + | "FITNESS_AGE" + /** W2: VASCULAR_AGE/PWV → age-delta band (passthrough re-frame). */ + | "VASCULAR_AGE_DELTA" + /** W3: transparent sleep composite. */ + | "SLEEP_SCORE" + /** W3: wellness/readiness index (extends the health-score blend). */ + | "READINESS" + /** W3: multi-signal coincident-deviation early-strain flag. */ + | "COINCIDENT_DEVIATION"; + +/** Archetype of a derived metric — drives shaping + the QA inventory. */ +export type DerivedArchetype = + /** Renders whenever its single input series exists; no min-input threshold beyond its own history. */ + | "any-user-baseline" + /** A device-computed value re-framed against a norm band — never recomputed. */ + | "passthrough-reframe" + /** Needs a minimum-inputs threshold, reweights around missing inputs, shows coverage/confidence. */ + | "composite"; + +export interface DerivedMetricMeta { + id: DerivedMetricId; + /** Stable English display name (surfaces localise their own prose). */ + displayName: string; + archetype: DerivedArchetype; + /** + * The named inputs the metric reads. For `VITALS_BASELINE` these are + * `MeasurementType` names (the caller selects one via the route's + * `type` opt); for a composite the set may also name non-measurement + * signals (e.g. "MOOD", a derived sub-score) the coverage model counts + * against — hence `string`, not `MeasurementType`. + */ + inputs: string[]; + /** + * Minimum distinct history-days an input needs before a band/headline + * is produced. Below the floor the metric returns `insufficient`. + */ + minHistoryDays: number; + /** + * For a composite: the minimum number of `inputs` that must be present + * before a headline is produced (no 1-of-N headline). `1` for + * any-user / passthrough metrics. + */ + minInputs: number; + /** `true` once a compute function backs this id; `false` = W2/W3 stub. */ + implemented: boolean; +} + +/** + * The vitals the typical-range baseline engine supports. The route's + * `type` opt must name one of these; any other type 422s. Mirrors the + * metric-1 input set from the catalogue (§1, row 1). + */ +export const VITALS_BASELINE_TYPES: MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + "PULSE", + "WEIGHT", +]; + +const REGISTRY: Record = { + VITALS_BASELINE: { + id: "VITALS_BASELINE", + displayName: "Personal typical range", + archetype: "any-user-baseline", + inputs: VITALS_BASELINE_TYPES, + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + FITNESS_AGE: { + id: "FITNESS_AGE", + displayName: "Cardio-fitness band", + archetype: "passthrough-reframe", + inputs: ["VO2_MAX"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + VASCULAR_AGE_DELTA: { + id: "VASCULAR_AGE_DELTA", + displayName: "Vascular-age delta", + archetype: "passthrough-reframe", + inputs: ["VASCULAR_AGE", "PULSE_WAVE_VELOCITY"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + SLEEP_SCORE: { + id: "SLEEP_SCORE", + displayName: "Sleep score", + archetype: "composite", + inputs: ["SLEEP_DURATION"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + READINESS: { + id: "READINESS", + displayName: "Wellness / readiness index", + archetype: "composite", + inputs: [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + "MOOD", + ], + minHistoryDays: 7, + minInputs: 2, + implemented: false, + }, + COINCIDENT_DEVIATION: { + id: "COINCIDENT_DEVIATION", + displayName: "Coincident-deviation flag", + archetype: "composite", + inputs: VITALS_BASELINE_TYPES, + minHistoryDays: 7, + minInputs: 2, + implemented: false, + }, +}; + +/** Closed set of ids the generic route accepts (Zod enum source). */ +export const DERIVED_METRIC_IDS = Object.keys(REGISTRY) as DerivedMetricId[]; + +/** Type guard narrowing an arbitrary string to a registered derived id. */ +export function isDerivedMetricId(value: string): value is DerivedMetricId { + return Object.prototype.hasOwnProperty.call(REGISTRY, value); +} + +/** Resolve a derived metric's metadata, or `null` when unregistered. */ +export function getDerivedMetricMeta( + metric: string, +): DerivedMetricMeta | null { + return isDerivedMetricId(metric) ? REGISTRY[metric] : null; +} + +/** `true` when the type is a vital the baseline engine supports. */ +export function isVitalsBaselineType(type: string): boolean { + return (VITALS_BASELINE_TYPES as string[]).includes(type); +} diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts new file mode 100644 index 000000000..905fffe83 --- /dev/null +++ b/src/lib/insights/derived/types.ts @@ -0,0 +1,98 @@ +/** + * v1.10.0 — the `Derived` discriminated union + its companion value + * shapes (coverage / confidence / provenance). + * + * This is the one consumption contract for the derived-metrics layer: + * every pure compute function returns `Derived` and every surface + * (dashboard tile, Insights card, Coach snapshot, daily briefing, + * doctor/FHIR report, the native iOS client) pattern-matches `status` + * rather than recomputing. The `ok` arm carries value + coverage + + * confidence + provenance; the `insufficient` arm still carries coverage + * + provenance so a surface renders the same gating UI ("track N more + * days") instead of a blank. + * + * Client-safe by construction — pure types + tiny pure builders, no + * server imports — so a `"use client"` component can value-import this + * module without dragging the route/lib server graph into the bundle + * (the v1.9.0 lesson). The compute functions live in server-only + * siblings (`baseline.ts`, …) and import these types only. + */ + +/** Confidence band rendered by the shared coverage meter. */ +export type DerivedConfidenceBand = "high" | "medium" | "low" | "draft"; + +/** + * The granularity the dominant read resolved against — mirrors the + * rollup/live-SQL provenance the graded-series + dashboard-snapshot reads + * already expose. `"live"` = a coverage-miss live-SQL fallback; `"none"` + * = no data backed the value. + */ +export type DerivedProvenanceSource = + | "DAY" + | "WEEK" + | "MONTH" + | "YEAR" + | "live" + | "none"; + +/** + * Where a derived value's inputs came from. Lets every surface show the + * same "from your last 30 days, DAY buckets" chip. + */ +export interface DerivedProvenance { + /** Named inputs that actually backed the value (e.g. ["RESTING_HEART_RATE"]). */ + inputs: string[]; + /** Granularity the dominant read resolved against. */ + source: DerivedProvenanceSource; + /** Trailing window the value summarises, in days (e.g. 30 for a 30-day baseline). */ + windowDays: number; + /** Compute time, for cache-staleness + the "as of" chip (ISO 8601, offset). */ + computedAt: string; +} + +export interface DerivedCoverage { + /** Inputs the metric WANTS (its full input set). */ + requiredInputs: number; + /** Inputs actually present. */ + presentInputs: number; + /** Days of history backing the value — the gating floor (< minHistoryDays → no band). */ + historyDays: number; + /** Named inputs still missing — drives the "track BP/HRV to sharpen this" nudge. */ + missing: string[]; +} + +export interface DerivedConfidence { + /** 0..100 — feeds the existing ConfidenceMeter component unchanged. */ + score: number; + band: DerivedConfidenceBand; +} + +/** + * The successful arm: every successful value carries all four facets. + */ +export interface DerivedOk { + status: "ok"; + value: T; + coverage: DerivedCoverage; + confidence: DerivedConfidence; + provenance: DerivedProvenance; +} + +/** + * The gated arm: no value, but coverage + provenance so the surface + * renders the same "track N more days" state rather than a blank. + */ +export interface DerivedInsufficient { + status: "insufficient"; + coverage: DerivedCoverage; + provenance: DerivedProvenance; + /** Why the value could not be produced (single short reason string). */ + reason: string; +} + +export type Derived = DerivedOk | DerivedInsufficient; + +/** Narrowing type guard — `true` when the value computed successfully. */ +export function isDerivedOk(d: Derived): d is DerivedOk { + return d.status === "ok"; +} From 4ad65a5b7db2b90fceeb93ecfc9540f28a1dba65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:01:42 +0200 Subject: [PATCH 02/49] feat(insights): add GET /api/insights/derived + OpenAPI contract Generic route mirroring the v1.8.7.1 metric-status precedent: GET /api/insights/derived?metric=[&type=]. apiHandler + Zod safeParse on the query, closed registry enum (unknown id 422s via returnAllZodIssues), cookie-or-Bearer auth, userId narrowed from the session (never a query field). The profile is read once and passed into the pure compute function. Returns the flat Derived union in the standard { data, error, meta } envelope so the native client decodes one stable shape and combines values across metrics. Register the query + response schemas (DerivedMetricQuery, DerivedMetricResponse + DerivedCoverage/Confidence/Provenance) and the path operation in the OpenAPI registry; regenerate docs/api/openapi.yaml. Add the route to the insights gate inventory (insightStatus-gated, no Coach prose). Route tests cover the 200 flat-envelope shape, session-narrowed userId, 422 on an unknown / missing metric, 401 unauthenticated, and the insufficient flattening (null value + confidence, populated reason). --- docs/api/openapi.yaml | 216 ++++++++++++++++++ .../coach-route-gate-inventory.test.ts | 4 + .../insights/derived/__tests__/route.test.ts | 148 ++++++++++++ src/app/api/insights/derived/route.ts | 103 +++++++++ src/lib/openapi/routes.ts | 132 +++++++++++ 5 files changed, 603 insertions(+) create mode 100644 src/app/api/insights/derived/__tests__/route.test.ts create mode 100644 src/app/api/insights/derived/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ffda5e6e9..6f5607bbf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2307,6 +2307,68 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/derived: + get: + tags: + - Insights + summary: Derived wellness metric (compute-once) + description: v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal + typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, + coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure + compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no + cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine + values across metrics. Auth via cookie or Bearer. + parameters: + - in: query + name: metric + schema: + type: string + enum: + - VITALS_BASELINE + - FITNESS_AGE + - VASCULAR_AGE_DELTA + - SLEEP_SCORE + - READINESS + - COINCIDENT_DEVIATION + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id + 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` + value with reason `not_implemented`." + required: true + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id + 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` + value with reason `not_implemented`." + - in: query + name: type + schema: + description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by + composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric + combinations stay forgiving. + type: string + enum: + - RESTING_HEART_RATE + - HEART_RATE_VARIABILITY + - RESPIRATORY_RATE + - OXYGEN_SATURATION + - BODY_TEMPERATURE + - SKIN_TEMPERATURE + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - BLOOD_GLUCOSE + - PULSE + - WEIGHT + description: For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by + composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations + stay forgiving. + responses: + "200": + description: The flat derived-metric value (ok or insufficient). + content: + application/json: + schema: + $ref: "#/components/schemas/DerivedMetricResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 components: schemas: PasskeyLoginVerifyRequest: @@ -7241,6 +7303,160 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + DerivedMetricResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DerivedMetricResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DerivedMetricResponse: + type: object + properties: + metric: + type: string + enum: + - VITALS_BASELINE + - FITNESS_AGE + - VASCULAR_AGE_DELTA + - SLEEP_SCORE + - READINESS + - COINCIDENT_DEVIATION + description: Echoes the requested derived-metric id (tags the union). + status: + type: string + enum: + - ok + - insufficient + description: "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries + `coverage` + `provenance` so the surface renders the same gating UI." + value: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } + for VITALS_BASELINE); null when 'insufficient'. + coverage: + $ref: "#/components/schemas/DerivedCoverage" + confidence: + anyOf: + - $ref: "#/components/schemas/DerivedConfidence" + - type: "null" + description: Present when status is 'ok'; null when 'insufficient'. + provenance: + $ref: "#/components/schemas/DerivedProvenance" + reason: + anyOf: + - type: string + - type: "null" + description: Why the value could not be produced; null when status is 'ok'. + required: + - metric + - status + - value + - coverage + - confidence + - provenance + - reason + additionalProperties: false + description: Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no + narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let + it render the same honesty chips. + DerivedCoverage: + type: object + properties: + requiredInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Inputs the metric wants (its full input set). + presentInputs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Inputs actually present in the user's data. + historyDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Distinct days of history backing the value (the gating floor). + missing: + type: array + items: + type: string + description: Named inputs still missing — drives the 'track N more' nudge. + required: + - requiredInputs + - presentInputs + - historyDays + - missing + additionalProperties: false + DerivedConfidence: + type: object + properties: + score: + type: number + description: 0..100 confidence; feeds the shared coverage meter unchanged. + band: + type: string + enum: + - high + - medium + - low + - draft + description: Confidence band the meter renders. + required: + - score + - band + additionalProperties: false + DerivedProvenance: + type: object + properties: + inputs: + type: array + items: + type: string + description: Named inputs that actually backed the value. + source: + type: string + enum: + - DAY + - WEEK + - MONTH + - YEAR + - live + - none + description: Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no + data backed the value. + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing window the value summarises, in days. + computedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Compute time (for cache-staleness + the 'as of' chip). + required: + - inputs + - source + - windowDays + - computedAt + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 8b4b61ac7..a8c5a8145 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -39,6 +39,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", "src/app/api/insights/correlations/route.ts", + // v1.10.0 — generic derived-wellness-metric route. Pure compute over + // the rollup tier; gates on the same `insightStatus` sub-flag as the + // assessment routes (no Coach prose). + "src/app/api/insights/derived/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the // same `insightStatus` sub-flag as the seven specialised status routes. diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts new file mode 100644 index 000000000..7711d0fcf --- /dev/null +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { + findUnique: vi.fn().mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + }), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the compute layer so the route test stays isolated. +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + computeDerivedMetric: vi.fn(async () => ({ + status: "ok", + value: { type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 100, band: "high" }, + provenance: { inputs: ["RESTING_HEART_RATE"], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + })), + }; +}); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { computeDerivedMetric } from "@/lib/insights/derived"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metric?: string, type?: string): NextRequest { + const url = new URL("http://localhost/api/insights/derived"); + if (metric !== undefined) url.searchParams.set("metric", metric); + if (type !== undefined) url.searchParams.set("type", type); + return new NextRequest(url); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + } as never); +}); + +describe("GET /api/insights/derived", () => { + it("returns 200 + a flat Derived envelope for a valid metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + metric: string; + status: string; + value: { center: number } | null; + coverage: { historyDays: number }; + confidence: { band: string } | null; + provenance: { source: string }; + reason: string | null; + } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.metric).toBe("VITALS_BASELINE"); + expect(body.data?.status).toBe("ok"); + expect(body.data?.value?.center).toBe(55); + expect(body.data?.confidence?.band).toBe("high"); + expect(body.data?.provenance.source).toBe("DAY"); + expect(body.data?.reason).toBeNull(); + }); + + it("narrows userId from the session into the compute call (never a query field)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("VITALS_BASELINE")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", metric: "VITALS_BASELINE" }), + ); + }); + + it("422s on an unknown metric without calling compute", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a missing metric param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("VITALS_BASELINE")); + expect(res.status).toBe(401); + }); + + it("flattens an insufficient result with a null value + reason", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(computeDerivedMetric).mockResolvedValueOnce({ + status: "insufficient", + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["RESTING_HEART_RATE"] }, + provenance: { inputs: ["RESTING_HEART_RATE"], source: "none", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + reason: "no_readings_in_window", + } as never); + const res = await callGet(makeReq("VITALS_BASELINE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { status: string; value: unknown; confidence: unknown; reason: string } | null; + }; + expect(body.data?.status).toBe("insufficient"); + expect(body.data?.value).toBeNull(); + expect(body.data?.confidence).toBeNull(); + expect(body.data?.reason).toBe("no_readings_in_window"); + }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts new file mode 100644 index 000000000..afceaec9f --- /dev/null +++ b/src/app/api/insights/derived/route.ts @@ -0,0 +1,103 @@ +/** + * v1.10.0 — generic derived-wellness-metric route. + * + * `GET /api/insights/derived?metric=[&type=]` + * serves the compute-once `Derived` value for any metric registered + * in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route + * precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed + * registry enum (unknown id → 422 via `returnAllZodIssues`), cookie OR + * Bearer auth, `userId` narrowed from the session/Bearer (never a query + * field). + * + * The `data` payload is the flat `Derived` union so iOS can decode + + * combine values across metrics: `{ metric, status, value?, coverage, + * confidence?, provenance, reason? }`. Numbers are pure compute over the + * rollup tier (Tier-1, no narrative) — no provider call, no cache table. + * + * Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids + * return a `not_implemented` insufficient until their compute lands in a + * later wave (never a 500, never a fabricated value). + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + computeDerivedMetric, + DERIVED_METRIC_IDS, + type DerivedMetricId, +} from "@/lib/insights/derived"; + +export const dynamic = "force-dynamic"; + +// Closed enum derived from the registry so the route + registry cannot +// drift — an id added to the registry is accepted automatically, an +// unknown id 422s rather than silently resolving to no metric. +const derivedQuerySchema = z.object({ + metric: z.enum(DERIVED_METRIC_IDS as [string, ...string[]]), + // Optional sub-target for metrics that baseline a single chosen vital + // (VITALS_BASELINE). Validated against the engine's supported set in + // the dispatcher; an unsupported value yields an `insufficient`, not a + // 422, so the contract stays forgiving for iOS combinations. + type: z.string().optional(), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = derivedQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + type: request.nextUrl.searchParams.get("type") ?? undefined, + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.derived.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as DerivedMetricId; + + // Profile read once, passed into the pure compute function (never + // re-fetched per metric — the pool-contention mitigation). + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { dateOfBirth: true, gender: true }, + }); + const sex = + profile?.gender === "MALE" || profile?.gender === "FEMALE" + ? profile.gender + : null; + + const derived = await computeDerivedMetric({ + metric, + userId: user.id, + profile: { + ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), + sex, + }, + type: parsed.data.type ?? null, + }); + + annotate({ + action: { name: "insights.derived" }, + meta: { metric, status: derived.status }, + }); + + // Flatten the discriminated union for the wire: `metric` tags it, + // `value`/`reason` are nullable so iOS decodes one stable shape. + return apiSuccess({ + metric, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: derived.status === "insufficient" ? derived.reason : null, + }); +}); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index a0e29069b..f2e299523 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -47,6 +47,10 @@ import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-pr import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; /** @@ -1278,6 +1282,109 @@ const metricStatusResponse = z "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// v1.10.0 — generic derived-wellness-metric route. The query enum is +// derived from the same registry the route validates against, so spec + +// route + cache scope cannot drift. `type` sub-targets the single vital +// a baseline metric (VITALS_BASELINE) bands over. +const derivedMetricQuery = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe( + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` value with reason `not_implemented`.", + ), + type: z + .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) + .optional() + .describe( + "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", + ), + }) + .meta({ id: "DerivedMetricQuery" }); + +const derivedCoverage = z + .object({ + requiredInputs: z + .number() + .int() + .describe("Inputs the metric wants (its full input set)."), + presentInputs: z + .number() + .int() + .describe("Inputs actually present in the user's data."), + historyDays: z + .number() + .int() + .describe("Distinct days of history backing the value (the gating floor)."), + missing: z + .array(z.string()) + .describe("Named inputs still missing — drives the 'track N more' nudge."), + }) + .meta({ id: "DerivedCoverage" }); + +const derivedConfidence = z + .object({ + score: z + .number() + .describe("0..100 confidence; feeds the shared coverage meter unchanged."), + band: z + .enum(["high", "medium", "low", "draft"]) + .describe("Confidence band the meter renders."), + }) + .meta({ id: "DerivedConfidence" }); + +const derivedProvenance = z + .object({ + inputs: z + .array(z.string()) + .describe("Named inputs that actually backed the value."), + source: z + .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) + .describe( + "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", + ), + windowDays: z + .number() + .int() + .describe("Trailing window the value summarises, in days."), + computedAt: z.iso + .datetime({ offset: true }) + .describe("Compute time (for cache-staleness + the 'as of' chip)."), + }) + .meta({ id: "DerivedProvenance" }); + +const derivedMetricResponse = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe("Echoes the requested derived-metric id (tags the union)."), + status: z + .enum(["ok", "insufficient"]) + .describe( + "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", + ), + value: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k } for VITALS_BASELINE); null when 'insufficient'.", + ), + coverage: derivedCoverage, + confidence: derivedConfidence + .nullable() + .describe("Present when status is 'ok'; null when 'insufficient'."), + provenance: derivedProvenance, + reason: z + .string() + .nullable() + .describe("Why the value could not be produced; null when status is 'ok'."), + }) + .meta({ + id: "DerivedMetricResponse", + description: + "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", + }); + // The seven specialised `*-status` routes accept an optional locale // override (the metric is fixed by the route path, unlike the generic // metric-status route which carries it as a query field). @@ -2996,6 +3103,31 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/derived": { + get: { + tags: ["Insights"], + summary: "Derived wellness metric (compute-once)", + description: + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + requestParams: { + query: derivedMetricQuery, + }, + responses: { + "200": { + description: "The flat derived-metric value (ok or insufficient).", + content: { + "application/json": { + schema: dataEnvelope( + derivedMetricResponse, + "DerivedMetricResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; export const openApiComponents: NonNullable = { From aa281d66492fad3c7de3c55820d2b92f6c060808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:01:48 +0200 Subject: [PATCH 03/49] feat(insights): sharpen the metric-status normalRange with age/sex norms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the reference-range enabler (derived/norms.ts) into the existing generic assessment path. When lookupNormalRange resolves an age/sex adjusted band for the metric + profile, the snapshot uses it in place of the coarse flat registry anchor and tags it normalRangeSource: age-sex-adjusted; otherwise the flat anchor is kept unchanged. Strictly additive — a metric with no sharper norm, or a profile missing demographics, behaves exactly as before. --- src/lib/insights/metric-status.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 1eeb8d71d..94ca0ca76 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -37,6 +37,7 @@ import { getPreviousInsightContext, } from "@/lib/insights/memory"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { lookupNormalRange } from "@/lib/insights/derived/norms"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { @@ -239,6 +240,19 @@ export async function generateMetricStatus(args: { ? Math.round((Date.now() - newest.getTime()) / (24 * 60 * 60 * 1000)) : null; + const ageYears = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" + ? user.gender + : null; + + // v1.10.0 F1 — sharpen the coarse registry `normalRange` with the + // age/sex reference-range enabler when one is available for this + // metric + profile; otherwise keep the existing flat anchor (strictly + // additive, never a regression). Age + sex only — no ancestry/region. + const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex); + const normalRange = sharpenedRange ?? meta.normalRange; + const snapshot = { locale, generatedForDay: todayKey, @@ -248,14 +262,15 @@ export async function generateMetricStatus(args: { displayName: meta.displayName, unit: meta.unit, direction: meta.direction, - ...(meta.normalRange ? { normalRange: meta.normalRange } : {}), + ...(normalRange ? { normalRange } : {}), + ...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}), }, // Profile context for placement only when stored — the medical note // limits population ranges to age + sex (NO ancestry/region). Omitted // when the profile leaves them blank, so the model leans on baseline. profile: { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex: user?.gender ?? null, + ageYears, + sex, }, dataCoverage: { totalMeasurements: measurements.length, From 32bc11f968b12baf9f9ed8df1c3eaf16e4fe719f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:14:49 +0200 Subject: [PATCH 04/49] feat(insights): add the derived-metrics design-system primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the four reusable presentational components the v1.10 vitals and score-anatomy surfaces share: a radial score ring, a coverage meter, a sparkline delta tile, and a provenance explainer. Each extends the existing dracula band + trend-sentiment vocabulary rather than a new colour system, and ships its populated, provisional/empty, and stale states up front so a sparse-data surface never goes blank. - score-ring: centred tabular-nums number on a Recharts RadialBarChart, band-coloured, size prop (sm tile / md / lg anatomy), count-up + sweep on mount via a 0 KB requestAnimationFrame hook; role=img aria-label restates the number so the ring is never colour-only. - coverage-meter: CSS-only filled/hollow dots driven by the Derived coverage + confidence facets, with an accessible breakdown tooltip (present vs required inputs, history days, missing list). - sparkline-delta-tile: extends the trend-card grammar with an inline Recharts area sparkline, a signed period delta, and an age/normal-range framing slot. - provenance-explainer: collapsible Popover (desktop) / bottom-sheet (mobile) rendering inputs, plain-language method, and an optional cited standard as React text children — no markdown library. All four read their data off the Derived union as a type-only import so the client bundle stays free of the server graph. Recharts only, no new runtime dependency. New i18n keys propagated across all six locales. --- messages/de.json | 28 +++ messages/en.json | 28 +++ messages/es.json | 28 +++ messages/fr.json | 28 +++ messages/it.json | 28 +++ messages/pl.json | 28 +++ .../derived/__tests__/coverage-meter.test.tsx | 82 +++++++ .../__tests__/provenance-explainer.test.tsx | 77 ++++++ .../derived/__tests__/score-ring.test.tsx | 55 +++++ .../__tests__/sparkline-delta-tile.test.tsx | 83 +++++++ .../insights/derived/band-tokens.ts | 66 ++++++ .../insights/derived/coverage-meter.tsx | 181 ++++++++++++++ src/components/insights/derived/index.ts | 29 +++ .../insights/derived/provenance-explainer.tsx | 209 +++++++++++++++++ .../insights/derived/score-ring.tsx | 198 ++++++++++++++++ .../insights/derived/sparkline-delta-tile.tsx | 221 ++++++++++++++++++ src/hooks/use-count-up.ts | 95 ++++++++ 17 files changed, 1464 insertions(+) create mode 100644 src/components/insights/derived/__tests__/coverage-meter.test.tsx create mode 100644 src/components/insights/derived/__tests__/provenance-explainer.test.tsx create mode 100644 src/components/insights/derived/__tests__/score-ring.test.tsx create mode 100644 src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx create mode 100644 src/components/insights/derived/band-tokens.ts create mode 100644 src/components/insights/derived/coverage-meter.tsx create mode 100644 src/components/insights/derived/index.ts create mode 100644 src/components/insights/derived/provenance-explainer.tsx create mode 100644 src/components/insights/derived/score-ring.tsx create mode 100644 src/components/insights/derived/sparkline-delta-tile.tsx create mode 100644 src/hooks/use-count-up.ts diff --git a/messages/de.json b/messages/de.json index 32dd32d17..ea34405fb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2460,6 +2460,34 @@ "bandVariable": "Deine Stimmung war merklich schwankend", "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" } + }, + "derived": { + "coverage": { + "summary": "{present} von {required} Eingaben · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basiert auf {count} Tagen Verlauf", + "missing": "Erfasse diese, um den Wert zu schärfen: {list}" + }, + "scoreRing": { + "aria": "Wert {score} von 100, {band}", + "ariaProvisional": "Wert noch nicht verfügbar — zu wenig Daten", + "provisionalCaption": "Noch zu wenig Daten", + "band": { + "green": "gut", + "yellow": "mittel", + "red": "niedrig" + } + }, + "provenance": { + "title": "So wird das berechnet", + "trigger": "So wird das berechnet", + "inputsLabel": "Eingaben", + "noInputs": "Noch keine Eingaben für diesen Wert vorhanden.", + "methodLabel": "Methode", + "window": "Aus deinen letzten {days} Tagen · {source}-Daten", + "asOf": "Stand {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index 07974000f..f9cf3ccc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2460,6 +2460,34 @@ "bandVariable": "Your mood has been noticeably variable", "bandVeryVariable": "Your mood has swung a lot lately" } + }, + "derived": { + "coverage": { + "summary": "{present} of {required} inputs · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Based on {count} days of history", + "missing": "Track these to sharpen this: {list}" + }, + "scoreRing": { + "aria": "Score {score} out of 100, {band}", + "ariaProvisional": "Score not available yet — not enough data", + "provisionalCaption": "Not enough data yet", + "band": { + "green": "good", + "yellow": "fair", + "red": "low" + } + }, + "provenance": { + "title": "How this is computed", + "trigger": "How this is computed", + "inputsLabel": "Inputs", + "noInputs": "No inputs backed this value yet.", + "methodLabel": "Method", + "window": "From your last {days} days · {source} data", + "asOf": "as of {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index ce163e91d..ee9f48e3d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2460,6 +2460,34 @@ "bandVariable": "Tu estado de ánimo ha sido notablemente variable", "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" } + }, + "derived": { + "coverage": { + "summary": "{present} de {required} entradas · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basado en {count} días de historial", + "missing": "Registra estos para afinar el valor: {list}" + }, + "scoreRing": { + "aria": "Puntuación {score} de 100, {band}", + "ariaProvisional": "Puntuación aún no disponible — datos insuficientes", + "provisionalCaption": "Datos aún insuficientes", + "band": { + "green": "bueno", + "yellow": "medio", + "red": "bajo" + } + }, + "provenance": { + "title": "Cómo se calcula esto", + "trigger": "Cómo se calcula esto", + "inputsLabel": "Entradas", + "noInputs": "Aún no hay entradas que respalden este valor.", + "methodLabel": "Método", + "window": "De tus últimos {days} días · datos de {source}", + "asOf": "a fecha de {date}", + "standardLink": "Estándar: {name}" + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 07d74a4ce..3530e02ba 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2460,6 +2460,34 @@ "bandVariable": "Votre humeur a été nettement variable", "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" } + }, + "derived": { + "coverage": { + "summary": "{present} entrées sur {required} · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basé sur {count} jours d'historique", + "missing": "Enregistre ceci pour affiner la valeur : {list}" + }, + "scoreRing": { + "aria": "Score {score} sur 100, {band}", + "ariaProvisional": "Score pas encore disponible — données insuffisantes", + "provisionalCaption": "Données encore insuffisantes", + "band": { + "green": "bon", + "yellow": "moyen", + "red": "faible" + } + }, + "provenance": { + "title": "Comment c'est calculé", + "trigger": "Comment c'est calculé", + "inputsLabel": "Entrées", + "noInputs": "Aucune entrée ne soutient encore cette valeur.", + "methodLabel": "Méthode", + "window": "Sur tes {days} derniers jours · données {source}", + "asOf": "au {date}", + "standardLink": "Norme : {name}" + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 59c1d98bb..994715c69 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2460,6 +2460,34 @@ "bandVariable": "Il tuo umore è stato notevolmente variabile", "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" } + }, + "derived": { + "coverage": { + "summary": "{present} di {required} input · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Basato su {count} giorni di cronologia", + "missing": "Registra questi per affinare il valore: {list}" + }, + "scoreRing": { + "aria": "Punteggio {score} su 100, {band}", + "ariaProvisional": "Punteggio non ancora disponibile — dati insufficienti", + "provisionalCaption": "Dati ancora insufficienti", + "band": { + "green": "buono", + "yellow": "medio", + "red": "basso" + } + }, + "provenance": { + "title": "Come viene calcolato", + "trigger": "Come viene calcolato", + "inputsLabel": "Input", + "noInputs": "Nessun input supporta ancora questo valore.", + "methodLabel": "Metodo", + "window": "Dai tuoi ultimi {days} giorni · dati {source}", + "asOf": "al {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index 3d5f69844..c17fdbd1d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2460,6 +2460,34 @@ "bandVariable": "Twój nastrój był zauważalnie zmienny", "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" } + }, + "derived": { + "coverage": { + "summary": "{present} z {required} danych · {percent}%", + "ratioLabel": "{present}/{required}", + "historyDays": "Na podstawie {count} dni historii", + "missing": "Zapisuj te dane, aby uściślić wartość: {list}" + }, + "scoreRing": { + "aria": "Wynik {score} na 100, {band}", + "ariaProvisional": "Wynik jeszcze niedostępny — za mało danych", + "provisionalCaption": "Wciąż za mało danych", + "band": { + "green": "dobry", + "yellow": "średni", + "red": "niski" + } + }, + "provenance": { + "title": "Jak to jest obliczane", + "trigger": "Jak to jest obliczane", + "inputsLabel": "Dane wejściowe", + "noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.", + "methodLabel": "Metoda", + "window": "Z ostatnich {days} dni · dane {source}", + "asOf": "na dzień {date}", + "standardLink": "Standard: {name}" + } } }, "thresholds": { diff --git a/src/components/insights/derived/__tests__/coverage-meter.test.tsx b/src/components/insights/derived/__tests__/coverage-meter.test.tsx new file mode 100644 index 000000000..f58ab2a51 --- /dev/null +++ b/src/components/insights/derived/__tests__/coverage-meter.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CoverageMeter } from "../coverage-meter"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { + DerivedConfidence, + DerivedCoverage, +} from "@/lib/insights/derived/types"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const FULL: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 5, + historyDays: 30, + missing: [], +}; +const PARTIAL: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 3, + historyDays: 12, + missing: ["HRV_SDNN", "RESTING_HEART_RATE"], +}; +const EMPTY: DerivedCoverage = { + requiredInputs: 5, + presentInputs: 0, + historyDays: 0, + missing: ["WEIGHT", "HRV_SDNN", "RESTING_HEART_RATE", "VO2_MAX", "SLEEP"], +}; +const HIGH: DerivedConfidence = { score: 90, band: "high" }; +const LOW: DerivedConfidence = { score: 35, band: "low" }; + +describe("", () => { + it("renders the root data-slot and ratio attributes (populated)", () => { + const html = render(); + expect(html).toContain('data-slot="coverage-meter"'); + expect(html).toContain('data-present="5"'); + expect(html).toContain('data-required="5"'); + expect(html).toContain('data-band="high"'); + }); + + it("lights all dots at full confidence", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBe(5); + expect(html).toContain("bg-dracula-green"); + }); + + it("lights a partial number of dots and tints by band (provisional)", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBeGreaterThan(0); + expect(lit).toBeLessThan(5); + expect(html).toContain("bg-dracula-orange"); + expect(html).toContain('data-percent="35"'); + }); + + it("falls back to the present/required ratio when confidence is absent", () => { + const html = render(); + // 3/5 = 60% + expect(html).toContain('data-percent="60"'); + }); + + it("renders zero lit dots for the empty/insufficient state", () => { + const html = render(); + const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; + expect(lit).toBe(0); + expect(html).toContain('data-percent="0"'); + }); + + it("carries an accessible summary label", () => { + const html = render(); + expect(html).toContain("aria-label"); + expect(html).toContain("3"); + expect(html).toContain("5"); + }); +}); diff --git a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx new file mode 100644 index 000000000..572e948fa --- /dev/null +++ b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ProvenanceExplainer } from "../provenance-explainer"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const PROVENANCE: DerivedProvenance = { + inputs: ["WEIGHT", "HEIGHT"], + source: "DAY", + windowDays: 30, + computedAt: "2026-06-02T08:00:00+02:00", +}; + +const EMPTY_PROVENANCE: DerivedProvenance = { + inputs: [], + source: "none", + windowDays: 0, + computedAt: "not-a-date", +}; + +describe("", () => { + it("renders an accessible trigger that controls the body (collapsed by default)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('aria-controls="prov-1"'); + expect(html).toContain("aria-label"); + }); + + it("meets the 44px hit-target floor on the trigger", () => { + const html = render( + , + ); + expect(html).toContain("min-h-11"); + expect(html).toContain("min-w-11"); + }); + + it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); + + it("accepts a cited standard prop without erroring", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); +}); diff --git a/src/components/insights/derived/__tests__/score-ring.test.tsx b/src/components/insights/derived/__tests__/score-ring.test.tsx new file mode 100644 index 000000000..dffccf055 --- /dev/null +++ b/src/components/insights/derived/__tests__/score-ring.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScoreRing } from "../score-ring"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the root data-slot with a band derived from the score (populated, good)", () => { + const html = render(); + expect(html).toContain('data-slot="score-ring"'); + expect(html).toContain('data-band="green"'); + expect(html).not.toContain('data-provisional="true"'); + }); + + it("derives a yellow band for a mid score", () => { + const html = render(); + expect(html).toContain('data-band="yellow"'); + }); + + it("derives a red band for a low score", () => { + const html = render(); + expect(html).toContain('data-band="red"'); + }); + + it("honours an explicit band override", () => { + const html = render(); + expect(html).toContain('data-band="green"'); + }); + + it("renders the provisional/empty state for a null score", () => { + const html = render(); + expect(html).toContain('data-provisional="true"'); + expect(html).toContain('data-band="none"'); + expect(html).toContain('data-slot="score-ring-provisional"'); + }); + + it("carries an aria-label restating the number (not colour-only)", () => { + const html = render(); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + expect(html).toContain("74"); + }); + + it("carries a provisional aria-label when no score is available", () => { + const html = render(); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + }); +}); diff --git a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx new file mode 100644 index 000000000..484f45509 --- /dev/null +++ b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { SparklineDeltaTile } from "../sparkline-delta-tile"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const base = { + label: "Resting HR", + unit: "bpm", + icon: Heart, +}; + +describe("", () => { + it("renders the value, unit and a framing line (populated)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile"'); + expect(html).toContain('data-slot="sparkline-delta-tile-value"'); + expect(html).toContain("58"); + expect(html).toContain("bpm"); + expect(html).toContain("typical for age 31"); + expect(html).toContain('data-slot="sparkline-delta-tile-framing"'); + }); + + it("paints a falling delta on an up-bad metric as green (improvement)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-delta"'); + expect(html).toContain("text-dracula-green"); + }); + + it("renders the empty state for a null value (no value, dashed sparkline slot)", () => { + const html = render(); + expect(html).toContain('data-slot="sparkline-delta-tile-value"'); + expect(html).toContain("—"); + // No series → dashed placeholder, not an SVG. + expect(html).toContain("border-dashed"); + }); + + it("reserves the sparkline slot but omits the chart when series < 2 points", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-spark"'); + expect(html).toContain("border-dashed"); + }); + + it("surfaces the stale caption when staleDays > 7", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sparkline-delta-tile-stale"'); + expect(html).toContain('data-stale-days="12"'); + }); + + it("omits the stale caption for fresh data", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="sparkline-delta-tile-stale"'); + }); +}); diff --git a/src/components/insights/derived/band-tokens.ts b/src/components/insights/derived/band-tokens.ts new file mode 100644 index 000000000..0204d56f6 --- /dev/null +++ b/src/components/insights/derived/band-tokens.ts @@ -0,0 +1,66 @@ +/** + * v1.10.0 — shared score-band token vocabulary for the derived-metrics + * design system. Extracted so the new primitives (score-ring, + * coverage-meter, sparkline-delta-tile, provenance-explainer) paint the + * exact same band colour grammar the shipped `health-score-card` already + * uses, without each component redeclaring the map (concept cohesion). + * + * No new tokens — these point at the existing `--dracula-*` variables in + * `globals.css`. A green/yellow/red band is the one semantic the whole + * v1.10 score language extends. + */ + +export type ScoreBand = "green" | "yellow" | "red"; + +/** Tailwind text-colour class per band (the headline number colour). */ +export const BAND_NUMBER_CLASS: Record = { + green: "text-dracula-green", + yellow: "text-dracula-orange", + red: "text-dracula-red", +}; + +/** Tailwind background class per band (progress/fill bars). */ +export const BAND_PROGRESS_CLASS: Record = { + green: "bg-dracula-green", + yellow: "bg-dracula-orange", + red: "bg-dracula-red", +}; + +/** Tailwind border-colour class per band (card / chip outlines). */ +export const BAND_BORDER_CLASS: Record = { + green: "border-dracula-green/40", + yellow: "border-dracula-orange/40", + red: "border-dracula-red/40", +}; + +/** + * The raw CSS variable per band — used where a Recharts `fill` needs an + * actual colour string rather than a Tailwind class (the score ring's + * ``). Reads the same `--dracula-*` tokens so dark and + * Alucard light modes track automatically. + */ +export const BAND_VAR: Record = { + green: "var(--dracula-green)", + yellow: "var(--dracula-orange)", + red: "var(--dracula-red)", +}; + +/** + * Map a 0..100 score to its band. Mirrors the thresholds the existing + * health-score blend uses (≥ 70 green, ≥ 40 yellow, else red) so a + * derived score and the composite health score never disagree on colour + * for the same number. + */ +export function bandForScore(score: number): ScoreBand { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** Clamp a score into the 0..100 rendering range, defending bad input. */ +export function clampScore(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +} diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx new file mode 100644 index 000000000..5cf194da4 --- /dev/null +++ b/src/components/insights/derived/coverage-meter.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useId } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { + DerivedConfidence, + DerivedConfidenceBand, + DerivedCoverage, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — the single reusable data-confidence indicator. + * + * `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs` + * ratio off `Derived.coverage`, tinted by the `confidence.band`, with an + * accessible breakdown tooltip (present vs required inputs, history days, + * and the named missing inputs). This is the one place the whole app speaks + * data-confidence — every score tile, anatomy view, correlation card, + * briefing and report header renders it identically. + * + * It is the load-bearing graceful-degradation primitive: a partial score is + * shown WITH its confidence, never silently downgraded and never blank. The + * dots read as intentional ("4 of 5 inputs · 80%"), not apologetic. + * + * CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime. + */ + +export interface CoverageMeterProps { + coverage: DerivedCoverage; + /** + * Optional confidence facet. When present, the dots take the band tint + * and the percent label reads the confidence score; when omitted the + * meter falls back to the present/required ratio for both. + */ + confidence?: DerivedConfidence; + /** Total number of dots rendered. Defaults to 5 (the `●●●●○` grammar). */ + dots?: number; + /** Visual size. `sm` rides inside a grid tile, `md` on the anatomy view. */ + size?: "sm" | "md"; + /** Optional className applied to the outer wrapper. */ + className?: string; +} + +/** Dot tint per confidence band — reuses the existing dracula vocabulary. */ +const BAND_DOT_CLASS: Record = { + high: "bg-dracula-green", + medium: "bg-dracula-yellow", + low: "bg-dracula-orange", + draft: "bg-dracula-red", +}; + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.round(value))); +} + +export function CoverageMeter({ + coverage, + confidence, + dots = 5, + size = "sm", + className, +}: CoverageMeterProps) { + const { t } = useTranslations(); + const tooltipId = useId(); + + const required = clampInt(coverage.requiredInputs, 0, 999); + const present = clampInt(coverage.presentInputs, 0, required || 999); + const totalDots = clampInt(dots, 1, 12); + + // The fill ratio prefers the explicit confidence score (so the meter + // tracks the same number the band is derived from); without it, fall + // back to the present/required ratio. + const ratio = + confidence != null + ? clampInt(confidence.score, 0, 100) / 100 + : required > 0 + ? present / required + : 0; + const litDots = clampInt(ratio * totalDots, 0, totalDots); + const percent = Math.round(ratio * 100); + const band: DerivedConfidenceBand = confidence?.band ?? "low"; + const dotLit = BAND_DOT_CLASS[band]; + + const dotSize = size === "md" ? "h-2.5 w-2.5" : "h-2 w-2"; + + const summaryLabel = t("insights.derived.coverage.summary", { + present, + required, + percent, + }); + + return ( + + + + + + +
+

{summaryLabel}

+

+ {t("insights.derived.coverage.historyDays", { + count: clampInt(coverage.historyDays, 0, 99999), + })} +

+ {coverage.missing.length > 0 && ( +

+ {t("insights.derived.coverage.missing", { + list: coverage.missing.join(", "), + })} +

+ )} +
+
+
+
+ ); +} diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts new file mode 100644 index 000000000..e37fd5087 --- /dev/null +++ b/src/components/insights/derived/index.ts @@ -0,0 +1,29 @@ +/** + * v1.10.0 — the derived-metrics design-system primitives barrel. + * + * Four reusable, presentational components that extend the existing token + + * sentiment vocabulary; none introduce a new colour system. Reused across + * the dashboard, Insights pages, the Coach / briefing and the doctor/FHIR + * report. See `.planning/v1.10-build/design-sota-and-direction.md`. + */ + +export { ScoreRing, type ScoreRingProps } from "./score-ring"; +export { CoverageMeter, type CoverageMeterProps } from "./coverage-meter"; +export { + SparklineDeltaTile, + type SparklineDeltaTileProps, +} from "./sparkline-delta-tile"; +export { + ProvenanceExplainer, + type ProvenanceExplainerProps, + type ProvenanceStandard, +} from "./provenance-explainer"; +export { + bandForScore, + clampScore, + BAND_NUMBER_CLASS, + BAND_PROGRESS_CLASS, + BAND_BORDER_CLASS, + BAND_VAR, + type ScoreBand, +} from "./band-tokens"; diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx new file mode 100644 index 000000000..64c217beb --- /dev/null +++ b/src/components/insights/derived/provenance-explainer.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useId, useState } from "react"; +import { ExternalLink, Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — the "how this is computed / standard cited" surface. + * + * A collapsible `ⓘ` explainer that renders, as React text children (NO + * markdown library — the XSS rule), the metric's inputs, a plain-language + * method, and an optional link to the cited standard (IEEE / RFC / WHO / + * LOINC / Wikipedia). Reads the `provenance` field off `Derived` for the + * inputs chip + the "as of" + window framing. + * + * Surface follows the shipped `health-score-delta-explainer` pattern: + * Popover on `md+`, bottom-sheet on phone-class viewports via + * `ResponsiveSheet`. Owns its own open state. aria-describedby threads the + * trigger to the body. + */ + +export interface ProvenanceStandard { + /** Display name of the cited standard (e.g. "WHO BMI classification"). */ + name: string; + /** Absolute URL to the standard. Rendered as a plain ``, never HTML. */ + url: string; +} + +export interface ProvenanceExplainerProps { + /** The provenance facet off `Derived` — inputs, source, window, asOf. */ + provenance: DerivedProvenance; + /** + * Plain-language method description. Text children only — the caller + * passes a localised string; the component never parses markup. + */ + method: React.ReactNode; + /** Optional cited standard rendered as an external link in the footer. */ + standard?: ProvenanceStandard; + /** Accessible title of the surface (sheet header / popover first line). */ + title?: string; + /** Optional className for the trigger button. */ + className?: string; + /** Optional id the parent threads as aria-describedby on its own element. */ + bodyId?: string; +} + +export function ProvenanceExplainer({ + provenance, + method, + standard, + title, + className, + bodyId, +}: ProvenanceExplainerProps) { + const { t, locale } = useTranslations(); + const isMobile = useIsMobile(); + const [open, setOpen] = useState(false); + const generatedId = useId(); + const resolvedBodyId = bodyId ?? generatedId; + + const resolvedTitle = title ?? t("insights.derived.provenance.title"); + const triggerLabel = t("insights.derived.provenance.trigger"); + + const asOf = (() => { + const parsed = new Date(provenance.computedAt); + if (Number.isNaN(parsed.getTime())) return null; + try { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "2-digit", + }).format(parsed); + } catch { + return null; + } + })(); + + const body = ( + + ); + + const trigger = ( + + ); + + if (isMobile) { + return ( + <> + {trigger} + +
{body}
+
+ + ); + } + + return ( + + {trigger} + +

+ {resolvedTitle} +

+ {body} +
+
+ ); +} diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx new file mode 100644 index 000000000..06514dc1e --- /dev/null +++ b/src/components/insights/derived/score-ring.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + Label, + PolarRadiusAxis, + RadialBar, + RadialBarChart, + ResponsiveContainer, +} from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { useCountUp } from "@/hooks/use-count-up"; +import { + bandForScore, + BAND_NUMBER_CLASS, + BAND_VAR, + clampScore, + type ScoreBand, +} from "./band-tokens"; + +/** + * v1.10.0 — the composite-score dial. A radial Recharts ring with the + * `tabular-nums` number centred, band-coloured, sweeping to fill on first + * paint. Rendered small in a grid tile and large on the score-anatomy + * detail view from the same component via the `size` prop — one score + * language, two intensities (the Hybrid direction). + * + * Built on raw Recharts `RadialBarChart` (the existing house pattern — 6 + * sites already use Recharts via `ResponsiveContainer`), NOT the shadcn + * `chart` primitive: the ring needs no per-series CSS-var injection, so it + * paints its fill straight from the `--dracula-*` band token and avoids + * adding a second `dangerouslySetInnerHTML` to the tree. 0 KB new runtime. + * + * `score === null` renders the provisional/empty state: an unfilled ring + * with an em-dash and a localised "not enough data yet" caption, so a + * sparse-data hero never goes blank. + * + * 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. + */ + +const SIZE: Record< + NonNullable, + { px: number; numberClass: string; labelClass: string; barSize: number } +> = { + sm: { px: 96, numberClass: "text-2xl", labelClass: "text-[10px]", barSize: 10 }, + md: { px: 140, numberClass: "text-4xl", labelClass: "text-xs", barSize: 14 }, + lg: { px: 200, numberClass: "text-6xl", labelClass: "text-sm", barSize: 18 }, +}; + +export interface ScoreRingProps { + /** The 0..100 score, or `null` for the provisional/empty state. */ + score: number | null; + /** Band override; when omitted it is derived from the score via thresholds. */ + band?: ScoreBand; + /** Short label rendered under the number (e.g. "Readiness", "/100"). */ + label?: string; + /** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */ + size?: "sm" | "md" | "lg"; + /** Disable the sweep + count-up (e.g. when already animated by a parent). */ + animate?: boolean; + className?: string; +} + +export function ScoreRing({ + score, + band, + label, + size = "md", + animate = true, + className, +}: ScoreRingProps) { + const { t } = useTranslations(); + const dims = SIZE[size]; + + 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)"; + + // Count-up only feeds the centred number; the ring arc reads the final + // value (Recharts owns its own sweep via isAnimationActive). + const displayed = useCountUp(clamped, { enabled: animate && hasScore }); + const displayedRounded = Math.round(displayed); + + const ariaLabel = hasScore + ? t("insights.derived.scoreRing.aria", { + score: Math.round(clamped), + band: t(`insights.derived.scoreRing.band.${resolvedBand}`), + }) + : t("insights.derived.scoreRing.ariaProvisional"); + + // RadialBar fills the arc proportionally to the bar's value against the + // PolarRadiusAxis [0,100] domain; the track spans a full clockwise circle + // from 12 o'clock, so a value of 74 fills 74% of the ring. + const data = [{ name: "score", value: hasScore ? clamped : 0, fill }]; + const startAngle = 90; + const endAngle = -270; + + // Geometry — keep the inner ring thin and the centre clear for the number. + const outerRadius = "100%"; + const innerRadius = "78%"; + + return ( +
+ + + + + + + + {!hasScore && ( + + {t("insights.derived.scoreRing.provisionalCaption")} + + )} +
+ ); +} diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx new file mode 100644 index 000000000..d95a2fef6 --- /dev/null +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; +import { cn } from "@/lib/utils"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { + getTrendSentiment, + sentimentColorClass, + type TrendDirectionSentiment, +} from "@/lib/insights/trend-sentiment"; + +/** + * v1.10.0 — the Apple-Health-Highlights grid tile. + * + * Extends the shipped `trend-card` grammar (label + icon row, big + * `tabular-nums` value, sentiment-coloured trend arrow) with the single + * biggest visible grid upgrade the design direction names: an inline + * Recharts sparkline, a signed period delta, and an age / normal-range + * framing slot. Reuses the already-imported Recharts (Area sparkline, + * 40–60px fixed height) — 0 KB new runtime. + * + * States, defined up front (no fallback afterthought): + * - populated: value + sparkline + delta + framing line + * - empty (`value == null`): em-dash, no sparkline, optional framing line + * - stale (`staleDays`): "last value Xd ago" caption, value kept visible + * Fixed footprint so a tile never reflows its neighbours. + */ + +export interface SparklineDeltaTileProps { + label: string; + /** Latest reading; `null` renders the empty state. */ + value: number | null; + unit: string; + icon: React.ComponentType<{ className?: string }>; + /** + * Trailing sparkline points (oldest → newest). < 2 points suppresses the + * sparkline (a line needs at least two). Reuses Recharts, capped to a + * small fixed height. + */ + series?: number[]; + /** Signed delta vs the baseline; drives the arrow + the (Δ) caption. */ + delta?: number | null; + /** Whether an up move is good / bad / neutral for this metric. */ + directionSentiment?: TrendDirectionSentiment; + /** + * The age / normal-range framing line (e.g. "typical for age 31", + * "~5 yrs younger", "within normal range"). Plain text children — no + * markdown. Rendered muted under the value row. + */ + framing?: React.ReactNode; + /** Days since the last reading; > 7 surfaces the stale caption. */ + staleDays?: number | null; + /** Decimal places for the value + delta. Defaults to 1. */ + precision?: number; + className?: string; +} + +export function SparklineDeltaTile({ + label, + value, + unit, + icon: Icon, + series, + delta = null, + directionSentiment = "neutral", + framing, + staleDays = null, + precision = 1, + className, +}: SparklineDeltaTileProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const arrowSentiment = getTrendSentiment(delta ?? null, directionSentiment); + const trendColor = sentimentColorClass(arrowSentiment); + + const TrendIcon = ((): typeof ArrowUp => { + if (delta == null) return Minus; + if (Math.abs(delta) < 0.05) return ArrowRight; + return delta > 0 ? ArrowUp : ArrowDown; + })(); + + const formatDelta = (d: number): string => { + if (Math.abs(d) < 0.05) return "±0"; + const sign = d > 0 ? "+" : "−"; + return `${sign}${fmt.number(Math.abs(d), precision)}`; + }; + + const sparkData = + series && series.length >= 2 + ? series.map((v, i) => ({ i, v })) + : null; + + // The sparkline tints with the trend sentiment so it reads "same signal" + // as the arrow; neutral metrics ride the muted-foreground line. + const strokeVar = + arrowSentiment === "positive" + ? "var(--dracula-green)" + : arrowSentiment === "negative" + ? "var(--dracula-orange)" + : "var(--muted-foreground)"; + const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`; + + return ( +
+
+ + {label} + + +
+ +
+ + {value !== null ? fmt.number(value, precision) : "—"} + + + {unit} + + +
+ + {/* Inline sparkline — fixed 40px height so the tile never reflows. + Suppressed (with the height reserved) when there is no series. */} +
+ {sparkData ? ( + + + + + + + + + + + + + ) : ( + + + {/* Framing + delta caption row. */} +
+ {framing ? ( + + {framing} + + ) : ( + + )} + {delta != null && ( + + {formatDelta(delta)} + + )} +
+ + {staleDays != null && staleDays > 7 && ( + + {t("dashboard.staleHint", { count: staleDays })} + + )} +
+ ); +} diff --git a/src/hooks/use-count-up.ts b/src/hooks/use-count-up.ts new file mode 100644 index 000000000..ab4b6c1b0 --- /dev/null +++ b/src/hooks/use-count-up.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * v1.10.0 — a tiny zero-dependency count-up animation hook for the + * score-ring / score-number "sweep to fill on first paint" affordance + * the premium-health direction calls for. No motion library — ~30 LOC of + * `requestAnimationFrame` instead, per the 0 KB runtime budget. + * + * Returns a number that eases from 0 to `target` once, on mount (and + * re-runs when `target` changes). Honours `prefers-reduced-motion`: a + * reduced-motion user (or any environment without rAF, e.g. SSR / the + * Vitest jsdom render) gets the final value immediately with no + * intermediate frames — the state is seeded to `target` so no + * synchronous-in-effect setState is needed for the no-animation path. + */ +export function useCountUp( + target: number, + options: { durationMs?: number; enabled?: boolean } = {}, +): number { + const { durationMs = 600, enabled = true } = options; + // Seed at the final value so the reduced-motion / SSR path is already + // correct without any setState; only the animated path drives frames, + // and those setState calls all happen inside the rAF callback (async). + const [value, setValue] = useState(target); + const frameRef = useRef(null); + + useEffect(() => { + const prefersReduced = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + if ( + !enabled || + prefersReduced || + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + !Number.isFinite(target) + ) { + // The animated path is skipped; the rAF callback below never runs, + // so the displayed value stays at whatever the last frame / seed + // left it. Schedule a single async correction in case the previous + // target differed — done via rAF so it is not a sync-in-effect set. + if ( + typeof window !== "undefined" && + typeof window.requestAnimationFrame === "function" + ) { + frameRef.current = window.requestAnimationFrame(() => + setValue(target), + ); + return () => { + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + } + return; + } + + const start = performance.now(); + + const tick = (now: number) => { + const elapsed = now - start; + const progress = Math.min(1, elapsed / durationMs); + // ease-out cubic — matches the "never bouncy, ease-out ~300–500ms" + // motion note in the design direction. + const eased = 1 - Math.pow(1 - progress, 3); + setValue(target * eased); + if (progress < 1) { + frameRef.current = window.requestAnimationFrame(tick); + } else { + setValue(target); + } + }; + + // First frame (and every subsequent one) runs inside the rAF callback, + // so the initial 0-state set is asynchronous — no cascading render. + frameRef.current = window.requestAnimationFrame((now) => { + setValue(0); + tick(now); + }); + + return () => { + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + }, [target, durationMs, enabled]); + + return value; +} From 663a3ee09d992927b064ec55c8dbe4b44132618a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:30:09 +0200 Subject: [PATCH 05/49] feat(insights): add the vitals dashboard + its derived metric tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the Apple-Health-Highlights grid of personal-vital tiles on the Insights overview, backed by four new pure derived-metric compute functions over the established seam: - cardio-fitness band ("Fitness Age" frame) — VO2max placed against the FRIEND-registry age/sex norm (Kaminsky 2015), surfaced + trended, never re-derived. - vascular-age delta — Withings vascular-age vs chronological age, a relative wellness indicator (Vlachopoulos 2010 PWV framing), no medical claim. - HRV (SDNN) balance — reuses the typical-range engine for the band and places the recent average as balanced / above / below; SDNN never relabelled RMSSD; cadence-gated. - BMI from weight + height — exact WHO classification, the fallback when no device BMI exists. Each metric carries coverage + confidence + provenance and cites its standard through the provenance explainer. The dashboard renders one SparklineDeltaTile per available vital: an absent vital does not render, a still-building one shows the honest coverage meter rather than a headline from too little history. Tiles read the generic /api/insights/derived route through a TanStack Query hook keyed off the centralised factory. Regenerates the OpenAPI contract for the two new metric ids and adds the vitals copy across all six locales. --- docs/api/openapi.yaml | 16 +- messages/de.json | 24 ++ messages/en.json | 24 ++ messages/es.json | 24 ++ messages/fr.json | 24 ++ messages/it.json | 24 ++ messages/pl.json | 24 ++ src/app/api/insights/derived/route.ts | 3 +- src/app/insights/page.tsx | 18 + .../__tests__/vitals-dashboard.test.tsx | 126 +++++++ src/components/insights/derived/index.ts | 8 + .../insights/derived/use-derived-metric.ts | 67 ++++ .../insights/derived/vitals-dashboard.tsx | 313 ++++++++++++++++++ .../insights/derived/__tests__/bmi.test.ts | 58 ++++ .../derived/__tests__/dispatch.test.ts | 13 +- .../derived/__tests__/fitness-age.test.ts | 82 +++++ .../derived/__tests__/hrv-balance.test.ts | 61 ++++ .../derived/__tests__/vascular-age.test.ts | 75 +++++ src/lib/insights/derived/baseline.ts | 5 + src/lib/insights/derived/bmi.ts | 169 ++++++++++ src/lib/insights/derived/dispatch.ts | 24 ++ src/lib/insights/derived/fitness-age.ts | 195 +++++++++++ src/lib/insights/derived/hrv-balance.ts | 142 ++++++++ src/lib/insights/derived/index.ts | 17 + src/lib/insights/derived/registry.ts | 28 +- src/lib/insights/derived/vascular-age.ts | 179 ++++++++++ src/lib/openapi/routes.ts | 2 +- src/lib/query-keys.ts | 11 + 28 files changed, 1742 insertions(+), 14 deletions(-) create mode 100644 src/components/insights/derived/__tests__/vitals-dashboard.test.tsx create mode 100644 src/components/insights/derived/use-derived-metric.ts create mode 100644 src/components/insights/derived/vitals-dashboard.tsx create mode 100644 src/lib/insights/derived/__tests__/bmi.test.ts create mode 100644 src/lib/insights/derived/__tests__/fitness-age.test.ts create mode 100644 src/lib/insights/derived/__tests__/hrv-balance.test.ts create mode 100644 src/lib/insights/derived/__tests__/vascular-age.test.ts create mode 100644 src/lib/insights/derived/bmi.ts create mode 100644 src/lib/insights/derived/fitness-age.ts create mode 100644 src/lib/insights/derived/hrv-balance.ts create mode 100644 src/lib/insights/derived/vascular-age.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6f5607bbf..3e810ff0f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2330,13 +2330,15 @@ paths: - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION - description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id - 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` - value with reason `not_implemented`." + - HRV_BALANCE + - BMI + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, + READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an + `insufficient` value with reason `not_implemented`." required: true - description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id - 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` - value with reason `not_implemented`." + description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, + READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an + `insufficient` value with reason `not_implemented`." - in: query name: type schema: @@ -7332,6 +7334,8 @@ components: - SLEEP_SCORE - READINESS - COINCIDENT_DEVIATION + - HRV_BALANCE + - BMI description: Echoes the requested derived-metric id (tags the union). status: type: string diff --git a/messages/de.json b/messages/de.json index ea34405fb..e05531307 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2487,6 +2487,30 @@ "window": "Aus deinen letzten {days} Tagen · {source}-Daten", "asOf": "Stand {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Deine Vitalwerte", + "building": "Typischer Bereich entsteht — {count} von {target} Tagen", + "typicalRange": "Typisch für dich: {low}–{high}", + "fitnessYounger": "Ausdauer wie ~{years} J. jünger", + "fitnessOlder": "Ausdauer ~{years} J. über deinem Alter", + "fitnessTypical": "Ausdauer typisch für dein Alter", + "fitnessNoNorm": "Alter und Geschlecht ergänzen, um dies einzuordnen", + "vascularBelow": "{years} J. unter deinem Alter", + "vascularAbove": "{years} J. über deinem Alter", + "vascularMatch": "Im Einklang mit deinem Alter", + "vascularNoAge": "Geburtsdatum ergänzen, um dies einzuordnen", + "hrvBand": { + "balanced": "Für dich ausgewogen", + "unbalanced": "Über deinem üblichen Bereich", + "low": "Unter deinem üblichen Bereich" + }, + "bmiCategory": { + "underweight": "Untergewicht (WHO)", + "normal": "Normalbereich (WHO)", + "overweight": "Übergewicht (WHO)", + "obese": "Adipositas (WHO)" + } } } }, diff --git a/messages/en.json b/messages/en.json index f9cf3ccc7..da93a9e3a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2487,6 +2487,30 @@ "window": "From your last {days} days · {source} data", "asOf": "as of {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Your vitals", + "building": "Building your typical range — {count} of {target} days", + "typicalRange": "Typical for you: {low}–{high}", + "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", + "fitnessOlder": "Cardio fitness ~{years} yr above your age", + "fitnessTypical": "Cardio fitness typical for your age", + "fitnessNoNorm": "Add your age and sex to frame this", + "vascularBelow": "{years} yr below your age", + "vascularAbove": "{years} yr above your age", + "vascularMatch": "In line with your age", + "vascularNoAge": "Add your date of birth to frame this", + "hrvBand": { + "balanced": "Balanced for you", + "unbalanced": "Above your usual range", + "low": "Below your usual range" + }, + "bmiCategory": { + "underweight": "Underweight (WHO)", + "normal": "Normal range (WHO)", + "overweight": "Overweight (WHO)", + "obese": "Obese (WHO)" + } } } }, diff --git a/messages/es.json b/messages/es.json index ee9f48e3d..6e4f16b4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2487,6 +2487,30 @@ "window": "De tus últimos {days} días · datos de {source}", "asOf": "a fecha de {date}", "standardLink": "Estándar: {name}" + }, + "vitals": { + "sectionTitle": "Tus constantes", + "building": "Creando tu rango habitual — {count} de {target} días", + "typicalRange": "Habitual para ti: {low}–{high}", + "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", + "fitnessOlder": "Forma cardiovascular ~{years} años por encima de tu edad", + "fitnessTypical": "Forma cardiovascular típica para tu edad", + "fitnessNoNorm": "Añade tu edad y sexo para enmarcar esto", + "vascularBelow": "{years} años por debajo de tu edad", + "vascularAbove": "{years} años por encima de tu edad", + "vascularMatch": "Acorde con tu edad", + "vascularNoAge": "Añade tu fecha de nacimiento para enmarcar esto", + "hrvBand": { + "balanced": "Equilibrado para ti", + "unbalanced": "Por encima de tu rango habitual", + "low": "Por debajo de tu rango habitual" + }, + "bmiCategory": { + "underweight": "Bajo peso (OMS)", + "normal": "Rango normal (OMS)", + "overweight": "Sobrepeso (OMS)", + "obese": "Obesidad (OMS)" + } } } }, diff --git a/messages/fr.json b/messages/fr.json index 3530e02ba..2835dcd36 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2487,6 +2487,30 @@ "window": "Sur tes {days} derniers jours · données {source}", "asOf": "au {date}", "standardLink": "Norme : {name}" + }, + "vitals": { + "sectionTitle": "Vos constantes", + "building": "Construction de votre plage habituelle — {count} sur {target} jours", + "typicalRange": "Habituel pour vous : {low}–{high}", + "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", + "fitnessOlder": "Forme cardio ~{years} ans au-dessus de votre âge", + "fitnessTypical": "Forme cardio typique pour votre âge", + "fitnessNoNorm": "Ajoutez votre âge et votre sexe pour situer cela", + "vascularBelow": "{years} ans en dessous de votre âge", + "vascularAbove": "{years} ans au-dessus de votre âge", + "vascularMatch": "En phase avec votre âge", + "vascularNoAge": "Ajoutez votre date de naissance pour situer cela", + "hrvBand": { + "balanced": "Équilibré pour vous", + "unbalanced": "Au-dessus de votre plage habituelle", + "low": "En dessous de votre plage habituelle" + }, + "bmiCategory": { + "underweight": "Insuffisance pondérale (OMS)", + "normal": "Plage normale (OMS)", + "overweight": "Surpoids (OMS)", + "obese": "Obésité (OMS)" + } } } }, diff --git a/messages/it.json b/messages/it.json index 994715c69..5dc16442c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2487,6 +2487,30 @@ "window": "Dai tuoi ultimi {days} giorni · dati {source}", "asOf": "al {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "I tuoi parametri vitali", + "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", + "typicalRange": "Abituale per te: {low}–{high}", + "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", + "fitnessOlder": "Forma cardio ~{years} anni oltre la tua età", + "fitnessTypical": "Forma cardio tipica per la tua età", + "fitnessNoNorm": "Aggiungi età e sesso per inquadrare questo dato", + "vascularBelow": "{years} anni sotto la tua età", + "vascularAbove": "{years} anni sopra la tua età", + "vascularMatch": "In linea con la tua età", + "vascularNoAge": "Aggiungi la data di nascita per inquadrare questo dato", + "hrvBand": { + "balanced": "Equilibrato per te", + "unbalanced": "Sopra il tuo intervallo abituale", + "low": "Sotto il tuo intervallo abituale" + }, + "bmiCategory": { + "underweight": "Sottopeso (OMS)", + "normal": "Intervallo normale (OMS)", + "overweight": "Sovrappeso (OMS)", + "obese": "Obesità (OMS)" + } } } }, diff --git a/messages/pl.json b/messages/pl.json index c17fdbd1d..c7f892257 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2487,6 +2487,30 @@ "window": "Z ostatnich {days} dni · dane {source}", "asOf": "na dzień {date}", "standardLink": "Standard: {name}" + }, + "vitals": { + "sectionTitle": "Twoje parametry życiowe", + "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", + "typicalRange": "Typowe dla Ciebie: {low}–{high}", + "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", + "fitnessOlder": "Kondycja ~{years} lat powyżej Twojego wieku", + "fitnessTypical": "Kondycja typowa dla Twojego wieku", + "fitnessNoNorm": "Dodaj wiek i płeć, aby to ująć w kontekście", + "vascularBelow": "{years} lat poniżej Twojego wieku", + "vascularAbove": "{years} lat powyżej Twojego wieku", + "vascularMatch": "Zgodnie z Twoim wiekiem", + "vascularNoAge": "Dodaj datę urodzenia, aby to ująć w kontekście", + "hrvBand": { + "balanced": "Zrównoważone dla Ciebie", + "unbalanced": "Powyżej Twojego zwykłego zakresu", + "low": "Poniżej Twojego zwykłego zakresu" + }, + "bmiCategory": { + "underweight": "Niedowaga (WHO)", + "normal": "Zakres prawidłowy (WHO)", + "overweight": "Nadwaga (WHO)", + "obese": "Otyłość (WHO)" + } } } }, diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index afceaec9f..65356e615 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -67,7 +67,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // re-fetched per metric — the pool-contention mitigation). const profile = await prisma.user.findUnique({ where: { id: user.id }, - select: { dateOfBirth: true, gender: true }, + select: { dateOfBirth: true, gender: true, heightCm: true }, }); const sex = profile?.gender === "MALE" || profile?.gender === "FEMALE" @@ -80,6 +80,7 @@ export const GET = apiHandler(async (request: NextRequest) => { profile: { ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), sex, + heightCm: profile?.heightCm ?? null, }, type: parsed.data.type ?? null, }); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index d4912eb09..ab5d7812e 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -69,6 +69,22 @@ const TrendsRow = dynamic( ), }, ); +// v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of +// personal-typical-range + passthrough-reframe tiles). Deferred behind +// `next/dynamic` like the other below-the-fold blocks; each tile owns its +// own derived-metric query and un-mounts when its vital is absent. +const VitalsDashboard = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.VitalsDashboard, + })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); /** * v1.4.25 W4d — Insights mother page. @@ -230,6 +246,8 @@ export default function InsightsPage() {
+ + {flags.briefing && ( ({ + useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a), +})); + +import { VitalsDashboard } from "../vitals-dashboard"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +type Resp = DerivedMetricResponse>; + +function ok(value: Record): { data: Resp; isLoading: false } { + return { + isLoading: false, + data: { + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, + }, + }; +} +function insufficient(reason: string, historyDays = 0): { data: Resp; isLoading: false } { + return { + isLoading: false, + data: { + metric: "X", + status: "insufficient", + value: null, + coverage: { requiredInputs: 1, presentInputs: reason === "no_readings_in_window" ? 0 : 1, historyDays, missing: [] }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason, + }, + }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe("", () => { + it("renders an available baseline vital as an ok tile", () => { + useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { + if (metric === "VITALS_BASELINE" && opts?.type === "RESTING_HEART_RATE") { + return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-slot="vitals-dashboard"'); + expect(html).toContain('data-metric="RESTING_HEART_RATE"'); + expect(html).toContain('data-state="ok"'); + // Typical-range framing rendered. + expect(html).toContain("Typical for you"); + }); + + it("hides an absent vital entirely (no tile)", () => { + useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window")); + const html = render(); + // The grid + heading still render, but no value tile. + expect(html).toContain('data-slot="vitals-dashboard-grid"'); + expect(html).not.toContain('data-state="ok"'); + expect(html).not.toContain('data-metric="WEIGHT"'); + }); + + it("shows a provisional baseline tile with the coverage meter, not a headline", () => { + useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { + if (metric === "VITALS_BASELINE" && opts?.type === "WEIGHT") { + return insufficient("insufficient_history_for_band", 4); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="WEIGHT"'); + expect(html).toContain('data-state="provisional"'); + expect(html).toContain('data-slot="coverage-meter"'); + expect(html).toContain("Building your typical range"); + }); + + it("renders the fitness-age tile with its age framing", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "FITNESS_AGE") { + return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="FITNESS_AGE"'); + expect(html).toContain("yr younger"); + }); + + it("renders the BMI tile with its WHO category", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "BMI") { + return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="BMI"'); + expect(html).toContain("Normal range (WHO)"); + }); + + it("hides BMI when there is no height/weight", () => { + useDerivedMetric.mockImplementation((metric: string) => { + if (metric === "BMI") return insufficient("no_height_on_profile"); + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).not.toContain('data-metric="BMI"'); + }); +}); diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index e37fd5087..fc4afbf01 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -27,3 +27,11 @@ export { BAND_VAR, type ScoreBand, } from "./band-tokens"; + +// ── W2b vitals dashboard surface + query hook ──────────────────────── +export { VitalsDashboard } from "./vitals-dashboard"; +export { + useDerivedMetric, + type DerivedMetricResponse, + type UseDerivedMetricOptions, +} from "./use-derived-metric"; diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts new file mode 100644 index 000000000..2230fb180 --- /dev/null +++ b/src/components/insights/derived/use-derived-metric.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DerivedCoverage, + DerivedConfidence, + DerivedProvenance, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — TanStack Query hook for the generic derived-metric route. + * + * `GET /api/insights/derived?metric=[&type=]` returns the + * compute-once flattened `Derived` the W2b dashboard tiles + per-metric + * detail cards read. The queryKey comes from the centralised factory + * (`queryKeys.insightsDerived`) so the bare-array ESLint rule stays + * satisfied and the cache never poisons across metrics; the read unwraps + * `(await res.json()).data` per the envelope convention. + * + * Type-only import of the `Derived` facets — no server module enters the + * client bundle (the v1.9.0 lesson). The value `T` is left generic so each + * caller narrows to its own metric payload (`FitnessAgeValue`, `BmiValue`, …) + * imported as `import type`. + */ + +/** The flattened wire shape the route emits in the `data` envelope slot. */ +export interface DerivedMetricResponse { + metric: string; + status: "ok" | "insufficient"; + value: T | null; + coverage: DerivedCoverage; + confidence: DerivedConfidence | null; + provenance: DerivedProvenance; + reason: string | null; +} + +export interface UseDerivedMetricOptions { + /** Optional sub-target (the chosen vital for `VITALS_BASELINE`). */ + type?: string | null; + /** Gate the fetch (e.g. on auth). Defaults to enabled. */ + enabled?: boolean; +} + +export function useDerivedMetric( + metric: string, + options: UseDerivedMetricOptions = {}, +) { + const { type = null, enabled = true } = options; + return useQuery({ + queryKey: queryKeys.insightsDerived(metric, type), + queryFn: async (): Promise> => { + const params = new URLSearchParams({ metric }); + if (type) params.set("type", type); + const res = await fetch(`/api/insights/derived?${params.toString()}`); + if (!res.ok) { + throw new Error(`derived ${metric} request failed (${res.status})`); + } + return (await res.json()).data as DerivedMetricResponse; + }, + enabled, + // Pure compute over the rollup tier — cheap to recompute, but the + // numbers only move on a fresh ingest. A short stale window keeps the + // dashboard snappy without hammering the route on every focus. + staleTime: 60_000, + }); +} diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx new file mode 100644 index 000000000..1f33cc834 --- /dev/null +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useMemo } from "react"; +import { Gauge, HeartPulse, Scale } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { + MEASUREMENT_TYPE_ICONS, + MEASUREMENT_TYPE_LABEL_KEYS, +} from "@/components/measurements/measurement-list-meta"; +import { SparklineDeltaTile } from "./sparkline-delta-tile"; +import { CoverageMeter } from "./coverage-meter"; +import { useDerivedMetric } from "./use-derived-metric"; +import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; +// Type-only — the compute payloads never drag the server graph into the bundle. +import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; +import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age"; +import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"; +import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance"; +import type { BmiValue } from "@/lib/insights/derived/bmi"; + +/** + * v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid). + * + * A responsive grid of `SparklineDeltaTile`s, one per available vital, each + * framed with its personal typical range (the flagship baseline metric) plus + * the four passthrough/derived re-frames (cardio-fitness band, vascular-age + * delta, HRV balance, BMI). Every tile is data-availability-gated: + * - absent (`no_readings_in_window` / `no_height` / `no_weight`) → the tile + * does not render at all (no apologetic empty card). + * - provisional (`insufficient_history_for_band`) → the tile renders with a + * `CoverageMeter` that shows the honest "N of 7 days" state, never a + * headline number from too-little history. + * - ok → the full value + framing line + sparkline placeholder. + * + * Each tile owns its own `useDerivedMetric` query (keyed off the centralised + * factory) so a sparse vital simply un-mounts rather than blocking the grid. + */ + +const SECTION_VITALS: string[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]; + +/** Up-is-bad for the vitals where a rise is unfavourable. */ +const UP_BAD_VITALS = new Set([ + "RESTING_HEART_RATE", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + "WEIGHT", +]); +/** Up-is-good for the vitals where a rise is favourable. */ +const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); + +function vitalSentiment(type: string): TrendDirectionSentiment { + if (UP_BAD_VITALS.has(type)) return "up-bad"; + if (UP_GOOD_VITALS.has(type)) return "up-good"; + return "neutral"; +} + +interface DashboardProps { + enabled?: boolean; + className?: string; +} + +/** A single personal-typical-range tile for one vital. */ +function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const { data, isLoading } = useDerivedMetric( + "VITALS_BASELINE", + { type, enabled }, + ); + + if (isLoading || !data) return null; + // Absent → don't render the tile at all. + if (data.status === "insufficient" && data.reason === "no_readings_in_window") { + return null; + } + + const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge; + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; + const label = labelKey ? t(labelKey) : type; + const unit = getUnitForType(type); + + // Provisional — building the band; show value-less coverage state. + if (data.status === "insufficient") { + return ( +
+
+ + {label} + + +
+

+ {t("insights.derived.vitals.building", { + count: data.coverage.historyDays, + target: 7, + })} +

+ +
+ ); + } + + const v = data.value!; + const framing = t("insights.derived.vitals.typicalRange", { + low: fmt.number(v.low, 1), + high: fmt.number(v.high, 1), + }); + + return ( +
+ +
+ ); +} + +/** Cardio-fitness band tile (VO₂max passthrough re-frame). */ +function FitnessAgeTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("FITNESS_AGE", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = + v.fitnessAgeDeltaYears != null + ? v.fitnessAgeDeltaYears < 0 + ? t("insights.derived.vitals.fitnessYounger", { + years: Math.abs(v.fitnessAgeDeltaYears), + }) + : v.fitnessAgeDeltaYears > 0 + ? t("insights.derived.vitals.fitnessOlder", { + years: v.fitnessAgeDeltaYears, + }) + : t("insights.derived.vitals.fitnessTypical") + : t("insights.derived.vitals.fitnessNoNorm"); + return ( +
+ +
+ ); +} + +/** Vascular-age delta tile (Withings passthrough re-frame). */ +function VascularAgeTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric( + "VASCULAR_AGE_DELTA", + { enabled }, + ); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = + v.deltaYears != null + ? v.deltaYears < 0 + ? t("insights.derived.vitals.vascularBelow", { + years: Math.abs(Math.round(v.deltaYears)), + }) + : v.deltaYears > 0 + ? t("insights.derived.vitals.vascularAbove", { + years: Math.round(v.deltaYears), + }) + : t("insights.derived.vitals.vascularMatch") + : t("insights.derived.vitals.vascularNoAge"); + return ( +
+ +
+ ); +} + +/** HRV (SDNN) balance tile (reuses the baseline engine). */ +function HrvBalanceTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("HRV_BALANCE", { + enabled, + }); + if (isLoading || !data) return null; + if (data.status === "insufficient" && data.reason === "no_readings_in_window") { + return null; + } + if (data.status === "insufficient") { + return ( +
+
+ + {t("measurements.typeHeartRateVariability")} + + +
+

+ {t("insights.derived.vitals.building", { + count: data.coverage.historyDays, + target: 7, + })} +

+ +
+ ); + } + const v = data.value!; + const framing = t(`insights.derived.vitals.hrvBand.${v.band}`); + return ( +
+ +
+ ); +} + +/** BMI tile (weight + height fallback). */ +function BmiTile({ enabled }: { enabled: boolean }) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("BMI", { enabled }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`); + return ( +
+ +
+ ); +} + +export function VitalsDashboard({ enabled = true, className }: DashboardProps) { + const { t } = useTranslations(); + const vitals = useMemo(() => SECTION_VITALS, []); + + return ( +
+

+ {t("insights.derived.vitals.sectionTitle")} +

+
+ + + + + {vitals + .filter((type) => type !== "HEART_RATE_VARIABILITY") + .map((type) => ( + + ))} +
+
+ ); +} diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts new file mode 100644 index 000000000..1b5407786 --- /dev/null +++ b/src/lib/insights/derived/__tests__/bmi.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findFirst: (...a: unknown[]) => findFirst(...a) } }, +})); + +import { computeBmi, classifyBmi } from "../bmi"; + +const NOW = new Date("2026-06-02T07:00:00Z"); + +beforeEach(() => vi.clearAllMocks()); + +describe("classifyBmi (WHO)", () => { + it("underweight < 18.5 (yellow)", () => { + expect(classifyBmi(17)).toEqual({ category: "underweight", band: "yellow" }); + }); + it("normal 18.5–24.9 (green)", () => { + expect(classifyBmi(22)).toEqual({ category: "normal", band: "green" }); + }); + it("overweight 25–29.9 (yellow)", () => { + expect(classifyBmi(27)).toEqual({ category: "overweight", band: "yellow" }); + }); + it("obese ≥ 30 (red)", () => { + expect(classifyBmi(31)).toEqual({ category: "obese", band: "red" }); + }); +}); + +describe("computeBmi", () => { + it("insufficient with no height on profile (never reads weight)", async () => { + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: null }, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_height_on_profile"); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("insufficient when height present but no recent weight", async () => { + findFirst.mockResolvedValueOnce(null); + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_weight_in_window"); + }); + + it("ok exact BMI from weight + height", async () => { + findFirst.mockResolvedValueOnce({ value: 80 }); + const r = await computeBmi("u1", { ageYears: 40, sex: "MALE", heightCm: 180 }, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + // 80 / 1.8^2 = 24.69 → rounded to 24.7 + expect(r.value.bmi).toBe(24.7); + expect(r.value.category).toBe("normal"); + expect(r.value.band).toBe("green"); + expect(r.value.weightKg).toBe(80); + expect(r.value.heightCm).toBe(180); + expect(r.confidence.score).toBeGreaterThan(0); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 416a400d3..271bd453f 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -24,10 +24,15 @@ const NOW = new Date("2026-06-02T07:00:00Z"); beforeEach(() => vi.clearAllMocks()); describe("registry", () => { - it("exposes the flagship as implemented and the rest as stubs", () => { + it("exposes the implemented vitals-tier metrics and the composite stubs", () => { expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("VASCULAR_AGE_DELTA")?.implemented).toBe(true); + expect(getDerivedMetricMeta("HRV_BALANCE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("BMI")?.implemented).toBe(true); + // Composites still land in W3. expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); - expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); + expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -43,7 +48,9 @@ describe("registry", () => { it("DERIVED_METRIC_IDS is the full closed enum", () => { expect(DERIVED_METRIC_IDS).toContain("VITALS_BASELINE"); expect(DERIVED_METRIC_IDS).toContain("READINESS"); - expect(DERIVED_METRIC_IDS.length).toBe(6); + expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE"); + expect(DERIVED_METRIC_IDS).toContain("BMI"); + expect(DERIVED_METRIC_IDS.length).toBe(8); }); }); diff --git a/src/lib/insights/derived/__tests__/fitness-age.test.ts b/src/lib/insights/derived/__tests__/fitness-age.test.ts new file mode 100644 index 000000000..08879d6b4 --- /dev/null +++ b/src/lib/insights/derived/__tests__/fitness-age.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } }, +})); + +import { + computeFitnessAge, + placeVo2Band, + fitnessAgeDeltaYears, +} from "../fitness-age"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const MALE_40 = { ageYears: 40, sex: "MALE" as const }; +const NO_DEMO = { ageYears: null, sex: null }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeVo2Band", () => { + // FRIEND 40–49 male band is { low: 35, high: 45 }. + it("green at/above the reference high", () => { + expect(placeVo2Band(46, { low: 35, high: 45 })).toBe("green"); + expect(placeVo2Band(45, { low: 35, high: 45 })).toBe("green"); + }); + it("yellow inside the band", () => { + expect(placeVo2Band(40, { low: 35, high: 45 })).toBe("yellow"); + }); + it("red below the band low", () => { + expect(placeVo2Band(30, { low: 35, high: 45 })).toBe("red"); + }); + it("null without a reference", () => { + expect(placeVo2Band(40, null)).toBeNull(); + }); +}); + +describe("fitnessAgeDeltaYears", () => { + it("fitter-than-midpoint reads as negative (younger) years", () => { + // midpoint 40; 45 → -5 + expect(fitnessAgeDeltaYears(45, { low: 35, high: 45 })).toBe(-5); + }); + it("below-midpoint reads as positive (older) years", () => { + expect(fitnessAgeDeltaYears(35, { low: 35, high: 45 })).toBe(5); + }); + it("null without a reference", () => { + expect(fitnessAgeDeltaYears(40, null)).toBeNull(); + }); +}); + +describe("computeFitnessAge", () => { + it("insufficient when no VO2max reading exists", async () => { + findMany.mockResolvedValueOnce([]); + const r = await computeFitnessAge("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_readings_in_window"); + }); + + it("ok with a band + fitness-age delta when demographics are present", async () => { + findMany.mockResolvedValueOnce([{ value: 46 }, { value: 44 }, { value: 43 }]); + const r = await computeFitnessAge("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.vo2Max).toBe(46); + expect(r.value.band).toBe("green"); + expect(r.value.referenceBand).toEqual({ low: 35, high: 45 }); + // ≥3 readings → trend = 46 - 44 = 2 + expect(r.value.trendDelta).toBe(2); + expect(r.value.fitnessAgeDeltaYears).toBe(-6); // midpoint 40, 46 → -6 + } + }); + + it("ok but band/delta null without demographics; trend suppressed under 3 readings", async () => { + findMany.mockResolvedValueOnce([{ value: 42 }, { value: 40 }]); + const r = await computeFitnessAge("u1", NO_DEMO, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBeNull(); + expect(r.value.fitnessAgeDeltaYears).toBeNull(); + expect(r.value.trendDelta).toBeNull(); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts new file mode 100644 index 000000000..b293eb19a --- /dev/null +++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeVitalsBaseline = vi.fn(); +vi.mock("../baseline", () => ({ + computeVitalsBaseline: (...a: unknown[]) => computeVitalsBaseline(...a), +})); + +import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeHrvBalance", () => { + it("low below the band floor", () => { + expect(placeHrvBalance(30, 40, 80)).toBe("low"); + }); + it("balanced inside the band", () => { + expect(placeHrvBalance(60, 40, 80)).toBe("balanced"); + }); + it("unbalanced above the band ceiling", () => { + expect(placeHrvBalance(90, 40, 80)).toBe("unbalanced"); + }); +}); + +describe("computeHrvBalance", () => { + it("passes the baseline insufficient through verbatim", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "insufficient", + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 3, missing: [] }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: "insufficient_history_for_band", + }); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.reason).toBe("insufficient_history_for_band"); + expect(r.coverage.historyDays).toBe(3); + } + }); + + it("builds a balanced band off the baseline center", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("balanced"); + expect(r.value.recentAvg).toBe(60); + expect(r.value.baselineLow).toBe(40); + expect(r.value.baselineHigh).toBe(80); + expect(r.value.sampleDays).toBe(21); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/vascular-age.test.ts b/src/lib/insights/derived/__tests__/vascular-age.test.ts new file mode 100644 index 000000000..e99b480e8 --- /dev/null +++ b/src/lib/insights/derived/__tests__/vascular-age.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => findMany(...a), + findFirst: (...a: unknown[]) => findFirst(...a), + }, + }, +})); + +import { computeVascularAgeDelta, placeVascularBand } from "../vascular-age"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +const AGE_40 = { ageYears: 40, sex: "MALE" as const }; +const NO_AGE = { ageYears: null, sex: null }; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeVascularBand", () => { + it("green when ≥2 years below chronological", () => { + expect(placeVascularBand(-3)).toBe("green"); + expect(placeVascularBand(-2)).toBe("green"); + }); + it("yellow within ±2 years", () => { + expect(placeVascularBand(0)).toBe("yellow"); + expect(placeVascularBand(1)).toBe("yellow"); + }); + it("red when ≥2 years above", () => { + expect(placeVascularBand(3)).toBe("red"); + }); + it("null without a delta", () => { + expect(placeVascularBand(null)).toBeNull(); + }); +}); + +describe("computeVascularAgeDelta", () => { + it("insufficient when no vascular-age reading", async () => { + findMany.mockResolvedValueOnce([]); + findFirst.mockResolvedValueOnce(null); + const r = await computeVascularAgeDelta("u1", AGE_40, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") expect(r.reason).toBe("no_readings_in_window"); + }); + + it("ok with delta + band + PWV context; trend with ≥3 readings", async () => { + findMany.mockResolvedValueOnce([{ value: 36 }, { value: 38 }, { value: 39 }]); + findFirst.mockResolvedValueOnce({ value: 7.2 }); + const r = await computeVascularAgeDelta("u1", AGE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.vascularAge).toBe(36); + expect(r.value.deltaYears).toBe(-4); // 36 - 40 + expect(r.value.band).toBe("green"); + expect(r.value.pulseWaveVelocity).toBe(7.2); + expect(r.value.trendDelta).toBe(-2); // 36 - 38 + expect(r.provenance.inputs).toContain("PULSE_WAVE_VELOCITY"); + } + }); + + it("ok but delta/band null without a profile age", async () => { + findMany.mockResolvedValueOnce([{ value: 50 }]); + findFirst.mockResolvedValueOnce(null); + const r = await computeVascularAgeDelta("u1", NO_AGE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.deltaYears).toBeNull(); + expect(r.value.band).toBeNull(); + expect(r.value.trendDelta).toBeNull(); + expect(r.provenance.inputs).not.toContain("PULSE_WAVE_VELOCITY"); + } + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 585b7388b..3da330ec4 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -72,6 +72,11 @@ export interface VitalsBaselineValue { export interface BaselineProfile { ageYears: number | null; sex: "MALE" | "FEMALE" | null; + /** + * Height in cm from `User.heightCm`, when set. Consumed by the BMI + * metric (weight ÷ height²); `null` when the profile has no height. + */ + heightCm?: number | null; } export interface VitalsBaselineOpts { diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts new file mode 100644 index 000000000..6c19e2cc0 --- /dev/null +++ b/src/lib/insights/derived/bmi.ts @@ -0,0 +1,169 @@ +/** + * v1.10.0 — catalogue metric #9: BMI from weight + height. + * + * `computeBmi(userId, profile, opts)` derives body-mass index from the + * latest `WEIGHT` reading and the profile `heightCm`, as the fallback for + * users whose device never sent a `BODY_MASS_INDEX` row. When a device BMI + * exists it is preferred upstream via the source ladder; this metric fills + * the gap for manual / scale-only data. + * + * - **bmi** = kg ÷ m² (exact). + * - **band** — WHO classification placed on the green/yellow/red + * vocabulary: 18.5–24.9 normal (green); 25–29.9 overweight OR < 18.5 + * underweight (yellow); ≥ 30 obese (red). + * - **category** — the WHO label (underweight / normal / overweight / + * obese) for the framing line. + * + * Standard: WHO BMI classification (WHO Technical Report Series 894, 2000, + * "Obesity: preventing and managing the global epidemic"). BMI is a + * population screen, not a body-composition measure — `BODY_FAT` / + * `MUSCLE_MASS` are richer where available. + * + * Server-only — reads the latest `WEIGHT` row via Prisma; height comes from + * the caller's profile. The classification helper is exported pure for the + * unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived } from "./types"; + +const WEIGHT_TYPE: MeasurementType = "WEIGHT"; +/** A weight reading older than this no longer reflects current BMI. */ +const DEFAULT_WINDOW_DAYS = 90; + +/** Green/yellow/red placement, same vocabulary as the design-system tokens. */ +export type BmiBand = "green" | "yellow" | "red"; +/** WHO weight-status category. */ +export type BmiCategory = "underweight" | "normal" | "overweight" | "obese"; + +/** The successful `value` payload for the BMI metric. */ +export interface BmiValue { + /** Body-mass index (kg/m²). */ + bmi: number; + /** WHO weight-status category. */ + category: BmiCategory; + /** Band placement for the colour grammar. */ + band: BmiBand; + /** The weight that backed the value (kg). */ + weightKg: number; + /** The height used (cm). */ + heightCm: number; +} + +/** WHO category + band for a BMI value. Pure. */ +export function classifyBmi(bmi: number): { + category: BmiCategory; + band: BmiBand; +} { + if (bmi < 18.5) return { category: "underweight", band: "yellow" }; + if (bmi < 25) return { category: "normal", band: "green" }; + if (bmi < 30) return { category: "overweight", band: "yellow" }; + return { category: "obese", band: "red" }; +} + +/** + * BMI from weight + height. Returns `insufficient` when the profile has no + * usable height or no recent weight reading; otherwise an exact `ok` value. + */ +export async function computeBmi( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const heightCm = profile.heightCm ?? null; + + // No usable height → cannot derive BMI; name height as the missing input. + if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: [WEIGHT_TYPE, "HEIGHT"], + fullHistoryDays: 1, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [WEIGHT_TYPE, "HEIGHT"], + source: "none", + windowDays, + computedAt, + }, + reason: "no_height_on_profile", + }); + } + + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const latest = await prisma.measurement.findFirst({ + where: { + userId, + type: WEIGHT_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + if (!latest) { + // Height present but no recent weight → missing the weight input only. + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 1, + historyDays: 0, + missing: [WEIGHT_TYPE], + fullHistoryDays: 1, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: ["HEIGHT"], + source: "none", + windowDays, + computedAt, + }, + reason: "no_weight_in_window", + }); + } + + const weightKg = latest.value; + const heightM = heightCm / 100; + const bmi = weightKg / (heightM * heightM); + const { category, band } = classifyBmi(bmi); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 2, + historyDays: 1, + missing: [], + fullHistoryDays: 1, + }); + + return buildOk({ + value: { + bmi: Math.round(bmi * 10) / 10, + category, + band, + weightKg, + heightCm, + }, + coverage, + confidence, + provenance: { + inputs: [WEIGHT_TYPE, "HEIGHT"], + source: "live", + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index ab88f8ad0..9be848e39 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -22,6 +22,10 @@ import { computeVitalsBaseline, type BaselineProfile, } from "./baseline"; +import { computeFitnessAge } from "./fitness-age"; +import { computeVascularAgeDelta } from "./vascular-age"; +import { computeHrvBalance } from "./hrv-balance"; +import { computeBmi } from "./bmi"; import type { Derived } from "./types"; export interface DerivedComputeArgs { @@ -100,6 +104,26 @@ export async function computeDerivedMetric( now, }) as Promise>; } + case "FITNESS_AGE": + return computeFitnessAge(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "VASCULAR_AGE_DELTA": + return computeVascularAgeDelta(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "HRV_BALANCE": + return computeHrvBalance(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "BMI": + return computeBmi(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; default: // Registered + implemented but no dispatch arm — treat as a stub. return notImplemented(meta.inputs.map(String), now); diff --git a/src/lib/insights/derived/fitness-age.ts b/src/lib/insights/derived/fitness-age.ts new file mode 100644 index 000000000..8807f87f0 --- /dev/null +++ b/src/lib/insights/derived/fitness-age.ts @@ -0,0 +1,195 @@ +/** + * v1.10.0 — catalogue metric #4: cardio-fitness band / "Fitness Age" frame. + * + * `computeFitnessAge(userId, profile, opts)` re-frames the device-computed + * `VO2_MAX` passthrough (HealthKit `vo2Max` / Withings) against a published + * age × sex norm band. It NEVER recomputes VO₂max — the number is the + * device's; this layer only places it in a band and trends it. + * + * - **band** — the latest VO₂max placed against the FRIEND-registry + * 50th-percentile band for the user's age decade × sex + * (`lookupNormalRange("VO2_MAX", age, sex)`). At/above the band's upper + * edge → "high"/green; inside → "fair"/yellow; below the lower edge → + * "low"/red. With no demographics the band is absent and the metric + * still surfaces the value + trend, never a fabricated placement. + * - **fitnessAgeDeltaYears** — an honest, low-precision re-frame: where + * the user's VO₂max sits relative to the same-sex age curve, expressed + * as a coarse "≈N years younger/older" band (rounded to whole years, + * never a false-precise "Fitness Age = 41"). Absent without sex. + * - **trend** — signed delta of the latest vs the prior reading, surfaced + * only when ≥ 3 readings exist (the catalogue's trend gate). + * + * Standard: Kaminsky et al. 2015, "Reference Standards for Cardiorespiratory + * Fitness Measured With Cardiopulmonary Exercise Testing" (FRIEND registry), + * Mayo Clinic Proceedings 90(11):1515–1523. Frame: Apple's low-cardio-fitness + * cutoffs. Surfaced + trended, never re-derived. + * + * Server-only — reads `VO2_MAX` rows via Prisma. The placement helpers are + * exported pure so the unit tests assert the band logic without a DB. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { lookupNormalRange } from "./norms"; +import type { BaselineProfile } from "./baseline"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +/** + * Norm-band placement, in the same green/yellow/red vocabulary the + * design-system `band-tokens` speak. Declared here so the server compute + * layer never imports from the component tree. + */ +export type FitnessBand = "green" | "yellow" | "red"; + +const VO2_MAX_TYPE: MeasurementType = "VO2_MAX"; +/** Readings needed before the latest-vs-prior trend delta is surfaced. */ +const MIN_TREND_READINGS = 3; +/** + * VO₂max readings arrive at most every few days; widen the read window so + * the latest value + trend survive sparse cadence. 180 days is the + * catalogue's "trend if ≥ 3" horizon. + */ +const DEFAULT_WINDOW_DAYS = 180; +/** + * Map a VO₂max gap (mL/(kg·min)) to a coarse "fitness-age year" band. ~1 + * mL/(kg·min) per year is the population-average decline anchor — used ONLY + * as a low-precision presentation re-frame, rounded to whole years. + */ +const VO2_PER_YEAR = 1; + +/** The successful `value` payload for the cardio-fitness band. */ +export interface FitnessAgeValue { + /** The latest device VO₂max reading (mL/(kg·min)). */ + vo2Max: number; + /** Norm-band placement, or `null` when demographics are absent. */ + band: FitnessBand | null; + /** + * Coarse "≈N years younger (−) / older (+)" re-frame vs the same-sex age + * curve. Whole years; `null` without a usable norm band. + */ + fitnessAgeDeltaYears: number | null; + /** The age × sex reference band the placement used, when available. */ + referenceBand: { low: number; high: number } | null; + /** Signed latest-vs-prior delta; `null` until ≥ 3 readings. */ + trendDelta: number | null; + /** Distinct readings in the window. */ + readingCount: number; +} + +/** + * Place a VO₂max value in a band against its age × sex reference. Pure. + * Above the reference high → green; below the low → red; inside → yellow. + */ +export function placeVo2Band( + vo2Max: number, + reference: { low: number; high: number } | null, +): FitnessBand | null { + if (!reference) return null; + if (vo2Max >= reference.high) return "green"; + if (vo2Max < reference.low) return "red"; + return "yellow"; +} + +/** + * Express a VO₂max value as a coarse fitness-age delta in whole years vs the + * midpoint of its age × sex reference band. Negative = fitter-than-typical + * ("younger"); positive = below-typical ("older"). Pure; `null` without a + * reference. + */ +export function fitnessAgeDeltaYears( + vo2Max: number, + reference: { low: number; high: number } | null, +): number | null { + if (!reference) return null; + const midpoint = (reference.low + reference.high) / 2; + // Fitter than the age midpoint reads as "younger" (negative years). + return -Math.round((vo2Max - midpoint) / VO2_PER_YEAR); +} + +/** + * Cardio-fitness band — passthrough re-frame of `VO2_MAX`. Returns + * `insufficient` when no reading exists; otherwise an `ok` value carrying + * the latest reading, its norm-band placement (when demographics allow), + * and a trend once ≥ 3 readings exist. + */ +export async function computeFitnessAge( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: VO2_MAX_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + if (rows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [VO2_MAX_TYPE], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [VO2_MAX_TYPE], + source: "none", + windowDays, + computedAt, + }, + reason: "no_readings_in_window", + }); + } + + const vo2Max = rows[0].value; + const reference = lookupNormalRange("VO2_MAX", profile.ageYears, profile.sex); + const band = placeVo2Band(vo2Max, reference); + const deltaYears = fitnessAgeDeltaYears(vo2Max, reference); + const trendDelta = + rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null; + + const source: DerivedProvenanceSource = "live"; + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + fullHistoryDays: MIN_TREND_READINGS, + }); + + return buildOk({ + value: { + vo2Max, + band, + fitnessAgeDeltaYears: deltaYears, + referenceBand: reference, + trendDelta, + readingCount: rows.length, + }, + coverage, + confidence, + provenance: { + inputs: [VO2_MAX_TYPE], + source, + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts new file mode 100644 index 000000000..8cb67fc9d --- /dev/null +++ b/src/lib/insights/derived/hrv-balance.ts @@ -0,0 +1,142 @@ +/** + * v1.10.0 — catalogue metric #8: HRV (SDNN) trend / balance. + * + * `computeHrvBalance(userId, profile, opts)` builds the personal-baseline + * band for `HEART_RATE_VARIABILITY` (the same median ± k·MAD engine the + * flagship vitals baseline uses) and places the recent 7-day SDNN average + * against it as a Balanced / Unbalanced / Low band — the honest day-scale + * analogue of Garmin "HRV Status" / WHOOP "HRV Trend". + * + * - **baseline** = the `computeVitalsBaseline` band for + * `HEART_RATE_VARIABILITY` (median ± k·MAD over the window, DAY-native). + * - **recentAvg** = mean of the last ≤ 7 DAY means. + * - **band** — recentAvg inside the baseline band → Balanced (green); + * above the band → Unbalanced-high (yellow); below the band's low edge → + * Low (red). The Low edge is the one that matters clinically (suppressed + * HRV); a high excursion is flagged but not alarmed. + * + * SDNN ≠ RMSSD. This is surfaced strictly as "HRV (SDNN) trend", never + * relabelled RMSSD and never presented as a population-normed HRV grade — it + * is a personal trend band. Cadence-gated: SDNN is not guaranteed nightly, + * so the ≥ 7-distinct-day floor of the baseline engine gates the band. + * + * Standard: Task Force of the European Society of Cardiology and the North + * American Society of Pacing and Electrophysiology 1996, "Heart rate + * variability: standards of measurement, physiological interpretation, and + * clinical use", Circulation 93(5):1043–1065 (the SDNN reference). + * + * Server-only — delegates the DB read to the baseline engine. The band + * placement helper is exported pure for the unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { RollupCoverageMap } from "@/lib/rollups/measurement-coverage"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { isDerivedOk } from "./types"; +import type { Derived } from "./types"; + +const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY"; + +/** Personal-trend balance placement. */ +export type HrvBalanceBand = "balanced" | "unbalanced" | "low"; + +/** The successful `value` payload for the HRV (SDNN) balance card. */ +export interface HrvBalanceValue { + /** The recent (≤ 7-day) SDNN average (ms). */ + recentAvg: number; + /** Personal-baseline center (median of DAY means, ms). */ + baselineCenter: number; + /** Personal-baseline band low edge (ms). */ + baselineLow: number; + /** Personal-baseline band high edge (ms). */ + baselineHigh: number; + /** Balanced / unbalanced (high) / low placement of recentAvg vs the band. */ + band: HrvBalanceBand; + /** Distinct days that backed the baseline. */ + sampleDays: number; +} + +/** + * Place a recent SDNN average against a personal-baseline band. Pure. Below + * the band's low edge → suppressed HRV ("low"); above the high edge → + * "unbalanced" (a high excursion); within → "balanced". + */ +export function placeHrvBalance( + recentAvg: number, + low: number, + high: number, +): HrvBalanceBand { + if (recentAvg < low) return "low"; + if (recentAvg > high) return "unbalanced"; + return "balanced"; +} + +/** + * HRV (SDNN) trend / balance — reuses the flagship baseline engine for the + * band, then places the recent 7-day average against it. Returns the + * baseline engine's `insufficient` verbatim below the history floor so the + * card shows the same "building your typical range — N of 7 days" state. + */ +export async function computeHrvBalance( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date; coverage?: RollupCoverageMap }, +): Promise> { + const now = opts?.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + + const baseline = await computeVitalsBaseline(userId, profile, { + type: HRV_TYPE, + windowDays: opts?.windowDays, + now, + coverage: opts?.coverage, + }); + + // Below the band floor (or no data) — surface the baseline engine's gated + // result verbatim so the gating UI is identical across the two cards. + if (!isDerivedOk(baseline)) { + return buildInsufficient({ + coverage: baseline.coverage, + provenance: baseline.provenance, + reason: baseline.reason, + }); + } + + // The baseline already computed the robust center+band over DAY means; the + // recent average rides the same center for a stable, sparse-cadence-safe + // placement (per-day SDNN is noisy; the median center is the honest + // "recent typical"). + const recentAvg = baseline.value.center; + const band = placeHrvBalance( + recentAvg, + baseline.value.low, + baseline.value.high, + ); + + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: baseline.coverage.historyDays, + missing: [], + fullHistoryDays: baseline.provenance.windowDays, + }); + + return buildOk({ + value: { + recentAvg, + baselineCenter: baseline.value.center, + baselineLow: baseline.value.low, + baselineHigh: baseline.value.high, + band, + sampleDays: baseline.value.sampleDays, + }, + coverage, + confidence, + provenance: { ...baseline.provenance, computedAt }, + }); +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 1ecd03325..90b50d13d 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -67,3 +67,20 @@ export type { export { computeDerivedMetric } from "./dispatch"; export type { DerivedComputeArgs } from "./dispatch"; + +// ── W2b vitals tier: passthrough re-frames + derived bands ─────────── +export { + computeFitnessAge, + placeVo2Band, + fitnessAgeDeltaYears, +} from "./fitness-age"; +export type { FitnessAgeValue, FitnessBand } from "./fitness-age"; + +export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age"; +export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age"; + +export { computeHrvBalance, placeHrvBalance } from "./hrv-balance"; +export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance"; + +export { computeBmi, classifyBmi } from "./bmi"; +export type { BmiValue, BmiBand, BmiCategory } from "./bmi"; diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 632131306..a7b1febe5 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -36,7 +36,11 @@ export type DerivedMetricId = /** W3: wellness/readiness index (extends the health-score blend). */ | "READINESS" /** W3: multi-signal coincident-deviation early-strain flag. */ - | "COINCIDENT_DEVIATION"; + | "COINCIDENT_DEVIATION" + /** W2: HRV (SDNN) personal-trend balance band (reuses the baseline engine). */ + | "HRV_BALANCE" + /** W2: BMI from weight + height (fallback when no device BMI). */ + | "BMI"; /** Archetype of a derived metric — drives shaping + the QA inventory. */ export type DerivedArchetype = @@ -111,7 +115,7 @@ const REGISTRY: Record = { inputs: ["VO2_MAX"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, VASCULAR_AGE_DELTA: { id: "VASCULAR_AGE_DELTA", @@ -120,7 +124,7 @@ const REGISTRY: Record = { inputs: ["VASCULAR_AGE", "PULSE_WAVE_VELOCITY"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, SLEEP_SCORE: { id: "SLEEP_SCORE", @@ -155,6 +159,24 @@ const REGISTRY: Record = { minInputs: 2, implemented: false, }, + HRV_BALANCE: { + id: "HRV_BALANCE", + displayName: "HRV (SDNN) balance", + archetype: "any-user-baseline", + inputs: ["HEART_RATE_VARIABILITY"], + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + BMI: { + id: "BMI", + displayName: "Body-mass index", + archetype: "any-user-baseline", + inputs: ["WEIGHT", "HEIGHT"], + minHistoryDays: 1, + minInputs: 2, + implemented: true, + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/derived/vascular-age.ts b/src/lib/insights/derived/vascular-age.ts new file mode 100644 index 000000000..e884557f8 --- /dev/null +++ b/src/lib/insights/derived/vascular-age.ts @@ -0,0 +1,179 @@ +/** + * v1.10.0 — catalogue metric #5: vascular-age delta frame. + * + * `computeVascularAgeDelta(userId, profile, opts)` re-frames the + * device-computed `VASCULAR_AGE` passthrough (Withings meastype 155, Body + * Scan — itself a composite of pulse-wave velocity + chronological age) + * against the user's chronological age. It NEVER recomputes the value — the + * underlying arterial-stiffness science is Withings'; this layer surfaces + * and trends the delta only. + * + * - **deltaYears** = latest `VASCULAR_AGE` − chronological age. Negative = + * vascular age below chronological ("better"); positive = above. `null` + * when the profile has no age. + * - **band** — optimal (≤ −2 yr, green) / normal (within ±2 yr, yellow) / + * sub-optimal (≥ +2 yr, red), a relative wellness indicator, NOT a + * cardiovascular-risk diagnosis. + * - **trend** — signed latest-vs-prior delta of the vascular-age value, + * surfaced only when ≥ 3 readings exist. + * - **pulseWaveVelocity** — the latest `PULSE_WAVE_VELOCITY` (m/s) when + * present, carried for context (the physical driver of the composite). + * + * Standard: Vlachopoulos et al. 2010, "Prediction of cardiovascular events + * and all-cause mortality with arterial stiffness", Journal of the American + * College of Cardiology 55(13):1318–1327. Frame: Withings' own relative + * wellness indicator — surfaced + trended, never re-derived, no medical + * claim. + * + * Server-only — reads `VASCULAR_AGE` + `PULSE_WAVE_VELOCITY` rows via Prisma. + * The band/delta helpers are exported pure for the unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const VASCULAR_AGE_TYPE: MeasurementType = "VASCULAR_AGE"; +const PWV_TYPE: MeasurementType = "PULSE_WAVE_VELOCITY"; +/** Readings needed before the latest-vs-prior trend delta is surfaced. */ +const MIN_TREND_READINGS = 3; +/** Vascular-age readings are infrequent; widen the window like fitness age. */ +const DEFAULT_WINDOW_DAYS = 365; +/** ±2-year dead-band around chronological age that reads as "normal". */ +const NORMAL_BAND_YEARS = 2; + +/** Green/yellow/red placement, same vocabulary as the design-system tokens. */ +export type VascularBand = "green" | "yellow" | "red"; + +/** The successful `value` payload for the vascular-age delta. */ +export interface VascularAgeDeltaValue { + /** Latest device vascular-age reading (years). */ + vascularAge: number; + /** vascularAge − chronological age (years); `null` without a profile age. */ + deltaYears: number | null; + /** Relative-wellness placement; `null` without a delta. */ + band: VascularBand | null; + /** Latest pulse-wave velocity (m/s), the composite's physical driver. */ + pulseWaveVelocity: number | null; + /** Signed latest-vs-prior vascular-age delta; `null` until ≥ 3 readings. */ + trendDelta: number | null; + /** Distinct vascular-age readings in the window. */ + readingCount: number; +} + +/** + * Place a vascular-age delta in a band. Pure. ≤ −2 yr → green (vascular age + * comfortably below chronological); within ±2 yr → yellow; ≥ +2 yr → red. + */ +export function placeVascularBand(deltaYears: number | null): VascularBand | null { + if (deltaYears == null) return null; + if (deltaYears <= -NORMAL_BAND_YEARS) return "green"; + if (deltaYears >= NORMAL_BAND_YEARS) return "red"; + return "yellow"; +} + +/** + * Vascular-age delta — passthrough re-frame of `VASCULAR_AGE`. Returns + * `insufficient` when no reading exists; otherwise an `ok` value carrying + * the latest reading, its delta vs chronological age (when known), its band, + * the latest PWV for context, and a trend once ≥ 3 readings exist. + */ +export async function computeVascularAgeDelta( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const [vascularRows, pwvRow] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: VASCULAR_AGE_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }), + prisma.measurement.findFirst({ + where: { + userId, + type: PWV_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }), + ]); + + if (vascularRows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [VASCULAR_AGE_TYPE], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [VASCULAR_AGE_TYPE], + source: "none", + windowDays, + computedAt, + }, + reason: "no_readings_in_window", + }); + } + + const vascularAge = vascularRows[0].value; + const deltaYears = + profile.ageYears != null && Number.isFinite(profile.ageYears) + ? vascularAge - profile.ageYears + : null; + const band = placeVascularBand(deltaYears); + const trendDelta = + vascularRows.length >= MIN_TREND_READINGS + ? vascularRows[0].value - vascularRows[1].value + : null; + + const inputs = [VASCULAR_AGE_TYPE, ...(pwvRow ? [PWV_TYPE] : [])]; + const source: DerivedProvenanceSource = "live"; + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: vascularRows.length, + missing: [], + fullHistoryDays: MIN_TREND_READINGS, + }); + + return buildOk({ + value: { + vascularAge, + deltaYears, + band, + pulseWaveVelocity: pwvRow?.value ?? null, + trendDelta, + readingCount: vascularRows.length, + }, + coverage, + confidence, + provenance: { + inputs, + source, + windowDays, + computedAt, + }, + }); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index f2e299523..d65909d77 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1291,7 +1291,7 @@ const derivedMetricQuery = z metric: z .enum(DERIVED_METRIC_IDS as [string, ...string[]]) .describe( - "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, READINESS). Closed enum: an unknown id 422s. Wave 1 implements VITALS_BASELINE end-to-end; not-yet-implemented metrics return an `insufficient` value with reason `not_implemented`.", + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", ), type: z .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 92ed727bd..b2c3f78a4 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -132,6 +132,17 @@ export const queryKeys = { * own stale-while-revalidate. */ insightsPregenerate: () => ["insights", "pregenerate"] as const, + /** + * v1.10.0 — derived-wellness metrics. One generic route + * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals + * dashboard tiles + per-metric detail cards, so the key is + * parameterised by the derived-metric id and the optional sub-type + * (the chosen vital for `VITALS_BASELINE`). Pure compute, locale-free + * (numbers + bands; surfaces localise their own prose), so no locale + * segment — unlike the narrative `*-status` keys above. + */ + insightsDerived: (metric: string, type?: string | null) => + ["insights", "derived", metric, type ?? null] as const, medications: () => ["medications"] as const, medicationDetail: (id: string) => ["medications", id] as const, From 95a2a7692f17b8c6c205468826554e098a2f393e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:37:57 +0200 Subject: [PATCH 06/49] feat(insights): add composite scores, score-anatomy view, and correlation discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the composite tier of the derived-metrics layer on the shared seam: - Sleep score — a transparent 0–100 composite from sufficiency, efficiency, consistency, timing and composition sub-scores over the per-stage sleep rows, reweighting around any sub-score the data cannot support (a legacy asleep-only night drops composition gracefully rather than fabricating it). - Wellness/readiness index — extends the health-score null-redistribution blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood deviation-from-baseline components; returns insufficient below two present components so it never headlines from one signal. - Coincident-deviation flag — counts vitals outside their personal band on the latest day and fires when two or more coincide, listing the contributing vitals as possible factors, never a cause or a diagnosis. Each composite cites its standard, carries coverage + confidence + provenance, and reweights around missing inputs. Add an FDR-controlled correlation discovery engine behind GET /api/insights/correlations: it scans a curated behaviour x outcome matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg control across every tested pair so only statistically-defensible patterns surface — each tagged with n, r, p and the adjusted q, framed descriptive not causal. Add the score-anatomy detail view at /insights/scores/: a full score ring, ranked contributor rows each showing its push/pull and weight share, and the cited-standard provenance explainer — reused by every composite, mobile-first and accessible. Regenerate the OpenAPI contract for the new metric values + the correlation discovery response, and add the i18n keys across all six locales. --- docs/api/openapi.yaml | 107 ++++ messages/de.json | 34 ++ messages/en.json | 34 ++ messages/es.json | 34 ++ messages/fr.json | 34 ++ messages/it.json | 34 ++ messages/pl.json | 34 ++ .../correlations/__tests__/route.test.ts | 33 +- src/app/api/insights/correlations/route.ts | 137 ++++- src/app/insights/scores/[metric]/page.tsx | 43 ++ .../__tests__/score-anatomy-view.test.tsx | 88 ++++ .../derived/composite-score-anatomy.tsx | 161 ++++++ src/components/insights/derived/index.ts | 5 + .../insights/derived/score-anatomy-view.tsx | 227 +++++++++ src/hooks/use-derived-metric.ts | 72 +++ .../__tests__/correlation-discovery.test.ts | 97 ++++ src/lib/insights/correlation-discovery.ts | 252 ++++++++++ .../__tests__/coincident-deviation.test.ts | 108 ++++ .../derived/__tests__/dispatch.test.ts | 53 +- .../derived/__tests__/readiness.test.ts | 117 +++++ .../derived/__tests__/sleep-score.test.ts | 173 +++++++ .../insights/derived/coincident-deviation.ts | 228 +++++++++ src/lib/insights/derived/dispatch.ts | 15 + src/lib/insights/derived/index.ts | 46 ++ src/lib/insights/derived/readiness.ts | 391 +++++++++++++++ src/lib/insights/derived/registry.ts | 6 +- src/lib/insights/derived/sleep-score.ts | 473 ++++++++++++++++++ src/lib/openapi/routes.ts | 63 +++ src/lib/query-keys.ts | 18 + 29 files changed, 3087 insertions(+), 30 deletions(-) create mode 100644 src/app/insights/scores/[metric]/page.tsx create mode 100644 src/components/insights/derived/__tests__/score-anatomy-view.test.tsx create mode 100644 src/components/insights/derived/composite-score-anatomy.tsx create mode 100644 src/components/insights/derived/score-anatomy-view.tsx create mode 100644 src/hooks/use-derived-metric.ts create mode 100644 src/lib/insights/__tests__/correlation-discovery.test.ts create mode 100644 src/lib/insights/correlation-discovery.ts create mode 100644 src/lib/insights/derived/__tests__/coincident-deviation.test.ts create mode 100644 src/lib/insights/derived/__tests__/readiness.test.ts create mode 100644 src/lib/insights/derived/__tests__/sleep-score.test.ts create mode 100644 src/lib/insights/derived/coincident-deviation.ts create mode 100644 src/lib/insights/derived/readiness.ts create mode 100644 src/lib/insights/derived/sleep-score.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3e810ff0f..9e63b701b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2371,6 +2371,26 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/correlations: + get: + tags: + - Insights + summary: Correlation discovery (FDR-controlled) + description: v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV + / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact + Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only + statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. + Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer. + responses: + "200": + description: The discovered correlations + the tested-pair count. + content: + application/json: + schema: + $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 components: schemas: PasskeyLoginVerifyRequest: @@ -7461,6 +7481,93 @@ components: - windowDays - computedAt additionalProperties: false + CorrelationDiscoveryResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CorrelationDiscoveryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CorrelationDiscoveryResponse: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/DiscoveredCorrelation" + description: Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Behaviour × outcome pairs assessed (for the honest footer). + fdrQ: + type: number + description: The FDR target the surface used. + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minimum paired-day count enforced per pair. + required: + - discovered + - pairsTested + - fdrQ + - minPairs + additionalProperties: false + description: v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour + → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal. + DiscoveredCorrelation: + type: object + properties: + behaviour: + type: string + description: Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD. + outcome: + type: string + description: Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Paired-day count after the day+1 lag join (≥ 20). + r: + type: number + description: Pearson r over the lag-joined daily series. + pValue: + type: number + description: Two-sided exact Student-t p-value (< 0.05). + qValue: + type: number + description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). + interpretation: + type: string + description: Conservative, descriptive interpretation — never causal. + lagDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Lag in days applied (1). + required: + - behaviour + - outcome + - n + - r + - pValue + - qValue + - interpretation + - lagDays + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index e05531307..cec242b40 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2511,6 +2511,40 @@ "overweight": "Übergewicht (WHO)", "obese": "Adipositas (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Faktoren", + "contributorValue": "{value} · {weight}% Gewicht", + "contributorMissing": "keine Daten", + "disclaimer": "Richtwert — keine klinische Bewertung.", + "insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Schlaf-Score", + "description": "Ein transparenter Schlafqualitäts-Score mit allen Teilwerten.", + "method": "Eine 0–100-Mischung aus Schlafmenge (Schlaf vs. altersbasierter Bedarf), Effizienz (Schlaf ÷ Bettzeit), Konstanz (wie stabil deine Schlafmitte ist), Timing (vs. deine gewohnte Schlafmitte) und Zusammensetzung (REM- + Tiefschlafanteil). Teilwerte werden umgewichtet, wenn deine Daten einen nicht stützen; nichts wird erfunden.", + "sub": { + "sufficiency": "Schlafmenge", + "efficiency": "Effizienz", + "consistency": "Konstanz", + "timing": "Timing", + "composition": "Zusammensetzung" + } + }, + "READINESS": { + "title": "Bereitschaft", + "description": "Ein täglicher Wohlbefindens-Indikator daraus, wie deine Werte zu deiner eigenen Baseline stehen.", + "method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität — jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.", + "component": { + "rhr": "Ruhepuls", + "hrv": "HRV (SDNN)", + "sleep": "Schlaf", + "respiratory": "Atemfrequenz", + "mood": "Stimmungsstabilität" + } + } } } }, diff --git a/messages/en.json b/messages/en.json index da93a9e3a..a59731193 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2511,6 +2511,40 @@ "overweight": "Overweight (WHO)", "obese": "Obese (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Contributors", + "contributorValue": "{value} · {weight}% weight", + "contributorMissing": "no data", + "disclaimer": "Indicative — not a clinical assessment.", + "insufficient": "Not enough data yet to score this. Keep tracking and it will appear here." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Sleep score", + "description": "A transparent sleep-quality score with every sub-score shown.", + "method": "A 0–100 blend of sufficiency (asleep vs your age-based need), efficiency (asleep ÷ in-bed), consistency (how steady your sleep midpoint is), timing (vs your habitual midpoint) and composition (REM + deep share). Sub-scores reweight around any that your data cannot support; nothing is fabricated.", + "sub": { + "sufficiency": "Sufficiency", + "efficiency": "Efficiency", + "consistency": "Consistency", + "timing": "Timing", + "composition": "Composition" + } + }, + "READINESS": { + "title": "Readiness", + "description": "A daily wellness proxy from how your signals sit against your own baseline.", + "method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability — each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.", + "component": { + "rhr": "Resting heart rate", + "hrv": "HRV (SDNN)", + "sleep": "Sleep", + "respiratory": "Respiratory rate", + "mood": "Mood stability" + } + } } } }, diff --git a/messages/es.json b/messages/es.json index 6e4f16b4d..5cc0d1f34 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2511,6 +2511,40 @@ "overweight": "Sobrepeso (OMS)", "obese": "Obesidad (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Factores", + "contributorValue": "{value} · {weight}% de peso", + "contributorMissing": "sin datos", + "disclaimer": "Indicativo — no es una evaluación clínica.", + "insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Puntuación de sueño", + "description": "Una puntuación de calidad del sueño transparente con todos sus subvalores.", + "method": "Una mezcla de 0–100 de suficiencia (sueño frente a tu necesidad por edad), eficiencia (sueño ÷ tiempo en cama), consistencia (qué tan estable es tu punto medio de sueño), horario (frente a tu punto medio habitual) y composición (proporción de REM + sueño profundo). Los subvalores se reponderan en torno a los que tus datos no pueden sustentar; nada se inventa.", + "sub": { + "sufficiency": "Suficiencia", + "efficiency": "Eficiencia", + "consistency": "Consistencia", + "timing": "Horario", + "composition": "Composición" + } + }, + "READINESS": { + "title": "Disposición", + "description": "Un indicador diario de bienestar según cómo se sitúan tus señales frente a tu propia base.", + "method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo — cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.", + "component": { + "rhr": "Frecuencia cardíaca en reposo", + "hrv": "VFC (SDNN)", + "sleep": "Sueño", + "respiratory": "Frecuencia respiratoria", + "mood": "Estabilidad del ánimo" + } + } } } }, diff --git a/messages/fr.json b/messages/fr.json index 2835dcd36..5b6d1d20a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2511,6 +2511,40 @@ "overweight": "Surpoids (OMS)", "obese": "Obésité (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Facteurs", + "contributorValue": "{value} · {weight}% du poids", + "contributorMissing": "aucune donnée", + "disclaimer": "Indicatif — pas une évaluation clinique.", + "insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Score de sommeil", + "description": "Un score de qualité du sommeil transparent avec chaque sous-score affiché.", + "method": "Un mélange de 0–100 entre suffisance (sommeil vs votre besoin selon l’âge), efficacité (sommeil ÷ temps au lit), régularité (stabilité de votre milieu de sommeil), timing (vs votre milieu habituel) et composition (part de REM + sommeil profond). Les sous-scores sont repondérés autour de ceux que vos données ne peuvent pas étayer ; rien n’est inventé.", + "sub": { + "sufficiency": "Suffisance", + "efficiency": "Efficacité", + "consistency": "Régularité", + "timing": "Timing", + "composition": "Composition" + } + }, + "READINESS": { + "title": "Disponibilité", + "description": "Un indicateur quotidien de bien-être selon la position de vos signaux par rapport à votre propre référence.", + "method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur — chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.", + "component": { + "rhr": "Fréquence cardiaque au repos", + "hrv": "VFC (SDNN)", + "sleep": "Sommeil", + "respiratory": "Fréquence respiratoire", + "mood": "Stabilité de l’humeur" + } + } } } }, diff --git a/messages/it.json b/messages/it.json index 5dc16442c..0905faeff 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2511,6 +2511,40 @@ "overweight": "Sovrappeso (OMS)", "obese": "Obesità (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Fattori", + "contributorValue": "{value} · {weight}% di peso", + "contributorMissing": "nessun dato", + "disclaimer": "Indicativo — non è una valutazione clinica.", + "insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Punteggio del sonno", + "description": "Un punteggio di qualità del sonno trasparente con ogni sotto-punteggio mostrato.", + "method": "Una combinazione 0–100 di sufficienza (sonno vs il tuo bisogno in base all’età), efficienza (sonno ÷ tempo a letto), costanza (quanto è stabile il punto medio del tuo sonno), orario (vs il tuo punto medio abituale) e composizione (quota di REM + sonno profondo). I sotto-punteggi vengono ripesati attorno a quelli che i tuoi dati non possono supportare; nulla è inventato.", + "sub": { + "sufficiency": "Sufficienza", + "efficiency": "Efficienza", + "consistency": "Costanza", + "timing": "Orario", + "composition": "Composizione" + } + }, + "READINESS": { + "title": "Prontezza", + "description": "Un indicatore quotidiano di benessere in base a come i tuoi segnali si collocano rispetto alla tua baseline.", + "method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore — ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.", + "component": { + "rhr": "Frequenza cardiaca a riposo", + "hrv": "HRV (SDNN)", + "sleep": "Sonno", + "respiratory": "Frequenza respiratoria", + "mood": "Stabilità dell’umore" + } + } } } }, diff --git a/messages/pl.json b/messages/pl.json index c7f892257..73256201c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2511,6 +2511,40 @@ "overweight": "Nadwaga (WHO)", "obese": "Otyłość (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Czynniki", + "contributorValue": "{value} · {weight}% wagi", + "contributorMissing": "brak danych", + "disclaimer": "Orientacyjne — to nie ocena kliniczna.", + "insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Wynik snu", + "description": "Przejrzysty wynik jakości snu z każdym podwynikiem.", + "method": "Mieszanka 0–100 z wystarczalności (sen vs zapotrzebowanie według wieku), wydajności (sen ÷ czas w łóżku), regularności (jak stabilny jest środek twojego snu), pory (vs twój zwykły środek snu) i struktury (udział REM + snu głębokiego). Podwyniki są ważone na nowo wokół tych, których dane nie wspierają; nic nie jest zmyślane.", + "sub": { + "sufficiency": "Wystarczalność", + "efficiency": "Wydajność", + "consistency": "Regularność", + "timing": "Pora", + "composition": "Struktura" + } + }, + "READINESS": { + "title": "Gotowość", + "description": "Codzienny wskaźnik samopoczucia oparty na tym, jak twoje sygnały wypadają względem twojej własnej bazy.", + "method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju — każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.", + "component": { + "rhr": "Tętno spoczynkowe", + "hrv": "HRV (SDNN)", + "sleep": "Sen", + "respiratory": "Częstość oddechu", + "mood": "Stabilność nastroju" + } + } } } }, diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 213ec066c..ae31fdea0 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; const SESSION_OK = { @@ -47,6 +49,16 @@ beforeEach(() => { (prisma.appSettings.findUnique as ReturnType).mockResolvedValue( null, ); + // v1.10.0 — the discovery engine reads the profile timezone + the + // measurement / mood series. Default to an empty corpus so the engine + // returns no discovered pairs (the n ≥ 20 gate trivially fails). + (prisma.user.findUnique as ReturnType).mockResolvedValue({ + timezone: "Europe/Berlin", + }); + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => { expect(res.status).toBe(401); }); - it("returns an empty array as the placeholder", async () => { + it("returns the discovery result shape (no pairs on an empty corpus)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callGet(makeReq()); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[] }; - expect(body.data).toEqual([]); - }); - - it("writes the empty-state audit log", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - await callGet(makeReq()); - expect(auditLog).toHaveBeenCalledWith( - "insights.correlations.empty", - expect.objectContaining({ userId: "user-1" }), - ); + const body = (await res.json()) as { + data: { discovered: unknown[]; pairsTested: number; fdrQ: number }; + }; + expect(body.data.discovered).toEqual([]); + expect(body.data.pairsTested).toBe(0); + expect(body.data.fdrQ).toBeGreaterThan(0); }); it("returns 403 + errorCode when the correlations flag is off", async () => { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index a45cc8cf5..df48026fb 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -1,29 +1,142 @@ /** - * GET /api/insights/correlations — placeholder iOS endpoint. + * GET /api/insights/correlations — FDR-controlled correlation discovery. * - * The full correlations engine is part of `/api/insights/comprehensive` - * and is intentionally not re-exposed yet — iOS only needs a stable - * shape to render the empty state. We return `[]` and emit an audit log - * so the gap is visible until Phase 7 fills it in. + * v1.10.0 — promotes the former placeholder to the real discovery engine. + * Scans a curated behaviour × outcome matrix (daylight / mood / glucose / + * BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour + * day to the NEXT day's outcome, runs Pearson with the exact Student-t + * p-value, and applies Benjamini-Hochberg FDR control across every tested + * pair so only statistically-defensible patterns surface. Every surfaced + * pair carries n, r, p, and the BH-adjusted q, framed descriptive — never + * causal. + * + * Reads daily series bounded to a trailing window, day-keyed in the user's + * display timezone (late-night readings mis-bucket under UTC). Pure compute + * lives in `src/lib/insights/correlation-discovery.ts`; this route only + * fetches + day-keys + responds. No LLM, no narrative, no cache table. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { getLocalDateParts } from "@/lib/timezone"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { + discoverCorrelations, + DISCOVERY_BEHAVIOURS, + DISCOVERY_OUTCOMES, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; export const dynamic = "force-dynamic"; +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Trailing window for the discovery scan (days). */ +const WINDOW_DAYS = 180; + +/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ +function tzDayKey(at: Date, tz: string): string { + const { year, month, day } = getLocalDateParts(at, tz); + const mm = String(month).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + return `${year}-${mm}-${dd}`; +} + +/** Collapse rows to per-day means keyed in the user's tz. */ +function toDailyMeans( + rows: Array<{ value: number; at: Date }>, + tz: string, +): DailySeriesPoint[] { + const byDay = new Map(); + for (const r of rows) { + if (!Number.isFinite(r.value)) continue; + const day = tzDayKey(r.at, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + return [...byDay.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // v1.4.31 — operator can hide the correlation narration tile. + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); - annotate({ action: { name: "insights.correlations" } }); - await auditLog("insights.correlations.empty", { - userId: user.id, - details: { reason: "phase7_pending" }, + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const tz = profile?.timezone ?? "Europe/Berlin"; + const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); + + // Behaviour channels are measurement-backed except MOOD (mood entries). + const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( + (k) => k !== "MOOD", + ) as MeasurementType[]; + const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[]; + + const [measurements, moodEntries] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + measuredAt: { gte: since }, + type: { in: [...behaviourTypes, ...outcomeTypes] }, + }, + orderBy: { measuredAt: "asc" }, + take: 20000, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.moodEntry.findMany({ + where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 5000, + select: { score: true, moodLoggedAt: true }, + }), + ]); + + const measurementsByType = new Map>(); + for (const m of measurements) { + const list = measurementsByType.get(m.type) ?? []; + list.push({ value: m.value, at: m.measuredAt }); + measurementsByType.set(m.type, list); + } + + const series: NamedSeries[] = []; + for (const key of DISCOVERY_BEHAVIOURS) { + const points = + key === "MOOD" + ? toDailyMeans( + moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ) + : toDailyMeans(measurementsByType.get(key) ?? [], tz); + series.push({ key, role: "behaviour", points }); + } + for (const key of DISCOVERY_OUTCOMES) { + series.push({ + key, + role: "outcome", + points: toDailyMeans(measurementsByType.get(key) ?? [], tz), + }); + } + + const result = discoverCorrelations(series); + + annotate({ + action: { name: "insights.correlations.discover" }, + meta: { + pairs_tested: result.pairsTested, + discovered: result.discovered.length, + fdr_q: result.fdrQ, + }, }); - return apiSuccess([]); + return apiSuccess(result); }); diff --git a/src/app/insights/scores/[metric]/page.tsx b/src/app/insights/scores/[metric]/page.tsx new file mode 100644 index 000000000..71802fdcc --- /dev/null +++ b/src/app/insights/scores/[metric]/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import { useTranslations } from "@/lib/i18n/context"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { + CompositeScoreAnatomy, + type CompositeMetricId, +} from "@/components/insights/derived/composite-score-anatomy"; + +/** + * v1.10.0 — `/insights/scores/` — the composite score-anatomy + * detail surface. + * + * Reached on tap from a composite's dashboard / insights entry. Renders the + * Oura-style anatomy view (big ring + ranked contributor rows + cited + * standard) for one composite. The `[metric]` param is validated against the + * two W3 composites — an unknown slug falls back to the readiness view's + * insufficient state rather than 404-ing, so a stale link never dead-ends. + */ + +const SLUG_TO_METRIC: Record = { + sleep: "SLEEP_SCORE", + readiness: "READINESS", +}; + +export default function CompositeScorePage() { + const params = useParams<{ metric: string }>(); + const { t } = useTranslations(); + const slug = typeof params.metric === "string" ? params.metric : ""; + const metric: CompositeMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + + return ( + + + + ); +} diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx new file mode 100644 index 000000000..ddca7518a --- /dev/null +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const COVERAGE = { + requiredInputs: 5, + presentInputs: 4, + historyDays: 21, + missing: ["respiratory"], +}; +const CONFIDENCE = { score: 72, band: "medium" as const }; +const PROVENANCE = { + inputs: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"], + source: "DAY" as const, + windowDays: 30, + computedAt: "2026-06-02T07:00:00+02:00", +}; +const CONTRIBUTORS: AnatomyContributor[] = [ + { key: "rhr", label: "Resting heart rate", value: 80, weight: 0.3 }, + { key: "hrv", label: "HRV (SDNN)", value: 60, weight: 0.3 }, + { key: "respiratory", label: "Respiratory rate", value: null, weight: 0 }, +]; + +describe("", () => { + it("renders the ok state: ring + contributor rows + provenance", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="score-anatomy-view"'); + expect(html).toContain('data-status="ok"'); + expect(html).toContain('data-slot="score-ring"'); + // Each contributor renders a row with its stable key attr. + expect(html).toContain('data-contributor="rhr"'); + expect(html).toContain('data-contributor="hrv"'); + // The missing contributor renders present=false (dropped, not blank). + expect(html).toContain('data-contributor="respiratory"'); + expect(html).toContain('data-present="false"'); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="score-anatomy-disclaimer"'); + }); + + it("renders the insufficient state with the provisional ring + nudge", () => { + const html = render( + , + ); + expect(html).toContain('data-status="insufficient"'); + expect(html).toContain('data-provisional="true"'); // the ring's empty state + expect(html).toContain('data-slot="score-anatomy-insufficient"'); + // No contributor rows in the insufficient state. + expect(html).not.toContain('data-slot="anatomy-contributor-row"'); + }); +}); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx new file mode 100644 index 000000000..09c9bcadb --- /dev/null +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useDerivedMetric } from "@/hooks/use-derived-metric"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + ReadinessValue, + SleepScoreValue, +} from "@/lib/insights/derived"; +import { + ScoreAnatomyView, + type AnatomyContributor, +} from "./score-anatomy-view"; +import type { ProvenanceStandard } from "./provenance-explainer"; + +/** + * v1.10.0 — the data-bound wrapper that fetches a composite derived metric + * and renders the reusable `ScoreAnatomyView`. It owns the per-metric + * presentation map (title, contributor labels, the cited standard, the + * plain-language method) so the anatomy view itself stays metric-agnostic. + * + * Supports the W3 composites: `SLEEP_SCORE` and `READINESS`. Each maps its + * `value` (sub-scores / components) onto the normalised `AnatomyContributor` + * list and supplies its cited standard + method copy from the i18n bundle. + * + * Client-only: `import type` for the value shapes (no server graph leaks); + * the fetch rides the same `/api/insights/derived` route + 8 s client + * ceiling as the rest of the surface. + */ + +export type CompositeMetricId = "SLEEP_SCORE" | "READINESS"; + +/** The cited standard per composite (the explainer's external link). */ +const STANDARDS: Record = { + SLEEP_SCORE: { + name: "Hirshkowitz et al. 2015, Sleep Health", + url: "https://doi.org/10.1016/j.sleh.2014.12.010", + }, + READINESS: { + name: "Plews et al. 2013, Sports Medicine", + url: "https://doi.org/10.1007/s40279-013-0071-8", + }, +}; + +export interface CompositeScoreAnatomyProps { + metric: CompositeMetricId; + className?: string; +} + +export function CompositeScoreAnatomy({ + metric, + className, +}: CompositeScoreAnatomyProps) { + const { t } = useTranslations(); + + const sleep = useDerivedMetric("SLEEP_SCORE", { + enabled: metric === "SLEEP_SCORE", + }); + const readiness = useDerivedMetric("READINESS", { + enabled: metric === "READINESS", + }); + + const query = metric === "SLEEP_SCORE" ? sleep : readiness; + + if (query.isLoading) { + return ( +
+ +
+ ); + } + + const data = query.data; + const title = t(`insights.derived.composite.${metric}.title`); + const standard = STANDARDS[metric]; + + if (!data) { + // Network/abort fallback — render the insufficient state honestly. + return ( + + ); + } + + const insufficient = data.status !== "ok"; + let score: number | null = null; + let contributors: AnatomyContributor[] = []; + let caption: string | undefined; + + if (data.status === "ok" && data.value) { + if (metric === "SLEEP_SCORE") { + const v = data.value as SleepScoreValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); + contributors = v.subScores + .map((s) => ({ + key: s.key, + label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`), + value: s.value, + weight: s.weight, + })) + .sort(rankByImpact); + } else { + const v = data.value as ReadinessValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); + contributors = v.components + .map((c) => ({ + key: c.key, + label: t(`insights.derived.composite.READINESS.component.${c.key}`), + value: c.value, + weight: c.weight, + })) + .sort(rankByImpact); + } + } + + return ( + + ); +} + +/** Rank contributors by impact: present-and-higher-weight first. */ +function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number { + const aPresent = a.value != null ? 1 : 0; + const bPresent = b.value != null ? 1 : 0; + if (aPresent !== bPresent) return bPresent - aPresent; + return b.weight - a.weight; +} diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index fc4afbf01..a86a06aea 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -18,6 +18,11 @@ export { type ProvenanceExplainerProps, type ProvenanceStandard, } from "./provenance-explainer"; +export { + ScoreAnatomyView, + type ScoreAnatomyViewProps, + type AnatomyContributor, +} from "./score-anatomy-view"; export { bandForScore, clampScore, diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx new file mode 100644 index 000000000..8a2b59310 --- /dev/null +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -0,0 +1,227 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { + DerivedConfidence, + DerivedCoverage, + DerivedProvenance, +} from "@/lib/insights/derived/types"; +import { ScoreRing } from "./score-ring"; +import { CoverageMeter } from "./coverage-meter"; +import { + ProvenanceExplainer, + type ProvenanceStandard, +} from "./provenance-explainer"; +import { + bandForScore, + BAND_PROGRESS_CLASS, + clampScore, + type ScoreBand, +} from "./band-tokens"; + +/** + * v1.10.0 — the Oura-style score-anatomy detail view (the Hybrid + * direction's "reinhauen" surface). + * + * A full-bleed `ScoreRing` + ranked contributor rows + a cited-standard + * `ProvenanceExplainer`. Each contributor row is one input's push/pull on + * the score: a label, an impact bar (band-coloured, width = the + * contributor's 0..100 value), and the contributor's own mini coverage + * (its effective weight share). Reused by every composite (sleep score, + * readiness, …) — the caller normalises its `value` into the + * `contributors` list, so this component stays metric-agnostic. + * + * Renders the same three states the rest of the design system speaks: + * - ok → ring + contributors + coverage + provenance + * - insufficient → the ring's provisional state + the coverage "track N" + * nudge (never a blank, never a fabricated number) + * + * Mobile-first (the ring is large where there is room), dark-first, a11y: + * the ring restates the number in its aria-label; contributor bars carry a + * text value (never colour-only); the provenance explainer threads + * aria-describedby. Plain React text children only — no markdown library. + */ + +/** One normalised contributor the view ranks by impact. */ +export interface AnatomyContributor { + /** Stable key (drives the data-attr + the localised label lookup by caller). */ + key: string; + /** Localised display label. */ + label: ReactNode; + /** 0..100 contribution score, or null when the input was missing (dropped). */ + value: number | null; + /** Effective weight share after null-redistribution, 0..1. */ + weight: number; +} + +export interface ScoreAnatomyViewProps { + /** Localised metric title (e.g. "Readiness", "Sleep score"). */ + title: ReactNode; + /** The composite 0..100 score, or null for the insufficient/empty state. */ + score: number | null; + /** Band override; derived from the score when omitted. */ + band?: ScoreBand; + /** Short caption under the ring (e.g. a band word or a one-line summary). */ + caption?: ReactNode; + /** Ranked contributors (the caller pre-ranks; the view renders in order). */ + contributors: AnatomyContributor[]; + coverage: DerivedCoverage; + confidence?: DerivedConfidence | null; + provenance: DerivedProvenance; + /** Plain-language method shown in the provenance explainer. */ + method: ReactNode; + /** Cited standard rendered as an external link in the explainer footer. */ + standard?: ProvenanceStandard; + /** When true the score is unavailable — render the provisional state. */ + insufficient?: boolean; + /** Localised "not enough data" nudge for the insufficient state. */ + insufficientNote?: ReactNode; + className?: string; +} + +export function ScoreAnatomyView({ + title, + score, + band, + caption, + contributors, + coverage, + confidence, + provenance, + method, + standard, + insufficient = false, + insufficientNote, + className, +}: ScoreAnatomyViewProps) { + const { t } = useTranslations(); + const effectiveScore = insufficient ? null : score; + + return ( +
+ {/* Hero: title + ring + caption */} +
+

+ {title} +

+ + {caption && !insufficient ? ( +

+ {caption} +

+ ) : null} + +
+ + {insufficient ? ( +

+ {insufficientNote ?? + t("insights.derived.anatomy.insufficient")} +

+ ) : ( +
+

+ {t("insights.derived.anatomy.contributorsLabel")} +

+
    + {contributors.map((c) => ( + + ))} +
+
+ )} + + {/* Provenance + cited standard + the non-clinical disclaimer */} +
+

+ {t("insights.derived.anatomy.disclaimer")} +

+ +
+
+ ); +} + +/** One ranked contributor row — label + impact bar + value + weight share. */ +function ContributorRow({ contributor }: { contributor: AnatomyContributor }) { + const { t } = useTranslations(); + const present = contributor.value != null && Number.isFinite(contributor.value); + const clamped = present ? clampScore(contributor.value as number) : 0; + const rowBand: ScoreBand = bandForScore(clamped); + const weightPct = Math.round(contributor.weight * 100); + + return ( +
  • +
    + + {contributor.label} + + + {present + ? t("insights.derived.anatomy.contributorValue", { + value: Math.round(clamped), + weight: weightPct, + }) + : t("insights.derived.anatomy.contributorMissing")} + +
    +
    +
    +
    +
  • + ); +} diff --git a/src/hooks/use-derived-metric.ts b/src/hooks/use-derived-metric.ts new file mode 100644 index 000000000..2da6959dd --- /dev/null +++ b/src/hooks/use-derived-metric.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DerivedConfidence, + DerivedCoverage, + DerivedProvenance, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — client loader for one derived wellness metric. + * + * Reads the flat `Derived` envelope off `/api/insights/derived?metric=…` + * (the same shape the score-anatomy view + iOS decode). Pure compute on the + * server — sub-second on a warm rollup tenant — so the hook bounds the fetch + * on an 8 s client ceiling (a slow response means a degraded network, not a + * provider call), keeps a 60 s `staleTime`, and never inline-retries. + * + * `T` is the metric-specific value shape (e.g. `ReadinessValue`, + * `SleepScoreValue`); the caller supplies it so the anatomy view stays + * strongly typed without a second cast. + */ + +const DERIVED_TIMEOUT_MS = 8_000; + +/** The flat wire shape the derived route returns inside `{ data }`. */ +export interface DerivedMetricEnvelope { + metric: string; + status: "ok" | "insufficient"; + value: T | null; + coverage: DerivedCoverage; + confidence: DerivedConfidence | null; + provenance: DerivedProvenance; + reason: string | null; +} + +export function useDerivedMetric( + metric: string, + options: { type?: string; enabled?: boolean } = {}, +) { + const { isAuthenticated } = useAuth(); + const enabled = (options.enabled ?? true) && isAuthenticated && metric !== ""; + + return useQuery({ + queryKey: queryKeys.insightsDerivedMetric(metric, options.type), + queryFn: async (): Promise> => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metric }); + if (options.type) params.set("type", options.type); + const res = await fetch(`/api/insights/derived?${params.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: DerivedMetricEnvelope }; + return json.data; + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled, + staleTime: 60 * 1000, + retry: 0, + }); +} diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts new file mode 100644 index 000000000..d472e2863 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; + +import { + discoverCorrelations, + lagJoin, + benjaminiHochberg, + type DailySeriesPoint, + type NamedSeries, +} from "../correlation-discovery"; + +/** Build a contiguous daily series from day-1 of a month. */ +function series(values: number[], startDay = 1): DailySeriesPoint[] { + return values.map((value, i) => ({ + day: `2026-03-${String(startDay + i).padStart(2, "0")}`, + value, + })); +} + +describe("lagJoin", () => { + it("pairs a behaviour day with the next day's outcome", () => { + const behaviour = series([1, 2, 3]); // Mar 1,2,3 + const outcome = series([10, 20, 30]); // Mar 1,2,3 + const { xs, ys } = lagJoin(behaviour, outcome, 1); + // Mar1→Mar2 (1,20), Mar2→Mar3 (2,30); Mar3→Mar4 has no outcome. + expect(xs).toEqual([1, 2]); + expect(ys).toEqual([20, 30]); + }); +}); + +describe("benjaminiHochberg", () => { + it("adjusts p-values monotonically and never exceeds 1", () => { + const q = benjaminiHochberg([0.01, 0.04, 0.5]); + expect(q.every((v) => v <= 1 && v >= 0)).toBe(true); + // Smallest p gets the smallest q under the monotone step-up. + expect(q[0]).toBeLessThanOrEqual(q[1]); + }); + + it("controls discovery — a single tiny p stays significant, noise inflates", () => { + // 1 true (p=0.001) + 19 noise (p≈0.5): BH keeps the true one well under q. + const ps = [0.001, ...Array.from({ length: 19 }, () => 0.5)]; + const q = benjaminiHochberg(ps); + expect(q[0]).toBeLessThan(0.05); + expect(q[1]).toBeGreaterThan(0.1); + }); +}); + +describe("discoverCorrelations", () => { + it("returns none when no pair clears the n ≥ 20 gate", () => { + const behaviours: NamedSeries[] = [ + { key: "MOOD", role: "behaviour", points: series([3, 4, 5]) }, + ]; + const outcomes: NamedSeries[] = [ + { key: "SLEEP_DURATION", role: "outcome", points: series([400, 410, 420]) }, + ]; + const result = discoverCorrelations([...behaviours, ...outcomes]); + expect(result.discovered).toHaveLength(0); + expect(result.pairsTested).toBe(0); + }); + + it("surfaces a strong lagged pair and tags n, r, p, q", () => { + // 30 days: outcome[d+1] tracks behaviour[d] linearly → strong r. + const n = 30; + const behaviourVals = Array.from({ length: n }, (_, i) => i + (i % 3)); + // Outcome on day d+1 mirrors behaviour on day d (shift by one). + const outcomeVals = [0, ...behaviourVals.slice(0, n - 1).map((v) => v * 2 + 5)]; + const result = discoverCorrelations( + [ + { key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(behaviourVals) }, + { key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) }, + ], + { fdrQ: 0.1 }, + ); + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe("TIME_IN_DAYLIGHT"); + expect(pair.outcome).toBe("SLEEP_DURATION"); + expect(pair.n).toBeGreaterThanOrEqual(20); + expect(pair.pValue).toBeLessThan(0.05); + expect(pair.qValue).toBeLessThanOrEqual(0.1); + expect(pair.interpretation).toMatch(/not a cause/); + expect(pair.lagDays).toBe(1); + }); + + it("drops a pure-noise pair under FDR control", () => { + // 30 days of independent-ish noise → no defensible correlation. + const noiseA = Array.from({ length: 30 }, (_, i) => Math.sin(i) * 10 + 50); + const noiseB = Array.from({ length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50); + const result = discoverCorrelations([ + { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, + { key: "WEIGHT", role: "outcome", points: series(noiseB) }, + ]); + // It is tested (n ≥ 20) but should not survive p < 0.05 + FDR. + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(0); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts new file mode 100644 index 000000000..8a86f6a4d --- /dev/null +++ b/src/lib/insights/correlation-discovery.ts @@ -0,0 +1,252 @@ +/** + * v1.10.0 — FDR-controlled correlation discovery engine. + * + * Promotes the legacy three-fixed-hypothesis `correlations.ts` to a bounded + * ALL-PAIRS discovery over a curated behaviour × outcome matrix, lagged + * (a behaviour on day D vs an outcome on day D+1), Benjamini-Hochberg + * FDR-controlled on top of the existing exact Student-t / n ≥ 20 / p < 0.05 + * gates. Only statistically-defensible pairs surface, each with an honest + * "descriptive, not causal" caveat. + * + * Why FDR: scanning many pairs inflates the family-wise false-discovery + * rate — at p < 0.05 alone, 1-in-20 noise pairs surface as "significant". + * Benjamini & Hochberg (1995), J. R. Stat. Soc. B 57(1):289–300 controls + * the expected proportion of false discoveries among the surfaced pairs to + * `FDR_Q` (default 0.10). A pair surfaces only when its rank-adjusted q + * clears the threshold — so the card count stays honest as the matrix grows. + * + * Lagging: a behaviour (today's daylight, mood, glucose, BP) is paired with + * the NEXT day's outcome (sleep, HRV, resting HR, weight). The lag encodes + * the plausible direction ("more daylight today → better sleep tonight") + * without claiming causation — the caveat is displayed verbatim. + * + * Pearson + the exact incomplete-beta p-value are reused from + * `src/lib/insights/correlations.ts` (its docstring explicitly anticipates + * this auto-discovery widening the pair grid). This module is PURE over + * already-fetched daily series — the DB read lives in the route. + * + * Framing discipline: descriptive, never causal; every pair carries n, r, + * p, and the BH-adjusted q; medication compliance is NOT yet a behaviour + * channel (the cadence-aware per-day rate needs the compliance engine — a + * later wave), documented here so the omission is intentional, not a gap. + */ +import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; + +/** Default Benjamini-Hochberg target false-discovery rate. */ +export const FDR_Q = 0.1; + +/** A single day's value for one metric (daily mean / sum). */ +export interface DailySeriesPoint { + /** Day key YYYY-MM-DD. */ + day: string; + value: number; +} + +/** A named daily series feeding the discovery matrix. */ +export interface NamedSeries { + /** Stable channel key (e.g. "TIME_IN_DAYLIGHT", "MOOD"). */ + key: string; + /** Whether the channel is a behaviour (lag source) or an outcome (lag target). */ + role: "behaviour" | "outcome"; + points: DailySeriesPoint[]; +} + +/** One discovered, FDR-surviving correlation pair. */ +export interface DiscoveredCorrelation { + behaviour: string; + outcome: string; + /** Paired-day count after the day+1 lag join. */ + n: number; + /** Pearson r (lag-joined). */ + r: number; + /** Two-sided exact Student-t p-value. */ + pValue: number; + /** Benjamini-Hochberg adjusted q-value. */ + qValue: number; + /** Conservative, descriptive interpretation — never causal. */ + interpretation: string; + /** Lag in days applied (always 1 here). */ + lagDays: number; +} + +export interface CorrelationDiscoveryResult { + /** Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. */ + discovered: DiscoveredCorrelation[]; + /** How many behaviour × outcome pairs were tested (for the honest footer). */ + pairsTested: number; + /** The FDR target the surface used. */ + fdrQ: number; + /** Minimum paired-day count enforced per pair. */ + minPairs: number; +} + +/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */ +function shiftDay(day: string, lagDays: number): string { + const [y, m, d] = day.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + lagDays); + return dt.toISOString().slice(0, 10); +} + +/** + * Lag-join a behaviour series (day D) against an outcome series (day D+lag). + * Returns the paired (behaviour, outcome) value arrays. Pure. + */ +export function lagJoin( + behaviour: DailySeriesPoint[], + outcome: DailySeriesPoint[], + lagDays: number, +): { xs: number[]; ys: number[] } { + const outcomeByDay = new Map(outcome.map((p) => [p.day, p.value])); + const xs: number[] = []; + const ys: number[] = []; + for (const b of behaviour) { + const target = outcomeByDay.get(shiftDay(b.day, lagDays)); + if (target != null && Number.isFinite(b.value) && Number.isFinite(target)) { + xs.push(b.value); + ys.push(target); + } + } + return { xs, ys }; +} + +interface RawPair { + behaviour: string; + outcome: string; + n: number; + r: number; + pValue: number; +} + +/** + * Apply the Benjamini-Hochberg step-up procedure to a set of p-values. + * Returns the BH-adjusted q-value per input index (monotone-enforced from + * the largest rank down), so a caller can surface pairs whose q ≤ `fdrQ`. + * Pure. `m` is the number of tests (the full family), which can exceed + * `pValues.length` if some pairs were dropped before ranking — but here we + * pass every tested pair, so `m === pValues.length`. + */ +export function benjaminiHochberg( + pValues: number[], + m: number = pValues.length, +): number[] { + const indexed = pValues.map((p, i) => ({ p, i })); + indexed.sort((a, b) => a.p - b.p); + const q = new Array(pValues.length); + // Walk from the largest p (rank m) down, enforcing monotonicity so a + // smaller p never gets a larger q than a larger p. + let prev = 1; + for (let rank = indexed.length; rank >= 1; rank--) { + const { p, i } = indexed[rank - 1]; + const raw = (p * m) / rank; + prev = Math.min(prev, raw); + q[i] = Math.min(1, prev); + } + return q; +} + +/** + * Build a conservative, descriptive interpretation for a surviving pair. + * Sign of `r` flips the phrasing; never claims causation. + */ +function interpret(behaviour: string, outcome: string, r: number): string { + const b = humanise(behaviour); + const o = humanise(outcome); + if (r <= -0.1) { + return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`; + } + if (r >= 0.1) { + return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`; + } + return `${b} and next-day ${o} move together only weakly in this window.`; +} + +/** Lower-case, space-separated label from a channel key. */ +function humanise(key: string): string { + return key.replace(/_/g, " ").toLowerCase(); +} + +/** + * Run the FDR-controlled discovery over the behaviour × outcome matrix. + * + * 1. For every (behaviour, outcome) pair, lag-join (D → D+1) and run + * Pearson with the exact p-value. Pairs below `minPairs` paired days + * are dropped (not tested — they cannot be defensibly assessed). + * 2. Across the tested pairs, compute BH q-values. + * 3. Surface pairs with p < 0.05 AND q ≤ `fdrQ`, ranked by q then |r|. + * + * Pure — the caller fetches the daily series. + */ +export function discoverCorrelations( + series: NamedSeries[], + opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {}, +): CorrelationDiscoveryResult { + const lagDays = opts.lagDays ?? 1; + const minPairs = opts.minPairs ?? MIN_PAIRED_N; + const fdrQ = opts.fdrQ ?? FDR_Q; + + const behaviours = series.filter((s) => s.role === "behaviour"); + const outcomes = series.filter((s) => s.role === "outcome"); + + const tested: RawPair[] = []; + for (const b of behaviours) { + for (const o of outcomes) { + if (b.key === o.key) continue; + const { xs, ys } = lagJoin(b.points, o.points, lagDays); + if (xs.length < minPairs) continue; + const result = pearson({ xs, ys, minPairs }); + if (result.status !== "ok") continue; + tested.push({ + behaviour: b.key, + outcome: o.key, + n: result.n, + r: result.r, + pValue: result.pValue, + }); + } + } + + const pairsTested = tested.length; + if (pairsTested === 0) { + return { discovered: [], pairsTested: 0, fdrQ, minPairs }; + } + + const qValues = benjaminiHochberg(tested.map((t) => t.pValue)); + const discovered: DiscoveredCorrelation[] = tested + .map((t, i) => ({ ...t, qValue: qValues[i] })) + .filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ) + .map((t) => ({ + behaviour: t.behaviour, + outcome: t.outcome, + n: t.n, + r: t.r, + pValue: t.pValue, + qValue: Math.round(t.qValue * 1000) / 1000, + interpretation: interpret(t.behaviour, t.outcome, t.r), + lagDays, + })) + .sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r)); + + return { discovered, pairsTested, fdrQ, minPairs }; +} + +/** + * The curated discovery matrix — the channels the engine pairs. Behaviours + * (lag sources) on the left, outcomes (lag targets) on the right. Medication + * compliance is deliberately NOT yet a behaviour channel — its cadence-aware + * per-day rate needs the compliance engine; folding it in is a later wave. + */ +export const DISCOVERY_BEHAVIOURS = [ + "TIME_IN_DAYLIGHT", + "MOOD", + "BLOOD_GLUCOSE", + "BLOOD_PRESSURE_SYS", + "ACTIVITY_STEPS", +] as const; + +export const DISCOVERY_OUTCOMES = [ + "SLEEP_DURATION", + "HEART_RATE_VARIABILITY", + "RESTING_HEART_RATE", + "WEIGHT", +] as const; diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts new file mode 100644 index 000000000..5ef846c60 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); + +import { prisma } from "@/lib/db"; +import { + computeCoincidentDeviation, + classifyDeviation, + COINCIDENT_FIRE_THRESHOLD, +} from "../coincident-deviation"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const findMany = prisma.measurement.findMany as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + findMany.mockResolvedValue([]); +}); + +describe("classifyDeviation", () => { + it("flags above / below / in-band", () => { + expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above"); + expect(classifyDeviation("PULSE", 50, 60, 80, 70).direction).toBe("below"); + expect(classifyDeviation("PULSE", 70, 60, 80, 70).direction).toBe("in"); + expect(classifyDeviation("PULSE", 70, 60, 80, 70).outside).toBe(false); + }); +}); + +describe("computeCoincidentDeviation", () => { + it("returns insufficient with fewer than two banded vitals", async () => { + // Only one vital has a usable band. + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") { + return Array.from({ length: 10 }, (_, i) => ({ + value: 58, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })); + } + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("too_few_banded_vitals"); + } + }); + + it("fires when ≥ 2 vitals sit outside their band on the latest day", async () => { + // RHR + HRV each have a flat band, then a final outlier day that + // pushes both outside the band. + const baseline = ( + base: number, + outlier: number, + ): Array<{ value: number; measuredAt: Date }> => [ + ...Array.from({ length: 9 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })), + { value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") }, + ]; + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95); + if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.contributing.length).toBeGreaterThanOrEqual( + COINCIDENT_FIRE_THRESHOLD, + ); + expect(result.value.fired).toBe(true); + } + }); + + it("does not fire when vitals stay inside their bands", async () => { + const flat = (base: number): Array<{ value: number; measuredAt: Date }> => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })); + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return flat(58); + if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.fired).toBe(false); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 271bd453f..6babe8d0a 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) } }, + prisma: { + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + }, })); vi.mock("@/lib/rollups/measurement-coverage", () => ({ probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), @@ -33,6 +36,13 @@ describe("registry", () => { // Composites still land in W3. expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false); + it("exposes the flagship + W3 composites as implemented; the passthroughs stay stubs", () => { + expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true); + expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true); + // FITNESS_AGE + VASCULAR_AGE_DELTA land in a sibling wave. + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -68,7 +78,7 @@ describe("computeDerivedMetric dispatch", () => { it("returns not_implemented for a stubbed metric (never a 500)", async () => { const result = await computeDerivedMetric({ - metric: "READINESS", + metric: "FITNESS_AGE", userId: "u1", profile: PROFILE, now: NOW, @@ -79,6 +89,45 @@ describe("computeDerivedMetric dispatch", () => { } }); + it("routes READINESS to the engine (no data → insufficient, not not_implemented)", async () => { + const result = await computeDerivedMetric({ + metric: "READINESS", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_components"); + } + }); + + it("routes SLEEP_SCORE to the engine (no sleep → insufficient)", async () => { + const result = await computeDerivedMetric({ + metric: "SLEEP_SCORE", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_sleep_in_window"); + } + }); + + it("routes COINCIDENT_DEVIATION to the engine (no bands → insufficient)", async () => { + const result = await computeDerivedMetric({ + metric: "COINCIDENT_DEVIATION", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("too_few_banded_vitals"); + } + }); + it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => { const result = await computeDerivedMetric({ metric: "VITALS_BASELINE", diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts new file mode 100644 index 000000000..44dfd345f --- /dev/null +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); + +import { prisma } from "@/lib/db"; +import { + computeReadiness, + blendReadinessComponents, + scoreDeviation, + READINESS_MIN_COMPONENTS, + type ReadinessComponentKey, +} from "../readiness"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const measurementFindMany = prisma.measurement.findMany as ReturnType; +const moodFindMany = prisma.moodEntry.findMany as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + measurementFindMany.mockResolvedValue([]); + moodFindMany.mockResolvedValue([]); +}); + +describe("scoreDeviation", () => { + it("scores 100 on or in the good direction (lower-better)", () => { + expect(scoreDeviation(50, 60, 5, "lower-better")).toBe(100); + expect(scoreDeviation(60, 60, 5, "lower-better")).toBe(100); + }); + + it("suppresses the score one spread worse (lower-better RHR elevated)", () => { + expect(scoreDeviation(65, 60, 5, "lower-better")).toBe(50); + expect(scoreDeviation(70, 60, 5, "lower-better")).toBe(0); + }); + + it("suppresses on an HRV drop (higher-better)", () => { + expect(scoreDeviation(55, 60, 5, "higher-better")).toBe(50); + expect(scoreDeviation(65, 60, 5, "higher-better")).toBe(100); + }); + + it("degrades gracefully when the spread is zero", () => { + expect(scoreDeviation(61, 60, 0, "lower-better")).toBe(50); + expect(scoreDeviation(59, 60, 0, "lower-better")).toBe(100); + }); +}); + +describe("blendReadinessComponents reweighting", () => { + it("renormalises over present components and drops missing ones", () => { + const raw: Record = { + rhr: 80, + hrv: 70, + sleep: null, + respiratory: null, + mood: 60, + }; + const { score, components } = blendReadinessComponents(raw); + const sleep = components.find((c) => c.key === "sleep")!; + expect(sleep.weight).toBe(0); + const presentSum = components + .filter((c) => c.value !== null) + .reduce((s, c) => s + c.weight, 0); + expect(presentSum).toBeCloseTo(1, 5); + expect(score).toBeGreaterThan(0); + }); +}); + +describe("computeReadiness gating", () => { + it("returns insufficient below the minimum-components floor (no 1-of-N headline)", async () => { + // No data at all → 0 components present. + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_components"); + expect(result.coverage.presentInputs).toBeLessThan( + READINESS_MIN_COMPONENTS, + ); + } + }); + + it("produces a headline once ≥ 2 deviation components are present", async () => { + // RHR + HRV baselines: ≥ 7 distinct days each, plus a latest reading. + const days = (type: string, base: number) => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + // 10 distinct days within May (15..24) — all valid calendar days. + measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`), + type, + })); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65); + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const present = result.value.components.filter((c) => c.value !== null); + expect(present.length).toBeGreaterThanOrEqual(READINESS_MIN_COMPONENTS); + expect(result.value.score).toBeGreaterThanOrEqual(0); + expect(result.value.score).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts new file mode 100644 index 000000000..1dc05154a --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { + computeSleepScore, + blendSleepSubScores, + reconstructNights, + sleepNeedMinutes, + scoreSufficiency, + scoreEfficiency, + scoreComposition, + scoreConsistency, + scoreTiming, + type SleepSubScoreKey, +} from "../sleep-score"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const findMany = prisma.measurement.findMany as ReturnType; + +beforeEach(() => vi.clearAllMocks()); + +/** Build per-stage rows for one night keyed on a wake-day. */ +function night(day: string, stages: Array<[string | null, number, string]>) { + return stages.map(([sleepStage, value, hhmm]) => ({ + value, + measuredAt: new Date(`${day}T${hhmm}:00Z`), + sleepStage: sleepStage as never, + })); +} + +describe("pure sleep scorers", () => { + it("sleepNeedMinutes is age-banded with an adult default", () => { + expect(sleepNeedMinutes(40)).toBe(7 * 60); + expect(sleepNeedMinutes(10)).toBe(10 * 60); + expect(sleepNeedMinutes(null)).toBe(7 * 60); + }); + + it("sufficiency caps at 100 and never penalises oversleep", () => { + expect(scoreSufficiency(7 * 60, 7 * 60)).toBe(100); + expect(scoreSufficiency(10 * 60, 7 * 60)).toBe(100); + expect(scoreSufficiency(3.5 * 60, 7 * 60)).toBe(50); + }); + + it("efficiency is null without in-bed minutes, else the asleep ratio", () => { + expect(scoreEfficiency(420, null)).toBeNull(); + expect(scoreEfficiency(420, 480)).toBe(88); + }); + + it("composition is null without a stage breakdown, 100 inside the band", () => { + expect(scoreComposition(0, 0, 420, false)).toBeNull(); + // REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48]. + expect(scoreComposition(90, 60, 420, true)).toBe(100); + }); + + it("consistency is null below 3 nights, 100 for a flat midpoint", () => { + expect(scoreConsistency([100, 100])).toBeNull(); + expect(scoreConsistency([180, 180, 180])).toBe(100); + }); + + it("timing is null without a habitual window", () => { + expect(scoreTiming(180, null, 1)).toBeNull(); + expect(scoreTiming(180, 180, 5)).toBe(100); + }); +}); + +describe("blendSleepSubScores reweighting", () => { + it("renormalises weights over the present sub-scores (drops a missing one)", () => { + const raw: Record = { + sufficiency: 80, + efficiency: 90, + consistency: 70, + timing: 60, + composition: null, // legacy ASLEEP-only night + }; + const { score, subScores } = blendSleepSubScores(raw); + const composition = subScores.find((s) => s.key === "composition")!; + expect(composition.weight).toBe(0); + // Present weights sum to 1. + const sum = subScores.reduce((s, c) => s + c.weight, 0); + expect(sum).toBeCloseTo(1, 5); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); +}); + +describe("reconstructNights", () => { + it("sums asleep stages, derives in-bed, flags the stage breakdown", () => { + const rows = night("2026-06-02", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ["AWAKE", 30, "05:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(390); + expect(nights[0].inBedMinutes).toBe(480); + expect(nights[0].hasStageBreakdown).toBe(true); + expect(nights[0].remMinutes).toBe(90); + expect(nights[0].deepMinutes).toBe(60); + }); +}); + +describe("computeSleepScore", () => { + it("returns insufficient when no sleep rows exist", async () => { + findMany.mockResolvedValue([]); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_sleep_in_window"); + } + }); + + it("scores a multi-stage night and shows the present sub-scores", async () => { + // Three nights so consistency/timing can compute. + const rows = [ + ...night("2026-05-31", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ]), + ...night("2026-06-01", [ + ["IN_BED", 470, "06:10"], + ["REM", 85, "02:10"], + ["CORE", 250, "03:10"], + ["DEEP", 55, "04:10"], + ]), + ...night("2026-06-02", [ + ["IN_BED", 480, "06:05"], + ["REM", 95, "02:05"], + ["CORE", 235, "03:05"], + ["DEEP", 65, "04:05"], + ]), + ]; + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBeGreaterThan(0); + expect(result.value.windowNights).toBe(3); + // All five sub-scores present on a full-stage multi-night history. + const present = result.value.subScores.filter((s) => s.value !== null); + expect(present.length).toBe(5); + expect(result.coverage.presentInputs).toBe(5); + } + }); + + it("reweights around composition on a legacy ASLEEP-only night", async () => { + const rows = [ + ...night("2026-05-31", [["ASLEEP", 420, "06:00"]]), + ...night("2026-06-01", [["ASLEEP", 430, "06:10"]]), + ...night("2026-06-02", [["ASLEEP", 410, "06:05"]]), + ]; + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const composition = result.value.subScores.find( + (s) => s.key === "composition", + )!; + expect(composition.value).toBeNull(); + expect(composition.weight).toBe(0); + expect(result.coverage.missing).toContain("composition"); + } + }); +}); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts new file mode 100644 index 000000000..74359d7f5 --- /dev/null +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -0,0 +1,228 @@ +/** + * v1.10.0 — Coincident-deviation flag (catalogue metric #2, COMPOSITE). + * + * "2 of your vitals are outside their usual range this morning — possible + * factors: illness, alcohol, altitude, a hard workout." For each vital + * with an established personal band (#1 `VITALS_BASELINE`), check whether + * the latest reading falls outside the band; **≥ 2 outside on the same day + * → fire** a flag listing the contributing vitals. No black-box score — it + * is a transparent COUNT of deviations, mirroring Apple Vitals' ≥2-metric + * next-morning notification. + * + * Coverage gate: ≥ 2 vitals each with an established band. Below that the + * flag is `insufficient` (it cannot coincide). The minimum-inputs floor is + * the composite contract — it never emits a single composite number, and + * never labels a cause ("illness"); it lists the contributing vitals and + * frames them as "possible factors" only. + * + * Standard: the same MAD / personal-baseline basis as #1 (Hampel 1974; + * Leys et al. 2013). Multi-signal coincidence is descriptive, NOT a + * diagnosis. Frame: Apple Vitals / WHOOP Health Monitor / Fitbit Health + * Metrics / Oura Symptom Radar — all descriptive, none continuous. + * + * Server-only — fans the baseline engine across the supported vitals with + * one shared coverage probe (the pool-contention mitigation). The pure + * classifier is exported for tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { VITALS_BASELINE_TYPES } from "./registry"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_DAYS = 30; +/** ≥ this many out-of-band vitals on a day fires the flag. */ +export const COINCIDENT_FIRE_THRESHOLD = 2; +/** Need ≥ this many banded vitals before the flag can even coincide. */ +export const COINCIDENT_MIN_BANDS = 2; + +/** One vital's standing against its personal band today. */ +export interface VitalDeviation { + type: MeasurementType; + /** Today's value. */ + value: number; + /** Band center (median). */ + center: number; + low: number; + high: number; + /** True when today's value falls outside [low, high]. */ + outside: boolean; + /** "above" / "below" the band, or "in" when inside. */ + direction: "above" | "below" | "in"; +} + +export interface CoincidentDeviationValue { + /** True when ≥ COINCIDENT_FIRE_THRESHOLD vitals are outside their band. */ + fired: boolean; + /** All banded vitals checked today (the anatomy view lists them). */ + vitals: VitalDeviation[]; + /** Just the out-of-band vitals (the contributing factors). */ + contributing: VitalDeviation[]; + /** The day the flag was evaluated (YYYY-MM-DD). */ + day: string; +} + +// ── pure classifier (exported for tests) ─────────────────────────────── + +/** Classify one vital's latest value against its band. Pure. */ +export function classifyDeviation( + type: MeasurementType, + value: number, + low: number, + high: number, + center: number, +): VitalDeviation { + const above = value > high; + const below = value < low; + return { + type, + value, + center, + low, + high, + outside: above || below, + direction: above ? "above" : below ? "below" : "in", + }; +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface CoincidentDeviationOpts { + windowDays?: number; + now?: Date; + coverage?: RollupCoverageMap; +} + +/** + * The most recent DAY mean for a type within the window, plus its day key. + * Bounded raw read; null when no reading in the window. + */ +async function readLatestDayMean( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, +): Promise<{ value: number; day: string } | null> { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: since } }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return null; + // Derive the most-recent day defensively (do not assume the DB ordering) + // so the "today" reading is always the genuine latest, then mean its rows. + let day = ""; + for (const r of rows) { + const d = r.measuredAt.toISOString().slice(0, 10); + if (d > day) day = d; + } + const sameDay = rows.filter( + (r) => r.measuredAt.toISOString().slice(0, 10) === day, + ); + return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day }; +} + +/** + * Compute the coincident-deviation flag across the supported vitals. Below + * `COINCIDENT_MIN_BANDS` banded vitals it returns `insufficient`. + */ +export async function computeCoincidentDeviation( + userId: string, + profile: BaselineProfile, + opts: CoincidentDeviationOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + + const vitals: VitalDeviation[] = []; + let latestDay = ""; + let anyDaySource = false; + + // Fan the baseline engine across the supported vitals. Each baseline read + // shares the one coverage probe (no per-vital re-probe). + for (const type of VITALS_BASELINE_TYPES) { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") continue; + if (baseline.provenance.source === "DAY") anyDaySource = true; + const latest = await readLatestDayMean(userId, type, windowDays, now); + if (!latest) continue; + if (latest.day > latestDay) latestDay = latest.day; + vitals.push( + classifyDeviation( + type, + latest.value, + baseline.value.low, + baseline.value.high, + baseline.value.center, + ), + ); + } + + const inputs = vitals.map((v) => String(v.type)); + const source: DerivedProvenanceSource = + vitals.length === 0 ? "none" : anyDaySource ? "DAY" : "live"; + + if (vitals.length < COINCIDENT_MIN_BANDS) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: COINCIDENT_MIN_BANDS, + presentInputs: vitals.length, + historyDays: 0, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { + inputs: inputs.length > 0 ? inputs : VITALS_BASELINE_TYPES.map(String), + source, + windowDays, + computedAt, + }, + reason: "too_few_banded_vitals", + }); + } + + const contributing = vitals.filter((v) => v.outside); + const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; + + const { coverage: cov, confidence } = deriveCoverage({ + // The coverage axis is "how many vitals could have coincided". + requiredInputs: vitals.length, + presentInputs: vitals.length, + historyDays: windowDays, + missing: [], + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { + fired, + vitals, + contributing, + day: latestDay, + }, + coverage: cov, + confidence, + provenance: { inputs, source, windowDays, computedAt }, + }); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index 9be848e39..c393f7535 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -26,6 +26,9 @@ import { computeFitnessAge } from "./fitness-age"; import { computeVascularAgeDelta } from "./vascular-age"; import { computeHrvBalance } from "./hrv-balance"; import { computeBmi } from "./bmi"; +import { computeSleepScore } from "./sleep-score"; +import { computeReadiness } from "./readiness"; +import { computeCoincidentDeviation } from "./coincident-deviation"; import type { Derived } from "./types"; export interface DerivedComputeArgs { @@ -121,6 +124,18 @@ export async function computeDerivedMetric( }) as Promise>; case "BMI": return computeBmi(args.userId, args.profile, { + case "SLEEP_SCORE": + return computeSleepScore(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "READINESS": + return computeReadiness(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "COINCIDENT_DEVIATION": + return computeCoincidentDeviation(args.userId, args.profile, { windowDays: args.windowDays, now, }) as Promise>; diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 90b50d13d..d0db86158 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -84,3 +84,49 @@ export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance"; export { computeBmi, classifyBmi } from "./bmi"; export type { BmiValue, BmiBand, BmiCategory } from "./bmi"; +// ── W3 composites (server-only compute engines) ────────────────────── +export { + computeSleepScore, + blendSleepSubScores, + reconstructNights, + sleepNeedMinutes, + scoreSufficiency, + scoreEfficiency, + scoreComposition, + scoreConsistency, + scoreTiming, + SLEEP_SUBSCORE_WEIGHTS, +} from "./sleep-score"; +export type { + SleepScoreValue, + SleepScoreOpts, + SleepSubScore, + SleepSubScoreKey, + NightSummary, +} from "./sleep-score"; + +export { + computeReadiness, + blendReadinessComponents, + scoreDeviation, + READINESS_WEIGHTS, + READINESS_MIN_COMPONENTS, +} from "./readiness"; +export type { + ReadinessValue, + ReadinessOpts, + ReadinessComponent, + ReadinessComponentKey, +} from "./readiness"; + +export { + computeCoincidentDeviation, + classifyDeviation, + COINCIDENT_FIRE_THRESHOLD, + COINCIDENT_MIN_BANDS, +} from "./coincident-deviation"; +export type { + CoincidentDeviationValue, + CoincidentDeviationOpts, + VitalDeviation, +} from "./coincident-deviation"; diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts new file mode 100644 index 000000000..1a2d4ccb2 --- /dev/null +++ b/src/lib/insights/derived/readiness.ts @@ -0,0 +1,391 @@ +/** + * v1.10.0 — Wellness / readiness index (catalogue metric #7, COMPOSITE). + * + * A daily "how recovered are you" proxy, framed as a wellness index, with + * every contributing component SHOWN. It extends the existing + * `health-score.ts` weighted-blend + null-redistribution pattern with + * deviation-from-baseline components: + * + * - **RHR deviation** — resting HR below/at baseline scores high; + * an elevation suppresses the score. + * - **HRV deviation** — SDNN at/above baseline scores high; a drop + * suppresses it. (Surfaced as "HRV (SDNN)" — it + * is NEVER relabelled RMSSD, and this is never + * called "Recovery".) + * - **Sleep adequacy** — the Sleep Score (#6) folded in as a 0..100 + * component (stubbed behind coverage so this + * builds independently and lights up when sleep + * data exists). + * - **Respiratory-rate stability** — closeness to the personal baseline. + * - **Mood stability** — the existing mood-aggregate stability score. + * + * Each component is 0..100; weights renormalise over the PRESENT components + * (exactly as the health score redistributes a null pillar). Below the + * minimum-inputs floor (`minInputs = 2`) it returns `insufficient` — never + * a headline from 1-of-N. The overnight-HR-return contributor is dropped + * (needs intra-night HR — absent from the schema). + * + * Standard: RHR elevation + HRV suppression as recovery/strain markers — + * Plews et al. 2013, Sports Medicine 43(9):773–781; Buchheit 2014, + * Frontiers in Physiology 5:73. Framing: our own heuristic — a daily + * wellness proxy, NOT a training-recovery score, NOT clinical. + * + * Server-only — reads the rollup tier (via the baseline engine) + the + * sleep score + mood aggregates. The pure blend is exported for tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { computeMoodStability } from "@/lib/insights/mood-aggregates"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { computeSleepScore } from "./sleep-score"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_DAYS = 30; +/** Minimum present components before a headline is produced (no 1-of-N). */ +export const READINESS_MIN_COMPONENTS = 2; + +/** Transparent component weights (renormalised over present components). */ +export const READINESS_WEIGHTS = { + rhr: 0.25, + hrv: 0.25, + sleep: 0.25, + respiratory: 0.1, + mood: 0.15, +} as const; + +export type ReadinessComponentKey = keyof typeof READINESS_WEIGHTS; + +/** One readiness contributor the anatomy view ranks by impact. */ +export interface ReadinessComponent { + key: ReadinessComponentKey; + /** 0..100, or null when the input is missing (drops from the blend). */ + value: number | null; + /** Effective weight after null-redistribution, 0..1. */ + weight: number; +} + +export interface ReadinessValue { + score: number; + band: "green" | "yellow" | "red"; + components: ReadinessComponent[]; +} + +// ── pure deviation scorers (exported for tests) ──────────────────────── + +function clamp100(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, Math.round(v))); +} + +function bandForScore(score: number): "green" | "yellow" | "red" { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** + * Map a today-vs-baseline deviation to a 0..100 component score. `spread` + * is the band half-width (k·MAD·σ-scale from the baseline engine); a value + * one spread in the "bad" direction lands near 50, two spreads near 0. + * + * `direction = "lower-better"` (RHR, respiratory rate): a value above + * baseline suppresses the score. `direction = "higher-better"` (HRV): a + * value below baseline suppresses it. On-baseline (or in the good + * direction) scores 100. + */ +export function scoreDeviation( + today: number, + center: number, + spread: number, + direction: "lower-better" | "higher-better", +): number { + if (!Number.isFinite(spread) || spread <= 0) { + // No usable spread → degrade to a coarse same/above/below. + if (today === center) return 100; + const worse = + direction === "lower-better" ? today > center : today < center; + return worse ? 50 : 100; + } + const worseDelta = + direction === "lower-better" ? today - center : center - today; + if (worseDelta <= 0) return 100; // at or in the good direction. + // 1 spread worse → 50, 2 spreads → 0 (linear). + return clamp100(100 - (worseDelta / spread) * 50); +} + +/** + * Blend the present components with null-redistribution (the + * `health-score.ts` pattern). Returns the composite + per-component + * effective weights for the anatomy view. Pure. + */ +export function blendReadinessComponents( + raw: Record, +): { score: number; components: ReadinessComponent[] } { + const keys = Object.keys(READINESS_WEIGHTS) as ReadinessComponentKey[]; + const present = keys.filter((k) => raw[k] !== null); + const totalBaseWeight = present.reduce((s, k) => s + READINESS_WEIGHTS[k], 0); + const components: ReadinessComponent[] = keys.map((k) => ({ + key: k, + value: raw[k], + weight: + raw[k] === null || totalBaseWeight === 0 + ? 0 + : READINESS_WEIGHTS[k] / totalBaseWeight, + })); + let composite = 0; + for (const c of components) { + if (c.value !== null) composite += c.value * c.weight; + } + return { score: clamp100(composite), components }; +} + +// ── helpers ───────────────────────────────────────────────────────────── + +/** + * The most recent DAY mean for a type within the window — the "today" + * value the deviation is measured against. Bounded raw read; null when no + * reading in the window. + */ +async function readLatestDayMean( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, +): Promise { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: since } }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return null; + // Derive the most-recent day defensively (do not assume the DB ordering). + let latestDay = ""; + for (const r of rows) { + const d = r.measuredAt.toISOString().slice(0, 10); + if (d > latestDay) latestDay = d; + } + const sameDay = rows.filter( + (r) => r.measuredAt.toISOString().slice(0, 10) === latestDay, + ); + return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length; +} + +/** + * Score one baseline-deviation vital component (RHR / HRV / respiratory). + * Returns null when the baseline is insufficient or there is no recent + * reading — the component then drops from the blend. + */ +async function scoreVitalDeviation( + userId: string, + profile: BaselineProfile, + type: MeasurementType, + direction: "lower-better" | "higher-better", + windowDays: number, + now: Date, + coverage: RollupCoverageMap, +): Promise<{ value: number | null; source: DerivedProvenanceSource }> { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") { + return { value: null, source: baseline.provenance.source }; + } + const today = await readLatestDayMean(userId, type, windowDays, now); + if (today == null) { + return { value: null, source: baseline.provenance.source }; + } + const value = scoreDeviation( + today, + baseline.value.center, + baseline.value.spread, + direction, + ); + return { value, source: baseline.provenance.source }; +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface ReadinessOpts { + windowDays?: number; + now?: Date; + /** Pre-probed coverage (shared across metrics in one request). */ + coverage?: RollupCoverageMap; +} + +/** + * Compute the wellness/readiness index. Below `READINESS_MIN_COMPONENTS` + * present components it returns `insufficient` (no 1-of-N headline). + */ +export async function computeReadiness( + userId: string, + profile: BaselineProfile, + opts: ReadinessOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + + const inputNames = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + "MOOD", + ]; + + // Vital deviations (shared coverage map → one probe per request). + const [rhr, hrv, resp] = await Promise.all([ + scoreVitalDeviation( + userId, + profile, + "RESTING_HEART_RATE", + "lower-better", + windowDays, + now, + coverage, + ), + scoreVitalDeviation( + userId, + profile, + "HEART_RATE_VARIABILITY", + "higher-better", + windowDays, + now, + coverage, + ), + scoreVitalDeviation( + userId, + profile, + "RESPIRATORY_RATE", + "lower-better", + windowDays, + now, + coverage, + ), + ]); + + // Sleep adequacy — folds the Sleep Score in (stubbed behind coverage: + // null when there is no scorable night, dropping the component). + const sleep = await computeSleepScore(userId, profile, { windowDays, now }); + const sleepValue = sleep.status === "ok" ? sleep.value.score : null; + + // Mood stability — reuse the shipped mood-aggregate stability score over + // the window's daily means. + const moodValue = await readMoodStability(userId, windowDays, now); + + const raw: Record = { + rhr: rhr.value, + hrv: hrv.value, + sleep: sleepValue, + respiratory: resp.value, + mood: moodValue, + }; + + const { score, components } = blendReadinessComponents(raw); + const presentCount = components.filter((c) => c.value !== null).length; + const missing = components + .filter((c) => c.value === null) + .map((c) => c.key); + + // Provenance source: DAY when any vital read the rollup tier, else live. + const anyDay = [rhr.source, hrv.source, resp.source].includes("DAY"); + const source: DerivedProvenanceSource = + presentCount === 0 ? "none" : anyDay ? "DAY" : "live"; + + if (presentCount < READINESS_MIN_COMPONENTS) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: components.length, + presentInputs: presentCount, + historyDays: 0, + missing, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: inputNames, source, windowDays, computedAt }, + reason: "insufficient_components", + }); + } + + const { coverage: cov, confidence } = deriveCoverage({ + requiredInputs: components.length, + presentInputs: presentCount, + historyDays: windowDays, + missing, + fullHistoryDays: windowDays, + }); + + const presentInputNames = inputNames.filter((_, i) => { + const key = ( + ["rhr", "hrv", "sleep", "respiratory", "mood"] as ReadinessComponentKey[] + )[i]; + return raw[key] !== null; + }); + + return buildOk({ + value: { score, band: bandForScore(score), components }, + coverage: cov, + confidence, + provenance: { + inputs: presentInputNames.length > 0 ? presentInputNames : inputNames, + source, + windowDays, + computedAt, + }, + }); +} + +/** + * Mood stability over the window — reads daily-mean mood and feeds the + * shipped `computeMoodStability` (0..100, higher = steadier). Null below + * the stability floor. + */ +async function readMoodStability( + userId: string, + windowDays: number, + now: Date, +): Promise { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 2000, + select: { score: true, moodLoggedAt: true }, + }); + if (rows.length === 0) return null; + // Collapse to daily means keyed by dayOffset (newest = 0), matching the + // mood-aggregate DailyPoint shape. + const byDay = new Map(); + for (const r of rows) { + const offset = Math.floor( + (now.getTime() - r.moodLoggedAt.getTime()) / MS_PER_DAY, + ); + const acc = byDay.get(offset) ?? { sum: 0, count: 0 }; + acc.sum += r.score; + acc.count += 1; + byDay.set(offset, acc); + } + const daily = [...byDay.entries()].map(([dayOffset, acc]) => ({ + dayOffset, + value: acc.sum / acc.count, + })); + const stability = computeMoodStability(daily); + return stability ? stability.score : null; +} diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index a7b1febe5..e14c5a5b5 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -133,7 +133,7 @@ const REGISTRY: Record = { inputs: ["SLEEP_DURATION"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, READINESS: { id: "READINESS", @@ -148,7 +148,7 @@ const REGISTRY: Record = { ], minHistoryDays: 7, minInputs: 2, - implemented: false, + implemented: true, }, COINCIDENT_DEVIATION: { id: "COINCIDENT_DEVIATION", @@ -157,7 +157,7 @@ const REGISTRY: Record = { inputs: VITALS_BASELINE_TYPES, minHistoryDays: 7, minInputs: 2, - implemented: false, + implemented: true, }, HRV_BALANCE: { id: "HRV_BALANCE", diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts new file mode 100644 index 000000000..600734e31 --- /dev/null +++ b/src/lib/insights/derived/sleep-score.ts @@ -0,0 +1,473 @@ +/** + * v1.10.0 — transparent Sleep Score (catalogue metric #6, COMPOSITE). + * + * A 0–100 sleep-quality composite whose sub-scores are SHOWN, not a black + * box. Built from the per-stage `SLEEP_DURATION` rows HealthKit + Withings + * write (one row per stage per night, `value` in minutes, `sleepStage` ∈ + * { IN_BED, AWAKE, ASLEEP, REM, CORE, DEEP }). Combines: + * + * - **Sufficiency** = total asleep minutes vs an age-based need target + * (Hirshkowitz et al. 2015 National Sleep Foundation recommendations). + * - **Efficiency** = asleep ÷ in-bed minutes (AASM scoring convention; + * ≥ 85 % is the clinical "good" floor). + * - **Consistency** = SD of the sleep midpoint across the window (lower + * is better) — a regularity proxy. + * - **Composition** = REM% + Deep% of total asleep vs Ohayon et al. 2017 + * age norms. + * + * Each sub-score is 0..100 with its own transparent weight; the composite + * **reweights around missing sub-scores** exactly as `health-score.ts` + * null-redistributes (a legacy `ASLEEP`-only night yields Sufficiency + + * Efficiency + Consistency + Timing, dropping Composition gracefully — it + * is never fabricated). Restfulness / Restoration are NOT derivable (no + * per-stage HR, no WASO timestamps) and are omitted, not faked. + * + * Standard: AASM scoring conventions (efficiency); Hirshkowitz et al. 2015, + * Sleep Health 1(1):40–43 (age-based duration need); Ohayon et al. 2017, + * Sleep Health 3(1):6–19 (stage-percentage norms by age). Framing + * discipline: consumer sleep-staging vs PSG is only moderately accurate — + * the copy stays directional, never clinical. + * + * Server-only — reads raw `SLEEP_DURATION` rows via Prisma (per-stage rows + * are not rolled up by stage, so the night reconstruction reads raw, but + * bounded to the window). The pure scorers are exported for unit tests. + */ +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Default trailing window for the consistency/timing baseline (days). */ +const DEFAULT_WINDOW_DAYS = 30; +/** A night needs ≥ this many stage rows to score at all. */ +const MIN_STAGE_ROWS_PER_NIGHT = 1; + +/** Transparent sub-score weights (shown to the user, sum to 1 over present). */ +export const SLEEP_SUBSCORE_WEIGHTS = { + sufficiency: 0.3, + efficiency: 0.25, + consistency: 0.2, + timing: 0.1, + composition: 0.15, +} as const; + +export type SleepSubScoreKey = keyof typeof SLEEP_SUBSCORE_WEIGHTS; + +/** One sub-score row the anatomy view renders as a contributor. */ +export interface SleepSubScore { + key: SleepSubScoreKey; + /** 0..100, or null when the night lacks the inputs (drops from the blend). */ + value: number | null; + /** Effective weight after null-redistribution, 0..1. */ + weight: number; +} + +export interface SleepScoreValue { + /** The composite 0..100. */ + score: number; + /** Band derived from the score (green/yellow/red). */ + band: "green" | "yellow" | "red"; + /** The night this score describes (YYYY-MM-DD of the wake day). */ + night: string; + /** Total asleep minutes that night. */ + asleepMinutes: number; + /** Total in-bed minutes that night (asleep + awake-in-bed), when known. */ + inBedMinutes: number | null; + /** The transparent sub-scores (present + dropped). */ + subScores: SleepSubScore[]; + /** Nights in the window that backed the consistency/timing baseline. */ + windowNights: number; +} + +const ASLEEP_STAGES: ReadonlySet = new Set([ + "ASLEEP", + "REM", + "CORE", + "DEEP", +]); + +// ── pure scorers (exported for tests) ───────────────────────────────── + +/** + * Age-based sleep-need target in minutes (National Sleep Foundation, + * Hirshkowitz et al. 2015). Adult default 7–9 h; we anchor the target at + * the lower-recommended bound so hitting it scores 100. + */ +export function sleepNeedMinutes(ageYears: number | null): number { + if (ageYears == null || !Number.isFinite(ageYears)) return 7 * 60; // adult default + if (ageYears < 1) return 14 * 60; + if (ageYears < 3) return 12 * 60; + if (ageYears < 6) return 11 * 60; + if (ageYears < 14) return 10 * 60; + if (ageYears < 18) return 9 * 60; + if (ageYears < 65) return 7 * 60; + return 7 * 60; // 65+ NSF recommends 7–8 h +} + +/** Sufficiency: asleep vs need, capped at 100 (oversleep is not penalised). */ +export function scoreSufficiency( + asleepMinutes: number, + needMinutes: number, +): number | null { + if (needMinutes <= 0) return null; + return clamp100((asleepMinutes / needMinutes) * 100); +} + +/** + * Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor + * maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy + * nights with only asleep stages and no IN_BED row). + */ +export function scoreEfficiency( + asleepMinutes: number, + inBedMinutes: number | null, +): number | null { + if (inBedMinutes == null || inBedMinutes <= 0) return null; + const pct = (asleepMinutes / inBedMinutes) * 100; + return clamp100(pct); +} + +/** + * Composition: how close REM% + Deep% sit to the age-typical band + * (Ohayon et al. 2017). Adults: REM ≈ 20–25 %, Deep ≈ 13–23 % of total + * asleep. We score the combined REM+Deep fraction against a 33–48 % target + * window — inside → 100, falling off linearly outside. Null when no stage + * breakdown exists (a legacy `ASLEEP`-only night). + */ +export function scoreComposition( + remMinutes: number, + deepMinutes: number, + asleepMinutes: number, + hasStageBreakdown: boolean, +): number | null { + if (!hasStageBreakdown || asleepMinutes <= 0) return null; + const fraction = (remMinutes + deepMinutes) / asleepMinutes; + const lo = 0.33; + const hi = 0.48; + if (fraction >= lo && fraction <= hi) return 100; + // Linear fall-off: half a window-width away from the band → 0. + const halfWidth = (hi - lo) / 2 + 0.15; + const dist = fraction < lo ? lo - fraction : fraction - hi; + return clamp100(100 * (1 - dist / halfWidth)); +} + +/** + * Consistency: lower SD of the sleep midpoint (minutes-of-day) across the + * window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null + * below 3 nights (no variance signal). + */ +export function scoreConsistency(midpointMinutes: number[]): number | null { + if (midpointMinutes.length < 3) return null; + const mean = + midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length; + const variance = + midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) / + midpointMinutes.length; + const sd = Math.sqrt(variance); + const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic. + return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE)); +} + +/** + * Timing: this night's midpoint vs the user's habitual midpoint (the + * window mean). On-target → 100; 90 min off → 0. Null when the habitual + * window is not yet established (< 3 nights). + */ +export function scoreTiming( + nightMidpoint: number | null, + habitualMidpoint: number | null, + windowNights: number, +): number | null { + if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) { + return null; + } + const FULL_SCALE = 90; + const dist = Math.abs(nightMidpoint - habitualMidpoint); + return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE)); +} + +function clamp100(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, Math.round(v))); +} + +function bandForScore(score: number): "green" | "yellow" | "red" { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** + * Blend the present sub-scores with null-redistribution (the + * `health-score.ts` pattern). Returns the composite + the per-sub-score + * effective weights for the anatomy view. Pure. + */ +export function blendSleepSubScores( + raw: Record, +): { score: number; subScores: SleepSubScore[] } { + const keys = Object.keys(SLEEP_SUBSCORE_WEIGHTS) as SleepSubScoreKey[]; + const present = keys.filter((k) => raw[k] !== null); + const totalBaseWeight = present.reduce( + (s, k) => s + SLEEP_SUBSCORE_WEIGHTS[k], + 0, + ); + const subScores: SleepSubScore[] = keys.map((k) => ({ + key: k, + value: raw[k], + weight: + raw[k] === null || totalBaseWeight === 0 + ? 0 + : SLEEP_SUBSCORE_WEIGHTS[k] / totalBaseWeight, + })); + let composite = 0; + for (const s of subScores) { + if (s.value !== null) composite += s.value * s.weight; + } + return { score: clamp100(composite), subScores }; +} + +// ── night reconstruction ─────────────────────────────────────────────── + +interface SleepRow { + value: number; + measuredAt: Date; + sleepStage: SleepStage | null; +} + +export interface NightSummary { + /** Wake-day key (YYYY-MM-DD) the night is filed under. */ + night: string; + asleepMinutes: number; + awakeMinutes: number; + remMinutes: number; + deepMinutes: number; + inBedMinutes: number | null; + hasStageBreakdown: boolean; + /** Midpoint as minutes-of-day (0..1439), or null when timestamps collapse. */ + midpoint: number | null; +} + +/** + * Group raw stage rows into per-night summaries. A "night" is keyed by the + * latest stage row's calendar day (the wake day). Pure — the caller does + * the bounded DB read and passes rows in. + */ +export function reconstructNights(rows: SleepRow[]): NightSummary[] { + // Bucket rows by the wake-day key (UTC day of the row's measuredAt). + const byNight = new Map(); + for (const row of rows) { + const key = row.measuredAt.toISOString().slice(0, 10); + const list = byNight.get(key) ?? []; + list.push(row); + byNight.set(key, list); + } + const nights: NightSummary[] = []; + for (const [night, nightRows] of byNight) { + let asleep = 0; + let awake = 0; + let rem = 0; + let deep = 0; + let inBed = 0; + let sawInBed = false; + let sawStageBreakdown = false; + let earliest = Infinity; + let latest = -Infinity; + for (const r of nightRows) { + const stage = r.sleepStage; + const minutes = Number.isFinite(r.value) ? r.value : 0; + const t = r.measuredAt.getTime(); + if (t < earliest) earliest = t; + if (t > latest) latest = t; + if (stage === "IN_BED") { + inBed += minutes; + sawInBed = true; + continue; + } + if (stage === "AWAKE") { + awake += minutes; + continue; + } + if (stage && ASLEEP_STAGES.has(stage)) { + asleep += minutes; + if (stage === "REM") rem += minutes; + if (stage === "DEEP") deep += minutes; + if (stage === "REM" || stage === "CORE" || stage === "DEEP") { + sawStageBreakdown = true; + } + } else if (stage == null) { + // A bare SLEEP_DURATION row (no stage) is the night's total asleep. + asleep += minutes; + } + } + // In-bed = explicit IN_BED rows when present, else asleep + awake. + const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null; + const midpoint = + Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest + ? minutesOfDay(new Date((earliest + latest) / 2)) + : null; + nights.push({ + night, + asleepMinutes: asleep, + awakeMinutes: awake, + remMinutes: rem, + deepMinutes: deep, + inBedMinutes, + hasStageBreakdown: sawStageBreakdown, + midpoint, + }); + } + return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); +} + +function minutesOfDay(d: Date): number { + return d.getUTCHours() * 60 + d.getUTCMinutes(); +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface SleepScoreOpts { + windowDays?: number; + now?: Date; +} + +/** + * Compute the Sleep Score for the most recent night, using the trailing + * window for the consistency/timing baseline. Returns `insufficient` when + * no scorable night exists in the window. + */ +export async function computeSleepScore( + userId: string, + profile: BaselineProfile, + opts: SleepScoreOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const inputs = ["SLEEP_DURATION"]; + const required = 1; + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + + const rows = (await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" satisfies MeasurementType, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true }, + })) as SleepRow[]; + + if (rows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: required, + presentInputs: 0, + historyDays: 0, + missing: inputs, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { inputs, source: "none", windowDays, computedAt }, + reason: "no_sleep_in_window", + }); + } + + const nights = reconstructNights(rows).filter( + (n) => n.asleepMinutes > 0, + ); + const scorableNights = nights.filter( + (n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT, + ); + + if (scorableNights.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: required, + presentInputs: 0, + historyDays: 0, + missing: inputs, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { inputs, source: "live", windowDays, computedAt }, + reason: "no_scorable_night", + }); + } + + const latest = scorableNights[scorableNights.length - 1]; + const midpoints = scorableNights + .map((n) => n.midpoint) + .filter((m): m is number => m != null); + const habitualMidpoint = + midpoints.length > 0 + ? midpoints.reduce((s, v) => s + v, 0) / midpoints.length + : null; + + const needMinutes = sleepNeedMinutes(profile.ageYears); + + const raw: Record = { + sufficiency: scoreSufficiency(latest.asleepMinutes, needMinutes), + efficiency: scoreEfficiency(latest.asleepMinutes, latest.inBedMinutes), + consistency: scoreConsistency(midpoints), + timing: scoreTiming(latest.midpoint, habitualMidpoint, midpoints.length), + composition: scoreComposition( + latest.remMinutes, + latest.deepMinutes, + latest.asleepMinutes, + latest.hasStageBreakdown, + ), + }; + + const { score, subScores } = blendSleepSubScores(raw); + const presentCount = subScores.filter((s) => s.value !== null).length; + const missing = subScores + .filter((s) => s.value === null) + .map((s) => s.key); + + const { coverage, confidence } = deriveCoverage({ + // Coverage here is "sub-scores present / total possible" — the + // composite's input axis is its five sub-scores, not the single + // SLEEP_DURATION stream. + requiredInputs: subScores.length, + presentInputs: presentCount, + historyDays: scorableNights.length, + missing, + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { + score, + band: bandForScore(score), + night: latest.night, + asleepMinutes: latest.asleepMinutes, + inBedMinutes: latest.inBedMinutes, + subScores, + windowNights: scorableNights.length, + }, + coverage, + confidence, + provenance: { + inputs, + source: "live", + windowDays, + computedAt, + }, + }); +} + +function countNightRows(rows: SleepRow[], night: string): number { + let c = 0; + for (const r of rows) { + if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1; + } + return c; +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index d65909d77..e322b886c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1385,6 +1385,47 @@ const derivedMetricResponse = z "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", }); +// v1.10.0 — FDR-controlled correlation discovery result. One discovered, +// statistically-defensible behaviour → next-day-outcome pair. +const discoveredCorrelation = z + .object({ + behaviour: z + .string() + .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), + outcome: z + .string() + .describe("Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY."), + n: z.number().int().describe("Paired-day count after the day+1 lag join (≥ 20)."), + r: z.number().describe("Pearson r over the lag-joined daily series."), + pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), + qValue: z + .number() + .describe("Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold)."), + interpretation: z + .string() + .describe("Conservative, descriptive interpretation — never causal."), + lagDays: z.number().int().describe("Lag in days applied (1)."), + }) + .meta({ id: "DiscoveredCorrelation" }); + +const correlationDiscoveryResponse = z + .object({ + discovered: z + .array(discoveredCorrelation) + .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), + pairsTested: z + .number() + .int() + .describe("Behaviour × outcome pairs assessed (for the honest footer)."), + fdrQ: z.number().describe("The FDR target the surface used."), + minPairs: z.number().int().describe("Minimum paired-day count enforced per pair."), + }) + .meta({ + id: "CorrelationDiscoveryResponse", + description: + "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", + }); + // The seven specialised `*-status` routes accept an optional locale // override (the metric is fixed by the route path, unlike the generic // metric-status route which carries it as a query field). @@ -3128,6 +3169,28 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/correlations": { + get: { + tags: ["Insights"], + summary: "Correlation discovery (FDR-controlled)", + description: + "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The discovered correlations + the tested-pair count.", + content: { + "application/json": { + schema: dataEnvelope( + correlationDiscoveryResponse, + "CorrelationDiscoveryResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index b2c3f78a4..2f3e9652a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -143,6 +143,24 @@ export const queryKeys = { */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, + * v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`). + * One generic route backs every derived composite (sleep score, + * readiness, coincident-deviation, vitals baseline, …) so the key is + * parameterised by the metric id alongside the optional sub-type. Pure + * compute over the rollup tier — a measurement write keeps it fresh by + * construction (next read sees new buckets), so the `["insights"]` prefix + * is enough for invalidation fan-out. + */ + insightsDerivedMetric: (metric: string, type?: string) => + type + ? (["insights", "derived", metric, type] as const) + : (["insights", "derived", metric] as const), + /** + * v1.10.0 — FDR-controlled correlation discovery + * (`/api/insights/correlations`). Read-only descriptive surface; the + * `["insights"]` prefix keeps it in the existing invalidation fan-out. + */ + insightsCorrelations: () => ["insights", "correlations"] as const, medications: () => ["medications"] as const, medicationDetail: (id: string) => ["medications", id] as const, From 440f24bff219062cea959e607d392b70240bbf20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:36:39 +0200 Subject: [PATCH 07/49] fix(medications): drop the bottom-pinned action gap on the cards The v1.9.0 equal-height change set the card content to a flex column and pinned the action buttons to the bottom with mt-auto. On a shorter card next to a taller one in the grid, that opened an empty gap between the content and the buttons. The cards already stretch to a shared height via the grid, so the buttons can sit directly after the content: drop the flex column and the mt-auto wrapper on both card variants. The reserved dose-row slot stays, so the dose lines still align across the grid; only the gap is gone. --- .../medications/glp1-medication-card.tsx | 29 +++++++------------ .../medications/medication-card.tsx | 25 +++++++--------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index deb0c3d6d..876e5fe03 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -406,7 +406,7 @@ export function Glp1MedicationCard({ linkLabel={t("medications.openDetailPage")} /> - + {/* Take-now / overdue / very-overdue pill, shared with the generic medication card. The GLP-1 card historically omitted this row, which made Mounjaro feel different from @@ -423,13 +423,8 @@ export function Glp1MedicationCard({ {/* Injection state — last + next. The reserved min-height keeps a card whose last-injection line is absent the same vertical footprint as one that renders both lines, matching the generic - medication card's reserved last/next slot. This — together with - `h-full` + `mt-auto` — equalises the card height and pins a - shared bottom action baseline across the grid row. Note it does - NOT align the compliance bars row-for-row with a generic - sibling: the GLP-1-only rotation-hint block below pushes the - bars down by its own height, so only the header, the reserved - slot, and the pinned action row share a Y. */} + medication card's reserved last/next slot, so the dose rows line + up across the grid. */}
    {medication.lastTakenAt && (

    @@ -503,17 +498,15 @@ export function Glp1MedicationCard({ {/* Primary actions row — shared with the generic medication card. The GLP-1-specific side-effect quick-log lives in the header-actions overflow (kebab), not this row, so Mounjaro - and Ramipril share the canonical two-button primary row. - Pinned to the bottom with `mt-auto` so the action baseline is - shared across every card in the grid row, identical to the - generic medication card. */} + and Ramipril share the canonical two-button primary row. The + row sits directly after the content (the card still fills the + grid cell via h-full, but the actions are not pushed to the + bottom — that left a void between the content and the buttons). */} {medication.active && ( -

    - -
    + )} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 26a2c9a11..407cb6442 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -311,7 +311,7 @@ export function MedicationCard({ linkLabel={t("medications.openDetailPage")} /> - + {/* Status, last & next intake info */} {currentWindowStatus.status && ( {nextSchedule && currentWindowStatus.status !== "in_window" && @@ -409,17 +408,15 @@ export function MedicationCard({ /> )} - {/* Quick actions — primary buttons of the medication card. - Pinned to the bottom with `mt-auto` so the action baseline is - shared across every card in the grid row regardless of how many - optional rows above it rendered. */} + {/* Quick actions — primary buttons of the medication card. They sit + directly after the content (the card still fills the grid cell via + h-full, but the actions are not pushed to the bottom — that left a + void between the content and the buttons on shorter cards). */} {medication.active && ( -
    - -
    + )}
    From 14d79ab4531452e2b47b83bf05c7af0174c51fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 21:25:58 +0200 Subject: [PATCH 08/49] fix(insights): reconcile the derived vitals + composite waves Resolve the overlap where the vitals tier and the composite tier both extend the derived-metric registry, dispatch, query-key factory, i18n bundles and the OpenAPI contract: every derived metric (vitals baseline, fitness/vascular age, HRV balance, BMI, sleep score, readiness, coincident-deviation) is now wired and implemented, the dispatch test asserts the full set, and the locale bundles carry both the vitals and the score-anatomy/composite strings. --- src/lib/insights/derived/__tests__/dispatch.test.ts | 13 +++---------- src/lib/insights/derived/dispatch.ts | 3 +++ src/lib/query-keys.ts | 1 + 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 6babe8d0a..2f595b115 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -27,22 +27,15 @@ const NOW = new Date("2026-06-02T07:00:00Z"); beforeEach(() => vi.clearAllMocks()); describe("registry", () => { - it("exposes the implemented vitals-tier metrics and the composite stubs", () => { + it("exposes every derived metric as implemented", () => { expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(true); expect(getDerivedMetricMeta("VASCULAR_AGE_DELTA")?.implemented).toBe(true); expect(getDerivedMetricMeta("HRV_BALANCE")?.implemented).toBe(true); expect(getDerivedMetricMeta("BMI")?.implemented).toBe(true); - // Composites still land in W3. - expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); - expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false); - it("exposes the flagship + W3 composites as implemented; the passthroughs stay stubs", () => { - expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true); expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true); expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true); - // FITNESS_AGE + VASCULAR_AGE_DELTA land in a sibling wave. - expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -76,7 +69,7 @@ describe("computeDerivedMetric dispatch", () => { expect(result.status).toBe("insufficient"); }); - it("returns not_implemented for a stubbed metric (never a 500)", async () => { + it("routes FITNESS_AGE to its engine (no data → insufficient, not not_implemented)", async () => { const result = await computeDerivedMetric({ metric: "FITNESS_AGE", userId: "u1", @@ -85,7 +78,7 @@ describe("computeDerivedMetric dispatch", () => { }); expect(result.status).toBe("insufficient"); if (result.status === "insufficient") { - expect(result.reason).toBe("not_implemented"); + expect(result.reason).not.toBe("not_implemented"); } }); diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index c393f7535..d09346df2 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -124,6 +124,9 @@ export async function computeDerivedMetric( }) as Promise>; case "BMI": return computeBmi(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; case "SLEEP_SCORE": return computeSleepScore(args.userId, args.profile, { windowDays: args.windowDays, diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 2f3e9652a..f9101c8c7 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -143,6 +143,7 @@ export const queryKeys = { */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, + /** * v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`). * One generic route backs every derived composite (sleep score, * readiness, coincident-deviation, vitals baseline, …) so the key is From d247f6966a6844d5faf51c7482366e7405acc6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 21:50:30 +0200 Subject: [PATCH 09/49] feat(workouts): add a route-independent per-workout heart-rate series Indoor workouts had no home for their per-sample heart-rate series: it rode on WorkoutRoute.sampleTimestamps, which only exists when a workout carries GPS geometry. A treadmill or trainer session therefore lost the signal the training-strain reader needs. Add a WorkoutSamples child (workout_samples), 1:1 with the workout like WorkoutRoute, holding a JSONB `[{ t, hr?, speedMs?, power?, cadence? }]` array plus a denormalised sample_count. JSONB keeps the self-hosted Postgres extension-free, mirroring the route table's decision. Migration 0107. Also retire the stale cross-source workout-dedup marker on the Workout model: that work landed as the write-time dedupeWorkoutBatch() picker plus the read-time pickCanonicalWorkoutRows() picker, both resolving the user's source-priority ladder. The comment now documents the two layers instead of tracking them as open. --- .../migration.sql | 44 ++++++++++++ prisma/schema.prisma | 68 ++++++++++++++++--- 2 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/0107_v1100_workout_hr_series/migration.sql diff --git a/prisma/migrations/0107_v1100_workout_hr_series/migration.sql b/prisma/migrations/0107_v1100_workout_hr_series/migration.sql new file mode 100644 index 000000000..bf5dcb61c --- /dev/null +++ b/prisma/migrations/0107_v1100_workout_hr_series/migration.sql @@ -0,0 +1,44 @@ +-- v1.10.0 — per-workout heart-rate series (route-independent). +-- +-- A companion to `workout_routes` that holds the per-sample HR series +-- WITHOUT requiring GPS geometry. The route table only exists when a +-- workout has a GeoJSON LineString, so an INDOOR session (treadmill, +-- trainer, strength) had nowhere to store its heart-rate samples even +-- though HealthKit ships them. That series feeds the training-strain +-- engine (TRIMP / time-in-HR-zone), so decoupling it from the route is +-- what unblocks indoor strain. +-- +-- Shape rationale: +-- * `samples` JSONB — `[{ t: ISO string, hr?: int, speedMs?, power?, +-- cadence? }]`, one entry per recorded sample. Same JSONB decision +-- as `workout_routes.geometry` / `sample_timestamps`: no PostGIS +-- extension dependency on a self-hosted Postgres install, and the +-- blob mirrors HealthKit's per-sample shape directly. +-- * `sample_count` — denormalised `samples.length` so the strain +-- reader + observability can size the series without deserialising +-- the JSONB blob. The application caps the array length on write +-- (MAX_WORKOUT_HR_SAMPLES in src/lib/validations/workout.ts) so the +-- per-workout payload — and the largest new workout-side write +-- stream — stays bounded. +-- +-- 1:1 with the workout: `workout_id` is UNIQUE with an ON DELETE CASCADE +-- FK, exactly like `workout_routes`. A re-submitted batch that wins the +-- workout-row race but loses the sample-row race is an idempotent no-op +-- (the ingest path uses `skipDuplicates`), not a hard error. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- DROP TABLE IF EXISTS "workout_samples"; +-- A roll-back loses the captured per-workout HR series; the workout row, +-- its aggregates (avg/max/min HR), and the route survive untouched. + +CREATE TABLE IF NOT EXISTS "workout_samples" ( + "id" TEXT PRIMARY KEY, + "workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE, + -- JSONB array: [{ t: ISO string, hr?: int, speedMs?, power?, cadence? }] + "samples" JSONB NOT NULL, + -- Denormalised samples.length — sized without parsing the blob. + "sample_count" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2881102b3..82065d908 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -758,21 +758,28 @@ model Workout { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - route WorkoutRoute? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + route WorkoutRoute? + samples WorkoutSamples? /// Ingest dedup. NULL externalId is distinct so two manual /// entries on the same day never collide here. /// - /// TODO(v1.5 iOS): cross-source workout dedup. The current key is - /// (userId, source, externalId) — so the SAME workout ingested from - /// MANUAL plus the iOS HealthKit passthrough lands two rows because - /// `source` differs. Measurements already solved this via - /// `pickCanonicalSourceRows()` (the read-time canonical-source - /// picker in src/lib/analytics/source-priority.ts); workouts need - /// the symmetric helper before the dashboard "recent workouts" tile - /// can show one row per workout instead of one per source. Tracked - /// for v1.5 P1 alongside the iOS app. + /// Cross-source dedup (the SAME logical workout arriving from two + /// paired sensors — e.g. Apple Watch + Withings ScanWatch — under + /// different `source`/`externalId` pairs) is handled in two layers, + /// mirroring the measurement-side `pickCanonicalSourceRows()`: + /// * WRITE-time — `dedupeWorkoutBatch()` in + /// src/lib/workouts/canonical-rows.ts groups an inbound batch by + /// (userId, activityType, startedAt ± 90 s) and keeps one + /// canonical row per logical workout BEFORE the insert, so the + /// duplicate never reaches storage. + /// * READ-time — `pickCanonicalWorkoutRows()` in + /// src/lib/measurements/pick-canonical-workout-rows.ts collapses + /// any cross-batch twins (e.g. one source's row from Monday's + /// batch + the other's from Tuesday's backfill) so the dashboard + /// "recent workouts" tile shows one row per logical workout. + /// Both pickers resolve the user's `sourcePriorityJson` ladder. @@unique([userId, source, externalId]) /// Dashboard "recent workouts" query path. @@index([userId, startedAt]) @@ -801,6 +808,45 @@ model WorkoutRoute { @@map("workout_routes") } +// ─── Per-workout heart-rate (+ optional speed/power/cadence) series ── +// +// v1.10.0 — a route-independent companion to `WorkoutRoute`. +// +// The route table only exists when a workout has GPS geometry, so an +// INDOOR session (treadmill, bike trainer, strength) had no home for +// its per-sample heart-rate series even though HealthKit ships one. +// That series is the input the training-strain engine (TRIMP / +// time-in-HR-zone) needs, so decoupling it from the route is what +// unblocks indoor strain. +// +// `samples` is a JSONB array `[{ t, hr?, speedMs?, power?, cadence? }]` +// — `t` is an ISO timestamp, `hr` beats/min. Same JSONB-over-PostGIS +// decision as `WorkoutRoute` (no extension dependency on a self-hosted +// Postgres). 1:1 with the workout (FK `workout_id` UNIQUE) so a +// re-ingested batch that wins the workout-row race but loses the +// sample-row race is an idempotent no-op, exactly like the route table. +// +// `sample_count` is denormalised so the strain reader + observability +// can size the series without deserialising the JSONB blob. The ingest +// path caps the count (see MAX_WORKOUT_HR_SAMPLES in +// src/lib/validations/workout.ts) to keep the per-workout payload — and +// the largest new workout-side write stream — bounded. +model WorkoutSamples { + id String @id @default(cuid()) + workoutId String @unique @map("workout_id") + /// JSONB array of `{ t: ISO string, hr?: int, speedMs?, power?, + /// cadence? }`. One entry per recorded sample; ordered by `t` + /// ascending at ingest. Capped at MAX_WORKOUT_HR_SAMPLES. + samples Json + /// Denormalised `samples.length` — sized without parsing the blob. + sampleCount Int @map("sample_count") + createdAt DateTime @default(now()) @map("created_at") + + workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade) + + @@map("workout_samples") +} + // ─── Personal records (v1.4.25 W8d — schema only) ────────── // // MAX vs MIN direction is stored on the row so the future detection From 30be0c4e0d0a5feeaab54fb85118d31638196394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 21:50:42 +0200 Subject: [PATCH 10/49] feat(workouts): accept an optional HR series on the batch contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend POST /api/workouts/batch with an optional per-workout `samples` array — a route-independent heart-rate series so indoor workouts still yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?, power?, cadence? }`; the array is capped at 30 000 points (MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at 1 Hz while bounding the largest new workout-side write stream the same way the 20 000-point route cap bounds the geometry. Persistence rides the existing route-attachment path: the freshly written workout id is resolved once (by composite key, or the manual tuple), then both the route geometry and the HR series attach by FK with skipDuplicates so a re-submitted batch stays idempotent. A workout may carry both a GPS route and a canonical HR series, or just one. GET /api/workouts/{id} returns the series alongside the route. OpenAPI regenerated. `samples` keeps first-write-wins/overwrite parity with the workout row it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an existing workout never duplicates or mutates its series. --- docs/api/openapi.yaml | 46 +++++++ src/app/api/workouts/[id]/route.ts | 17 +++ .../workouts/__tests__/batch-create.test.ts | 120 ++++++++++++++++++ src/app/api/workouts/batch/route.ts | 91 +++++++++++-- src/lib/openapi/routes.ts | 14 +- src/lib/validations/__tests__/workout.test.ts | 91 +++++++++++++ src/lib/validations/workout.ts | 54 ++++++++ .../integration/workout-batch-create.test.ts | 43 +++++++ 8 files changed, 463 insertions(+), 13 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9e63b701b..08f8aa46d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -834,6 +834,35 @@ paths: - t required: - geometry + samples: + minItems: 1 + maxItems: 30000 + type: array + items: + type: object + properties: + t: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + hr: + type: integer + minimum: 20 + maximum: 300 + speedMs: + type: number + minimum: 0 + maximum: 50 + power: + type: number + minimum: 0 + maximum: 3000 + cadence: + type: number + minimum: 0 + maximum: 400 + required: + - t required: - sportType - startedAt @@ -4322,6 +4351,10 @@ components: anyOf: - $ref: "#/components/schemas/WorkoutRouteGeometry" - type: "null" + samples: + anyOf: + - $ref: "#/components/schemas/WorkoutHrSeries" + - type: "null" canonicalId: type: string required: @@ -4342,6 +4375,7 @@ components: - externalId - metadata - route + - samples - canonicalId additionalProperties: false WorkoutRouteGeometry: @@ -4358,6 +4392,18 @@ components: - geometry - sampleTimestamps additionalProperties: false + WorkoutHrSeries: + type: object + properties: + sampleCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + samples: {} + required: + - sampleCount + - samples + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 9c9980967..d2b7141a9 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -50,6 +50,12 @@ export const GET = apiHandler( createdAt: true, }, }, + samples: { + select: { + samples: true, + sampleCount: true, + }, + }, }, }); @@ -108,6 +114,16 @@ export const GET = apiHandler( } : null; + // v1.10.0 — route-independent per-workout HR series. Present for + // both indoor (no route) and outdoor workouts that shipped a + // `samples` array on ingest; null otherwise. + const samples = row.samples + ? { + sampleCount: row.samples.sampleCount, + samples: row.samples.samples, + } + : null; + return apiSuccess({ id: row.id, sportType: row.sportType, @@ -126,6 +142,7 @@ export const GET = apiHandler( externalId: row.externalId, metadata: row.metadata, route, + samples, // v1.4.32 — when the requested id is a non-canonical twin the // caller can redirect to `canonicalId` to land on the cluster // winner. `canonicalId === id` when the requested row already diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index 74a19557b..e834f4abb 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -21,6 +21,9 @@ vi.mock("@/lib/db", () => ({ workoutRoute: { createMany: vi.fn(), }, + workoutSamples: { + createMany: vi.fn(), + }, user: { findUnique: vi.fn(), }, @@ -123,6 +126,7 @@ beforeEach(() => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.workout.findFirst).mockResolvedValue(null); vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 }); // v1.4.43 W9 — default to no per-user source-priority override so // the write-time picker walks the canonical default ladder. Tests // that exercise a custom ladder override this in-line. @@ -386,6 +390,122 @@ describe("POST /api/workouts/batch — nested route attachment", () => { }); }); +describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => { + it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + select?: { id?: true }; + }) => { + if (args.select?.id) { + return [ + { + id: "wkt-indoor-id", + source: "APPLE_HEALTH", + externalId: "uuid-indoor", + }, + ]; + } + return []; + }) as never); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-indoor", { + sportType: "strength", + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118 }, + { t: "2026-05-14T06:30:10.000Z", hr: 124, power: 0, cadence: 0 }, + ], + }), + ], + }), + ); + expect(res.status).toBe(200); + // The route table is untouched — an indoor workout has no GPS route. + expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled(); + // The HR series lands in the dedicated child table by FK. + expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); + const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0]; + const rows = ( + call as { + data: Array<{ + workoutId: string; + sampleCount: number; + samples: unknown; + }>; + } + ).data; + expect(rows[0]?.workoutId).toBe("wkt-indoor-id"); + // `sampleCount` is the denormalised series length. + expect(rows[0]?.sampleCount).toBe(3); + expect((rows[0]?.samples as unknown[]).length).toBe(3); + }); + + it("attaches both a route and an HR series on one workout", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + select?: { id?: true }; + }) => { + if (args.select?.id) { + return [ + { + id: "wkt-both-id", + source: "APPLE_HEALTH", + externalId: "uuid-both", + }, + ]; + } + return []; + }) as never); + + const res = await POST( + makeRequest({ + workouts: [ + validWorkout("uuid-both", { + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }], + }), + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1); + expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1); + const routeRows = ( + vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as { + data: Array<{ workoutId: string }>; + } + ).data; + const sampleRows = ( + vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as { + data: Array<{ workoutId: string }>; + } + ).data; + // Both children attach to the SAME freshly-inserted workout id. + expect(routeRows[0]?.workoutId).toBe("wkt-both-id"); + expect(sampleRows[0]?.workoutId).toBe("wkt-both-id"); + }); + + it("does not touch the samples table when no workout ships a series", async () => { + vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ workouts: [validWorkout("uuid-no-series")] }), + ); + expect(res.status).toBe(200); + expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/workouts/batch — user source-priority (v1.4.43 W9)", () => { it("looks up the user's source-priority blob exactly once per batch", async () => { vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 29d13dea4..5ee00fb99 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -218,6 +218,13 @@ async function postBatch(request: NextRequest): Promise { geometry: unknown; sampleTimestamps: unknown; } | null; + /** Route-independent per-workout HR series. NULL when the entry + * ships no `samples` array. Persisted to the `WorkoutSamples` + * child by FK after the workout row lands. */ + samples: { + samples: unknown; + sampleCount: number; + } | null; /** Composite-key probe for the pre-flight findMany. NULL when * the entry has no externalId — those rows always pass through * as "inserted" because the NULL-distinct unique index can never @@ -237,6 +244,12 @@ async function postBatch(request: NextRequest): Promise { sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown, } : null; + const samples = w.samples + ? { + samples: w.samples as unknown, + sampleCount: w.samples.length, + } + : null; const row: Prisma.WorkoutCreateManyInput = { userId: user.id, sportType: w.sportType, @@ -262,6 +275,7 @@ async function postBatch(request: NextRequest): Promise { index, row, route, + samples, dedupKey: w.externalId ? { source: w.source, externalId: w.externalId } : null, @@ -372,19 +386,27 @@ async function postBatch(request: NextRequest): Promise { } // Look up the workouts we just wrote (or that the racing batch - // wrote) so we can attach routes by FK. We probe by the + // wrote) so we can attach their child rows (route geometry + + // the route-independent HR series) by FK. We probe by the // composite unique key for entries that have one, and by the // `(userId, source, startedAt, sportType)` tuple as a fallback // for manual entries (where externalId is null). The fallback // is best-effort — manual entries from the iOS client always // carry an HK uuid, so the null-externalId case is exercised // only by future surface (manual workout entry). - const routeAttachable = toInsert.filter((p) => p.route !== null); - if (routeAttachable.length > 0) { - const withExternal = routeAttachable.filter( + // + // v1.10.0 — `samples` (the per-workout HR series) rides the + // SAME id lookup as `route`: an indoor workout carries samples + // with no route, so the lookup runs whenever EITHER child is + // present on a to-insert entry. + const childAttachable = toInsert.filter( + (p) => p.route !== null || p.samples !== null, + ); + if (childAttachable.length > 0) { + const withExternal = childAttachable.filter( (p) => p.dedupKey !== null, ); - const withoutExternal = routeAttachable.filter( + const withoutExternal = childAttachable.filter( (p) => p.dedupKey === null, ); @@ -463,13 +485,30 @@ async function postBatch(request: NextRequest): Promise { return match ? { id: match.id } : null; }); + // Resolve the freshly-written workout id for a prepared + // entry — by composite key for externalId-bearing rows, by + // the deterministic (source, startedAt, sportType) tuple for + // manual rows. Shared by both child tables (route + samples) + // so the HR series and the route attach to the same row. + const resolveId = ( + p: Prepared, + manualIndex: number, + ): string | undefined => { + if (p.dedupKey !== null) { + return idBySourceExt.get( + `${p.dedupKey.source}::${p.dedupKey.externalId}`, + ); + } + return manualMatches[manualIndex]?.id ?? undefined; + }; + const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = []; + const samplesToInsert: Prisma.WorkoutSamplesCreateManyInput[] = []; for (let i = 0; i < withExternal.length; i++) { const p = withExternal[i]; - const id = idBySourceExt.get( - `${p.dedupKey!.source}::${p.dedupKey!.externalId}`, - ); - if (id && p.route) { + const id = resolveId(p, -1); + if (!id) continue; + if (p.route) { routesToInsert.push({ workoutId: id, geometry: p.route.geometry as Prisma.InputJsonValue, @@ -479,13 +518,21 @@ async function postBatch(request: NextRequest): Promise { : (p.route.sampleTimestamps as Prisma.InputJsonValue), }); } + if (p.samples) { + samplesToInsert.push({ + workoutId: id, + samples: p.samples.samples as Prisma.InputJsonValue, + sampleCount: p.samples.sampleCount, + }); + } } for (let i = 0; i < withoutExternal.length; i++) { const p = withoutExternal[i]; - const match = manualMatches[i]; - if (match?.id && p.route) { + const id = resolveId(p, i); + if (!id) continue; + if (p.route) { routesToInsert.push({ - workoutId: match.id, + workoutId: id, geometry: p.route.geometry as Prisma.InputJsonValue, sampleTimestamps: p.route.sampleTimestamps === null @@ -493,6 +540,13 @@ async function postBatch(request: NextRequest): Promise { : (p.route.sampleTimestamps as Prisma.InputJsonValue), }); } + if (p.samples) { + samplesToInsert.push({ + workoutId: id, + samples: p.samples.samples as Prisma.InputJsonValue, + sampleCount: p.samples.sampleCount, + }); + } } if (routesToInsert.length > 0) { @@ -508,6 +562,19 @@ async function postBatch(request: NextRequest): Promise { }); } } + + if (samplesToInsert.length > 0) { + for (let i = 0; i < samplesToInsert.length; i += CHUNK) { + await tx.workoutSamples.createMany({ + data: samplesToInsert.slice(i, i + CHUNK), + // 1:1 FK on `workoutId` (UNIQUE), same idempotency as + // the route child: a re-submitted batch that wins the + // workout-row race but loses the sample-row race is a + // no-op, not a hard error. + skipDuplicates: true, + }); + } + } } }); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index e322b886c..32d23dd53 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -125,7 +125,7 @@ coachPrefsSchema.meta({ createBatchWorkoutSchema.meta({ id: "CreateBatchWorkoutRequest", description: - "v1.4.25 W16b — typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).", + "Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).", }); const coachMessageFeedbackBody = z @@ -501,6 +501,17 @@ const workoutRouteGeometry = z }) .meta({ id: "WorkoutRouteGeometry" }); +// v1.10.0 — route-independent per-workout heart-rate series. Present +// for indoor and outdoor workouts that shipped a `samples` array on +// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?, +// power?, cadence? }]`; `sampleCount` is the denormalised length. +const workoutHrSeries = z + .object({ + sampleCount: z.number().int().nonnegative(), + samples: z.unknown(), + }) + .meta({ id: "WorkoutHrSeries" }); + const workoutDetailResponse = z .object({ id: z.string(), @@ -520,6 +531,7 @@ const workoutDetailResponse = z externalId: z.string().nullable(), metadata: z.unknown().nullable(), route: workoutRouteGeometry.nullable(), + samples: workoutHrSeries.nullable(), canonicalId: z.string(), }) .meta({ id: "WorkoutDetailResponse" }); diff --git a/src/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts index 557f63f33..297d88864 100644 --- a/src/lib/validations/__tests__/workout.test.ts +++ b/src/lib/validations/__tests__/workout.test.ts @@ -4,7 +4,9 @@ import { createWorkoutSchema, geoJsonLineStringSchema, MAX_ROUTE_POINTS, + MAX_WORKOUT_HR_SAMPLES, MAX_WORKOUTS_PER_BATCH, + workoutHrSamplesSchema, workoutRouteSamplesSchema, workoutSportTypeEnum, type CreateWorkoutInput, @@ -111,6 +113,61 @@ describe("workoutRouteSamplesSchema", () => { }); }); +describe("workoutHrSamplesSchema", () => { + it("accepts a route-independent HR series with optional channels", () => { + const parsed = workoutHrSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", hr: 142 }, + { t: "2026-05-14T07:00:05.000Z", hr: 145, speedMs: 3.1 }, + { t: "2026-05-14T07:00:10.000Z", hr: 150, power: 220, cadence: 88 }, + ]); + expect(parsed).toHaveLength(3); + }); + + it("accepts a bare-timestamp entry (sparse series)", () => { + expect( + workoutHrSamplesSchema.parse([{ t: "2026-05-14T07:00:00.000Z" }]), + ).toHaveLength(1); + }); + + it("rejects an empty series", () => { + expect(() => workoutHrSamplesSchema.parse([])).toThrow(); + }); + + it("rejects an out-of-range heart rate", () => { + expect(() => + workoutHrSamplesSchema.parse([ + { t: "2026-05-14T07:00:00.000Z", hr: 5 }, + ]), + ).toThrow(); + }); + + it("rejects a series above the per-workout sample cap", () => { + const oversized = Array.from( + { length: MAX_WORKOUT_HR_SAMPLES + 1 }, + (_v, i) => ({ + t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(), + hr: 140, + }), + ); + expect(() => workoutHrSamplesSchema.parse(oversized)).toThrow( + /sample.*cap/i, + ); + }); + + it("accepts a series exactly at the cap", () => { + const atCap = Array.from( + { length: MAX_WORKOUT_HR_SAMPLES }, + (_v, i) => ({ + t: new Date(Date.UTC(2026, 4, 14, 7, 0, 0) + i * 1000).toISOString(), + hr: 140, + }), + ); + expect(workoutHrSamplesSchema.parse(atCap)).toHaveLength( + MAX_WORKOUT_HR_SAMPLES, + ); + }); +}); + describe("createWorkoutSchema", () => { const minimalRun: Record = { sportType: "running", @@ -261,6 +318,40 @@ describe("createWorkoutSchema", () => { }); expect(parsed.route?.sampleTimestamps).toHaveLength(2); }); + + it("accepts a route-independent HR series on an indoor workout (no route)", () => { + const parsed = createWorkoutSchema.parse({ + sportType: "strength", + startedAt: "2026-05-14T06:30:00.000Z", + endedAt: "2026-05-14T07:15:00.000Z", + source: "APPLE_HEALTH", + externalId: "indoor-1", + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118 }, + ], + }); + expect(parsed.route).toBeUndefined(); + expect(parsed.samples).toHaveLength(2); + }); + + it("accepts both a GPS route and a canonical HR series on one workout", () => { + const parsed = createWorkoutSchema.parse({ + ...minimalRun, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.077, 49.452], + [11.078, 49.453], + ], + }, + }, + samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 142 }], + }); + expect(parsed.route?.geometry.coordinates).toHaveLength(2); + expect(parsed.samples).toHaveLength(1); + }); }); describe("createBatchWorkoutSchema", () => { diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index 5b8f35c46..f26864aae 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -91,6 +91,51 @@ export const workoutRouteSamplesSchema = z.array( export type WorkoutRouteSamples = z.infer; +/** + * Upper bound on per-workout heart-rate samples. HealthKit emits a HR + * sample roughly every 5 s during a workout — a 6-hour ultra at that + * cadence lands at ~4 320 points, and even a 1 Hz reconstruction of + * that session is ~21 600. 30 000 covers any realistic single session + * (including long endurance events sampled at 1 Hz) while still + * rejecting pathological payloads. The series is the largest new + * workout-side write stream, so the cap keeps each workout's payload — + * and the total request body — bounded the same way `MAX_ROUTE_POINTS` + * bounds the route geometry. iOS should downsample to ≤ this many + * points (e.g. 1-min buckets for very long sessions) before posting. + */ +export const MAX_WORKOUT_HR_SAMPLES = 30_000; + +/** + * Route-INDEPENDENT per-workout heart-rate series — the + * `WorkoutSamples.samples` shape. Mirrors `workoutRouteSamplesSchema` + * but carries no geometry coupling, so an indoor workout (no GPS) can + * still ship its HR signal for the training-strain engine. + * + * `t` is the per-sample ISO timestamp; `hr` beats/min. `speedMs` / + * `power` / `cadence` are optional companion channels HealthKit may + * report (cycling power meters, running cadence). Every channel is + * optional EXCEPT `t` so a sparse series (HR only) is valid; an entry + * with no signal channel at all is still permitted (a bare timestamp) + * but carries no analytical value. + */ +export const workoutHrSamplesSchema = z + .array( + z.object({ + t: z.iso.datetime({ offset: true }), + hr: z.number().int().min(20).max(300).optional(), + speedMs: z.number().min(0).max(50).optional(), + power: z.number().min(0).max(3000).optional(), + cadence: z.number().min(0).max(400).optional(), + }), + ) + .min(1, "samples requires at least 1 point") + .max( + MAX_WORKOUT_HR_SAMPLES, + `samples exceeds the ${MAX_WORKOUT_HR_SAMPLES}-point cap`, + ); + +export type WorkoutHrSamples = z.infer; + /** * Workout insert payload — the shape `POST /api/workouts/batch` accepts. * Defined here so the iOS Swift session can generate its DTO against the @@ -131,6 +176,15 @@ export const createWorkoutSchema = z sampleTimestamps: workoutRouteSamplesSchema.optional(), }) .optional(), + /** + * Route-independent per-workout heart-rate series. Persisted to the + * `WorkoutSamples` child so an indoor workout (no GPS route) still + * yields a strain-relevant HR signal. Independent of `route` — a + * GPS workout MAY carry both (`route.sampleTimestamps` for the + * geometry-aligned series, `samples` for the canonical HR series); + * an indoor workout carries only `samples`. + */ + samples: workoutHrSamplesSchema.optional(), }) .superRefine((value, ctx) => { if (value.endedAt.getTime() <= value.startedAt.getTime()) { diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index c1dde3885..0bcbf6277 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -97,6 +97,13 @@ interface WorkoutFixture { geometry: { type: "LineString"; coordinates: [number, number][] }; sampleTimestamps?: Array<{ t: string; speedMs?: number; hr?: number }>; }; + samples?: Array<{ + t: string; + hr?: number; + speedMs?: number; + power?: number; + cadence?: number; + }>; } function makeRequest( @@ -189,6 +196,42 @@ describe("POST /api/workouts/batch (real Postgres)", () => { expect(workouts[0]?.durationSec).toBe(45 * 60); }); + it("persists a route-independent HR series for an indoor workout (v1.10.0)", async () => { + const { POST } = await import("@/app/api/workouts/batch/route"); + + const body = { + workouts: [ + baseWorkout("hk-uuid-indoor-001", { + sportType: "strength", + // No route — an indoor session has no GPS geometry. + samples: [ + { t: "2026-05-14T06:30:00.000Z", hr: 110 }, + { t: "2026-05-14T06:30:05.000Z", hr: 118, power: 0, cadence: 0 }, + { t: "2026-05-14T06:30:10.000Z", hr: 124 }, + ], + }), + ], + }; + + const res = await POST(makeRequest(body)); + expect(res.status).toBe(200); + const json = (await res.json()) as { + data: { inserted: number }; + }; + expect(json.data.inserted).toBe(1); + + const workouts = await getPrismaClient().workout.findMany({ + where: { userId: TEST_USER_ID, externalId: "hk-uuid-indoor-001" }, + include: { route: true, samples: true }, + }); + expect(workouts).toHaveLength(1); + // No route row — the series lives in the dedicated child table. + expect(workouts[0]?.route).toBeNull(); + expect(workouts[0]?.samples).not.toBeNull(); + expect(workouts[0]?.samples?.sampleCount).toBe(3); + expect((workouts[0]?.samples?.samples as unknown[]).length).toBe(3); + }); + it("inserts a 100-workout batch with a mix of routed and routeless entries", async () => { const { POST } = await import("@/app/api/workouts/batch/route"); From 635e64ace75f4327960ee311393fde5770790e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:01:10 +0200 Subject: [PATCH 11/49] feat(healthkit): ingest seven additive HealthKit signals end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire seven previously-deferred HealthKit quantity identifiers through the full pipeline — enum, mapping, plausibility range, category, list-UI metadata, FHIR coding, and the generic metric-status assessment: CARDIO_RECOVERY heartRateRecoveryOneMinute bpm WRIST_TEMPERATURE appleSleepingWristTemperature celsius FALL_COUNT numberOfTimesFallen count SIX_MINUTE_WALK_DISTANCE sixMinuteWalkTestDistance m STAIR_ASCENT_SPEED stairAscentSpeed m/s STAIR_DESCENT_SPEED stairDescentSpeed m/s BREATHING_DISTURBANCES appleSleepingBreathingDisturbances count Each value rides raw on the wire (identity conversion). Fall count is cumulative (daily SUM); the stair speeds drain to a daily mean; the rest store as latest per-workout / per-night spot readings. Wrist temperature and breathing disturbances are flagged privacy-sensitive. The category sleep-apnea and walking-steadiness EVENT identifiers stay deferred — they are events, not quantities. Each new metric gains an Insights sub-page on the shared scaffold with the generic assessment card, localised across all six locales. Cardio fitness (VO2 max) — ingested since v1.4.23 but assessment-less — gains its own /insights/cardio-fitness page so it picks up the assessment instead of riding as a bare chart-row on the pulse page. Normal-range anchors are cited where defensible (six-minute walk 400-700 m, ATS guideline + population reference); the rest defer to the user's own baseline rather than fabricate a band. Migration 0104 appends the enum values additively (IF NOT EXISTS, forward-only). --- docs/api/openapi.yaml | 58 +++++++++ messages/de.json | 109 ++++++++++++++++- messages/en.json | 109 ++++++++++++++++- messages/es.json | 109 ++++++++++++++++- messages/fr.json | 109 ++++++++++++++++- messages/it.json | 109 ++++++++++++++++- messages/pl.json | 109 ++++++++++++++++- .../migration.sql | 36 ++++++ prisma/schema.prisma | 12 ++ .../insights/breathing-disturbances/page.tsx | 31 +++++ src/app/insights/cardio-fitness/page.tsx | 32 +++++ src/app/insights/cardio-recovery/page.tsx | 32 +++++ src/app/insights/falls/page.tsx | 30 +++++ src/app/insights/six-minute-walk/page.tsx | 31 +++++ src/app/insights/stair-ascent-speed/page.tsx | 31 +++++ src/app/insights/stair-descent-speed/page.tsx | 31 +++++ src/app/insights/wrist-temperature/page.tsx | 32 +++++ .../insights/insights-tab-strip.tsx | 30 +++++ .../measurements/measurement-list-meta.ts | 32 +++++ .../measurement-type-enum-coverage.test.ts | 23 +++- src/lib/dashboard-layout.ts | 9 ++ src/lib/fhir/loinc-map.ts | 47 ++++++++ .../insights/__tests__/metric-status.test.ts | 25 ++++ src/lib/insights/chart-tokens.ts | 18 +++ src/lib/insights/metric-availability.ts | 12 +- src/lib/insights/metric-status-registry.ts | 97 ++++++++++++++- src/lib/insights/sub-page-metric.ts | 29 +++++ .../__tests__/apple-health-mapping.test.ts | 86 ++++++++++++- src/lib/measurements/apple-health-mapping.ts | 114 ++++++++++++++++-- src/lib/measurements/categories.ts | 16 +++ .../__tests__/pr-detection-worker.test.ts | 11 +- src/lib/personal-records/pr-direction.ts | 17 +++ src/lib/validations/measurement.ts | 54 +++++++++ 33 files changed, 1596 insertions(+), 34 deletions(-) create mode 100644 prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql create mode 100644 src/app/insights/breathing-disturbances/page.tsx create mode 100644 src/app/insights/cardio-fitness/page.tsx create mode 100644 src/app/insights/cardio-recovery/page.tsx create mode 100644 src/app/insights/falls/page.tsx create mode 100644 src/app/insights/six-minute-walk/page.tsx create mode 100644 src/app/insights/stair-ascent-speed/page.tsx create mode 100644 src/app/insights/stair-descent-speed/page.tsx create mode 100644 src/app/insights/wrist-temperature/page.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08f8aa46d..d25cba861 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -376,6 +376,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES - in: query name: from schema: @@ -509,6 +516,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number measuredAt: @@ -2069,6 +2083,13 @@ paths: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES 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." @@ -2312,6 +2333,13 @@ paths: - AUDIO_EXPOSURE_HEADPHONE - AUDIO_EXPOSURE_EVENT - SLEEP_DURATION + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES 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 @@ -3165,17 +3193,25 @@ components: - double-support-time - step-length - walking-speed + - cardio-fitness + - falls + - six-minute-walk + - stair-ascent-speed + - stair-descent-speed - sleep + - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age + - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature + - wrist-temperature - mood - medications - blutdruck @@ -3715,6 +3751,13 @@ components: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number unit: @@ -4005,6 +4048,13 @@ components: - WALKING_DOUBLE_SUPPORT - WALKING_STEP_LENGTH - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES value: type: number unit: @@ -7766,17 +7816,25 @@ components: - double-support-time - step-length - walking-speed + - cardio-fitness + - falls + - six-minute-walk + - stair-ascent-speed + - stair-descent-speed - sleep + - breathing-disturbances - resting-pulse - hrv - pulse-wave-velocity - vascular-age + - cardio-recovery - environmental-audio - headphone-audio - audio-events - daylight - blood-glucose - skin-temperature + - wrist-temperature - mood - medications - blutdruck diff --git a/messages/de.json b/messages/de.json index cec242b40..76ca8c928 100644 --- a/messages/de.json +++ b/messages/de.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} Einzelwerte)", "expandDay": "Einzelwerte dieses Tages anzeigen", "collapseDay": "Einzelwerte dieses Tages ausblenden", - "loadError": "Messwerte konnten nicht geladen werden." + "loadError": "Messwerte konnten nicht geladen werden.", + "typeCardioRecovery": "Erholungspuls", + "typeWristTemperature": "Handgelenktemperatur", + "typeFallCount": "Stürze", + "typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke", + "typeStairAscentSpeed": "Treppensteig-Tempo", + "typeStairDescentSpeed": "Treppab-Tempo", + "typeBreathingDisturbances": "Atemaussetzer im Schlaf" }, "mood": { "title": "Stimmung", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Blutzucker", "bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.", "skinTemperatureTitle": "Hauttemperatur", - "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln." + "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.", + "cardioFitnessTitle": "Cardiofitness (VO₂max)", + "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der aussagekräftigste Laborwert für die Ausdauerfitness; ein höherer oder steigender Wert spricht für eine gute Herz-Kreislauf-Gesundheit.", + "cardioRecoveryTitle": "Erholungspuls", + "cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.", + "wristTemperatureTitle": "Handgelenktemperatur", + "wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen. Apple zeigt sie als Abweichung von deinem persönlichen Ausgangswert; ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.", + "fallsTitle": "Stürze", + "fallsBody": "Hier werden die von deiner Apple Watch erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.", + "sixMinuteWalkTitle": "6-Minuten-Gehstrecke", + "sixMinuteWalkBody": "Dies ist Apples Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Eine gleichbleibende oder steigende Strecke ist ein gutes Zeichen.", + "stairAscentSpeedTitle": "Treppensteig-Tempo", + "stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "stairDescentSpeedTitle": "Treppab-Tempo", + "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", + "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden." }, "stats": { "label": "Zusammenfassende Statistik", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Fitness", + "description": "Deine VO₂max im Zeitverlauf — das wichtigste Maß für die Ausdauerfitness.", + "chartTitle": "VO₂max (mL/(kg·min))", + "emptyState": { + "title": "Noch keine Fitnessdaten", + "description": "VO₂max-Werte erscheinen hier, sobald Apple Health sie aus deinen Workouts synchronisiert." + } + }, + "navCardioFitness": "Fitness", + "cardioRecovery": { + "title": "Erholungspuls", + "description": "Wie stark dein Puls eine Minute nach maximaler Belastung abfällt.", + "chartTitle": "Erholungspuls (bpm)", + "emptyState": { + "title": "Noch keine Erholungspuls-Daten", + "description": "Erholungspuls-Werte erscheinen hier, sobald Apple Health sie aus einem Workout synchronisiert." + } + }, + "navCardioRecovery": "Erholungspuls", + "wristTemperature": { + "title": "Handgelenktemperatur", + "description": "Deine nächtliche Handgelenktemperatur, gemessen an deinem eigenen Ausgangswert.", + "chartTitle": "Handgelenktemperatur (°C)", + "emptyState": { + "title": "Noch keine Handgelenktemperatur-Daten", + "description": "Werte erscheinen hier, sobald Apple Health das nächtliche Tragen synchronisiert." + } + }, + "navWristTemperature": "Handgelenk-Temp.", + "falls": { + "title": "Stürze", + "description": "Von deiner Apple Watch erkannte schwere Stürze, pro Tag gezählt.", + "chartTitle": "Stürze pro Tag", + "emptyState": { + "title": "Noch keine Sturzdaten", + "description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel." + } + }, + "navFalls": "Stürze", + "sixMinuteWalk": { + "title": "6-Minuten-Gehtest", + "description": "Apples Schätzung, wie weit du in sechs Minuten gehen könntest.", + "chartTitle": "6-Minuten-Gehstrecke (m)", + "emptyState": { + "title": "Noch keine 6-Minuten-Gehtest-Daten", + "description": "Schätzwerte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navSixMinuteWalk": "6-Min-Gehtest", + "stairAscentSpeed": { + "title": "Treppensteig-Tempo", + "description": "Wie schnell du Treppen hinaufgehst, gemessen von iPhone oder Watch.", + "chartTitle": "Treppensteig-Tempo (m/s)", + "emptyState": { + "title": "Noch keine Daten zum Treppensteig-Tempo", + "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navStairAscentSpeed": "Treppe hoch", + "stairDescentSpeed": { + "title": "Treppab-Tempo", + "description": "Wie schnell du Treppen hinabgehst, gemessen von iPhone oder Watch.", + "chartTitle": "Treppab-Tempo (m/s)", + "emptyState": { + "title": "Noch keine Daten zum Treppab-Tempo", + "description": "Werte erscheinen hier, sobald Apple Health synchronisiert." + } + }, + "navStairDescentSpeed": "Treppe runter", + "breathingDisturbances": { + "title": "Atemaussetzer", + "description": "Wie oft deine Atmung im Schlaf gestört war.", + "chartTitle": "Atemaussetzer im Schlaf", + "emptyState": { + "title": "Noch keine Daten zu Atemaussetzern", + "description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert." + } + }, + "navBreathingDisturbances": "Atmung" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index a59731193..813bd8806 100644 --- a/messages/en.json +++ b/messages/en.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} samples)", "expandDay": "Show samples for this day", "collapseDay": "Hide samples for this day", - "loadError": "Could not load measurements." + "loadError": "Could not load measurements.", + "typeCardioRecovery": "Cardio recovery", + "typeWristTemperature": "Wrist temperature", + "typeFallCount": "Falls", + "typeSixMinuteWalkDistance": "Six-minute walk distance", + "typeStairAscentSpeed": "Stair ascent speed", + "typeStairDescentSpeed": "Stair descent speed", + "typeBreathingDisturbances": "Sleep breathing disturbances" }, "mood": { "title": "Mood", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Blood glucose", "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.", "skinTemperatureTitle": "Skin temperature", - "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase." + "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", + "cardioFitnessTitle": "Cardio fitness (VO₂ max)", + "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort. It is the single best lab marker of aerobic fitness, and a higher or rising value is a strong sign of cardiovascular health.", + "cardioRecoveryTitle": "Cardio recovery", + "cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.", + "wristTemperatureTitle": "Wrist temperature", + "wristTemperatureBody": "Wrist temperature is measured while you sleep. Apple shows it as the difference from your personal baseline; a steady reading is normal, while a sustained rise can hint at illness, intense training, or a cycle change.", + "fallsTitle": "Falls", + "fallsBody": "This counts the hard falls your Apple Watch detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.", + "sixMinuteWalkTitle": "Six-minute walk distance", + "sixMinuteWalkBody": "This is Apple's estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. A steady or rising distance is a good sign.", + "stairAscentSpeedTitle": "Stair ascent speed", + "stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.", + "stairDescentSpeedTitle": "Stair descent speed", + "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", + "breathingDisturbancesTitle": "Sleep breathing disturbances", + "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician." }, "stats": { "label": "Summary statistics", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Cardio Fitness", + "description": "Your VO₂ max over time — the headline measure of aerobic fitness.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "No cardio-fitness data yet", + "description": "VO₂ max readings appear here once Apple Health syncs from your workouts." + } + }, + "navCardioFitness": "Cardio Fitness", + "cardioRecovery": { + "title": "Cardio Recovery", + "description": "How far your heart rate drops one minute after peak exercise.", + "chartTitle": "Cardio recovery (bpm)", + "emptyState": { + "title": "No cardio-recovery data yet", + "description": "Cardio-recovery readings appear here once Apple Health syncs from a workout." + } + }, + "navCardioRecovery": "Cardio Recovery", + "wristTemperature": { + "title": "Wrist Temperature", + "description": "Your overnight wrist temperature, tracked against your own baseline.", + "chartTitle": "Wrist temperature (°C)", + "emptyState": { + "title": "No wrist-temperature data yet", + "description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear." + } + }, + "navWristTemperature": "Wrist Temp", + "falls": { + "title": "Falls", + "description": "Hard falls your Apple Watch detected, counted per day.", + "chartTitle": "Falls per day", + "emptyState": { + "title": "No fall data yet", + "description": "Detected falls appear here once Apple Health syncs. No falls is the goal." + } + }, + "navFalls": "Falls", + "sixMinuteWalk": { + "title": "Six-Minute Walk", + "description": "Apple's estimate of how far you could walk in six minutes.", + "chartTitle": "Six-minute walk distance (m)", + "emptyState": { + "title": "No six-minute-walk data yet", + "description": "Six-minute-walk estimates appear here once Apple Health syncs." + } + }, + "navSixMinuteWalk": "6-Min Walk", + "stairAscentSpeed": { + "title": "Stair Ascent Speed", + "description": "How fast you climb stairs, measured by your iPhone or Watch.", + "chartTitle": "Stair ascent speed (m/s)", + "emptyState": { + "title": "No stair-ascent-speed data yet", + "description": "Stair-ascent-speed readings appear here once Apple Health syncs." + } + }, + "navStairAscentSpeed": "Stair Ascent", + "stairDescentSpeed": { + "title": "Stair Descent Speed", + "description": "How fast you go down stairs, measured by your iPhone or Watch.", + "chartTitle": "Stair descent speed (m/s)", + "emptyState": { + "title": "No stair-descent-speed data yet", + "description": "Stair-descent-speed readings appear here once Apple Health syncs." + } + }, + "navStairDescentSpeed": "Stair Descent", + "breathingDisturbances": { + "title": "Breathing Disturbances", + "description": "How often your breathing was disturbed during sleep.", + "chartTitle": "Sleep breathing disturbances", + "emptyState": { + "title": "No breathing-disturbance data yet", + "description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch." + } + }, + "navBreathingDisturbances": "Breathing" }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index 5cc0d1f34..97b5ae0d6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} muestras)", "expandDay": "Mostrar las muestras de este día", "collapseDay": "Ocultar las muestras de este día", - "loadError": "No se pudieron cargar las mediciones." + "loadError": "No se pudieron cargar las mediciones.", + "typeCardioRecovery": "Recuperación cardiaca", + "typeWristTemperature": "Temperatura de muñeca", + "typeFallCount": "Caídas", + "typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos", + "typeStairAscentSpeed": "Velocidad subiendo escaleras", + "typeStairDescentSpeed": "Velocidad bajando escaleras", + "typeBreathingDisturbances": "Alteraciones respiratorias del sueño" }, "mood": { "title": "Estado de ánimo", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glucosa en sangre", "bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.", "skinTemperatureTitle": "Temperatura de la piel", - "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual." + "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.", + "cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)", + "cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo. Es el mejor marcador de laboratorio de la forma aeróbica, y un valor más alto o en aumento es una buena señal de salud cardiovascular.", + "cardioRecoveryTitle": "Recuperación cardiaca", + "cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.", + "wristTemperatureTitle": "Temperatura de muñeca", + "wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes. Apple la muestra como la diferencia respecto a tu valor de referencia; un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.", + "fallsTitle": "Caídas", + "fallsBody": "Esto cuenta las caídas fuertes que detecta tu Apple Watch. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.", + "sixMinuteWalkTitle": "Distancia en marcha de 6 minutos", + "sixMinuteWalkBody": "Es la estimación de Apple de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. Una distancia estable o en aumento es una buena señal.", + "stairAscentSpeedTitle": "Velocidad subiendo escaleras", + "stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.", + "stairDescentSpeedTitle": "Velocidad bajando escaleras", + "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", + "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", + "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional." }, "stats": { "label": "Estadísticas resumidas", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Condición física", + "description": "Tu VO₂ máx a lo largo del tiempo, la medida principal de la forma aeróbica.", + "chartTitle": "VO₂ máx (mL/(kg·min))", + "emptyState": { + "title": "Aún no hay datos de condición física", + "description": "Los valores de VO₂ máx aparecen aquí cuando Apple Health los sincroniza desde tus entrenamientos." + } + }, + "navCardioFitness": "Condición física", + "cardioRecovery": { + "title": "Recuperación cardiaca", + "description": "Cuánto baja tu frecuencia cardiaca un minuto después del esfuerzo máximo.", + "chartTitle": "Recuperación cardiaca (lpm)", + "emptyState": { + "title": "Aún no hay datos de recuperación cardiaca", + "description": "Los valores de recuperación cardiaca aparecen aquí cuando Apple Health los sincroniza desde un entrenamiento." + } + }, + "navCardioRecovery": "Recuperación cardiaca", + "wristTemperature": { + "title": "Temperatura de muñeca", + "description": "Tu temperatura de muñeca durante la noche, comparada con tu valor de referencia.", + "chartTitle": "Temperatura de muñeca (°C)", + "emptyState": { + "title": "Aún no hay datos de temperatura de muñeca", + "description": "Los valores aparecen aquí cuando Apple Health sincroniza el uso nocturno." + } + }, + "navWristTemperature": "Temp. muñeca", + "falls": { + "title": "Caídas", + "description": "Caídas fuertes que detectó tu Apple Watch, contadas por día.", + "chartTitle": "Caídas por día", + "emptyState": { + "title": "Aún no hay datos de caídas", + "description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo." + } + }, + "navFalls": "Caídas", + "sixMinuteWalk": { + "title": "Marcha de 6 minutos", + "description": "La estimación de Apple de cuánto podrías caminar en seis minutos.", + "chartTitle": "Distancia en 6 minutos (m)", + "emptyState": { + "title": "Aún no hay datos de marcha de 6 minutos", + "description": "Las estimaciones aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navSixMinuteWalk": "Marcha 6 min", + "stairAscentSpeed": { + "title": "Velocidad subiendo escaleras", + "description": "Lo rápido que subes escaleras, medido por tu iPhone o Watch.", + "chartTitle": "Velocidad de subida (m/s)", + "emptyState": { + "title": "Aún no hay datos de subida de escaleras", + "description": "Los valores aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navStairAscentSpeed": "Subir escaleras", + "stairDescentSpeed": { + "title": "Velocidad bajando escaleras", + "description": "Lo rápido que bajas escaleras, medido por tu iPhone o Watch.", + "chartTitle": "Velocidad de bajada (m/s)", + "emptyState": { + "title": "Aún no hay datos de bajada de escaleras", + "description": "Los valores aparecen aquí cuando Apple Health se sincroniza." + } + }, + "navStairDescentSpeed": "Bajar escaleras", + "breathingDisturbances": { + "title": "Alteraciones respiratorias", + "description": "Con qué frecuencia se alteró tu respiración durante el sueño.", + "chartTitle": "Alteraciones respiratorias del sueño", + "emptyState": { + "title": "Aún no hay datos de alteraciones respiratorias", + "description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible." + } + }, + "navBreathingDisturbances": "Respiración" }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index 5b6d1d20a..4cfede66c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} échantillons)", "expandDay": "Afficher les échantillons de ce jour", "collapseDay": "Masquer les échantillons de ce jour", - "loadError": "Impossible de charger les mesures." + "loadError": "Impossible de charger les mesures.", + "typeCardioRecovery": "Récupération cardiaque", + "typeWristTemperature": "Température du poignet", + "typeFallCount": "Chutes", + "typeSixMinuteWalkDistance": "Distance de marche de 6 minutes", + "typeStairAscentSpeed": "Vitesse en montée d'escaliers", + "typeStairDescentSpeed": "Vitesse en descente d'escaliers", + "typeBreathingDisturbances": "Troubles respiratoires du sommeil" }, "mood": { "title": "Humeur", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glycémie", "bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.", "skinTemperatureTitle": "Température cutanée", - "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel." + "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.", + "cardioFitnessTitle": "Capacité cardio (VO₂ max)", + "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur marqueur de laboratoire de la forme aérobie, et une valeur plus élevée ou en hausse est un bon signe de santé cardiovasculaire.", + "cardioRecoveryTitle": "Récupération cardiaque", + "cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.", + "wristTemperatureTitle": "Température du poignet", + "wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil. Apple l'affiche comme l'écart par rapport à votre valeur de référence ; une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.", + "fallsTitle": "Chutes", + "fallsBody": "Ceci compte les chutes lourdes détectées par votre Apple Watch. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.", + "sixMinuteWalkTitle": "Distance de marche de 6 minutes", + "sixMinuteWalkBody": "C'est l'estimation d'Apple de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. Une distance stable ou en hausse est un bon signe.", + "stairAscentSpeedTitle": "Vitesse en montée d'escaliers", + "stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.", + "stairDescentSpeedTitle": "Vitesse en descente d'escaliers", + "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", + "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", + "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel." }, "stats": { "label": "Statistiques récapitulatives", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Condition physique", + "description": "Votre VO₂ max au fil du temps — la principale mesure de la forme aérobie.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Pas encore de données de condition physique", + "description": "Les valeurs de VO₂ max apparaissent ici dès qu'Apple Santé les synchronise depuis vos entraînements." + } + }, + "navCardioFitness": "Condition physique", + "cardioRecovery": { + "title": "Récupération cardiaque", + "description": "De combien votre fréquence cardiaque baisse une minute après l'effort maximal.", + "chartTitle": "Récupération cardiaque (bpm)", + "emptyState": { + "title": "Pas encore de données de récupération cardiaque", + "description": "Les valeurs de récupération cardiaque apparaissent ici dès qu'Apple Santé les synchronise depuis un entraînement." + } + }, + "navCardioRecovery": "Récupération cardiaque", + "wristTemperature": { + "title": "Température du poignet", + "description": "Votre température du poignet pendant la nuit, comparée à votre valeur de référence.", + "chartTitle": "Température du poignet (°C)", + "emptyState": { + "title": "Pas encore de données de température du poignet", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé synchronise le port nocturne." + } + }, + "navWristTemperature": "Temp. poignet", + "falls": { + "title": "Chutes", + "description": "Chutes lourdes détectées par votre Apple Watch, comptées par jour.", + "chartTitle": "Chutes par jour", + "emptyState": { + "title": "Pas encore de données de chutes", + "description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute." + } + }, + "navFalls": "Chutes", + "sixMinuteWalk": { + "title": "Marche de 6 minutes", + "description": "L'estimation d'Apple de la distance que vous pourriez parcourir en six minutes.", + "chartTitle": "Distance sur 6 minutes (m)", + "emptyState": { + "title": "Pas encore de données de marche de 6 minutes", + "description": "Les estimations apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navSixMinuteWalk": "Marche 6 min", + "stairAscentSpeed": { + "title": "Vitesse en montée d'escaliers", + "description": "À quelle vitesse vous montez les escaliers, mesurée par votre iPhone ou Watch.", + "chartTitle": "Vitesse de montée (m/s)", + "emptyState": { + "title": "Pas encore de données de montée d'escaliers", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navStairAscentSpeed": "Montée escaliers", + "stairDescentSpeed": { + "title": "Vitesse en descente d'escaliers", + "description": "À quelle vitesse vous descendez les escaliers, mesurée par votre iPhone ou Watch.", + "chartTitle": "Vitesse de descente (m/s)", + "emptyState": { + "title": "Pas encore de données de descente d'escaliers", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé se synchronise." + } + }, + "navStairDescentSpeed": "Descente escaliers", + "breathingDisturbances": { + "title": "Troubles respiratoires", + "description": "À quelle fréquence votre respiration a été perturbée pendant le sommeil.", + "chartTitle": "Troubles respiratoires du sommeil", + "emptyState": { + "title": "Pas encore de données de troubles respiratoires", + "description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible." + } + }, + "navBreathingDisturbances": "Respiration" }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index 0905faeff..95d1fba57 100644 --- a/messages/it.json +++ b/messages/it.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} campioni)", "expandDay": "Mostra i campioni di questa giornata", "collapseDay": "Nascondi i campioni di questa giornata", - "loadError": "Impossibile caricare le misurazioni." + "loadError": "Impossibile caricare le misurazioni.", + "typeCardioRecovery": "Recupero cardiaco", + "typeWristTemperature": "Temperatura del polso", + "typeFallCount": "Cadute", + "typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti", + "typeStairAscentSpeed": "Velocità in salita scale", + "typeStairDescentSpeed": "Velocità in discesa scale", + "typeBreathingDisturbances": "Disturbi respiratori nel sonno" }, "mood": { "title": "Umore", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Glicemia", "bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.", "skinTemperatureTitle": "Temperatura cutanea", - "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale." + "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.", + "cardioFitnessTitle": "Forma cardio (VO₂ max)", + "cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo. È il miglior marcatore di laboratorio della forma aerobica e un valore più alto o in aumento è un buon segno di salute cardiovascolare.", + "cardioRecoveryTitle": "Recupero cardiaco", + "cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.", + "wristTemperatureTitle": "Temperatura del polso", + "wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi. Apple la mostra come differenza dal tuo valore di riferimento; un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.", + "fallsTitle": "Cadute", + "fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo Apple Watch. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.", + "sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti", + "sixMinuteWalkBody": "È la stima di Apple di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Una distanza stabile o in aumento è un buon segno.", + "stairAscentSpeedTitle": "Velocità in salita scale", + "stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.", + "stairDescentSpeedTitle": "Velocità in discesa scale", + "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", + "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", + "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico." }, "stats": { "label": "Statistiche riepilogative", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Forma fisica", + "description": "Il tuo VO₂ max nel tempo: la misura principale della forma aerobica.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Nessun dato sulla forma fisica", + "description": "I valori di VO₂ max compaiono qui quando Apple Health li sincronizza dai tuoi allenamenti." + } + }, + "navCardioFitness": "Forma fisica", + "cardioRecovery": { + "title": "Recupero cardiaco", + "description": "Di quanto cala la frequenza cardiaca un minuto dopo lo sforzo massimo.", + "chartTitle": "Recupero cardiaco (bpm)", + "emptyState": { + "title": "Nessun dato sul recupero cardiaco", + "description": "I valori di recupero cardiaco compaiono qui quando Apple Health li sincronizza da un allenamento." + } + }, + "navCardioRecovery": "Recupero cardiaco", + "wristTemperature": { + "title": "Temperatura del polso", + "description": "La temperatura del polso durante la notte, rispetto al tuo valore di riferimento.", + "chartTitle": "Temperatura del polso (°C)", + "emptyState": { + "title": "Nessun dato sulla temperatura del polso", + "description": "I valori compaiono qui quando Apple Health sincronizza l'uso notturno." + } + }, + "navWristTemperature": "Temp. polso", + "falls": { + "title": "Cadute", + "description": "Cadute forti rilevate dal tuo Apple Watch, contate al giorno.", + "chartTitle": "Cadute al giorno", + "emptyState": { + "title": "Nessun dato sulle cadute", + "description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo." + } + }, + "navFalls": "Cadute", + "sixMinuteWalk": { + "title": "Cammino di 6 minuti", + "description": "La stima di Apple di quanto potresti camminare in sei minuti.", + "chartTitle": "Distanza in 6 minuti (m)", + "emptyState": { + "title": "Nessun dato sul cammino di 6 minuti", + "description": "Le stime compaiono qui quando Apple Health si sincronizza." + } + }, + "navSixMinuteWalk": "Cammino 6 min", + "stairAscentSpeed": { + "title": "Velocità in salita scale", + "description": "Quanto velocemente sali le scale, misurato da iPhone o Watch.", + "chartTitle": "Velocità in salita (m/s)", + "emptyState": { + "title": "Nessun dato sulla salita delle scale", + "description": "I valori compaiono qui quando Apple Health si sincronizza." + } + }, + "navStairAscentSpeed": "Salita scale", + "stairDescentSpeed": { + "title": "Velocità in discesa scale", + "description": "Quanto velocemente scendi le scale, misurato da iPhone o Watch.", + "chartTitle": "Velocità in discesa (m/s)", + "emptyState": { + "title": "Nessun dato sulla discesa delle scale", + "description": "I valori compaiono qui quando Apple Health si sincronizza." + } + }, + "navStairDescentSpeed": "Discesa scale", + "breathingDisturbances": { + "title": "Disturbi respiratori", + "description": "Quanto spesso la tua respirazione è stata disturbata durante il sonno.", + "chartTitle": "Disturbi respiratori nel sonno", + "emptyState": { + "title": "Nessun dato sui disturbi respiratori", + "description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile." + } + }, + "navBreathingDisturbances": "Respirazione" }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index 73256201c..db5d8c5a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -425,7 +425,14 @@ "dailyTotalCaption": "({count} pomiarów)", "expandDay": "Pokaż pomiary z tego dnia", "collapseDay": "Ukryj pomiary z tego dnia", - "loadError": "Nie udało się załadować pomiarów." + "loadError": "Nie udało się załadować pomiarów.", + "typeCardioRecovery": "Regeneracja serca", + "typeWristTemperature": "Temperatura nadgarstka", + "typeFallCount": "Upadki", + "typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego", + "typeStairAscentSpeed": "Prędkość wchodzenia po schodach", + "typeStairDescentSpeed": "Prędkość schodzenia po schodach", + "typeBreathingDisturbances": "Zaburzenia oddychania we śnie" }, "mood": { "title": "Nastrój", @@ -1994,7 +2001,23 @@ "bloodGlucoseTitle": "Poziom glukozy we krwi", "bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.", "skinTemperatureTitle": "Temperatura skóry", - "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego." + "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.", + "cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)", + "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, a wyższa lub rosnąca wartość świadczy o dobrym zdrowiu układu krążenia.", + "cardioRecoveryTitle": "Regeneracja serca", + "cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.", + "wristTemperatureTitle": "Temperatura nadgarstka", + "wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu. Apple pokazuje ją jako różnicę względem Twojej wartości bazowej; stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.", + "fallsTitle": "Upadki", + "fallsBody": "Tutaj liczone są poważne upadki wykryte przez Apple Watch. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.", + "sixMinuteWalkTitle": "Dystans marszu 6-minutowego", + "sixMinuteWalkBody": "To szacunek Apple, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Stały lub rosnący dystans to dobry znak.", + "stairAscentSpeedTitle": "Prędkość wchodzenia po schodach", + "stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.", + "stairDescentSpeedTitle": "Prędkość schodzenia po schodach", + "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", + "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", + "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem." }, "stats": { "label": "Statystyki podsumowujące", @@ -2546,7 +2569,87 @@ } } } - } + }, + "cardioFitness": { + "title": "Wydolność", + "description": "Twoje VO₂ max w czasie — główna miara wydolności tlenowej.", + "chartTitle": "VO₂ max (mL/(kg·min))", + "emptyState": { + "title": "Brak danych o wydolności", + "description": "Wartości VO₂ max pojawią się tutaj, gdy Apple Health zsynchronizuje je z Twoich treningów." + } + }, + "navCardioFitness": "Wydolność", + "cardioRecovery": { + "title": "Regeneracja serca", + "description": "O ile spada tętno minutę po szczytowym wysiłku.", + "chartTitle": "Regeneracja serca (bpm)", + "emptyState": { + "title": "Brak danych o regeneracji serca", + "description": "Wartości regeneracji serca pojawią się tutaj, gdy Apple Health zsynchronizuje je z treningu." + } + }, + "navCardioRecovery": "Regeneracja serca", + "wristTemperature": { + "title": "Temperatura nadgarstka", + "description": "Nocna temperatura nadgarstka, odniesiona do Twojej wartości bazowej.", + "chartTitle": "Temperatura nadgarstka (°C)", + "emptyState": { + "title": "Brak danych o temperaturze nadgarstka", + "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje nocne noszenie." + } + }, + "navWristTemperature": "Temp. nadgarstka", + "falls": { + "title": "Upadki", + "description": "Poważne upadki wykryte przez Apple Watch, liczone na dzień.", + "chartTitle": "Upadki na dzień", + "emptyState": { + "title": "Brak danych o upadkach", + "description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków." + } + }, + "navFalls": "Upadki", + "sixMinuteWalk": { + "title": "Marsz 6-minutowy", + "description": "Szacunek Apple, jak daleko możesz przejść w sześć minut.", + "chartTitle": "Dystans w 6 minut (m)", + "emptyState": { + "title": "Brak danych o marszu 6-minutowym", + "description": "Szacunki pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navSixMinuteWalk": "Marsz 6 min", + "stairAscentSpeed": { + "title": "Prędkość wchodzenia po schodach", + "description": "Jak szybko wchodzisz po schodach, mierzone przez iPhone'a lub Watch.", + "chartTitle": "Prędkość wchodzenia (m/s)", + "emptyState": { + "title": "Brak danych o wchodzeniu po schodach", + "description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navStairAscentSpeed": "Wchodzenie", + "stairDescentSpeed": { + "title": "Prędkość schodzenia po schodach", + "description": "Jak szybko schodzisz po schodach, mierzone przez iPhone'a lub Watch.", + "chartTitle": "Prędkość schodzenia (m/s)", + "emptyState": { + "title": "Brak danych o schodzeniu po schodach", + "description": "Wartości pojawią się tutaj, gdy Apple Health się zsynchronizuje." + } + }, + "navStairDescentSpeed": "Schodzenie", + "breathingDisturbances": { + "title": "Zaburzenia oddychania", + "description": "Jak często Twój oddech był zakłócany podczas snu.", + "chartTitle": "Zaburzenia oddychania we śnie", + "emptyState": { + "title": "Brak danych o zaburzeniach oddychania", + "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch." + } + }, + "navBreathingDisturbances": "Oddychanie" }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql b/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql new file mode 100644 index 000000000..02f6c64dc --- /dev/null +++ b/prisma/migrations/0104_v1100_additive_healthkit_metrics/migration.sql @@ -0,0 +1,36 @@ +-- v1.10.0 — extend `measurement_type` with seven additive HealthKit +-- signals that previously sat in the deferred set. +-- +-- Background: each identifier below was emitted by the iOS client but +-- mapped to no `MeasurementType`, so the batch route ack-200'd a +-- per-entry `skipped:"unmappable_identifier"` and the sample was lost. +-- They are now wired end-to-end (mapping + plausibility range + the +-- generic metric-status assessment surface). +-- +-- CARDIO_RECOVERY ← HKQuantityTypeIdentifierHeartRateRecoveryOneMinute +-- WRIST_TEMPERATURE ← HKQuantityTypeIdentifierAppleSleepingWristTemperature +-- FALL_COUNT ← HKQuantityTypeIdentifierNumberOfTimesFallen +-- SIX_MINUTE_WALK_DISTANCE ← HKQuantityTypeIdentifierSixMinuteWalkTestDistance +-- STAIR_ASCENT_SPEED ← HKQuantityTypeIdentifierStairAscentSpeed +-- STAIR_DESCENT_SPEED ← HKQuantityTypeIdentifierStairDescentSpeed +-- BREATHING_DISTURBANCES ← HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances +-- +-- Unit convention: every one of these ships raw on the wire and passes +-- through `convertToDbUnit` as identity — none is a 0..1 fraction, so the +-- ×100 percent-scaling path the gait-percent metrics use does NOT apply. +-- The convention block at the top of +-- `src/lib/measurements/apple-health-mapping.ts` spells the split out. +-- +-- Pure additive — no existing row carries any of these values. The +-- `IF NOT EXISTS` guard keeps the migration idempotent so the runner can +-- replay it on a partially-applied environment without failing on the +-- duplicate-enum-value error code. Forward-only; enum values cannot be +-- dropped in PostgreSQL, so there is no automatic down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_RECOVERY'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WRIST_TEMPERATURE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FALL_COUNT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SIX_MINUTE_WALK_DISTANCE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_ASCENT_SPEED'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAIR_DESCENT_SPEED'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCES'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82065d908..2e7e26b7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -532,6 +532,18 @@ enum MeasurementType { // and pass through `convertToDbUnit` as identity. WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m. WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s. + // v1.10.0 — additive HealthKit signals (WX-A) + // Seven previously-deferred quantity identifiers, wired end-to-end with + // the generic metric-status assessment treatment. Each rides the same + // server-side `convertToDbUnit` + plausibility-range path as the rest of + // the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`. + CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better). + WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot). + FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better. + SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better. + STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better. + STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better. + BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better. @@map("measurement_type") } diff --git a/src/app/insights/breathing-disturbances/page.tsx b/src/app/insights/breathing-disturbances/page.tsx new file mode 100644 index 000000000..a692ae338 --- /dev/null +++ b/src/app/insights/breathing-disturbances/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Wind } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/breathing-disturbances`. + * + * BREATHING_DISTURBANCES sub-page. Apple's per-night sleep-breathing + * index (iOS 18+), classified as NotElevated / Elevated; fewer is better. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsBreathingDisturbancesPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/cardio-fitness/page.tsx b/src/app/insights/cardio-fitness/page.tsx new file mode 100644 index 000000000..d4fbe4897 --- /dev/null +++ b/src/app/insights/cardio-fitness/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/cardio-fitness`. + * + * VO2_MAX sub-page. VO₂ max previously rode as a bare chart-row on the + * pulse page with no plain-language assessment; this dedicated page gives + * it the generic metric-status assessment treatment alongside the chart. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsCardioFitnessPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/cardio-recovery/page.tsx b/src/app/insights/cardio-recovery/page.tsx new file mode 100644 index 000000000..e859c4558 --- /dev/null +++ b/src/app/insights/cardio-recovery/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { HeartPulse } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/cardio-recovery`. + * + * CARDIO_RECOVERY sub-page. The heart-rate drop one minute after peak + * exercise; a larger drop signals fitter autonomic recovery. The series + * arrives from Apple Health sync after qualifying workouts — no + * manual-entry CTA. + */ +export default function InsightsCardioRecoveryPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/falls/page.tsx b/src/app/insights/falls/page.tsx new file mode 100644 index 000000000..34c32ad46 --- /dev/null +++ b/src/app/insights/falls/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { PersonStanding } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/falls`. + * + * FALL_COUNT sub-page. Hard-fall detections, summed per day; fewer is + * better. The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsFallsPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?" + /> + ); +} diff --git a/src/app/insights/six-minute-walk/page.tsx b/src/app/insights/six-minute-walk/page.tsx new file mode 100644 index 000000000..991e453c5 --- /dev/null +++ b/src/app/insights/six-minute-walk/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Footprints } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/six-minute-walk`. + * + * SIX_MINUTE_WALK_DISTANCE sub-page. Apple's estimated six-minute-walk-test + * distance; a mobility + cardiopulmonary endurance signal, higher-better. + * The series arrives from Apple Health sync — no manual-entry CTA. + */ +export default function InsightsSixMinuteWalkPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/stair-ascent-speed/page.tsx b/src/app/insights/stair-ascent-speed/page.tsx new file mode 100644 index 000000000..18ec8d6aa --- /dev/null +++ b/src/app/insights/stair-ascent-speed/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/stair-ascent-speed`. + * + * STAIR_ASCENT_SPEED sub-page. Gait speed measured while climbing stairs; + * a faster pace is the fitter signal. The series arrives from Apple Health + * sync — no manual-entry CTA. + */ +export default function InsightsStairAscentSpeedPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/stair-descent-speed/page.tsx b/src/app/insights/stair-descent-speed/page.tsx new file mode 100644 index 000000000..d7066413c --- /dev/null +++ b/src/app/insights/stair-descent-speed/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/stair-descent-speed`. + * + * STAIR_DESCENT_SPEED sub-page. Gait speed measured while descending + * stairs; the companion to ascent speed. The series arrives from Apple + * Health sync — no manual-entry CTA. + */ +export default function InsightsStairDescentSpeedPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx new file mode 100644 index 000000000..7376b6457 --- /dev/null +++ b/src/app/insights/wrist-temperature/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Thermometer } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.10.0 — `/insights/wrist-temperature`. + * + * WRIST_TEMPERATURE sub-page. Overnight wrist temperature; Apple frames + * it as a deviation from a personal baseline, so the assessment leans on + * the user's own trend rather than a fixed band. The series arrives from + * Apple Health sync — no manual-entry CTA. + */ +export default function InsightsWristTemperaturePage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?" + /> + ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 77613690f..bf59446f2 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -196,8 +196,28 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navWalkingSpeed", metric: "WALKING_SPEED", }, + // v1.10.0 — cardio fitness (VO2 max) + Apple-Health Mobility additions. + "cardio-fitness": { labelKey: "insights.navCardioFitness", metric: "VO2_MAX" }, + falls: { labelKey: "insights.navFalls", metric: "FALL_COUNT" }, + "six-minute-walk": { + labelKey: "insights.navSixMinuteWalk", + metric: "SIX_MINUTE_WALK_DISTANCE", + }, + "stair-ascent-speed": { + labelKey: "insights.navStairAscentSpeed", + metric: "STAIR_ASCENT_SPEED", + }, + "stair-descent-speed": { + labelKey: "insights.navStairDescentSpeed", + metric: "STAIR_DESCENT_SPEED", + }, // ── sleep ── sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" }, + // v1.10.0 — sleep breathing-disturbance index. + "breathing-disturbances": { + labelKey: "insights.navBreathingDisturbances", + metric: "BREATHING_DISTURBANCES", + }, // ── cardiovascular ── "resting-pulse": { labelKey: "insights.navRestingHr", @@ -212,6 +232,11 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navVascularAge", metric: "VASCULAR_AGE", }, + // v1.10.0 — cardio recovery (post-exercise HR drop). + "cardio-recovery": { + labelKey: "insights.navCardioRecovery", + metric: "CARDIO_RECOVERY", + }, // ── hearing ── "environmental-audio": { labelKey: "insights.navAudioExposureEnv", @@ -239,6 +264,11 @@ const SUB_PAGE_TABS: Record< labelKey: "insights.navSkinTemperature", metric: "SKIN_TEMPERATURE", }, + // v1.10.0 — overnight wrist temperature. + "wrist-temperature": { + labelKey: "insights.navWristTemperature", + metric: "WRIST_TEMPERATURE", + }, // ── mood ── mood: { labelKey: "insights.navMood", metric: "MOOD" }, // ── events ── diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 2686c99c0..1d270b042 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -33,6 +33,7 @@ import { Volume2, Headphones, Sun, + PersonStanding, type LucideIcon, } from "lucide-react"; @@ -81,6 +82,14 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── WALKING_STEP_LENGTH: "measurements.typeWalkingStepLength", WALKING_SPEED: "measurements.typeWalkingSpeed", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + CARDIO_RECOVERY: "measurements.typeCardioRecovery", + WRIST_TEMPERATURE: "measurements.typeWristTemperature", + FALL_COUNT: "measurements.typeFallCount", + SIX_MINUTE_WALK_DISTANCE: "measurements.typeSixMinuteWalkDistance", + STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed", + STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed", + BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -145,6 +154,19 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // WALKING_STEADINESS). WALKING_STEP_LENGTH: Footprints, WALKING_SPEED: Gauge, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // HeartPulse carries the post-exercise cardiac-recovery signal; + // Thermometer the overnight wrist reading; PersonStanding the + // fall-detection tally; Gauge the gait-speed family (stairs + 6MWT); + // Wind rounds out the sleep-breathing signal (SpO2 + resp rate + // already use it). + CARDIO_RECOVERY: HeartPulse, + WRIST_TEMPERATURE: Thermometer, + FALL_COUNT: PersonStanding, + SIX_MINUTE_WALK_DISTANCE: Footprints, + STAIR_ASCENT_SPEED: Gauge, + STAIR_DESCENT_SPEED: Gauge, + BREATHING_DISTURBANCES: Wind, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -203,4 +225,14 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // visual group on Insights. WALKING_STEP_LENGTH: "bg-chart-2/20 text-chart-2", WALKING_SPEED: "bg-chart-2/20 text-chart-2", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // chart-3 (cardio), chart-4 (temp), chart-2 (activity/gait family), + // chart-5 (sleep-breathing, shares the SpO2 family). + CARDIO_RECOVERY: "bg-chart-3/20 text-chart-3", + WRIST_TEMPERATURE: "bg-chart-4/20 text-chart-4", + FALL_COUNT: "bg-chart-2/20 text-chart-2", + SIX_MINUTE_WALK_DISTANCE: "bg-chart-2/20 text-chart-2", + STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2", + STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2", + BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index f644ffabe..5693adf6d 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -59,10 +59,18 @@ const EXPECTED_TYPES = [ // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── "WALKING_STEP_LENGTH", "WALKING_SPEED", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 38 canonical measurement types", () => { + it("exposes the 45 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -130,6 +138,19 @@ describe("measurementTypeEnum coverage", () => { // same clinical-layout deferral as the rest of the Mobility set. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A) held under the same + // v1.5+ clinical-layout PDF gate as the rest of the Apple-Health + // additions. Cardio recovery, wrist temperature, falls, six-minute + // walk, the stair gait speeds, and the sleep-breathing index each + // surface on their Insights sub-page; the doctor PDF picks them up + // once a clinical layout + reference ranges land. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index d393bbfdd..139814d2f 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -216,6 +216,15 @@ export const CHART_OVERLAY_KEYS = [ "walkingDoubleSupport", "walkingStepLength", "walkingSpeed", + // v1.10.0 — additive HealthKit signals (WX-A). One overlay-prefs slot + // per new chart surface; camel-case keys mirror the existing convention. + "cardioRecovery", + "wristTemperature", + "fallCount", + "sixMinuteWalkDistance", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 5e662a244..c54f2afdf 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -226,6 +226,53 @@ export const MEASUREMENT_LOINC: Record = { unit: "1", category: "vital-signs", }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // No stable LOINC term for these Apple-specific quantities, so each + // routes through the shared HEALTHKIT_CODESYSTEM with the raw + // HKQuantityTypeIdentifier… string as the code (a non-LOINC code under + // the LOINC namespace would fail FHIR conformance). + CARDIO_RECOVERY: { + loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + display: "Cardio recovery (1-minute heart-rate recovery)", + unit: "/min", + category: "vital-signs", + }, + WRIST_TEMPERATURE: { + loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + display: "Sleeping wrist temperature", + unit: "Cel", + category: "vital-signs", + }, + FALL_COUNT: { + loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen", + display: "Number of times fallen", + unit: "{falls}", + category: "activity", + }, + SIX_MINUTE_WALK_DISTANCE: { + loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + display: "Six-minute walk test distance", + unit: "m", + category: "activity", + }, + STAIR_ASCENT_SPEED: { + loinc: "HKQuantityTypeIdentifierStairAscentSpeed", + display: "Stair ascent speed", + unit: "m/s", + category: "vital-signs", + }, + STAIR_DESCENT_SPEED: { + loinc: "HKQuantityTypeIdentifierStairDescentSpeed", + display: "Stair descent speed", + unit: "m/s", + category: "vital-signs", + }, + BREATHING_DISTURBANCES: { + loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + display: "Sleeping breathing disturbances", + unit: "{count}", + category: "vital-signs", + }, }; /** BP panel + component LOINC codes. */ diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index db98c42ec..769b2ae13 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -93,6 +93,31 @@ describe("metric-status registry", () => { "ACTIVE_ENERGY_BURNED", ); }); + + it("registers the v1.10.0 additive HealthKit signals with the expected direction", () => { + const cases = [ + ["CARDIO_RECOVERY", "higher-better"], + ["WRIST_TEMPERATURE", "target-band"], + ["FALL_COUNT", "lower-better"], + ["SIX_MINUTE_WALK_DISTANCE", "higher-better"], + ["STAIR_ASCENT_SPEED", "higher-better"], + ["STAIR_DESCENT_SPEED", "higher-better"], + ["BREATHING_DISTURBANCES", "lower-better"], + ] as const; + for (const [id, direction] of cases) { + const meta = getMetricStatusMeta(id); + expect(meta, `${id} missing from registry`).not.toBeNull(); + expect(meta!.measurementType).toBe(id); + expect(meta!.direction).toBe(direction); + expect(METRIC_STATUS_IDS).toContain(id); + } + }); + + it("anchors a six-minute-walk normal range from the population reference", () => { + expect(getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange).toEqual( + { low: 400, high: 700 }, + ); + }); }); describe("archetype prompt templates", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index c51b1f2f8..cf9c2f131 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -79,6 +79,16 @@ export const ALLOWED_CHART_TOKENS = [ // ride the existing card-shelf renderer. "metric:WALKING_STEP_LENGTH", "metric:WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A). Same posture: the LLM + // can reference these in prose; the chart components ride the existing + // sub-page scaffold. + "metric:CARDIO_RECOVERY", + "metric:WRIST_TEMPERATURE", + "metric:FALL_COUNT", + "metric:SIX_MINUTE_WALK_DISTANCE", + "metric:STAIR_ASCENT_SPEED", + "metric:STAIR_DESCENT_SPEED", + "metric:BREATHING_DISTURBANCES", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -166,6 +176,14 @@ const ORPHAN_ENUMS = [ // v1.5.5 iOS-coord follow-up — raw-SI gait pair, same shape. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // v1.10.0 — additive HealthKit signals (WX-A), same shape. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ] as const; // `\b` boundaries keep ordinary English prose untouched — "weight" diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 566ee5157..923e35bcf 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -89,7 +89,17 @@ export type InsightMetric = | "WALKING_ASYMMETRY" | "WALKING_DOUBLE_SUPPORT" | "WALKING_STEP_LENGTH" - | "WALKING_SPEED"; + | "WALKING_SPEED" + // v1.10.0 — additive HealthKit signals (WX-A). Each key equals its + // MeasurementType string so the generic `hasMetricData` fallback gates + // it on `summaries[metric].count > 0` (auto-light-up on first reading). + | "CARDIO_RECOVERY" + | "WRIST_TEMPERATURE" + | "FALL_COUNT" + | "SIX_MINUTE_WALK_DISTANCE" + | "STAIR_ASCENT_SPEED" + | "STAIR_DESCENT_SPEED" + | "BREATHING_DISTURBANCES"; /** * Inputs the gating helper consumes. The `summaries` shape mirrors diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index eac071d10..4ce666921 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -109,7 +109,15 @@ export type MetricStatusMetricId = | "AUDIO_EXPOSURE_ENV" | "AUDIO_EXPOSURE_HEADPHONE" | "AUDIO_EXPOSURE_EVENT" - | "SLEEP_DURATION"; + | "SLEEP_DURATION" + // v1.10.0 — additive HealthKit signals (WX-A). + | "CARDIO_RECOVERY" + | "WRIST_TEMPERATURE" + | "FALL_COUNT" + | "SIX_MINUTE_WALK_DISTANCE" + | "STAIR_ASCENT_SPEED" + | "STAIR_DESCENT_SPEED" + | "BREATHING_DISTURBANCES"; /** * The registry. Keyed by the HealthKit metric id. Each entry's @@ -400,6 +408,93 @@ const REGISTRY: Record = { normalRange: { low: 420, high: 540 }, archetype: "sleep", }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery — bpm drop one minute after peak exercise. A larger + // drop is the fitter, healthier signal (higher-better). No fixed band: + // recovery scales with the peak HR + fitness, so the user's own + // baseline leads. A blunted HRR1 (the long-cited Cole et al. NEJM 1999 + // ≤12 bpm marker) is a mortality predictor, but the absolute placement + // depends on the workout intensity HealthLog does not capture here, so + // we defer wholly to the user's trend rather than anchor a band. + CARDIO_RECOVERY: { + id: "CARDIO_RECOVERY", + measurementType: "CARDIO_RECOVERY", + displayName: "Cardio recovery", + unit: "bpm", + direction: "higher-better", + archetype: "physiological-vital", + }, + // Wrist temperature — overnight skin-side reading. Apple frames it as a + // baseline deviation; we store the absolute °C and treat it as a + // target-band metric whose anchor is the user's own nightly baseline. + // No fixed population band: wrist (skin) temperature runs cooler than + // core and varies by room temperature + bedding, so a hard band would + // misplace it. The user's own series leads (same posture as + // SKIN_TEMPERATURE). + WRIST_TEMPERATURE: { + id: "WRIST_TEMPERATURE", + measurementType: "WRIST_TEMPERATURE", + displayName: "Wrist temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + }, + // Fall count — fewer is better. Zero is the target; any sustained + // non-zero count is a mobility-risk signal Apple surfaces prominently. + FALL_COUNT: { + id: "FALL_COUNT", + measurementType: "FALL_COUNT", + displayName: "Falls", + unit: "falls/day", + direction: "lower-better", + archetype: "mobility-gait", + }, + // Six-minute-walk distance — population reference for healthy adults is + // ~400–700 m (ATS 2002 guideline + general-population reference + // standards, Casanova et al. ERJ 2011 / population samples). The band + // is a coarse placement aid; age, height, and sex shift it, so the + // user's own baseline still leads. + SIX_MINUTE_WALK_DISTANCE: { + id: "SIX_MINUTE_WALK_DISTANCE", + measurementType: "SIX_MINUTE_WALK_DISTANCE", + displayName: "Six-minute walk distance", + unit: "m", + direction: "higher-better", + normalRange: { low: 400, high: 700 }, + archetype: "mobility-gait", + }, + // Stair ascent speed — faster is the fitter signal. No fixed band: + // stair pace depends on stair geometry + leg length; the user's own + // trend leads (same posture as WALKING_STEP_LENGTH). + STAIR_ASCENT_SPEED: { + id: "STAIR_ASCENT_SPEED", + measurementType: "STAIR_ASCENT_SPEED", + displayName: "Stair ascent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + }, + // Stair descent speed — gait companion to ascent speed. + STAIR_DESCENT_SPEED: { + id: "STAIR_DESCENT_SPEED", + measurementType: "STAIR_DESCENT_SPEED", + displayName: "Stair descent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + }, + // Breathing disturbances — per-night sleep-breathing index. Fewer is + // better. Apple classifies the index as NotElevated / Elevated rather + // than publishing a numeric cutoff, so no fixed band is encoded; the + // direction + the user's own baseline carry the placement. + BREATHING_DISTURBANCES: { + id: "BREATHING_DISTURBANCES", + measurementType: "BREATHING_DISTURBANCES", + displayName: "Sleep breathing disturbances", + unit: "count", + direction: "lower-better", + archetype: "sleep", + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 8b9a2be53..f91bb6b6d 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -80,14 +80,28 @@ const SUB_PAGE_METRIC = { "double-support-time": ["WALKING_DOUBLE_SUPPORT"], "step-length": ["WALKING_STEP_LENGTH"], "walking-speed": ["WALKING_SPEED"], + // v1.10.0 — VO2 max (cardio fitness) gains a dedicated sub-page so it + // carries the generic metric-status assessment instead of riding as a + // bare chart-row on the pulse page. + "cardio-fitness": ["VO2_MAX"], + // v1.10.0 — Apple-Health Mobility additions. + falls: ["FALL_COUNT"], + "six-minute-walk": ["SIX_MINUTE_WALK_DISTANCE"], + "stair-ascent-speed": ["STAIR_ASCENT_SPEED"], + "stair-descent-speed": ["STAIR_DESCENT_SPEED"], // ── sleep ── sleep: ["SLEEP_DURATION"], + // v1.10.0 — sleep breathing-disturbance index joins the sleep cluster. + "breathing-disturbances": ["BREATHING_DISTURBANCES"], // ── cardiovascular ── "resting-pulse": ["RESTING_HEART_RATE"], hrv: ["HEART_RATE_VARIABILITY"], // v1.7.0 — Withings cardiovascular risk markers. "pulse-wave-velocity": ["PULSE_WAVE_VELOCITY"], "vascular-age": ["VASCULAR_AGE"], + // v1.10.0 — cardio recovery (post-exercise HR drop) joins the + // cardiovascular cluster. + "cardio-recovery": ["CARDIO_RECOVERY"], // ── hearing (v1.7.0) — audio-exposure cluster ── "environmental-audio": ["AUDIO_EXPOSURE_ENV"], "headphone-audio": ["AUDIO_EXPOSURE_HEADPHONE"], @@ -97,6 +111,8 @@ const SUB_PAGE_METRIC = { // ── metabolic (v1.7.0) ── "blood-glucose": ["BLOOD_GLUCOSE"], "skin-temperature": ["SKIN_TEMPERATURE"], + // v1.10.0 — overnight wrist temperature joins the metabolic cluster. + "wrist-temperature": ["WRIST_TEMPERATURE"], // ── mood ── mood: ["MOOD"], // ── events (medication adherence is event-driven; no measurement series) ── @@ -165,6 +181,19 @@ export const SUB_PAGE_GROUP: Partial> = { // metabolic (v1.7.0) "blood-glucose": "metabolic", "skin-temperature": "metabolic", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // activity / mobility cluster + "cardio-fitness": "activity", + falls: "activity", + "six-minute-walk": "activity", + "stair-ascent-speed": "activity", + "stair-descent-speed": "activity", + // vitals cluster (breathing sits with respiratory rate) + "breathing-disturbances": "vitals", + // cardiovascular cluster + "cardio-recovery": "cardiovascular", + // metabolic cluster (overnight wrist reading) + "wrist-temperature": "metabolic", }; /** diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 0c30f9b4d..84fddec66 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -61,6 +61,11 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { "HKCategoryTypeIdentifierSleepAnalysis", "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", "HKQuantityTypeIdentifierVO2Max", + // v1.10.0 — overnight wrist temperature + the sleep-breathing + // index ride behind an explicit Health-share consent, same as + // HRV / VO2 max / sleep analysis. + "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", ].sort(), ); // Audio exposure + time-in-daylight are environment / lifestyle @@ -134,8 +139,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility — `AppleWalkingSteadiness` moved into the mapped - // set in v1.4.30. The remaining identifiers stay deferred. - "HKQuantityTypeIdentifierNumberOfTimesFallen", + // set in v1.4.30; `NumberOfTimesFallen` moved into the mapped set + // in v1.10.0 (FALL_COUNT). The walking-steadiness EVENT stays + // deferred (a category-type event, not a quantity). "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary "HKQuantityTypeIdentifierForcedExpiratoryVolume1", @@ -619,6 +625,8 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { "HKQuantityTypeIdentifierFlightsClimbed", "HKQuantityTypeIdentifierDistanceWalkingRunning", "HKQuantityTypeIdentifierTimeInDaylight", + // v1.10.0 — hard-fall detections accumulate across the day. + "HKQuantityTypeIdentifierNumberOfTimesFallen", ].sort(), ); }); @@ -654,6 +662,10 @@ describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { "WALKING_DOUBLE_SUPPORT", "WALKING_STEADINESS", "WALKING_HEART_RATE_AVERAGE", + // v1.10.0 — stair gait speeds arrive per-climb at sampling + // granularity; the per-day mean is the right consolidation. + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", ].sort(), ); }); @@ -675,3 +687,73 @@ describe("HIGH_FREQUENCY_MEAN_TYPES (v1.7.0)", () => { } }); }); + +describe("v1.10.0 additive HealthKit signals (WX-A)", () => { + const WXA = { + HKQuantityTypeIdentifierHeartRateRecoveryOneMinute: { + type: "CARDIO_RECOVERY", + dbUnit: "bpm", + }, + HKQuantityTypeIdentifierAppleSleepingWristTemperature: { + type: "WRIST_TEMPERATURE", + dbUnit: "celsius", + }, + HKQuantityTypeIdentifierNumberOfTimesFallen: { + type: "FALL_COUNT", + dbUnit: "count", + }, + HKQuantityTypeIdentifierSixMinuteWalkTestDistance: { + type: "SIX_MINUTE_WALK_DISTANCE", + dbUnit: "m", + }, + HKQuantityTypeIdentifierStairAscentSpeed: { + type: "STAIR_ASCENT_SPEED", + dbUnit: "m/s", + }, + HKQuantityTypeIdentifierStairDescentSpeed: { + type: "STAIR_DESCENT_SPEED", + dbUnit: "m/s", + }, + HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances: { + type: "BREATHING_DISTURBANCES", + dbUnit: "count", + }, + } as const; + + it("maps each new identifier to its canonical type + unit with identity conversion", () => { + for (const [hk, expected] of Object.entries(WXA)) { + const mapping = APPLE_HEALTH_TYPE_MAP[hk]; + expect(mapping, `${hk} mapping missing`).toBeDefined(); + expect(mapping!.measurementType).toBe(expected.type); + expect(mapping!.dbUnit).toBe(expected.dbUnit); + // None of the WX-A signals ships a 0..1 fraction — every one + // passes through `convertToDbUnit` as identity. + expect(mapping!.convertToDbUnit(42)).toBe(42); + } + }); + + it("removes each wired identifier from the deferred set", () => { + for (const hk of Object.keys(WXA)) { + expect( + HK_QUANTITY_TYPE_DEFERRED.has(hk), + `${hk} should no longer be deferred`, + ).toBe(false); + } + }); + + it("round-trips a cardio-recovery sample through mapAppleHealthEntry", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + value: 35, + unit: "count/min", + startDate: "2026-06-02T08:00:00.000Z", + endDate: "2026-06-02T08:01:00.000Z", + }); + expect(out).toEqual({ + type: "CARDIO_RECOVERY", + value: 35, + unit: "bpm", + takenAt: new Date("2026-06-02T08:01:00.000Z"), + }); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 4839bc4c5..813eb1a3b 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -470,6 +470,88 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v, aggregation: "mean", }, + + // ── v1.10.0 — additive HealthKit signals (WX-A) ───────────── + // Seven previously-deferred quantity identifiers wired end-to-end. + // Each one ships raw on the wire (no 0..1 fraction), so every entry + // takes the identity `convertToDbUnit` path — the ×100 percent + // scaling the gait-percent metrics use does not apply here. + + // Cardio recovery — the heart-rate drop one minute after peak + // exercise. iOS 16+. A larger drop is the fitter signal; we store + // the raw bpm delta. One sample per qualifying workout. + HKQuantityTypeIdentifierHeartRateRecoveryOneMinute: { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + measurementType: "CARDIO_RECOVERY", + hkUnit: "count/min", + dbUnit: "bpm", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Sleeping wrist temperature — iOS 16+ overnight reading. Apple's + // Health app frames it as a deviation from a personal baseline; we + // store the absolute °C reading and let the user's own series carry + // the baseline. One sample per night. + HKQuantityTypeIdentifierAppleSleepingWristTemperature: { + hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + measurementType: "WRIST_TEMPERATURE", + hkUnit: "degC", + dbUnit: "celsius", + convertToDbUnit: (v) => v, + aggregation: "latest", + isPrivacySensitive: true, + }, + // Fall count — hard-fall detections. Cumulative daily tally; the + // drain treats it like the other cumulative HK counts. + HKQuantityTypeIdentifierNumberOfTimesFallen: { + hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen", + measurementType: "FALL_COUNT", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + // Six-minute-walk-test distance — Apple's estimated 6MWT distance in + // metres. Mobility + cardiopulmonary endurance signal. Near-daily + // rollup; latest reading leads. + HKQuantityTypeIdentifierSixMinuteWalkTestDistance: { + hkIdentifier: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + measurementType: "SIX_MINUTE_WALK_DISTANCE", + hkUnit: "m", + dbUnit: "m", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + // Stair ascent speed — raw metres-per-second measured while climbing. + HKQuantityTypeIdentifierStairAscentSpeed: { + hkIdentifier: "HKQuantityTypeIdentifierStairAscentSpeed", + measurementType: "STAIR_ASCENT_SPEED", + hkUnit: "m/s", + dbUnit: "m/s", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Stair descent speed — gait companion to ascent speed. + HKQuantityTypeIdentifierStairDescentSpeed: { + hkIdentifier: "HKQuantityTypeIdentifierStairDescentSpeed", + measurementType: "STAIR_DESCENT_SPEED", + hkUnit: "m/s", + dbUnit: "m/s", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Breathing disturbances — iOS 18+ per-night sleep-breathing index + // Apple classifies as NotElevated / Elevated. Stored as the raw + // unitless count; one sample per night. + HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances: { + hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + measurementType: "BREATHING_DISTURBANCES", + hkUnit: "count", + dbUnit: "count", + convertToDbUnit: (v) => v, + aggregation: "latest", + isPrivacySensitive: true, + }, }; /** @@ -493,6 +575,10 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set = new Set([ // The remaining identifiers below stay deferred until a sibling // MeasurementType lands. "HKQuantityTypeIdentifierHeight", // already on User.heightCm - "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", - "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + // v1.10.0 — `HeartRateRecoveryOneMinute` + `AppleSleepingWristTemperature` + // moved into the mapping table (CARDIO_RECOVERY + WRIST_TEMPERATURE). "HKQuantityTypeIdentifierBasalEnergyBurned", "HKQuantityTypeIdentifierAppleExerciseTime", // implied by Workout rows "HKQuantityTypeIdentifierAppleStandTime", // implied by Workout rows @@ -626,9 +718,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ // Running / walking form — v1.5+ // v1.5.5 — `WalkingStepLength` + `WalkingSpeed` moved into the // mapping table (raw SI on the wire — metres and m/s respectively). - "HKQuantityTypeIdentifierStairAscentSpeed", - "HKQuantityTypeIdentifierStairDescentSpeed", - "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + // v1.10.0 — `StairAscentSpeed`, `StairDescentSpeed`, and + // `SixMinuteWalkTestDistance` moved into the mapping table + // (STAIR_ASCENT_SPEED / STAIR_DESCENT_SPEED / SIX_MINUTE_WALK_DISTANCE). "HKQuantityTypeIdentifierRunningSpeed", "HKQuantityTypeIdentifierRunningPower", "HKQuantityTypeIdentifierRunningStrideLength", @@ -655,7 +747,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierWorkoutEffortScore", "HKQuantityTypeIdentifierPhysicalEffort", // Sleep apnea + breathing (iOS 18) — v1.5+ - "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + // v1.10.0 — `AppleSleepingBreathingDisturbances` moved into the + // mapping table (BREATHING_DISTURBANCES). The category-type sleep-apnea + // EVENT stays deferred — it belongs in the event class, not the + // quantity map. "HKCategoryTypeIdentifierSleepApneaEvent", // Nutrition — Marc directive, indefinite hold "HKQuantityTypeIdentifierDietaryWater", @@ -697,9 +792,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the - // mapping table in v1.4.30 (R-F T1.5). The remaining identifiers - // stay deferred until a wellness sub-page surface lands. - "HKQuantityTypeIdentifierNumberOfTimesFallen", + // mapping table in v1.4.30 (R-F T1.5); `NumberOfTimesFallen` moved + // into the mapping table in v1.10.0 (FALL_COUNT). The walking- + // steadiness EVENT stays deferred — it is a category-type event, not + // a quantity, so it belongs in the event class rather than this map. "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical // bucket; no Measurement enum mapping today. diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 5ee820e9c..15956c8df 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -124,6 +124,22 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Metabolic ── ["BLOOD_GLUCOSE", "metabolic"], ["SKIN_TEMPERATURE", "metabolic"], + // v1.10.0 — wrist temperature is an overnight skin-side reading; + // it sits with skin temperature in the metabolic cluster. + ["WRIST_TEMPERATURE", "metabolic"], + + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery is the post-exercise HR-drop autonomic-fitness + // marker — it joins the derived cardiovascular surface. + ["CARDIO_RECOVERY", "cardiovascular"], + // Fall count, six-minute-walk distance, and the stair gait speeds are + // Apple-Health Mobility-section signals alongside walking steadiness. + ["FALL_COUNT", "activity"], + ["SIX_MINUTE_WALK_DISTANCE", "activity"], + ["STAIR_ASCENT_SPEED", "activity"], + ["STAIR_DESCENT_SPEED", "activity"], + // Breathing disturbances is a per-night sleep-breathing index. + ["BREATHING_DISTURBANCES", "sleep"], ]); /** diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index b51f9f29c..d11298dbe 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -18,7 +18,7 @@ import { __test__, detectPersonalRecordsForUser, } from "../pr-detection-worker"; -import { isPRTrackable } from "../pr-direction"; +import { getPRDirection, isPRTrackable } from "../pr-direction"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { @@ -891,7 +891,14 @@ describe("detectPersonalRecordsForUser — REG-9 cumulative day-sum (v1.4.46)", (r) => r.metricType === type && r.metricSlot === null, ); expect(pr, `expected PR for ${type}`).toBeDefined(); - expect(pr?.value, `${type} day-sum`).toBe(500); + // The PR is the winning day's SUM. For a MAX-direction cumulative + // type (steps, energy, …) the record is the biggest day (500). For + // a MIN-direction cumulative type (v1.10.0 FALL_COUNT — fewest + // falls is the record) the winning day is the smallest day-sum, so + // the seed days at 100 hold the record over the 500 day. + const expectedValue = + getPRDirection(type) === PersonalRecordDirection.MIN ? 100 : 500; + expect(pr?.value, `${type} day-sum`).toBe(expectedValue); } }); }); diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 95ee21890..27f37ca57 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -69,6 +69,14 @@ export function getPRDirection( // overall mobility resilience. MAX direction matches Apple's // own Mobility-section framing. case "WALKING_SPEED": + // v1.10.0 — cardio recovery (a larger one-minute HR drop is the + // fitter result), six-minute-walk distance (the classic endurance + // achievement), and the stair gait speeds (faster climb/descent = + // more leg strength + mobility) all read MAX. + case "CARDIO_RECOVERY": + case "SIX_MINUTE_WALK_DISTANCE": + case "STAIR_ASCENT_SPEED": + case "STAIR_DESCENT_SPEED": return PersonalRecordDirection.MAX; // MIN direction — lower value is the record. @@ -85,6 +93,11 @@ export function getPRDirection( // symmetric gait + shorter double-support fraction = healthier). case "WALKING_ASYMMETRY": case "WALKING_DOUBLE_SUPPORT": + // v1.10.0 — fall count and the sleep-breathing-disturbance index + // both read MIN: the "record" is the day/night with the fewest + // events, and fewer is unambiguously the goal. + case "FALL_COUNT": + case "BREATHING_DISTURBANCES": return PersonalRecordDirection.MIN; // Explicitly no PersonalRecord — see comment block above. @@ -118,6 +131,10 @@ export function getPRDirection( // direction; the future worker can still surface trend deltas // without claiming a record direction. case "WALKING_STEP_LENGTH": + // v1.10.0 — wrist temperature is homeostatic and Apple frames it as + // a baseline deviation, not a goal axis; neither a higher nor a + // lower reading is an achievement. Defer to a null PR direction. + case "WRIST_TEMPERATURE": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 114212994..ae9e7e616 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -47,6 +47,17 @@ export const measurementTypeEnum = z.enum([ // the convention block in `apple-health-mapping.ts`. "WALKING_STEP_LENGTH", "WALKING_SPEED", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Seven previously-deferred quantity identifiers; each ships raw on + // the wire (identity conversion). See the convention block in + // `apple-health-mapping.ts`. + "CARDIO_RECOVERY", + "WRIST_TEMPERATURE", + "FALL_COUNT", + "SIX_MINUTE_WALK_DISTANCE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", + "BREATHING_DISTURBANCES", ]); export const glucoseContextEnum = z.enum([ @@ -132,6 +143,22 @@ const unitMap: Record = { // match HealthKit's `m` / `m/s` defaults. WALKING_STEP_LENGTH: "m", WALKING_SPEED: "m/s", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery is the bpm drop one minute after peak exercise. + CARDIO_RECOVERY: "bpm", + // Overnight wrist temperature in °C (absolute reading; Apple's own + // display frames it as a baseline deviation, we store the reading). + WRIST_TEMPERATURE: "celsius", + // Hard-fall detections — a plain count. + FALL_COUNT: "count", + // Apple's estimated six-minute-walk-test distance in metres. + SIX_MINUTE_WALK_DISTANCE: "m", + // Stair gait speeds — raw metres-per-second (no scaling). + STAIR_ASCENT_SPEED: "m/s", + STAIR_DESCENT_SPEED: "m/s", + // Per-night breathing-disturbance index — a unitless count Apple + // classifies as NotElevated / Elevated. + BREATHING_DISTURBANCES: "count", }; export function getUnitForType(type: string): string { @@ -239,6 +266,33 @@ const VALUE_RANGES: Record = { // run. The 0.1 floor captures a very slow shuffle; 3.0 covers // race-walking record territory. WALKING_SPEED: { min: 0.1, max: 3.0 }, + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery (bpm) — the one-minute HR drop after peak exertion. + // Trained athletes can drop 50+ bpm; an unfit or autonomically + // impaired recovery sits in the single digits. 0 is a flat (poor) + // recovery; 100 is a generous ceiling beyond any plausible reading. + CARDIO_RECOVERY: { min: 0, max: 100 }, + // Wrist temperature (°C) — overnight skin-side reading. Runs cooler + // than core; the 30–42 band covers the plausible nighttime range and + // rejects obvious sensor glitches. Same shape as SKIN_TEMPERATURE. + WRIST_TEMPERATURE: { min: 30, max: 42 }, + // Fall count — a daily tally. A handful in a bad day is plausible; + // 50 is a generous ceiling that still catches a runaway sensor. + FALL_COUNT: { min: 0, max: 50 }, + // Six-minute-walk distance (metres). Healthy adults walk ~400–700 m; + // the 50 m floor captures severe impairment and the 1000 m ceiling + // covers an unusually fit reading without admitting sensor noise. + SIX_MINUTE_WALK_DISTANCE: { min: 50, max: 1000 }, + // Stair gait speed (m/s) — slower than level walking. Adult stair + // ascent sits around 0.4–0.7 m/s, descent slightly faster. The 0.05 + // floor captures a very slow climb; 2.0 is well above any sustained + // stair pace and rejects a free-fall artefact. + STAIR_ASCENT_SPEED: { min: 0.05, max: 2.0 }, + STAIR_DESCENT_SPEED: { min: 0.05, max: 2.0 }, + // Breathing-disturbance index (count). A per-night index; 0 is an + // undisturbed night and the 1000 ceiling is generous headroom over + // any plausible severe-apnea night. + BREATHING_DISTURBANCES: { min: 0, max: 1000 }, }; export function validateMeasurementRange( From b06b1cada4acf0b95914a81f6c28c91c438055d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:03 +0200 Subject: [PATCH 12/49] feat(healthkit): add a categorical device-event row class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a discrete EVENT row class to the Measurement model for the device-flagged health notifications a wearable produces on its own — irregular-rhythm, high/low heart rate, walking-steadiness and breathing-disturbance alerts. Following the sleep-stage / glucose-context nullable-context precedent, this is an additive nullable column on the existing table, not a new storage paradigm. Five new MeasurementType values carry the event class and a new rhythm_classification enum column carries the device's own verdict / severity (migration 0105, with a CHECK that pins the column to the event types). An event row stores value 1 and the device verdict; it never participates in trend, rollup, chart or personal-record analytics. For the irregular-rhythm class, HealthLog ingests only the classification RESULT the device's own certified on-device algorithm already produced. It never ingests a raw ECG waveform, never re-classifies, and never derives a verdict of its own. The Apple Health batch ingest maps the five category identifiers, with the device's HKCategoryValue codepoint resolved to the stored classification; an unknown codepoint degrades to a fallback verdict rather than dropping the event. --- .../migration.sql | 95 +++++++++ prisma/schema.prisma | 51 +++++ .../measurements/measurement-list-meta.ts | 24 +++ src/lib/measurements/apple-health-mapping.ts | 190 ++++++++++++++++-- src/lib/measurements/categories.ts | 13 ++ src/lib/personal-records/pr-direction.ts | 9 + src/lib/validations/measurement.ts | 50 +++++ 7 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/0105_v1100_categorical_event_rows/migration.sql diff --git a/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql b/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql new file mode 100644 index 000000000..6fe46a211 --- /dev/null +++ b/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql @@ -0,0 +1,95 @@ +-- v1.10.0 — categorical EVENT row class + device rhythm classification. +-- +-- Adds a discrete EVENT row class to the existing `Measurement` model, +-- following the `sleep_stage` / `glucose_context` nullable-context +-- precedent (migration 0021 + 0036) — an additive nullable column on the +-- existing table, NOT a new storage paradigm. An EVENT row stores ONE +-- on-device notification the user's wearable already produced (Apple Watch +-- irregular-rhythm / high-HR / low-HR / walking-steadiness, ScanWatch AFib +-- screening, sleep breathing-disturbance). `value` is always 1; the +-- device's own verdict / severity rides in the new `rhythm_classification` +-- column. +-- +-- ── Regulatory framing (load-bearing) ────────────────────────────────── +-- HealthLog stores ONLY the classification RESULT the device's +-- FDA-cleared / CE-marked on-device algorithm emitted. It never ingests a +-- raw ECG waveform, never re-classifies, and never produces a HealthLog +-- diagnosis. The surface is awareness/screening of the DEVICE's decision. +-- +-- Two schema objects: +-- +-- 1. Five new `measurement_type` enum values (the EVENT classes). +-- 2. A new `rhythm_classification` enum + a nullable column carrying the +-- device verdict / severity, NULL for every continuous measurement. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "measurements" DROP CONSTRAINT IF EXISTS "measurements_rhythm_classification_requires_event_type"; +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "rhythm_classification"; +-- DROP TYPE IF EXISTS "rhythm_classification"; +-- Postgres does not support removing an enum value, so the five new +-- `measurement_type` members stay; with no rows carrying them after a +-- column drop they are inert. + +-- ── 1. measurement_type — append the five EVENT classes ──────────────── +-- Pure additive; no existing row carries any of these values. The +-- `IF NOT EXISTS` guard keeps the migration idempotent across a +-- partially-applied environment. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'IRREGULAR_RHYTHM_NOTIFICATION'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HIGH_HEART_RATE_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LOW_HEART_RATE_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCE_EVENT'; + +-- ── 2. rhythm_classification — the device verdict / severity enum ────── +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'rhythm_classification') THEN + CREATE TYPE "rhythm_classification" AS ENUM ( + 'IRREGULAR', + 'NOT_DETECTED', + 'INCONCLUSIVE', + 'LOW', + 'VERY_LOW', + 'FIRED' + ); + END IF; +END +$$; + +-- Nullable column. NULL for every continuous measurement; non-NULL only +-- for the EVENT classes. Mirrors the `sleep_stage` additive-column shape. +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "rhythm_classification" "rhythm_classification"; + +-- Enforce: rhythm_classification is only set for the EVENT-class rows. +-- Mirrors the `measurements_sleep_stage_requires_type` CHECK (migration +-- 0036) and the glucose_context CHECK (migration 0021). +-- +-- The comparison casts `type` to text so the predicate references the +-- newly-added enum members as string literals. Postgres forbids using an +-- enum value in the same transaction that adds it (the ALTER TYPE ... ADD +-- VALUE statements above run in this same migration transaction); the +-- `::text` cast sidesteps that restriction without weakening the check. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurements_rhythm_classification_requires_event_type' + ) THEN + ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_rhythm_classification_requires_event_type" + CHECK ( + "rhythm_classification" IS NULL + OR "type"::text IN ( + 'IRREGULAR_RHYTHM_NOTIFICATION', + 'HIGH_HEART_RATE_EVENT', + 'LOW_HEART_RATE_EVENT', + 'WALKING_STEADINESS_EVENT', + 'BREATHING_DISTURBANCE_EVENT' + ) + ); + END IF; +END +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e7e26b7b..91855915b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -544,10 +544,52 @@ enum MeasurementType { STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better. STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better. BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better. + // v1.10.0 — categorical events (WX-B) + // Device-flagged EVENT rows. These are NOT continuous measurements — + // each row records a discrete on-device notification the user's wearable + // produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one + // fired event); the device's verdict / severity lands in the + // `rhythmClassification` column (see the `RhythmClassification` enum and + // the CHECK constraint in migration 0105). HealthLog never re-derives or + // re-classifies the underlying signal — it only stores and surfaces the + // result the device's own certified algorithm already emitted. + IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis. + HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive. + LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold. + WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`. + BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis. @@map("measurement_type") } +/// v1.10.0 — categorical events (WX-B) +/// The device's own classification RESULT / severity for an EVENT row. +/// Non-NULL only for the categorical EVENT `MeasurementType`s +/// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT, +/// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every +/// continuous measurement — mirroring the `GlucoseContext` / `SleepStage` +/// nullable-context precedent. +/// +/// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked +/// on-device algorithm already produced. It never re-classifies a raw +/// signal and never emits a diagnosis of its own. `IRREGULAR` means the +/// device flagged a possible irregular rhythm; `NOT_DETECTED` / +/// `INCONCLUSIVE` carry the device's own non-positive verdicts so the +/// awareness timeline can be honest rather than only ever showing alarms. +/// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the +/// neutral "this event occurred" verdict for the high/low-HR + breathing +/// events that have no finer device-side grading. +enum RhythmClassification { + IRREGULAR // device flagged a possible irregular rhythm (AFib screening) + NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm + INCONCLUSIVE // device could not classify (poor signal / out-of-range HR) + LOW // walking-steadiness: low + VERY_LOW // walking-steadiness: very low + FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance) + + @@map("rhythm_classification") +} + /// Context for glucose measurements — affects which target range applies. /// NULL for non-glucose measurements. enum GlucoseContext { @@ -608,6 +650,15 @@ model Measurement { // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext // pattern. A single night produces ~5 rows (one per stage). sleepStage SleepStage? @map("sleep_stage") + // v1.10.0 — categorical events (WX-B). The device's own classification + // verdict / severity for an EVENT row. NULL for every continuous + // measurement; non-NULL only for the categorical EVENT `MeasurementType`s. + // Mirrors the `sleepStage` / `glucoseContext` nullable-context columns. A + // CHECK constraint (migration 0105) enforces the type pairing so a + // continuous row can never carry a classification. NOT part of any unique + // index — dedup stays on `(userId, type, source, externalId)` keyed by the + // device's `HKSample.uuid`, so a re-posted event collapses to a duplicate. + rhythmClassification RhythmClassification? @map("rhythm_classification") // v1.4.25 W8c — device-type tag for the cross-source canonical // picker. Stored as a free-text string (kept simple — Prisma enum // would require coordinated client + server bumps every time we diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 1d270b042..8117efb56 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -90,6 +90,12 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed", STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed", BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances", + // ── v1.10.0 — categorical events (WX-B) ── + IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification", + HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent", + LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent", + WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent", + BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -167,6 +173,15 @@ export const MEASUREMENT_TYPE_ICONS: Record = { STAIR_ASCENT_SPEED: Gauge, STAIR_DESCENT_SPEED: Gauge, BREATHING_DISTURBANCES: Wind, + // ── v1.10.0 — categorical events (WX-B) ── + // Activity carries the irregular-rhythm trace shape; HeartPulse the + // high/low-HR cardio pair; Footprints the steadiness/mobility family; + // Wind the breathing family (SpO2 + respiratory rate already use it). + IRREGULAR_RHYTHM_NOTIFICATION: Activity, + HIGH_HEART_RATE_EVENT: HeartPulse, + LOW_HEART_RATE_EVENT: HeartPulse, + WALKING_STEADINESS_EVENT: Footprints, + BREATHING_DISTURBANCE_EVENT: Wind, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -235,4 +250,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2", STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2", BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5", + // ── v1.10.0 — categorical events (WX-B) ── + // chart-3 (cardio family) carries the rhythm + heart-rate events; + // chart-2 (activity/mobility) carries the steadiness event; chart-5 + // (respiratory/pulse family) carries the breathing event. + IRREGULAR_RHYTHM_NOTIFICATION: "bg-chart-3/20 text-chart-3", + HIGH_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3", + LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3", + WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2", + BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 813eb1a3b..e564cab9b 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -23,7 +23,11 @@ * unit, conversion, and aggregation columns are HealthLog-specific * decisions checked against Apple's HKUnit documentation. */ -import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import type { + MeasurementType, + SleepStage, + RhythmClassification, +} from "@/generated/prisma/client"; /** * How a batch of HealthKit samples for the same identifier should be @@ -67,8 +71,54 @@ export interface AppleHealthMapping { * sample and look up the DB-side stage here. */ sleepStageMap?: Record; + /** + * v1.10.0 — categorical events (WX-B). For the EVENT-class HealthKit + * category identifiers (irregular-rhythm / high-HR / low-HR / + * walking-steadiness / breathing-disturbance), maps the inbound + * `HKCategoryValue` integer codepoint to the device's verdict / + * severity (`RhythmClassification`). + * + * HealthLog NEVER re-derives this — the value is the result the + * device's own certified on-device algorithm already produced. When a + * mapping carries an `eventClassificationMap`, `mapAppleHealthEntry` + * resolves the codepoint to a `RhythmClassification`, forcing the + * stored `value` to 1 (one fired event) regardless of the inbound + * number. A codepoint absent from the map falls back to the entry's + * `fallbackClassification` so an unseen Apple codepoint is recorded as + * a (gracefully degraded) event rather than dropped. + */ + eventClassificationMap?: Record; + /** + * v1.10.0 — fallback verdict for an EVENT identifier whose inbound + * codepoint is missing / unknown. Only set alongside + * `eventClassificationMap`. The high/low-HR + breathing events have a + * single neutral `FIRED` verdict and a `notApplicable` (0) codepoint; + * they rely on this fallback rather than enumerating the codepoint. + */ + fallbackClassification?: RhythmClassification; } +/** + * v1.10.0 — categorical events (WX-B). + * + * `HKCategoryValueAppleWalkingSteadinessEvent` codepoints → the device's + * own severity verdict. Apple grades the falls-risk mobility flag as + * initial/repeat × low/very-low; HealthLog collapses the initial/repeat + * distinction (it is a re-notification of the same severity) into the two + * severity bands the awareness timeline surfaces. + * + * Source: Apple's `HKCategoryValueAppleWalkingSteadinessEvent` enum. + */ +export const APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP: Record< + number, + RhythmClassification +> = { + 1: "LOW", // initialLow + 2: "LOW", // repeatLow + 3: "VERY_LOW", // initialVeryLow + 4: "VERY_LOW", // repeatVeryLow +}; + /** * Numeric values of `HKCategoryValueSleepAnalysis`. Apple's enum is * platform-versioned; the values below match the iOS 16+ codepoints. @@ -370,6 +420,82 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { aggregation: "sum", }, + // ── v1.10.0 — categorical events (WX-B) ────────────────────── + // Device-flagged EVENT classes. Each is a discrete on-device + // notification, NOT a continuous reading. HealthLog ingests ONLY the + // classification RESULT the device's own certified algorithm produced + // — it never sees a raw ECG waveform, never re-classifies, never emits + // a diagnosis. The stored `value` is always 1 (one fired event); the + // device verdict / severity lands in `rhythmClassification`. + // + // Irregular-rhythm notification — Apple Watch's FDA-cleared / CE-marked + // irregular-rhythm-notification feature (and ScanWatch's certified AFib + // screening, which the iOS bridge maps onto this same identifier). The + // sample firing IS the device's "possible irregular rhythm" verdict; + // there is no finer HK gradation, so it resolves to `IRREGULAR`. + HKCategoryTypeIdentifierIrregularHeartRhythmEvent: { + hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + measurementType: "IRREGULAR_RHYTHM_NOTIFICATION", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + eventClassificationMap: { 0: "IRREGULAR" }, + fallbackClassification: "IRREGULAR", + }, + // High-HR event — sustained high heart rate above the user-configured + // threshold while apparently inactive. Single neutral `FIRED` verdict. + HKCategoryTypeIdentifierHighHeartRateEvent: { + hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent", + measurementType: "HIGH_HEART_RATE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // Low-HR event — sustained low heart rate below the user-configured + // threshold. Single neutral `FIRED` verdict. + HKCategoryTypeIdentifierLowHeartRateEvent: { + hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent", + measurementType: "LOW_HEART_RATE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // Walking-steadiness event — the falls-risk mobility flag. Apple grades + // it low / very-low (initial + repeat); the severity rides in + // `rhythmClassification` via `APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP`. + HKCategoryTypeIdentifierAppleWalkingSteadinessEvent: { + hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + measurementType: "WALKING_STEADINESS_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + eventClassificationMap: APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP, + fallbackClassification: "LOW", + }, + // Breathing-disturbance / sleep-apnea event — the device flagged an + // elevated breathing-disturbance signal during sleep. Screening signal + // only; single neutral `FIRED` verdict. + HKCategoryTypeIdentifierSleepApneaEvent: { + hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent", + measurementType: "BREATHING_DISTURBANCE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // ── v1.5.5 iOS-coord — six previously-deferred identifiers ─── // Background: each entry below sat in `HK_QUANTITY_TYPE_DEFERRED`. // `mapAppleHealthEntry()` returned null; the batch route emitted @@ -747,11 +873,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierWorkoutEffortScore", "HKQuantityTypeIdentifierPhysicalEffort", // Sleep apnea + breathing (iOS 18) — v1.5+ - // v1.10.0 — `AppleSleepingBreathingDisturbances` moved into the - // mapping table (BREATHING_DISTURBANCES). The category-type sleep-apnea - // EVENT stays deferred — it belongs in the event class, not the - // quantity map. - "HKCategoryTypeIdentifierSleepApneaEvent", + // v1.10.0 — both signals now mapped, so neither stays deferred: + // `AppleSleepingBreathingDisturbances` (the per-night quantity) → + // BREATHING_DISTURBANCES, and `HKCategoryTypeIdentifierSleepApneaEvent` + // (the fired screening event) → BREATHING_DISTURBANCE_EVENT. // Nutrition — Marc directive, indefinite hold "HKQuantityTypeIdentifierDietaryWater", "HKQuantityTypeIdentifierDietaryCaffeine", @@ -792,11 +917,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the - // mapping table in v1.4.30 (R-F T1.5); `NumberOfTimesFallen` moved - // into the mapping table in v1.10.0 (FALL_COUNT). The walking- - // steadiness EVENT stays deferred — it is a category-type event, not - // a quantity, so it belongs in the event class rather than this map. - "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + // mapping table in v1.4.30 (R-F T1.5). In v1.10.0 both remaining + // signals moved in too, so neither stays deferred: `NumberOfTimesFallen` + // → FALL_COUNT (the continuous count), and + // `AppleWalkingSteadinessEvent` → WALKING_STEADINESS_EVENT (the fired + // severity event). // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical // bucket; no Measurement enum mapping today. "HKQuantityTypeIdentifierForcedExpiratoryVolume1", @@ -810,11 +935,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierBloodAlcoholContent", "HKQuantityTypeIdentifierNikeFuel", // legacy Nike+iPod fitness points // Heart-rhythm event flags (iOS 9+ but watch-detected; iOS 18 - // refreshed surfaces) — would land as Measurement annotations rather - // than rows; defer until the annotation column lands. - "HKCategoryTypeIdentifierLowHeartRateEvent", - "HKCategoryTypeIdentifierHighHeartRateEvent", - "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + // refreshed surfaces). v1.10.0 — `LowHeartRateEvent`, + // `HighHeartRateEvent` + `IrregularHeartRhythmEvent` moved into the + // mapping table as the LOW/HIGH_HEART_RATE_EVENT + + // IRREGULAR_RHYTHM_NOTIFICATION categorical events. + // `LowCardioFitnessEvent` stays deferred (no awareness surface yet). "HKCategoryTypeIdentifierLowCardioFitnessEvent", // Audio-exposure events (iOS 13+) — Environmental + Headphone // moved into the mapping table in v1.4.30 (R-F T1.4) as @@ -857,6 +982,17 @@ export interface AppleHealthEntryInput { * types. */ sleepStage?: number; + /** + * v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` + * codepoint for an EVENT-class category sample (irregular-rhythm, + * high/low-HR, walking-steadiness, breathing-disturbance). The device + * already classified the signal; this is the codepoint of that + * verdict, looked up via the mapping's `eventClassificationMap`. + * Ignored for every non-event identifier. Falls back to the wire + * `sleepStage` field when omitted so an iOS build that reuses the + * existing category-value slot still round-trips. + */ + categoryValue?: number; } /** Output of `mapAppleHealthEntry()`. */ @@ -867,6 +1003,12 @@ export interface AppleHealthEntryOutput { takenAt: Date; /** Set only for sleep entries. */ sleepStage?: SleepStage; + /** + * v1.10.0 — set only for the categorical EVENT classes. The device's + * own verdict / severity for the fired event. NULL/undefined for every + * continuous measurement. + */ + rhythmClassification?: RhythmClassification; } /** @@ -908,5 +1050,21 @@ export function mapAppleHealthEntry( out.sleepStage = stage; } + // v1.10.0 — categorical events (WX-B). Resolve the device's verdict + // from the inbound codepoint. The `value` is already pinned to 1 by the + // mapping's `convertToDbUnit: () => 1`. We accept the codepoint from + // `categoryValue`, falling back to the legacy `sleepStage` category slot + // so an iOS build reusing that wire field still resolves. An unknown + // codepoint degrades to the mapping's `fallbackClassification` rather + // than dropping the event — the device fired it, so it happened. + if (mapping.fallbackClassification) { + const codepoint = input.categoryValue ?? input.sleepStage; + const fromMap = + codepoint !== undefined && mapping.eventClassificationMap + ? mapping.eventClassificationMap[codepoint] + : undefined; + out.rhythmClassification = fromMap ?? mapping.fallbackClassification; + } + return out; } diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 15956c8df..da203c360 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -140,6 +140,19 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["STAIR_DESCENT_SPEED", "activity"], // Breathing disturbances is a per-night sleep-breathing index. ["BREATHING_DISTURBANCES", "sleep"], + + // ── v1.10.0 — categorical events (WX-B) ── + // Device-flagged EVENT rows slot into the category their signal + // belongs to: the rhythm + heart-rate notifications join the + // cardiovascular surface, the walking-steadiness alert joins the + // activity/mobility cluster, and the breathing-disturbance flag joins + // sleep (it fires during sleep). They never participate in + // trend/rollup analytics — the category is only for list grouping. + ["IRREGULAR_RHYTHM_NOTIFICATION", "cardiovascular"], + ["HIGH_HEART_RATE_EVENT", "cardiovascular"], + ["LOW_HEART_RATE_EVENT", "cardiovascular"], + ["WALKING_STEADINESS_EVENT", "activity"], + ["BREATHING_DISTURBANCE_EVENT", "sleep"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 27f37ca57..8b80ff735 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -135,6 +135,15 @@ export function getPRDirection( // a baseline deviation, not a goal axis; neither a higher nor a // lower reading is an achievement. Defer to a null PR direction. case "WRIST_TEMPERATURE": + // v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are + // discrete occurrences (value is always 1), never a goal axis. A + // "personal record" for a health-notification event would be both + // meaningless and alarming, so they explicitly have no PR direction. + case "IRREGULAR_RHYTHM_NOTIFICATION": + case "HIGH_HEART_RATE_EVENT": + case "LOW_HEART_RATE_EVENT": + case "WALKING_STEADINESS_EVENT": + case "BREATHING_DISTURBANCE_EVENT": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index ae9e7e616..79574fc24 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -58,6 +58,38 @@ export const measurementTypeEnum = z.enum([ "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", "BREATHING_DISTURBANCES", + // ── v1.10.0 — categorical events (WX-B) ── + // Discrete device-flagged EVENT rows (value is always 1). The device's + // own verdict / severity rides in the `rhythmClassification` column. + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", +]); + +/** + * v1.10.0 — categorical events (WX-B). The closed set of EVENT-class + * MeasurementTypes. An EVENT row carries `value = 1`, an optional + * `rhythmClassification` verdict, and never participates in trend / rollup + * analytics (it is a discrete occurrence, not a continuous reading). Kept + * as a `Set` so ingest + read paths can branch on "is this an event row?". + */ +export const EVENT_MEASUREMENT_TYPES: ReadonlySet = new Set([ + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", +]); + +export const rhythmClassificationEnum = z.enum([ + "IRREGULAR", + "NOT_DETECTED", + "INCONCLUSIVE", + "LOW", + "VERY_LOW", + "FIRED", ]); export const glucoseContextEnum = z.enum([ @@ -159,6 +191,15 @@ const unitMap: Record = { // Per-night breathing-disturbance index — a unitless count Apple // classifies as NotElevated / Elevated. BREATHING_DISTURBANCES: "count", + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows are dimensionless occurrences (value is always 1). The + // canonical unit is the bare "event" so any accidental numeric surfacing + // reads sensibly; the awareness timeline never displays the value. + IRREGULAR_RHYTHM_NOTIFICATION: "event", + HIGH_HEART_RATE_EVENT: "event", + LOW_HEART_RATE_EVENT: "event", + WALKING_STEADINESS_EVENT: "event", + BREATHING_DISTURBANCE_EVENT: "event", }; export function getUnitForType(type: string): string { @@ -293,6 +334,15 @@ const VALUE_RANGES: Record = { // undisturbed night and the 1000 ceiling is generous headroom over // any plausible severe-apnea night. BREATHING_DISTURBANCES: { min: 0, max: 1000 }, + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows carry a fixed `value = 1` (one fired event). The range pins + // the value so a malformed ingest (value 0, or a stray sensor number) + // is rejected rather than stored as a phantom event. + IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, + HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, + LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, + WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, + BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, }; export function validateMeasurementRange( From a45325af52d48c2b9621213db1d6a78607f53c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:08 +0200 Subject: [PATCH 13/49] feat(healthkit): thread the device-event verdict through batch ingest Accept the optional HKCategoryValue codepoint on each batch entry and persist the resolved rhythm_classification on the stored row. The wire field is additive and ignored for non-event identifiers, so every existing caller is byte-for-byte unchanged. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 33 +++++++++++++++++++++++++ src/app/api/measurements/batch/route.ts | 10 ++++++++ src/lib/openapi/routes.ts | 9 +++++++ 3 files changed, 52 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d25cba861..7fdd80ece 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -383,6 +383,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT - in: query name: from schema: @@ -523,6 +528,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number measuredAt: @@ -2090,6 +2100,11 @@ paths: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT 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." @@ -2544,6 +2559,14 @@ components: type: integer minimum: 0 maximum: 20 + categoryValue: + description: v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, + walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, + which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it + never re-classifies. Ignored for non-event identifiers. + type: integer + minimum: 0 + maximum: 20 externalId: type: string minLength: 1 @@ -3758,6 +3781,11 @@ components: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number unit: @@ -4055,6 +4083,11 @@ components: - STAIR_ASCENT_SPEED - STAIR_DESCENT_SPEED - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT value: type: number unit: diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index f2c2713ba..152435efa 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -90,6 +90,12 @@ const batchEntrySchema = z.object({ startDate: z.iso.datetime({ offset: true }), endDate: z.iso.datetime({ offset: true }), sleepStage: z.number().int().min(0).max(20).optional(), + // v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` + // codepoint for an EVENT-class category sample (irregular-rhythm, + // high/low-HR, walking-steadiness, breathing-disturbance). Carries the + // device's own verdict / severity; resolved to a `rhythmClassification` + // by `mapAppleHealthEntry`. Ignored for every non-event identifier. + categoryValue: z.number().int().min(0).max(20).optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), // v1.8.6 W6 — optional per-entry source tag. Defaults to @@ -215,6 +221,7 @@ async function postBatch(request: NextRequest): Promise { startDate: entry.startDate, endDate: entry.endDate, sleepStage: entry.sleepStage, + categoryValue: entry.categoryValue, }); if (!mapped) { @@ -254,6 +261,9 @@ async function postBatch(request: NextRequest): Promise { externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, sleepStage: mapped.sleepStage ?? null, + // v1.10.0 — categorical events (WX-B). The device's own verdict / + // severity for an EVENT row; null for every continuous reading. + rhythmClassification: mapped.rhythmClassification ?? null, // v1.4.25 W8c — pass the iOS-supplied device-type through to // the row. Stays null for pre-W8c iOS builds; the canonical // picker treats null as `unknown` so legacy ingest keeps diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 32d23dd53..49574c5d2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -378,6 +378,15 @@ const batchEntrySchema = z .describe( "HKCategoryValueSleepAnalysis codepoint; only for sleep samples.", ), + categoryValue: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe( + "v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.", + ), externalId: z .string() .min(1) From 135141a750b3b1cadd4871bf07dacccb65dfbed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:14 +0200 Subject: [PATCH 14/49] feat(insights): surface device-flagged events as an awareness timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a timeline of the health notifications the user's device produced on its own, reachable from the Insights overview. The surface reflects the device's own decision: each row reads "Your device flagged …" and a permanent disclaimer states this is not a medical assessment by HealthLog and that HealthLog does not diagnose any condition. The surface is data-availability-gated — it un-mounts entirely when the user has no such events, so an account that never triggered one never sees an empty or alarming card. Copy renders as plain React text children; no markdown library is involved. --- messages/de.json | 30 ++- messages/en.json | 30 ++- messages/es.json | 30 ++- messages/fr.json | 30 ++- messages/it.json | 30 ++- messages/pl.json | 30 ++- .../rhythm-events/__tests__/route.test.ts | 129 +++++++++++++ src/app/api/insights/rhythm-events/route.ts | 83 +++++++++ src/app/insights/page.tsx | 14 ++ .../insights/rhythm-events-card.tsx | 176 ++++++++++++++++++ src/lib/query-keys.ts | 9 + 11 files changed, 579 insertions(+), 12 deletions(-) create mode 100644 src/app/api/insights/rhythm-events/__tests__/route.test.ts create mode 100644 src/app/api/insights/rhythm-events/route.ts create mode 100644 src/components/insights/rhythm-events-card.tsx diff --git a/messages/de.json b/messages/de.json index 76ca8c928..c06e57647 100644 --- a/messages/de.json +++ b/messages/de.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke", "typeStairAscentSpeed": "Treppensteig-Tempo", "typeStairDescentSpeed": "Treppab-Tempo", - "typeBreathingDisturbances": "Atemaussetzer im Schlaf" + "typeBreathingDisturbances": "Atemaussetzer im Schlaf", + "typeIrregularRhythmNotification": "Hinweis auf unregelmäßigen Rhythmus", + "typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz", + "typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz", + "typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität", + "typeBreathingDisturbanceEvent": "Atemstörung" }, "mood": { "title": "Stimmung", @@ -2649,7 +2654,28 @@ "description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert." } }, - "navBreathingDisturbances": "Atmung" + "navBreathingDisturbances": "Atmung", + "rhythmEvents": { + "sectionTitle": "Gesundheitshinweise des Geräts", + "sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an — es erkennt, bestätigt oder interpretiert sie nicht.", + "loadError": "Die Gerätehinweise konnten nicht geladen werden.", + "disclaimer": "Dies sind Hinweise, die dein Gerät mit seiner eigenen integrierten Erkennung erzeugt hat. Sie werden hier nur zu deiner Information angezeigt. Dies ist keine medizinische Bewertung durch HealthLog, und HealthLog stellt keine Diagnose. Wenn dich ein Hinweis beunruhigt, sprich mit deiner Ärztin oder deinem Arzt.", + "event": { + "irregularRhythm": "Hinweis auf unregelmäßigen Rhythmus", + "highHeartRate": "Hinweis auf hohe Herzfrequenz", + "lowHeartRate": "Hinweis auf niedrige Herzfrequenz", + "walkingSteadiness": "Hinweis zur Gehstabilität", + "breathingDisturbance": "Atemstörung während des Schlafs" + }, + "verdict": { + "irregular": "Dein Gerät hat einen möglichen unregelmäßigen Rhythmus erkannt.", + "notDetected": "Dein Gerät hat keinen unregelmäßigen Rhythmus erkannt.", + "inconclusive": "Dein Gerät konnte keine Messung vornehmen.", + "low": "Dein Gerät hat eine niedrige Gehstabilität erkannt.", + "veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.", + "fired": "Dein Gerät hat dieses Ereignis erkannt." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index 813bd8806..3f7169300 100644 --- a/messages/en.json +++ b/messages/en.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Six-minute walk distance", "typeStairAscentSpeed": "Stair ascent speed", "typeStairDescentSpeed": "Stair descent speed", - "typeBreathingDisturbances": "Sleep breathing disturbances" + "typeBreathingDisturbances": "Sleep breathing disturbances", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Mood", @@ -2649,7 +2654,28 @@ "description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch." } }, - "navBreathingDisturbances": "Breathing" + "navBreathingDisturbances": "Breathing", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index 97b5ae0d6..4fbcaa19d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos", "typeStairAscentSpeed": "Velocidad subiendo escaleras", "typeStairDescentSpeed": "Velocidad bajando escaleras", - "typeBreathingDisturbances": "Alteraciones respiratorias del sueño" + "typeBreathingDisturbances": "Alteraciones respiratorias del sueño", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Estado de ánimo", @@ -2649,7 +2654,28 @@ "description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible." } }, - "navBreathingDisturbances": "Respiración" + "navBreathingDisturbances": "Respiración", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index 4cfede66c..9a8ca4b09 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distance de marche de 6 minutes", "typeStairAscentSpeed": "Vitesse en montée d'escaliers", "typeStairDescentSpeed": "Vitesse en descente d'escaliers", - "typeBreathingDisturbances": "Troubles respiratoires du sommeil" + "typeBreathingDisturbances": "Troubles respiratoires du sommeil", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Humeur", @@ -2649,7 +2654,28 @@ "description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible." } }, - "navBreathingDisturbances": "Respiration" + "navBreathingDisturbances": "Respiration", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index 95d1fba57..68b53abe9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti", "typeStairAscentSpeed": "Velocità in salita scale", "typeStairDescentSpeed": "Velocità in discesa scale", - "typeBreathingDisturbances": "Disturbi respiratori nel sonno" + "typeBreathingDisturbances": "Disturbi respiratori nel sonno", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Umore", @@ -2649,7 +2654,28 @@ "description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile." } }, - "navBreathingDisturbances": "Respirazione" + "navBreathingDisturbances": "Respirazione", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index db5d8c5a8..917d7ecb9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego", "typeStairAscentSpeed": "Prędkość wchodzenia po schodach", "typeStairDescentSpeed": "Prędkość schodzenia po schodach", - "typeBreathingDisturbances": "Zaburzenia oddychania we śnie" + "typeBreathingDisturbances": "Zaburzenia oddychania we śnie", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Nastrój", @@ -2649,7 +2654,28 @@ "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch." } }, - "navBreathingDisturbances": "Oddychanie" + "navBreathingDisturbances": "Oddychanie", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/src/app/api/insights/rhythm-events/__tests__/route.test.ts b/src/app/api/insights/rhythm-events/__tests__/route.test.ts new file mode 100644 index 000000000..a94647a50 --- /dev/null +++ b/src/app/api/insights/rhythm-events/__tests__/route.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.10.0 — categorical events (WX-B). `GET /api/insights/rhythm-events`. + * + * The route serves the device-flagged event awareness timeline. The + * load-bearing behaviour under test: it narrows the query to the EVENT + * MeasurementTypes for the authenticated user, returns the device's own + * verdict per row, and reports `hasEvents: false` (the data-availability + * gate the UI keys off) when the user has none. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + measurement: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest(new URL("http://localhost/api/insights/rhythm-events")); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/insights/rhythm-events", () => { + it("returns hasEvents:false with an empty list when the user has no events", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { events: unknown[]; hasEvents: boolean }; + }; + expect(body.data.hasEvents).toBe(false); + expect(body.data.events).toEqual([]); + }); + + it("narrows the query to the EVENT MeasurementTypes for the session user only", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + await callGet(makeReq()); + const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0] + ?.where as { + userId: string; + type: { in: string[] }; + deletedAt: null; + }; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(where.type.in).toEqual( + expect.arrayContaining([ + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", + ]), + ); + // Never a continuous metric — only the five event classes. + expect(where.type.in).toHaveLength(5); + }); + + it("maps each row to the device verdict + occurrence timestamp", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "evt_1", + type: "IRREGULAR_RHYTHM_NOTIFICATION", + rhythmClassification: "IRREGULAR", + measuredAt: new Date("2026-06-01T09:15:00.000Z"), + source: "APPLE_HEALTH", + deviceType: "watch", + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + hasEvents: boolean; + events: Array<{ + id: string; + type: string; + classification: string | null; + occurredAt: string; + }>; + }; + }; + expect(body.data.hasEvents).toBe(true); + expect(body.data.events).toEqual([ + { + id: "evt_1", + type: "IRREGULAR_RHYTHM_NOTIFICATION", + classification: "IRREGULAR", + occurredAt: "2026-06-01T09:15:00.000Z", + source: "APPLE_HEALTH", + deviceType: "watch", + }, + ]); + }); +}); diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts new file mode 100644 index 000000000..9d45cb7d0 --- /dev/null +++ b/src/app/api/insights/rhythm-events/route.ts @@ -0,0 +1,83 @@ +/** + * v1.10.0 — device-flagged event awareness route (categorical events, + * WX-B). + * + * `GET /api/insights/rhythm-events` returns the authenticated user's + * timeline of device-flagged EVENT rows — irregular-rhythm / + * high-HR / low-HR / walking-steadiness / breathing-disturbance + * notifications the user's wearable (Apple Watch / Withings ScanWatch) + * already produced and synced. + * + * Regulatory framing (load-bearing): this surface is AWARENESS / SCREENING + * of the DEVICE's own decision. HealthLog stores and reflects ONLY the + * classification RESULT the device's FDA-cleared / CE-marked on-device + * algorithm emitted. It never ingests a raw ECG waveform, never + * re-classifies, and never produces a HealthLog diagnosis. The verdict the + * client renders (`rhythmClassification`) is the device's, verbatim. + * + * Data-availability-gated by construction: an account with no event rows + * gets `{ events: [], hasEvents: false }`, and the client un-mounts the + * whole surface rather than painting an empty / alarming card. + * + * Follows the `metric-status` / `derived` route precedent: `apiHandler` + * wrapper, cookie OR Bearer auth, `userId` narrowed from the session + * (never a query field), the `insightStatus` assistant-surface gate (no AI + * provider call — this is a pure DB read). + */ +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +// Cap the returned timeline. A healthy account produces a handful of +// these a year; the cap is a defensive bound, not an expected ceiling. +const MAX_EVENTS = 200; + +const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[]; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: EVENT_TYPE_LIST }, + deletedAt: null, + }, + select: { + id: true, + type: true, + rhythmClassification: true, + measuredAt: true, + source: true, + deviceType: true, + }, + orderBy: { measuredAt: "desc" }, + take: MAX_EVENTS, + }); + + const events = rows.map((r) => ({ + id: r.id, + type: r.type, + classification: r.rhythmClassification, + occurredAt: r.measuredAt.toISOString(), + source: r.source, + deviceType: r.deviceType, + })); + + annotate({ + action: { name: "insights.rhythm-events" }, + meta: { count: events.length }, + }); + + return apiSuccess({ + events, + hasEvents: events.length > 0, + }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index ab5d7812e..1302c484c 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -85,6 +85,18 @@ const VitalsDashboard = dynamic( ), }, ); +// v1.10.0 — categorical events (WX-B). The device-flagged event awareness +// timeline. Deferred like the other below-the-fold blocks; the card +// un-mounts itself when the user has no such events (no skeleton-then-empty +// flash — `ssr: false` + the card's own data gate), so it carries no +// loading placeholder of its own. +const RhythmEventsCard = dynamic( + () => + import("@/components/insights/rhythm-events-card").then((mod) => ({ + default: mod.RhythmEventsCard, + })), + { ssr: false }, +); /** * v1.4.25 W4d — Insights mother page. @@ -248,6 +260,8 @@ export default function InsightsPage() { + + {flags.briefing && ( = { + IRREGULAR_RHYTHM_NOTIFICATION: Activity, + HIGH_HEART_RATE_EVENT: HeartPulse, + LOW_HEART_RATE_EVENT: HeartPulse, + WALKING_STEADINESS_EVENT: Footprints, + BREATHING_DISTURBANCE_EVENT: Wind, +}; + +const EVENT_LABEL_KEYS: Record = { + IRREGULAR_RHYTHM_NOTIFICATION: "insights.rhythmEvents.event.irregularRhythm", + HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate", + LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate", + WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness", + BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance", +}; + +/** + * The device's verdict, surfaced verbatim. Each line is framed as the + * device's decision ("Your device flagged …"), never HealthLog's. + */ +const CLASSIFICATION_LABEL_KEYS: Record = { + IRREGULAR: "insights.rhythmEvents.verdict.irregular", + NOT_DETECTED: "insights.rhythmEvents.verdict.notDetected", + INCONCLUSIVE: "insights.rhythmEvents.verdict.inconclusive", + LOW: "insights.rhythmEvents.verdict.low", + VERY_LOW: "insights.rhythmEvents.verdict.veryLow", + FIRED: "insights.rhythmEvents.verdict.fired", +}; + +interface RhythmEventsCardProps { + enabled?: boolean; + className?: string; +} + +export function RhythmEventsCard({ + enabled = true, + className, +}: RhythmEventsCardProps) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.insightsRhythmEvents(), + queryFn: async () => { + const res = await fetch("/api/insights/rhythm-events"); + if (!res.ok) throw new Error(t("insights.rhythmEvents.loadError")); + const json = await res.json(); + return json.data as RhythmEventsResponse; + }, + enabled: enabled && isAuthenticated, + }); + + // Data-availability gate — never paint an empty / alarming card. + if (isLoading || !data || !data.hasEvents) return null; + + return ( +
    +
    +

    + {t("insights.rhythmEvents.sectionTitle")} +

    +

    + {t("insights.rhythmEvents.sectionIntro")} +

    +
    + +
      + {data.events.map((event) => { + const Icon = EVENT_ICONS[event.type] ?? Activity; + const labelKey = EVENT_LABEL_KEYS[event.type]; + const label = labelKey ? t(labelKey) : event.type; + const verdictKey = event.classification + ? CLASSIFICATION_LABEL_KEYS[event.classification] + : undefined; + const verdict = verdictKey ? t(verdictKey) : null; + return ( +
    1. + + + +
      +

      {label}

      + {verdict && ( +

      + {verdict} +

      + )} +

      + {fmt.dateTime(new Date(event.occurredAt))} +

      +
      +
    2. + ); + })} +
    + + {/* Load-bearing regulatory disclaimer — permanent, non-dismissible. + Plain React text children (no markdown library — XSS rule). */} +
    +
    +
    + ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index f9101c8c7..8504391d0 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -132,6 +132,15 @@ export const queryKeys = { * own stale-while-revalidate. */ insightsPregenerate: () => ["insights", "pregenerate"] as const, + /** + * v1.10.0 — categorical events (WX-B). The device-flagged event + * awareness timeline (`/api/insights/rhythm-events`). No locale segment + * — the payload is verdicts + timestamps; the surface localises its own + * prose. The `["insights"]` prefix keeps it in the standard insights + * invalidation fan-out (an Apple Health batch write that lands an event + * row busts it via the prefix). + */ + insightsRhythmEvents: () => ["insights", "rhythm-events"] as const, /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals From 95064b405af7f3edcb7438f41c7dd659db434fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:36 +0200 Subject: [PATCH 15/49] test(healthkit): cover device-event ingest + the awareness surface Add mapping tests for the five event classes (verdict resolution, severity grading, the value-pinned-to-1 contract, unknown-codepoint fallback, and that a continuous measurement never gets a classification), the awareness-card gating + device-verdict framing + disclaimer presence, and the events route narrowing + empty-state contract. Extend the enum, chart-token and Coach-gate drift guards for the new event types. --- .../coach-route-gate-inventory.test.ts | 5 + .../__tests__/rhythm-events-card.test.tsx | 115 ++++++++++++++ .../measurement-type-enum-coverage.test.ts | 19 ++- .../insights/__tests__/chart-tokens.test.ts | 11 +- .../__tests__/apple-health-mapping.test.ts | 141 ++++++++++++++++-- 5 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 src/components/insights/__tests__/rhythm-events-card.test.tsx diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index a8c5a8145..37ea6fd23 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -53,6 +53,11 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // `coach`: a user with assessments enabled but Coach disabled can warm. "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", + // v1.10.0 — device-flagged event awareness timeline (categorical + // events, WX-B). Pure DB read of the device's own verdicts; gates on + // the same `insightStatus` sub-flag as the assessment routes (no Coach + // prose). + "src/app/api/insights/rhythm-events/route.ts", "src/app/api/insights/weight-status/route.ts", ]; diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx new file mode 100644 index 000000000..7de61bc59 --- /dev/null +++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.10.0 — categorical events (WX-B). `` unit tests. + * + * The card is the device-flagged event awareness surface. The load-bearing + * behaviour under test: + * - data-availability gating: the card un-mounts entirely (renders + * nothing) when the user has no events or while the payload is in + * flight — never an empty / alarming card. + * - the device-verdict framing ("Your device flagged …") and the + * permanent regulatory disclaimer that states HealthLog does not make a + * medical assessment. + * + * The component depends on `useAuth` (gate) + TanStack Query (`useQuery`) + * for the payload, so both are mocked and the assertions run through SSR. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })), +})); + +const useQueryMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: unknown) => useQueryMock(opts), +})); + +const { RhythmEventsCard } = await import("../rhythm-events-card"); + +interface RhythmEvent { + id: string; + type: string; + classification: string | null; + occurredAt: string; + source: string; + deviceType: string | null; +} + +function renderWith( + data: { events: RhythmEvent[]; hasEvents: boolean } | undefined, +) { + useQueryMock.mockReturnValue({ data, isLoading: false }); + return renderToStaticMarkup( + + + , + ); +} + +const IRREGULAR_EVENT: RhythmEvent = { + id: "evt_1", + type: "IRREGULAR_RHYTHM_NOTIFICATION", + classification: "IRREGULAR", + occurredAt: "2026-06-01T09:15:00.000Z", + source: "APPLE_HEALTH", + deviceType: "watch", +}; + +const STEADINESS_EVENT: RhythmEvent = { + id: "evt_2", + type: "WALKING_STEADINESS_EVENT", + classification: "VERY_LOW", + occurredAt: "2026-05-20T17:00:00.000Z", + source: "APPLE_HEALTH", + deviceType: "phone", +}; + +describe("", () => { + it("renders nothing before the payload resolves", () => { + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when the user has no events (data-availability gate)", () => { + const html = renderWith({ events: [], hasEvents: false }); + expect(html).toBe(""); + }); + + it("renders the timeline + each event row when events exist", () => { + const html = renderWith({ + events: [IRREGULAR_EVENT, STEADINESS_EVENT], + hasEvents: true, + }); + expect(html).toContain('data-slot="rhythm-events-card"'); + expect(html).toContain('data-slot="rhythm-events-timeline"'); + const rows = (html.match(/data-slot="rhythm-event-row"/g) ?? []).length; + expect(rows).toBe(2); + expect(html).toContain('data-event-type="IRREGULAR_RHYTHM_NOTIFICATION"'); + expect(html).toContain('data-event-type="WALKING_STEADINESS_EVENT"'); + }); + + it("frames the verdict as the DEVICE's decision, never HealthLog's", () => { + const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true }); + expect(html).toContain('data-slot="rhythm-event-verdict"'); + // Verbatim device-decision framing — load-bearing regulatory copy. + expect(html).toContain("Your device flagged a possible irregular rhythm."); + }); + + it("renders the permanent regulatory disclaimer", () => { + const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true }); + expect(html).toContain('data-slot="rhythm-events-disclaimer"'); + // The disclaimer must state HealthLog does not make a medical + // assessment and does not diagnose. + expect(html).toContain("not a medical assessment by HealthLog"); + expect(html).toContain("does not diagnose"); + }); +}); diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5693adf6d..1985c040c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -67,10 +67,16 @@ const EXPECTED_TYPES = [ "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", "BREATHING_DISTURBANCES", + // ── v1.10.0 — categorical events (WX-B) ── + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 45 canonical measurement types", () => { + it("exposes the 50 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -151,6 +157,17 @@ describe("measurementTypeEnum coverage", () => { "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", "BREATHING_DISTURBANCES", + // v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are + // discrete on-device notifications, not vital-sign readings. They are + // surfaced on the Insights awareness timeline with their own regulatory + // disclaimer, never in the clinical vitals PDF (their value is always 1 + // and they carry the device's verdict, not a measured quantity). See + // doctor-report-pdf-core.ts for the matching exclusion rationale. + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index aeb86855a..f84525a4b 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { MeasurementType } from "@/generated/prisma/client"; +import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; import { ALLOWED_CHART_TOKENS, parseChartTokens, @@ -214,12 +215,20 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => { ); }); - it("covers every MeasurementType enum value", () => { + it("covers every chartable MeasurementType enum value", () => { // If a future schema migration adds a new MeasurementType, this test // fails until the new `metric:` is also added to the allowlist. // (`NOTE` is excluded if it ever appears — currently the schema has none.) + // + // v1.10.0 — the categorical EVENT classes (irregular-rhythm / + // high-low-HR / walking-steadiness / breathing-disturbance) are + // deliberately NOT chartable: they are discrete device-flagged + // occurrences with no continuous series, surfaced on the awareness + // timeline rather than the inline chart renderer. They carry no + // `metric:` token by design. const enumTokens = (Object.values(MeasurementType) as string[]) .filter((value) => value !== "NOTE") + .filter((value) => !EVENT_MEASUREMENT_TYPES.has(value)) .map((value) => `metric:${value}`); for (const token of enumTokens) { diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 84fddec66..2603f23af 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -66,6 +66,15 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { // HRV / VO2 max / sleep analysis. "HKQuantityTypeIdentifierAppleSleepingWristTemperature", "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + // v1.10.0 — the categorical EVENT classes carry health-screening + // verdicts (rhythm / heart-rate / mobility / breathing) the user + // explicitly enabled on-device; flagged privacy-sensitive so the + // server audit trail can distinguish them. + "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + "HKCategoryTypeIdentifierHighHeartRateEvent", + "HKCategoryTypeIdentifierLowHeartRateEvent", + "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + "HKCategoryTypeIdentifierSleepApneaEvent", ].sort(), ); // Audio exposure + time-in-daylight are environment / lifestyle @@ -138,11 +147,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { // Cardiovascular / clinical "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", - // Mobility — `AppleWalkingSteadiness` moved into the mapped - // set in v1.4.30; `NumberOfTimesFallen` moved into the mapped set - // in v1.10.0 (FALL_COUNT). The walking-steadiness EVENT stays - // deferred (a category-type event, not a quantity). - "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + // Mobility — `AppleWalkingSteadiness` moved into the mapped set in + // v1.4.30. In v1.10.0 both remaining identifiers moved in too: + // `NumberOfTimesFallen` → FALL_COUNT and `AppleWalkingSteadinessEvent` + // → WALKING_STEADINESS_EVENT, so neither stays deferred. // Respiratory / pulmonary "HKQuantityTypeIdentifierForcedExpiratoryVolume1", "HKQuantityTypeIdentifierForcedVitalCapacity", @@ -154,10 +162,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => { "HKQuantityTypeIdentifierElectrodermalActivity", "HKQuantityTypeIdentifierBloodAlcoholContent", "HKQuantityTypeIdentifierNikeFuel", - // Heart-rhythm event flags - "HKCategoryTypeIdentifierLowHeartRateEvent", - "HKCategoryTypeIdentifierHighHeartRateEvent", - "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + // Heart-rhythm event flags — `LowHeartRateEvent`, + // `HighHeartRateEvent` + `IrregularHeartRhythmEvent` moved into the + // mapped set in v1.10.0 as categorical events. `LowCardioFitnessEvent` + // stays deferred (no awareness surface yet). "HKCategoryTypeIdentifierLowCardioFitnessEvent", // Audio-exposure events — environmental + headphone moved into // the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general @@ -757,3 +765,118 @@ describe("v1.10.0 additive HealthKit signals (WX-A)", () => { }); }); }); + +describe("v1.10.0 categorical events (WX-B)", () => { + const ts = "2026-06-02T07:30:00.000Z"; + + it("maps every event identifier to its EVENT MeasurementType", () => { + const expected: Array<[string, string]> = [ + [ + "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + "IRREGULAR_RHYTHM_NOTIFICATION", + ], + ["HKCategoryTypeIdentifierHighHeartRateEvent", "HIGH_HEART_RATE_EVENT"], + ["HKCategoryTypeIdentifierLowHeartRateEvent", "LOW_HEART_RATE_EVENT"], + [ + "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + "WALKING_STEADINESS_EVENT", + ], + [ + "HKCategoryTypeIdentifierSleepApneaEvent", + "BREATHING_DISTURBANCE_EVENT", + ], + ]; + for (const [hkId, type] of expected) { + const mapping = APPLE_HEALTH_TYPE_MAP[hkId]; + expect(mapping, `${hkId} should be mapped`).toBeDefined(); + expect(mapping.measurementType).toBe(type); + // EVENT rows are always a single fired occurrence regardless of the + // inbound number — the device fired it once. + expect(mapping.convertToDbUnit(999)).toBe(1); + expect(mapping.dbUnit).toBe("event"); + } + }); + + it("resolves the irregular-rhythm event to the device's IRREGULAR verdict", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + value: 0, + unit: "event", + startDate: ts, + endDate: ts, + categoryValue: 0, + }); + expect(out).toEqual({ + type: "IRREGULAR_RHYTHM_NOTIFICATION", + value: 1, + unit: "event", + takenAt: new Date(ts), + rhythmClassification: "IRREGULAR", + }); + }); + + it("grades the walking-steadiness event severity from the codepoint", () => { + const low = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + value: 0, + unit: "event", + startDate: ts, + endDate: ts, + categoryValue: 1, // initialLow + }); + expect(low!.rhythmClassification).toBe("LOW"); + + const veryLow = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + value: 0, + unit: "event", + startDate: ts, + endDate: ts, + categoryValue: 3, // initialVeryLow + }); + expect(veryLow!.rhythmClassification).toBe("VERY_LOW"); + }); + + it("falls back to FIRED for the neutral high/low-HR + breathing events", () => { + for (const hkId of [ + "HKCategoryTypeIdentifierHighHeartRateEvent", + "HKCategoryTypeIdentifierLowHeartRateEvent", + "HKCategoryTypeIdentifierSleepApneaEvent", + ]) { + const out = mapAppleHealthEntry({ + hkIdentifier: hkId, + value: 0, + unit: "event", + startDate: ts, + endDate: ts, + }); + expect(out!.value).toBe(1); + expect(out!.rhythmClassification).toBe("FIRED"); + } + }); + + it("degrades an unknown steadiness codepoint to the fallback rather than dropping it", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + value: 0, + unit: "event", + startDate: ts, + endDate: ts, + categoryValue: 99, // Apple introduced a codepoint we don't enumerate + }); + expect(out).not.toBeNull(); + expect(out!.rhythmClassification).toBe("LOW"); + }); + + it("never sets rhythmClassification on a continuous measurement", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80, + unit: "kg", + startDate: ts, + endDate: ts, + categoryValue: 1, + }); + expect(out!.rhythmClassification).toBeUndefined(); + }); +}); From 9990815efea075901c99725654a49aa2dce7ca32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:21:17 +0200 Subject: [PATCH 16/49] feat(fhir): add additive SNOMED route and site codings to MedicationAdministration Emit a SNOMED CT coding alongside the existing .text anchor on the administration dosage. Route maps ORAL to 26643006 (Oral route) and INJECTION to 34206005 (Subcutaneous route, the form HealthLog injections take). Site keys on the eight InjectionSite enum values, collapsing to the three gross body-region concepts (abdomen 818983003, thigh 68367000, upper arm 40983000); laterality and the abdominal quadrant stay verbatim in the .text anchor rather than inventing lateralised concepts. Referencing concept ids in a FHIR instance is not redistributing the licensed SNOMED distribution, so this is safe to ship. The .text anchor is unchanged, so a consumer without a SNOMED binding is unaffected. Reconcile the dose fixture to the real uppercase enum value the live builder emits (the previous lowercased value never matched production). --- src/lib/fhir/__tests__/build-bundle.test.ts | 214 +++++++++++++++++++- src/lib/fhir/build-bundle.ts | 110 ++++++++-- src/lib/fhir/types.ts | 4 +- 3 files changed, 308 insertions(+), 20 deletions(-) diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 4605ba42e..f1312e1d7 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -516,7 +516,7 @@ describe("buildFhirDocumentBundle", () => { status: "completed", doseText: "10mg", dose: { value: 10, unit: "mg" }, - injectionSite: "abdomen-left", + injectionSite: "ABDOMEN_LEFT", atcCode: "A10BX10", rxNormCode: "2601723", deliveryForm: "INJECTION", @@ -542,8 +542,22 @@ describe("buildFhirDocumentBundle", () => { code: "mg", }); expect(a.dosage?.text).toBe("10mg"); + // Route carries an additive SNOMED coding (INJECTION → subcutaneous) plus + // the unchanged `.text` anchor. expect(a.dosage?.route?.text).toBe("Injection"); - expect(a.dosage?.site?.text).toBe("abdomen-left"); + expect(a.dosage?.route?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "34206005", + display: "Subcutaneous route", + }); + // Site `.text` is the raw enum value (laterality preserved); the SNOMED + // coding collapses to the gross body region. + expect(a.dosage?.site?.text).toBe("ABDOMEN_LEFT"); + expect(a.dosage?.site?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "818983003", + display: "Abdomen structure", + }); }); it("maps a skipped intake to a not-done administration with no dosage", () => { @@ -643,6 +657,202 @@ describe("buildFhirDocumentBundle", () => { expect(refs).toContain("MedicationAdministration/medadmin-1"); }); + it("emits the oral SNOMED route coding alongside the unchanged `.text`", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Metformin", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "500mg", + dose: { value: 500, unit: "mg" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const route = administrationsOf(bundle)[0].dosage?.route; + expect(route?.text).toBe("Oral"); + expect(route?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code: "26643006", + display: "Oral route", + }); + }); + + it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => { + const cases: Array<[string, string, string]> = [ + ["ABDOMEN_LEFT", "818983003", "Abdomen structure"], + ["ABDOMEN_RIGHT", "818983003", "Abdomen structure"], + ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"], + ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"], + ["THIGH_LEFT", "68367000", "Thigh structure"], + ["THIGH_RIGHT", "68367000", "Thigh structure"], + ["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"], + ["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"], + ]; + for (const [site, code, display] of cases) { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mounjaro", + effectiveAt: "2026-04-30T08:12:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: site, + atcCode: null, + rxNormCode: null, + deliveryForm: "INJECTION", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const fhirSite = administrationsOf(bundle)[0].dosage?.site; + // Laterality / quadrant survives verbatim on the `.text` anchor. + expect(fhirSite?.text).toBe(site); + expect(fhirSite?.coding?.[0]).toEqual({ + system: "http://snomed.info/sct", + code, + display, + }); + } + }); + + it("emits no route for an unknown delivery form and no site when absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medicationAdministrations: [ + { + medicationName: "Mystery", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "1 unit", + dose: { value: 1, unit: "unit" }, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "TOPICAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const dosage = administrationsOf(bundle)[0].dosage; + expect(dosage?.route).toBeUndefined(); + expect(dosage?.site).toBeUndefined(); + }); + + it("emits exactly the WHO ATC coding by default (no BfArM appended)", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", schedules: [] }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const coding = stmt?.medicationCodeableConcept.coding; + expect(coding).toHaveLength(1); + expect(coding?.[0].system).toBe("http://www.whocc.no/atc"); + expect( + coding?.some((c) => c.system === "http://fhir.de/CodeSystem/bfarm/atc"), + ).toBe(false); + }); + + it("appends the BfArM ATC coding AFTER the WHO entry when germanAtc is on", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [ + { name: "Empagliflozin", dose: "10mg", atcCode: "A10BK03", schedules: [] }, + ], + medicationAdministrations: [ + { + medicationName: "Empagliflozin", + effectiveAt: "2026-04-30T08:00:00.000Z", + status: "completed", + doseText: "10mg", + dose: { value: 10, unit: "mg" }, + injectionSite: null, + atcCode: "A10BK03", + rxNormCode: "1545150", + deliveryForm: "ORAL", + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + const stmtCoding = stmt?.medicationCodeableConcept.coding; + // WHO stays coding[0], byte-identical. + expect(stmtCoding?.[0]).toEqual({ + system: "http://www.whocc.no/atc", + code: "A10BK03", + display: "Empagliflozin", + }); + expect(stmtCoding?.[1]).toEqual({ + system: "http://fhir.de/CodeSystem/bfarm/atc", + code: "A10BK03", + display: "Empagliflozin", + }); + + // The administration concept reflects the flag identically; RxNorm follows. + const adminCoding = + administrationsOf(bundle)[0].medicationCodeableConcept.coding; + expect(adminCoding?.[0].system).toBe("http://www.whocc.no/atc"); + expect(adminCoding?.[1].system).toBe( + "http://fhir.de/CodeSystem/bfarm/atc", + ); + expect(adminCoding?.[2]).toEqual({ + system: "http://www.nlm.nih.gov/research/umls/rxnorm", + code: "1545150", + }); + }); + + it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }], + }), + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find( + (r): r is FhirMedicationStatement => + r.resourceType === "MedicationStatement", + ); + // Collapses to the text-only concept exactly as before. + expect(stmt?.medicationCodeableConcept.coding).toBeUndefined(); + expect(stmt?.medicationCodeableConcept.text).toBe("Herbal mix"); + }); + it("omits the mood Observation when mood is null (privacy default)", () => { const bundle = buildFhirDocumentBundle( makeData({ mood: null }), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 2dea9f6ca..0636ca0a2 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -69,22 +69,48 @@ const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; * free-text `.text` (the user's medication name) stays the anchor. */ const ATC_SYSTEM = "http://www.whocc.no/atc"; +/** + * German national ATC URI maintained by the BfArM. Same ATC classification + * as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never + * a replacement) when a German-region export is requested, so the WHO entry + * stays first and byte-identical for every consumer. + */ +const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc"; const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"; +/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */ +const SNOMED_SYSTEM = "http://snomed.info/sct"; + +/** Options threaded from the export route into the builder. Additive, all defaulted. */ +export interface FhirBuildOptions { + /** + * When true, additionally emit the German BfArM ATC URI alongside the WHO + * entry on each medication concept. The WHO coding stays first and + * byte-identical; this only appends a second URI for the same leaf code. + * Defaults off; the route turns it on for a German-region export. + */ + germanAtc?: boolean; +} /** * Build a `medicationCodeableConcept` from a medication's free-text name * plus its optional user-asserted codes. ATC is emitted first (primary), * RxNorm second (secondary); both are omitted when NULL, collapsing to * exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code. + * When `germanAtc` is set, the same ATC leaf code is also published under + * the BfArM URI AFTER the WHO entry — additive, never reordering WHO. */ function medicationConcept( name: string, atcCode: string | null | undefined, rxNormCode: string | null | undefined, + germanAtc: boolean, ): FhirCodeableConcept { const coding: NonNullable = []; if (atcCode) { coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + if (germanAtc) { + coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); + } } if (rxNormCode) { coding.push({ system: RXNORM_SYSTEM, code: rxNormCode }); @@ -122,22 +148,71 @@ function doseQuantity(value: number, unit: string): { } /** - * v1.9.0 — text-only route of administration derived from the - * medication's delivery form. Text-only (no SNOMED licence concern); the - * design doc upgrades to coded routes jointly with iOS later. Returns - * `undefined` for an unknown / absent form so no empty route is emitted. + * Route of administration derived from the medication's delivery form, + * carrying an additive SNOMED CT `coding` alongside the existing `.text` + * anchor. HealthLog injections are subcutaneous (the injection-site picker + * exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to + * the subcutaneous route. Returns `undefined` for an unknown / absent form + * so no empty route is emitted. */ +const ROUTE_SNOMED: Record = { + ORAL: { code: "26643006", display: "Oral route" }, + INJECTION: { code: "34206005", display: "Subcutaneous route" }, +}; + function routeConcept( deliveryForm: string | null, ): FhirCodeableConcept | undefined { - switch (deliveryForm) { - case "ORAL": - return { text: "Oral" }; - case "INJECTION": - return { text: "Injection" }; - default: - return undefined; - } + const text = + deliveryForm === "ORAL" + ? "Oral" + : deliveryForm === "INJECTION" + ? "Injection" + : undefined; + if (!text) return undefined; + const snomed = ROUTE_SNOMED[deliveryForm as string]; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; +} + +/** + * Administration body-site keyed on the raw `InjectionSite` enum value, + * carrying an additive SNOMED CT body-region `coding` alongside the `.text` + * anchor. The map collapses the eight enum members to three gross body-region + * concepts (abdomen / thigh / upper arm); laterality (left/right) and the + * abdominal quadrant are NOT lateralised SNOMED concepts here — they are + * preserved verbatim in the human-readable `.text` (the raw enum value), so + * no information is lost. + */ +const SITE_SNOMED: Record = { + ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, + THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, + THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, + UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, + UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, +}; + +function siteConcept(injectionSite: string): FhirCodeableConcept { + const snomed = SITE_SNOMED[injectionSite]; + // Preserve the full enum value (incl. laterality) as the readable anchor. + const text = injectionSite; + return snomed + ? { + coding: [ + { system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display }, + ], + text, + } + : { text }; } /** Escape the five XML-significant characters for the xhtml narrative. */ @@ -198,7 +273,9 @@ export function buildFhirDocumentBundle( data: DoctorReportData, identity: FhirPatientIdentity, now: Date = new Date(), + options: FhirBuildOptions = {}, ): FhirBundle { + const germanAtc = options.germanAtc ?? false; const patientId = "patient-1"; const patientRef: FhirReference = { reference: `Patient/${patientId}` }; const entries: FhirBundleEntry[] = []; @@ -479,6 +556,7 @@ export function buildFhirDocumentBundle( med.name, med.atcCode, med.rxNormCode, + germanAtc, ), subject: patientRef, }; @@ -509,15 +587,15 @@ export function buildFhirDocumentBundle( admin.medicationName, admin.atcCode, admin.rxNormCode, + germanAtc, ), subject: patientRef, effectiveDateTime: admin.effectiveAt, }; // Dosage: only when a structured dose Quantity exists. Carry the - // free-text dose + route + site alongside it. Text-only route/site - // (no SNOMED licence concern); the design doc upgrades to coded - // routes jointly with iOS later. + // free-text dose + route + site alongside it. Route and site each carry + // an additive SNOMED coding plus the existing `.text` anchor. if (admin.dose) { const dosage: FhirDosage = { dose: doseQuantity(admin.dose.value, admin.dose.unit), @@ -525,7 +603,7 @@ export function buildFhirDocumentBundle( if (admin.doseText) dosage.text = admin.doseText; const route = routeConcept(admin.deliveryForm); if (route) dosage.route = route; - if (admin.injectionSite) dosage.site = { text: admin.injectionSite }; + if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite); resource.dosage = dosage; } diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 649e6f54f..df65bbc80 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -86,9 +86,9 @@ export interface FhirDosage { * is available; a text-only dose stays on the `MedicationStatement`. */ dose?: FhirQuantity; - /** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */ + /** Route of administration: an additive SNOMED `coding` plus the `.text` anchor. */ route?: FhirCodeableConcept; - /** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */ + /** Administration body-site (e.g. injection site): additive SNOMED `coding` plus `.text`. */ site?: FhirCodeableConcept; } From e893a43005ddefcdd880d7b3e1e62d3445260361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:21:26 +0200 Subject: [PATCH 17/49] feat(fhir): wire the German BfArM ATC locale toggle into the export route The builder already accepts a germanAtc option that appends the BfArM ATC URI after the WHO entry. Thread it from the health-record export route: an explicit germanAtc flag on the selection payload wins, otherwise it derives from a German-region locale. The WHO coding stays coding[0], byte-identical and locale-independent, so a WHO-only consumer is unaffected. Add the optional germanAtc field to the strict export selection schema and regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 2 ++ src/app/api/export/health-record/route.ts | 18 ++++++++++++++++-- src/lib/validations/health-record-export.ts | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7fdd80ece..bc8cb5bbd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -148,6 +148,8 @@ paths: type: boolean includeAiSummary: type: boolean + germanAtc: + type: boolean required: - format additionalProperties: false diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 842845a11..b8f14a602 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -90,6 +90,10 @@ export const POST = apiHandler(async (request: NextRequest) => { request.headers.get("accept-language") ?? "", ); + // BfArM ATC: an explicit selection flag wins; otherwise derive it from a + // German-region locale. The WHO ATC coding is unaffected either way. + const germanAtc = selection.germanAtc ?? locale === "de"; + const [data, userTz, userRow] = await Promise.all([ collectDoctorReportData(user.id, range, { practiceName, sections }), resolveUserTimezone(user.id), @@ -136,7 +140,12 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (selection.format === "fhir") { - const bundle = buildFhirDocumentBundle(data, { insuranceNumber }); + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber }, + undefined, + { germanAtc }, + ); const json = JSON.stringify(bundle); annotate({ meta: { format: "fhir", bytes: json.length, days: range.days }, @@ -178,7 +187,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } // format === "package": one zip holding the PDF + FHIR Bundle + a README. - const bundle = buildFhirDocumentBundle(data, { insuranceNumber }); + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber }, + undefined, + { germanAtc }, + ); const readme = t("doctorReport.packageReadme"); const zipped = zipSync( { diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 4afe72394..ca76980fa 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -97,6 +97,10 @@ export const exportSelectionSchema = z practiceName: z.string().max(120).optional(), includeCharts: z.boolean().optional(), includeAiSummary: z.boolean().optional(), + // FHIR only: additionally emit the German BfArM ATC URI alongside the WHO + // entry on each medication concept (WHO stays first, byte-identical). When + // omitted, the route derives it from a German-region locale. + germanAtc: z.boolean().optional(), }) .strict(); From 51fc48f93c9372e6b179b2fea6371da332f5b92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:21:33 +0200 Subject: [PATCH 18/49] feat(fhir): raise the MedicationAdministration cap to 5000 and make it configurable Raise the per-export administration ceiling from 1000 to 5000 (~3.5 years at four doses a day) and resolve it from FHIR_MAX_MEDICATION_ADMINISTRATIONS, bounded 1..50000 with a fallback to 5000 on an unset or out-of-range value. The windowed-then-capped newest-first behaviour and the honest-truncation narrative are unchanged; this only raises the ceiling so it bites far less often. Add the var to the compose environment whitelist and the production env example so it reaches the container and check-env stays green. --- .env.production.example | 11 ++++ docker-compose.yml | 5 ++ src/lib/__tests__/doctor-report-data.test.ts | Bin 4837 -> 5805 bytes src/lib/doctor-report-data.ts | 51 ++++++++++++++----- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/.env.production.example b/.env.production.example index cda623ef2..5ac450ad8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -62,6 +62,17 @@ API_TOKEN_HMAC_KEY="" # BACKUP_ENCRYPTION_KEY="" +# ----------------------------------------------------------------------------- +# FHIR health-record export -- optional +# ----------------------------------------------------------------------------- +# Per-export ceiling on the number of MedicationAdministration rows in the +# FHIR Bundle. The report window already scopes the set; this is a coarse +# safety ceiling on the serialised Bundle size. Bounded 1..50000; any unset +# or out-of-range value falls back to the default of 5000. Raise it only for +# an unusual multi-year, many-medication workload. +# FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000" + + # ----------------------------------------------------------------------------- # Dashboard snapshot -- BUILD-TIME toggle (default ON) # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 3d621b511..53ffc0a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,11 @@ services: BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" + # Per-export ceiling on MedicationAdministration rows in the FHIR + # health-record Bundle. Bounded 1..50000, default 5000. Listed here so + # an operator override in .env actually reaches the container (the + # `environment:` block is a whitelist). + FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}" depends_on: db: condition: service_healthy diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index ea31002923db6a7b9e477c4f5936743c64da98c7..7047219ad2a93fc92017400ee627a90a0120d1d4 100644 GIT binary patch delta 870 zcmbV~%}N6?6orxQx^$s##7k71flirO6roZ3jtV}M z?_o0NNYO>w<%Qhuo^x_O&)3Pz@Km9elR5V3vX41u)NmWI3^dE$8p=p zTZ}w5zH;{fa5+O}q7V!rfA?2g0qBtZ!`KRuIqB_AP3hHEo}HoZ1Sw-Ua{*GA(+SUk z3xgw6u;6CQ1w3S}D%kYV1$YtUJR{}t-eFMK#4D`z`C*5oy{lhHH91Oo-#j|`X+`0> uAgxJcktixg=)GFhp`9R`_#HGRS?#j^gsM;7;6nMm4NBQ?O|t delta 18 acmZ3h`&4y8(&R~^A`|E8Zq8<0D# diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index dc217cb1d..68e72d47a 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -207,19 +207,46 @@ const MAX_RANGE_DAYS = 730; const DEFAULT_RANGE_DAYS = 90; /** - * v1.9.0 — hard ceiling on the number of `MedicationAdministration` - * source rows materialised per export. The report window is already - * bounded at {@link MAX_RANGE_DAYS} (730 days), but a chronic medication - * dosed several times a day across that window — multiplied by several - * such medications — can still produce thousands of administration - * resources in a single Bundle (e.g. 4×/day × 730 days ≈ 2 920 per med). - * Cap at the MOST-RECENT 1 000 acted intakes overall: that preserves the - * recent-adherence picture a clinician cares about while bounding the - * in-memory array and the serialised Bundle. When the cap trims rows the - * aggregator flags it so the FHIR narrative can disclose the truncation - * rather than silently dropping history. + * Default ceiling on the number of `MedicationAdministration` source rows + * materialised per export, and the bounds an operator override is clamped + * to. The report window is already bounded at {@link MAX_RANGE_DAYS} + * (730 days), but a chronic medication dosed several times a day across + * that window — multiplied by several such medications — can still produce + * thousands of administration resources in a single Bundle. The default of + * 5 000 covers ~3.5 years at four doses a day, so it effectively never bites + * on a realistic report period while still bounding a pathological export. */ -const MAX_MEDICATION_ADMINISTRATIONS = 1000; +const DEFAULT_MAX_MEDICATION_ADMINISTRATIONS = 5000; +const MIN_MEDICATION_ADMINISTRATIONS = 1; +const MAX_MEDICATION_ADMINISTRATIONS_CEILING = 50000; + +/** + * Resolve the administration ceiling from `FHIR_MAX_MEDICATION_ADMINISTRATIONS`. + * Accepts a positive integer within `[1, 50000]`; any unset / non-integer / + * out-of-range value falls back to the default. Exported as a pure function so + * the resolution can be tested without import-time env coupling. + */ +export function resolveMaxMedicationAdministrations( + raw: string | undefined, +): number { + const parsed = Number(raw); + return Number.isInteger(parsed) && + parsed >= MIN_MEDICATION_ADMINISTRATIONS && + parsed <= MAX_MEDICATION_ADMINISTRATIONS_CEILING + ? parsed + : DEFAULT_MAX_MEDICATION_ADMINISTRATIONS; +} + +/** + * The cap is a coarse safety ceiling resolved once at module load, not a + * per-request knob — the report window is the natural bound; this only + * guards a pathological multi-year, many-medication export. When it trims + * rows the aggregator flags it so the FHIR narrative discloses the + * truncation rather than silently dropping history. + */ +const MAX_MEDICATION_ADMINISTRATIONS = resolveMaxMedicationAdministrations( + process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS, +); export interface DoctorReportRange { /** Start of the reporting window (UTC, inclusive). */ From 33b75361f35a60686d20f2be9ff23a39d3c5e988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:30:22 +0200 Subject: [PATCH 19/49] feat(measurements): add a COMPUTED source and first-class score types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a server-owned COMPUTED measurement source for rows a server engine derives rather than a device or a person reports, plus three first-class 0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit "score", plausibility range 0..100). COMPUTED is read-only from a client's perspective: it joins the read / decode enum so the native client can decode the rows it queries and charts, but every client-facing write surface rejects it exactly like WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH, MANUAL} and the single-entry POST 422s a COMPUTED source. All three score types thread through every type-exhaustiveness site: the unit + plausibility-range maps, the PR-direction resolver (scores carry no record direction, like the device events), the category overlay (a new "scores" cluster), the list-view label / icon / colour maps with locale strings across all six bundles, and the insight chart-token allowlist + orphan-enum stripper. They are not clinical vitals, so they stay out of the doctor-report PDF. Only the recovery score is computed today; the stress and strain types are defined now so the engines that compute them later need no schema change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS). --- docs/api/openapi.yaml | 105 ++++++++++++++++++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- .../0106_v1100_computed_scores/migration.sql | 37 ++++++ prisma/schema.prisma | 18 +++ .../measurements/measurement-list-meta.ts | 15 +++ .../measurement-type-enum-coverage.test.ts | 14 ++- src/lib/doctor-report-pdf-core.ts | 7 ++ src/lib/insights/chart-tokens.ts | 13 +++ .../__tests__/apple-health-mapping.test.ts | 7 ++ src/lib/measurements/categories.ts | 14 ++- src/lib/personal-records/pr-direction.ts | 8 ++ .../validations/__tests__/measurement.test.ts | 46 ++++++++ src/lib/validations/measurement.ts | 69 +++++++++++- 18 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/0106_v1100_computed_scores/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bc8cb5bbd..8fbe742ce 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -390,6 +390,9 @@ paths: - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE - in: query name: from schema: @@ -535,6 +538,9 @@ paths: - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE value: type: number measuredAt: @@ -552,6 +558,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED glucoseContext: type: string enum: @@ -801,6 +808,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED externalId: type: string maxLength: 128 @@ -1411,6 +1419,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -1421,6 +1430,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -1431,6 +1441,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -1441,6 +1452,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -1451,6 +1463,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -1461,6 +1474,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -1471,6 +1485,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -1481,6 +1496,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -1491,6 +1507,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -1501,6 +1518,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -1511,6 +1529,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -1521,6 +1540,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -1531,6 +1551,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -1541,6 +1562,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED metricPriority: type: object properties: @@ -1554,6 +1576,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -1564,6 +1587,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -1574,6 +1598,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -1584,6 +1609,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -1594,6 +1620,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -1604,6 +1631,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -1614,6 +1642,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -1624,6 +1653,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -1634,6 +1664,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -1644,6 +1675,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -1654,6 +1686,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -1664,6 +1697,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -1674,6 +1708,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -1684,6 +1719,7 @@ paths: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED deviceTypePriority: type: object properties: @@ -2107,6 +2143,9 @@ paths: - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE 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." @@ -3788,6 +3827,9 @@ components: - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE value: type: number unit: @@ -3803,6 +3845,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED notes: anyOf: - type: string @@ -4090,6 +4133,9 @@ components: - LOW_HEART_RATE_EVENT - WALKING_STEADINESS_EVENT - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE value: type: number unit: @@ -4105,6 +4151,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED notes: anyOf: - type: string @@ -4322,6 +4369,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED externalId: anyOf: - type: string @@ -4424,6 +4472,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED externalId: anyOf: - type: string @@ -5685,6 +5734,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -5695,6 +5745,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -5705,6 +5756,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -5715,6 +5767,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -5725,6 +5778,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -5735,6 +5789,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -5745,6 +5800,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -5755,6 +5811,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -5765,6 +5822,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -5775,6 +5833,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -5785,6 +5844,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -5795,6 +5855,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -5805,6 +5866,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -5815,6 +5877,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED metricPriority: type: object properties: @@ -5828,6 +5891,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -5838,6 +5902,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -5848,6 +5913,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -5858,6 +5924,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -5868,6 +5935,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -5878,6 +5946,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -5888,6 +5957,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -5898,6 +5968,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -5908,6 +5979,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -5918,6 +5990,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -5928,6 +6001,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -5938,6 +6012,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -5948,6 +6023,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -5958,6 +6034,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED additionalProperties: false deviceTypePriority: type: object @@ -6017,6 +6094,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -6027,6 +6105,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -6037,6 +6116,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -6047,6 +6127,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -6057,6 +6138,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -6067,6 +6149,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -6077,6 +6160,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -6087,6 +6171,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -6097,6 +6182,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -6107,6 +6193,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -6117,6 +6204,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -6127,6 +6215,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -6137,6 +6226,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -6147,6 +6237,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED metricPriority: type: object properties: @@ -6160,6 +6251,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED activeEnergy: maxItems: 8 type: array @@ -6170,6 +6262,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED walkingRunningDistance: maxItems: 8 type: array @@ -6180,6 +6273,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED flightsClimbed: maxItems: 8 type: array @@ -6190,6 +6284,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED sleep: maxItems: 8 type: array @@ -6200,6 +6295,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED weight: maxItems: 8 type: array @@ -6210,6 +6306,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bloodPressure: maxItems: 8 type: array @@ -6220,6 +6317,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED pulse: maxItems: 8 type: array @@ -6230,6 +6328,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyFat: maxItems: 8 type: array @@ -6240,6 +6339,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED bodyTemperature: maxItems: 8 type: array @@ -6250,6 +6350,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED spo2: maxItems: 8 type: array @@ -6260,6 +6361,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED hrv: maxItems: 8 type: array @@ -6270,6 +6372,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED restingHeartRate: maxItems: 8 type: array @@ -6280,6 +6383,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED vo2Max: maxItems: 8 type: array @@ -6290,6 +6394,7 @@ components: - WITHINGS - IMPORT - APPLE_HEALTH + - COMPUTED additionalProperties: false deviceTypePriority: type: object diff --git a/messages/de.json b/messages/de.json index c06e57647..f24eb7f65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz", "typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz", "typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität", - "typeBreathingDisturbanceEvent": "Atemstörung" + "typeBreathingDisturbanceEvent": "Atemstörung", + "typeRecoveryScore": "Erholungs-Score", + "typeStressScore": "Stress-Score", + "typeStrainScore": "Belastungs-Score" }, "mood": { "title": "Stimmung", diff --git a/messages/en.json b/messages/en.json index 3f7169300..d0cf7cd26 100644 --- a/messages/en.json +++ b/messages/en.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "High heart rate notification", "typeLowHeartRateEvent": "Low heart rate notification", "typeWalkingSteadinessEvent": "Walking steadiness alert", - "typeBreathingDisturbanceEvent": "Breathing disturbance" + "typeBreathingDisturbanceEvent": "Breathing disturbance", + "typeRecoveryScore": "Recovery score", + "typeStressScore": "Stress score", + "typeStrainScore": "Strain score" }, "mood": { "title": "Mood", diff --git a/messages/es.json b/messages/es.json index 4fbcaa19d..f37f81050 100644 --- a/messages/es.json +++ b/messages/es.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "High heart rate notification", "typeLowHeartRateEvent": "Low heart rate notification", "typeWalkingSteadinessEvent": "Walking steadiness alert", - "typeBreathingDisturbanceEvent": "Breathing disturbance" + "typeBreathingDisturbanceEvent": "Breathing disturbance", + "typeRecoveryScore": "Recovery score", + "typeStressScore": "Stress score", + "typeStrainScore": "Strain score" }, "mood": { "title": "Estado de ánimo", diff --git a/messages/fr.json b/messages/fr.json index 9a8ca4b09..df15b2e71 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "High heart rate notification", "typeLowHeartRateEvent": "Low heart rate notification", "typeWalkingSteadinessEvent": "Walking steadiness alert", - "typeBreathingDisturbanceEvent": "Breathing disturbance" + "typeBreathingDisturbanceEvent": "Breathing disturbance", + "typeRecoveryScore": "Recovery score", + "typeStressScore": "Stress score", + "typeStrainScore": "Strain score" }, "mood": { "title": "Humeur", diff --git a/messages/it.json b/messages/it.json index 68b53abe9..ca367ad3b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "High heart rate notification", "typeLowHeartRateEvent": "Low heart rate notification", "typeWalkingSteadinessEvent": "Walking steadiness alert", - "typeBreathingDisturbanceEvent": "Breathing disturbance" + "typeBreathingDisturbanceEvent": "Breathing disturbance", + "typeRecoveryScore": "Recovery score", + "typeStressScore": "Stress score", + "typeStrainScore": "Strain score" }, "mood": { "title": "Umore", diff --git a/messages/pl.json b/messages/pl.json index 917d7ecb9..c39329307 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -437,7 +437,10 @@ "typeHighHeartRateEvent": "High heart rate notification", "typeLowHeartRateEvent": "Low heart rate notification", "typeWalkingSteadinessEvent": "Walking steadiness alert", - "typeBreathingDisturbanceEvent": "Breathing disturbance" + "typeBreathingDisturbanceEvent": "Breathing disturbance", + "typeRecoveryScore": "Recovery score", + "typeStressScore": "Stress score", + "typeStrainScore": "Strain score" }, "mood": { "title": "Nastrój", diff --git a/prisma/migrations/0106_v1100_computed_scores/migration.sql b/prisma/migrations/0106_v1100_computed_scores/migration.sql new file mode 100644 index 000000000..6ca88ebd8 --- /dev/null +++ b/prisma/migrations/0106_v1100_computed_scores/migration.sql @@ -0,0 +1,37 @@ +-- v1.10.0 — computed scores (WX-C): COMPUTED source + the three `*_SCORE` +-- measurement types. +-- +-- Two purely-additive enum extensions, no new columns and no backfill: +-- +-- 1. A new `measurement_source` value `COMPUTED` for server-derived rows. +-- A `COMPUTED` Measurement is minted by a nightly engine from the user's +-- already-stored signals (the Recovery score is the first); it is NEVER +-- ingested from a client. The client-facing batch + single-POST write +-- surfaces reject it exactly like `WITHINGS` / `IMPORT`. It is part of +-- the enum so the read/response shapes (and the iOS decoder) can decode +-- the rows it surfaces. +-- +-- 2. Three new `measurement_type` values — the server-derived wellness +-- scores `RECOVERY_SCORE`, `STRESS_SCORE`, `STRAIN_SCORE` (0–100, unit +-- `score`). Only RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + +-- STRAIN_SCORE are defined now so the later engines that compute them +-- need no schema change. These are NOT clinical vitals — they never +-- appear in the doctor-report PDF. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- Postgres does not support removing an enum value, so the four new +-- members stay; with no rows carrying them (a `COMPUTED` source / a +-- `*_SCORE` type) after the nightly job is disabled they are inert. + +-- ── 1. measurement_source — append the COMPUTED server-owned source ──── +-- Pure additive; no existing row carries this value. The `IF NOT EXISTS` +-- guard keeps the migration idempotent across a partially-applied environment. +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'COMPUTED'; + +-- ── 2. measurement_type — append the three computed-score classes ────── +-- Pure additive; no existing row carries any of these values. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RECOVERY_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRAIN_SCORE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91855915b..a8787dec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -558,6 +558,16 @@ enum MeasurementType { LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold. WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`. BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis. + // v1.10.0 — computed scores (WX-C) + // Server-derived wellness scores (0–100, unit `score`). Each is minted by a + // nightly engine from the user's already-stored signals and persisted as a + // `COMPUTED`-source Measurement row — never ingested from a client. Only + // RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are + // defined now so the later engines that compute them need no schema change. + // These are NOT clinical vitals — they never appear in the doctor-report PDF. + RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment. + STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0. + STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0. @@map("measurement_type") } @@ -606,6 +616,14 @@ enum MeasurementSource { WITHINGS IMPORT APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT"). + // v1.10.0 — computed scores (WX-C) + // Server-derived rows. A `COMPUTED` measurement is minted by a nightly + // engine from the user's already-stored signals (the Recovery score is the + // first); it is NEVER ingested from a client. Server-owned / read-only: + // the client-facing batch + single-POST write surfaces reject it exactly + // like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode + // the rows it queries / charts. + COMPUTED @@map("measurement_source") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 8117efb56..727a49812 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -96,6 +96,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent", WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent", BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent", + // ── v1.10.0 — computed scores (WX-C) ── + RECOVERY_SCORE: "measurements.typeRecoveryScore", + STRESS_SCORE: "measurements.typeStressScore", + STRAIN_SCORE: "measurements.typeStrainScore", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -182,6 +186,11 @@ export const MEASUREMENT_TYPE_ICONS: Record = { LOW_HEART_RATE_EVENT: HeartPulse, WALKING_STEADINESS_EVENT: Footprints, BREATHING_DISTURBANCE_EVENT: Wind, + // ── v1.10.0 — computed scores (WX-C) ── + // Gauge reads as a composite "index / score" dial for all three. + RECOVERY_SCORE: Gauge, + STRESS_SCORE: Gauge, + STRAIN_SCORE: Gauge, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -259,4 +268,10 @@ export const MEASUREMENT_TYPE_COLORS: Record = { LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3", WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2", BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5", + // ── v1.10.0 — computed scores (WX-C) ── + // chart-1 (Dracula purple) marks the server-derived composites as their + // own visual group, distinct from the raw-signal families above. + RECOVERY_SCORE: "bg-chart-1/20 text-chart-1", + STRESS_SCORE: "bg-chart-1/20 text-chart-1", + STRAIN_SCORE: "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 1985c040c..60989c670 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -73,10 +73,14 @@ const EXPECTED_TYPES = [ "LOW_HEART_RATE_EVENT", "WALKING_STEADINESS_EVENT", "BREATHING_DISTURBANCE_EVENT", + // ── v1.10.0 — computed scores (WX-C) ── + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 50 canonical measurement types", () => { + it("exposes the 53 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -168,6 +172,14 @@ describe("measurementTypeEnum coverage", () => { "LOW_HEART_RATE_EVENT", "WALKING_STEADINESS_EVENT", "BREATHING_DISTURBANCE_EVENT", + // v1.10.0 — computed scores (WX-C). Server-derived wellness scores + // (Recovery / Stress / Strain) are 0–100 composites recomputed nightly, + // not measured clinical vitals. They surface on their own Insights + // cluster with a "descriptive, not clinical" disclaimer and never belong + // in the clinical vitals PDF. See doctor-report-pdf-core.ts. + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index c8969c458..b640da16b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -181,6 +181,13 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * table for clinical readability. Glucose ships separately via * per-context `glucoseStats`. Sleep + activity are intentionally * excluded from a clinical-focused report. + * + * v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types + * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals: + * they are 0–100 composites recomputed nightly from the underlying signals + * and carry a "descriptive, not a clinical assessment" disclaimer. They stay + * out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in + * `measurement-type-enum-coverage.test.ts`). */ export const DOCTOR_REPORT_VITAL_TYPES = [ "WEIGHT", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index cf9c2f131..0fb4e6df3 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -89,6 +89,14 @@ export const ALLOWED_CHART_TOKENS = [ "metric:STAIR_ASCENT_SPEED", "metric:STAIR_DESCENT_SPEED", "metric:BREATHING_DISTURBANCES", + // v1.10.0 — computed scores (WX-C). Server-derived 0–100 wellness scores. + // Unlike the categorical EVENT classes they ARE a continuous daily series, + // so they carry a `metric:` token and render through the generic + // chart renderer. Only RECOVERY_SCORE is computed in v1.10.0; the other + // two tokens are reserved for the later engines. + "metric:RECOVERY_SCORE", + "metric:STRESS_SCORE", + "metric:STRAIN_SCORE", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -184,6 +192,11 @@ const ORPHAN_ENUMS = [ "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", "BREATHING_DISTURBANCES", + // v1.10.0 — computed scores (WX-C), same shape. Strip the bare enum name + // if the model drops it into prose (mirrors the MOOD_SCORE precedent). + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", ] 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 2603f23af..1a45a173a 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -32,6 +32,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "PULSE_WAVE_VELOCITY", "VASCULAR_AGE", "VISCERAL_FAT", + // v1.10.0 — computed scores (WX-C). Server-derived wellness scores + // (Recovery / Stress / Strain) are minted by a nightly engine from the + // user's already-stored signals; HealthKit ships no identifier for them + // and they are never ingested, so they have no HK mapping by design. + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index da203c360..d7c5ac283 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -42,7 +42,11 @@ export type MeasurementCategory = | "hearing" | "environment" | "cardiovascular" - | "metabolic"; + | "metabolic" + // v1.10.0 — computed scores (WX-C). Server-derived wellness scores + // (Recovery / Stress / Strain). Their own presentation cluster — they are + // composite indices, not a raw signal in any of the above buckets. + | "scores"; /** * Per-MeasurementType → category mapping. The map is exhaustive over @@ -153,6 +157,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["LOW_HEART_RATE_EVENT", "cardiovascular"], ["WALKING_STEADINESS_EVENT", "activity"], ["BREATHING_DISTURBANCE_EVENT", "sleep"], + + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived wellness scores share their own presentation cluster. + // The category is only for list grouping — scores never participate in + // trend/rollup analytics (they ARE a nightly-computed composite). + ["RECOVERY_SCORE", "scores"], + ["STRESS_SCORE", "scores"], + ["STRAIN_SCORE", "scores"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 8b80ff735..eedfab519 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -144,6 +144,14 @@ export function getPRDirection( case "LOW_HEART_RATE_EVENT": case "WALKING_STEADINESS_EVENT": case "BREATHING_DISTURBANCE_EVENT": + // v1.10.0 — computed scores (WX-C). The server-derived wellness scores + // are themselves derived composites recomputed nightly from the + // underlying signals; a "personal record" on a derived score would + // double-count the PRs of its own inputs and read as noise. They + // explicitly have no PR direction, like the categorical events above. + case "RECOVERY_SCORE": + case "STRESS_SCORE": + case "STRAIN_SCORE": return null; } } diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 7137e5e02..1ab1bca1a 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -271,4 +271,50 @@ describe("measurement validation", () => { expect(parsed.success).toBe(true); }); }); + + // v1.10.0 — computed scores (WX-C). + describe("computed scores", () => { + it("canonical unit is 'score' for each computed-score type", () => { + expect(getUnitForType("RECOVERY_SCORE")).toBe("score"); + expect(getUnitForType("STRESS_SCORE")).toBe("score"); + expect(getUnitForType("STRAIN_SCORE")).toBe("score"); + }); + + it("plausibility range is 0..100 for a computed score", () => { + expect(validateMeasurementRange("RECOVERY_SCORE", 0)).toBeNull(); + expect(validateMeasurementRange("RECOVERY_SCORE", 100)).toBeNull(); + expect(validateMeasurementRange("RECOVERY_SCORE", -1)).not.toBeNull(); + expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull(); + }); + + it("rejects the server-owned COMPUTED source on a client write", () => { + const parsed = createMeasurementSchema.safeParse({ + type: "RECOVERY_SCORE", + value: 72, + measuredAt: "2026-06-02T12:00:00Z", + source: "COMPUTED", + }); + expect(parsed.success).toBe(false); + if (!parsed.success) { + const issue = parsed.error.issues.find((i) => + i.path.includes("source"), + ); + expect(issue, "expected a source-path rejection").toBeDefined(); + expect(issue!.message).toMatch(/server-owned/i); + } + }); + + it("still accepts a client-writable source for a score-typed manual row", () => { + // The COMPUTED source is the only one this refine blocks; a MANUAL + // write of any type stays valid (the source guard is orthogonal to + // whether a UI ever offers manual score entry). + const parsed = createMeasurementSchema.safeParse({ + type: "RECOVERY_SCORE", + value: 72, + measuredAt: "2026-06-02T12:00:00Z", + source: "MANUAL", + }); + expect(parsed.success).toBe(true); + }); + }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 79574fc24..7c17e4ed1 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -66,6 +66,29 @@ export const measurementTypeEnum = z.enum([ "LOW_HEART_RATE_EVENT", "WALKING_STEADINESS_EVENT", "BREATHING_DISTURBANCE_EVENT", + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived wellness scores (0–100, unit `score`). Minted by a + // nightly engine from the user's already-stored signals and persisted as a + // `COMPUTED`-source row — never ingested from a client (the batch + + // single-POST write surfaces reject the COMPUTED source). Only + // RECOVERY_SCORE is computed in v1.10.0; the other two are defined now so + // the later engines that compute them need no schema change. + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", +]); + +/** + * v1.10.0 — computed scores (WX-C). The closed set of server-derived + * `*_SCORE` MeasurementTypes. Each is a 0–100 wellness score persisted with + * `source = COMPUTED` by a nightly engine — never ingested from a client. + * Kept as a `Set` so read/write paths can branch on "is this a computed + * score?" the same way `EVENT_MEASUREMENT_TYPES` gates the event rows. + */ +export const SCORE_MEASUREMENT_TYPES: ReadonlySet = new Set([ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", ]); /** @@ -104,8 +127,31 @@ export const measurementSourceEnum = z.enum([ "WITHINGS", "IMPORT", "APPLE_HEALTH", + // v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED` + // row is minted by a nightly engine, never written by a client. It is part + // of this enum so the read/response shapes (and the iOS decoder) can decode + // the rows it surfaces; the client-facing write surfaces reject it — see + // `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`. + "COMPUTED", ]); +/** + * v1.10.0 — computed scores (WX-C). The subset of `MeasurementSource` a + * client may attribute on a write. `COMPUTED` is server-owned (a nightly + * engine mints it) and `WITHINGS` / `IMPORT` are owned by the Withings + * webhook + CSV importer respectively, so all three are excluded — letting a + * client forge a row attributed to them would pollute the per-source + * canonical picker with rows the server never produced. The single-entry + * POST validates `source` against this set; the batch route mirrors the same + * exclusion with its own narrower `{APPLE_HEALTH, MANUAL}` allowlist. + */ +export const WRITABLE_MEASUREMENT_SOURCES = [ + "MANUAL", + "WITHINGS", + "IMPORT", + "APPLE_HEALTH", +] as const; + const unitMap: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", @@ -200,6 +246,12 @@ const unitMap: Record = { LOW_HEART_RATE_EVENT: "event", WALKING_STEADINESS_EVENT: "event", BREATHING_DISTURBANCE_EVENT: "event", + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived 0–100 wellness scores. The canonical unit is the bare + // "score" so the value reads sensibly anywhere it surfaces. + RECOVERY_SCORE: "score", + STRESS_SCORE: "score", + STRAIN_SCORE: "score", }; export function getUnitForType(type: string): string { @@ -343,6 +395,12 @@ const VALUE_RANGES: Record = { LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived 0–100 scores. The plausibility band pins them to the + // score range so a malformed store (a stray negative or > 100) is rejected. + RECOVERY_SCORE: { min: 0, max: 100 }, + STRESS_SCORE: { min: 0, max: 100 }, + STRAIN_SCORE: { min: 0, max: 100 }, }; export function validateMeasurementRange( @@ -395,7 +453,16 @@ export const createMeasurementSchema = z "Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.", path: ["glucoseContext"], }, - ); + ) + // v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned: + // a nightly engine mints those rows, a client never does. Reject it here + // so the single-entry POST cannot forge a server-derived row (the batch + // route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}` + // allowlist). + .refine((data) => data.source !== "COMPUTED", { + message: "The COMPUTED source is server-owned and cannot be set by a client.", + path: ["source"], + }); export const updateMeasurementSchema = z.object({ value: z.number().min(0).max(500000).optional(), From 3dcec54726c38748aa6a64f8ed8953e94d049a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:30:34 +0200 Subject: [PATCH 20/49] feat(insights): compute and store a nightly recovery score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a recovery-score engine that turns a user's already-stored nightly signals into a stored 0-100 score. It reuses the readiness blend — the resting-heart-rate, HRV (SDNN), sleep and respiratory-rate deviation from the personal baseline, with the null-redistribution the health score already uses — so the stored series and the live readiness surface can never drift. The lineage is the recovery-marker literature (Plews 2013, Buchheit 2014): a higher score reads as more recovered. It is a descriptive wellness proxy, not a clinical or training-recovery assessment, and it is only stored when enough components are present — below that floor no row is written rather than a headline from one signal. A nightly job computes each eligible user's score and upserts it as a COMPUTED RECOVERY_SCORE row, idempotent per user per day on the externalId day key, so a re-fired tick overwrites in place instead of duplicating. The score is never computed on a page visit. The queue is registered in the worker's queue inventory, scheduled, and wired to a handler; a grep guard pins all four points so it cannot silently never run. --- .../insights/__tests__/recovery-score.test.ts | 134 +++++++++++++ src/lib/insights/recovery-score.ts | 178 ++++++++++++++++++ .../__tests__/recovery-score-queue.test.ts | 75 ++++++++ src/lib/jobs/__tests__/recovery-score.test.ts | 116 ++++++++++++ src/lib/jobs/recovery-score.ts | 130 +++++++++++++ src/lib/jobs/reminder-worker.ts | 36 ++++ 6 files changed, 669 insertions(+) create mode 100644 src/lib/insights/__tests__/recovery-score.test.ts create mode 100644 src/lib/insights/recovery-score.ts create mode 100644 src/lib/jobs/__tests__/recovery-score-queue.test.ts create mode 100644 src/lib/jobs/__tests__/recovery-score.test.ts create mode 100644 src/lib/jobs/recovery-score.ts diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts new file mode 100644 index 000000000..a705d9191 --- /dev/null +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -0,0 +1,134 @@ +/** + * v1.10.0 — computed scores (WX-C). Recovery-score engine + persistence. + * + * The engine reuses the READINESS derived blend (mocked here) and writes a + * `COMPUTED RECOVERY_SCORE` row. These tests pin: + * - the per-day idempotency-key + canonical-timestamp helpers, + * - the "store on ok, write nothing on insufficient" gate, + * - the upsert shape (idempotent per user per day). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import type { Derived } from "@/lib/insights/derived/types"; +import type { ReadinessValue } from "@/lib/insights/derived/readiness"; + +const computeReadinessMock = + vi.fn<(...args: unknown[]) => Promise>>(); + +vi.mock("@/lib/insights/derived/readiness", () => ({ + computeReadiness: (...args: unknown[]) => computeReadinessMock(...args), +})); + +import { + recoveryDayKey, + recoveryExternalId, + recoveryMeasuredAt, + persistRecoveryScore, + RECOVERY_SCORE_EXTERNAL_ID_PREFIX, +} from "../recovery-score"; + +const NOW = new Date("2026-06-02T08:30:00Z"); + +function okReadiness(score: number): Derived { + return { + status: "ok", + value: { score, band: "green", components: [] }, + coverage: { requiredInputs: 5, presentInputs: 4, historyDays: 30, missing: [] }, + confidence: { score: 80, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: NOW.toISOString() }, + }; +} + +function insufficientReadiness(): Derived { + return { + status: "insufficient", + coverage: { requiredInputs: 5, presentInputs: 1, historyDays: 0, missing: ["hrv"] }, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: NOW.toISOString() }, + reason: "insufficient_components", + }; +} + +function makePrisma() { + const upsert = vi.fn().mockResolvedValue({}); + const findUnique = vi + .fn() + .mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 }); + return { + prisma: { + measurement: { upsert }, + user: { findUnique }, + } as unknown as Parameters[0], + upsert, + findUnique, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("recovery-score helpers", () => { + it("keys the day + externalId by the UTC calendar day", () => { + expect(recoveryDayKey(NOW)).toBe("2026-06-02"); + expect(recoveryExternalId(NOW)).toBe( + `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + ); + }); + + it("anchors the canonical timestamp at noon UTC on the scored day", () => { + expect(recoveryMeasuredAt(NOW).toISOString()).toBe( + "2026-06-02T12:00:00.000Z", + ); + }); +}); + +describe("persistRecoveryScore", () => { + it("stores a COMPUTED RECOVERY_SCORE row when the blend is ok", async () => { + const { prisma, upsert } = makePrisma(); + computeReadinessMock.mockResolvedValue(okReadiness(72)); + + const result = await persistRecoveryScore(prisma, "user-1", NOW); + + expect(result).toEqual({ outcome: "stored", score: 72 }); + expect(upsert).toHaveBeenCalledTimes(1); + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "user-1", + type: "RECOVERY_SCORE", + source: "COMPUTED", + externalId: "recovery:2026-06-02", + }); + expect(arg.create).toMatchObject({ + userId: "user-1", + type: "RECOVERY_SCORE", + source: "COMPUTED", + value: 72, + unit: "score", + externalId: "recovery:2026-06-02", + }); + expect(arg.update).toMatchObject({ value: 72 }); + }); + + it("writes NOTHING when the readiness blend is insufficient", async () => { + const { prisma, upsert } = makePrisma(); + computeReadinessMock.mockResolvedValue(insufficientReadiness()); + + const result = await persistRecoveryScore(prisma, "user-1", NOW); + + expect(result).toEqual({ outcome: "insufficient", score: null }); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("is idempotent per user per day — re-runs upsert the same key", async () => { + const { prisma, upsert } = makePrisma(); + computeReadinessMock.mockResolvedValue(okReadiness(64)); + + await persistRecoveryScore(prisma, "user-1", NOW); + await persistRecoveryScore(prisma, "user-1", NOW); + + expect(upsert).toHaveBeenCalledTimes(2); + const firstKey = upsert.mock.calls[0][0].where.userId_type_source_externalId; + const secondKey = upsert.mock.calls[1][0].where.userId_type_source_externalId; + expect(secondKey).toEqual(firstKey); + }); +}); diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts new file mode 100644 index 000000000..aa470df68 --- /dev/null +++ b/src/lib/insights/recovery-score.ts @@ -0,0 +1,178 @@ +/** + * v1.10.0 — computed scores (WX-C). The Recovery score ENGINE + persistence. + * + * This is the PERSISTING counterpart to the compute-on-read `READINESS` + * derived engine (`src/lib/insights/derived/readiness.ts`). The derived + * engine answers a live request with a `Derived`; this module reuses that + * exact math and writes the result as a queryable / chartable / + * iOS-readable `Measurement` row so the score has the same first-class + * storage shape as a vital — a daily 0–100 series the dashboard, charts, and + * the native client can read without recomputing. + * + * The row is: + * - `type = RECOVERY_SCORE` + * - `source = COMPUTED` (server-owned; a client can never POST it) + * - `unit = "score"` (the canonical unit; see `validations/measurement`) + * - `value = 0..100` (plausibility-pinned to {min:0, max:100}) + * - `externalId = recovery:YYYY-MM-DD` (the per-day idempotency key) + * + * Idempotent per user per day: a re-run upserts on + * `(userId, type, source, externalId)` rather than minting a duplicate, so + * the nightly job is safe to re-fire and a same-day re-compute (e.g. after a + * late watch sync) overwrites the day's score in place. + * + * Honest confidence: the score is only stored when the underlying readiness + * blend reaches its minimum-component floor (`READINESS_MIN_COMPONENTS`). + * Below that the engine returns `insufficient` and NO row is written — the + * series never carries a headline derived from 1-of-N signals. + * + * Standard / framing: RHR elevation + HRV (SDNN) suppression as recovery + * markers — Plews et al. 2013, Sports Medicine 43(9):773–781; Buchheit 2014, + * Frontiers in Physiology 5:73 (same lineage as the READINESS derived + * engine). The score is descriptive — a daily wellness proxy, NOT a clinical + * or training-recovery assessment, and it is excluded from the doctor PDF. + * + * Server-only — never call this on a page visit. It runs from the nightly + * pg-boss job in `src/lib/jobs/recovery-score.ts`. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { + computeReadiness, + type ReadinessValue, +} from "@/lib/insights/derived/readiness"; +import type { BaselineProfile } from "@/lib/insights/derived/baseline"; +import type { Derived } from "@/lib/insights/derived/types"; + +/** The per-day idempotency-key prefix for a stored Recovery score row. */ +export const RECOVERY_SCORE_EXTERNAL_ID_PREFIX = "recovery:"; + +/** + * The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. The Recovery + * series is one row per UTC day — the same day-keying the rollup tier and + * the daily-mean consolidation use, so the score lines up with the buckets + * it summarises. + */ +export function recoveryDayKey(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** The full `externalId` for a given day's Recovery score row. */ +export function recoveryExternalId(now: Date): string { + return `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}${recoveryDayKey(now)}`; +} + +/** + * The canonical timestamp a stored Recovery row carries: noon UTC on the + * scored day. Noon (not midnight) keeps the row inside its own UTC day under + * any reasonable display-timezone offset, mirroring the daily-stats + * convention so a chart never mis-buckets the point at a day boundary. + */ +export function recoveryMeasuredAt(now: Date): Date { + return new Date(`${recoveryDayKey(now)}T12:00:00.000Z`); +} + +export interface RecoveryComputeResult { + /** The readiness blend the score is read from (exposed for the job log + tests). */ + readiness: Derived; + /** The 0..100 score to persist, or null when the blend was insufficient. */ + score: number | null; +} + +/** + * Compute the Recovery score for one user as of `now`. Pure of persistence — + * the job calls this, then writes the row. Reuses the READINESS derived + * engine verbatim (its RHR/HRV/sleep/respiratory deviation blend IS the + * recovery model) so the two surfaces can never drift. + */ +export async function computeRecoveryScore( + prisma: PrismaClient, + userId: string, + profile: BaselineProfile, + now: Date, +): Promise { + // The derived engine reads `@/lib/db`'s prisma; it does not accept an + // injected client. We only pass `prisma` through for the profile read in + // the helper below so the job can share one client. `now` is injected so + // the result is deterministic for tests. + const readiness = await computeReadiness(userId, profile, { now }); + const score = readiness.status === "ok" ? readiness.value.score : null; + return { readiness, score }; +} + +/** + * Build the `BaselineProfile` the readiness blend needs from the user row — + * the same shape the live `/api/insights/derived` route builds. `prisma` is + * the (worker) client so the job shares one connection. + */ +export async function loadRecoveryProfile( + prisma: PrismaClient, + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + return { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + heightCm: user?.heightCm ?? null, + }; +} + +export interface PersistRecoveryResult { + /** "stored" when a row was upserted; "insufficient" when the blend gated. */ + outcome: "stored" | "insufficient"; + /** The persisted score, or null when insufficient. */ + score: number | null; +} + +/** + * Compute + persist one user's Recovery score for the `now` day. Upserts on + * the `(userId, type, source, externalId)` key so a re-run overwrites the + * day's row in place rather than duplicating it. Writes NOTHING when the + * readiness blend is insufficient — no phantom score from too few signals. + */ +export async function persistRecoveryScore( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const profile = await loadRecoveryProfile(prisma, userId); + const { score } = await computeRecoveryScore(prisma, userId, profile, now); + + if (score === null) { + return { outcome: "insufficient", score: null }; + } + + const externalId = recoveryExternalId(now); + const measuredAt = recoveryMeasuredAt(now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "RECOVERY_SCORE", + source: "COMPUTED", + externalId, + }, + }, + create: { + userId, + type: "RECOVERY_SCORE", + source: "COMPUTED", + value: score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: score, + measuredAt, + }, + }); + + return { outcome: "stored", score }; +} diff --git a/src/lib/jobs/__tests__/recovery-score-queue.test.ts b/src/lib/jobs/__tests__/recovery-score-queue.test.ts new file mode 100644 index 000000000..c2d203f2d --- /dev/null +++ b/src/lib/jobs/__tests__/recovery-score-queue.test.ts @@ -0,0 +1,75 @@ +/** + * v1.10.0 — computed scores (WX-C). Recovery-score queue registration guard. + * + * Same source-text-grep approach as the other queue-wiring guards: assert + * the queue is imported, registered in `allQueues`, scheduled, and wired to + * a `boss.work` handler — without booting pg-boss + Prisma. An unregistered + * queue silently never drains (the recurring past bug this guards against). + * + * Also pins the iOS-write rejection of the server-owned COMPUTED source on + * the batch ingest route: the `batchSourceEnum` allowlist must stay exactly + * `{APPLE_HEALTH, MANUAL}`, so COMPUTED (like WITHINGS / IMPORT) can never be + * forged by a client. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +const BATCH_ROUTE_PATH = join( + __dirname, + "..", + "..", + "..", + "app", + "api", + "measurements", + "batch", + "route.ts", +); +const batchSource = readFileSync(BATCH_ROUTE_PATH, "utf8"); + +describe("reminder-worker — recovery-score wiring", () => { + it("imports the queue symbols from the recovery-score module", () => { + expect(workerSource).toMatch( + /from\s*["']@\/lib\/jobs\/recovery-score["']/, + ); + expect(workerSource).toMatch(/\bRECOVERY_SCORE_QUEUE\b/); + expect(workerSource).toMatch(/\bRECOVERY_SCORE_CRON\b/); + expect(workerSource).toMatch(/\brunRecoveryScore\b/); + }); + + it("registers the recovery-score queue in the allQueues loop", () => { + const allQueuesMatch = workerSource.match( + /const allQueues\s*=\s*\[([\s\S]*?)\];/, + ); + expect(allQueuesMatch).not.toBeNull(); + expect(allQueuesMatch![1]).toMatch(/\bRECOVERY_SCORE_QUEUE\b/); + }); + + it("schedules the recovery-score cron", () => { + expect(workerSource).toMatch( + /\[RECOVERY_SCORE_QUEUE,\s*RECOVERY_SCORE_CRON\]/, + ); + }); + + it("registers a boss.work handler that runs the recovery-score pass", () => { + expect(workerSource).toMatch( + /boss\.work[\s\S]{0,200}RECOVERY_SCORE_QUEUE[\s\S]{0,400}runRecoveryScore/, + ); + }); +}); + +describe("measurements batch route — COMPUTED iOS-write rejection", () => { + it("keeps the batch source allowlist to APPLE_HEALTH + MANUAL only", () => { + // The server-owned sources (COMPUTED / WITHINGS / IMPORT) are excluded + // by construction: the client-facing batch enum is exactly this pair. + expect(batchSource).toMatch( + /batchSourceEnum\s*=\s*z\.enum\(\[\s*"APPLE_HEALTH"\s*,\s*"MANUAL"\s*\]\)/, + ); + expect(batchSource).not.toMatch(/batchSourceEnum[\s\S]{0,80}"COMPUTED"/); + }); +}); diff --git a/src/lib/jobs/__tests__/recovery-score.test.ts b/src/lib/jobs/__tests__/recovery-score.test.ts new file mode 100644 index 000000000..472433673 --- /dev/null +++ b/src/lib/jobs/__tests__/recovery-score.test.ts @@ -0,0 +1,116 @@ +/** + * v1.10.0 — computed scores (WX-C). Recovery-score nightly run logic. + * + * Drives `runRecoveryScore` with a stubbed prisma + a mocked per-user + * persist so the tally (stored / insufficient / errored) and the + * one-bad-user-does-not-block-the-cohort guarantee are pinned without a DB. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const persistMock = vi.fn(); +const annotateMock = vi.fn(); + +vi.mock("@/lib/insights/recovery-score", () => ({ + persistRecoveryScore: (...args: unknown[]) => persistMock(...args), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...args: unknown[]) => annotateMock(...args), +})); + +import { + runRecoveryScore, + findRecoveryScoreCandidates, + RECOVERY_SCORE_RECENCY_DAYS, +} from "../recovery-score"; + +const NOW = new Date("2026-06-02T08:00:00Z"); + +function makePrisma(userIds: string[]) { + const findMany = vi + .fn() + .mockResolvedValue(userIds.map((userId) => ({ userId }))); + return { + prisma: { measurement: { findMany } } as unknown as Parameters< + typeof runRecoveryScore + >[0], + findMany, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("findRecoveryScoreCandidates", () => { + it("queries live recovery-input rows inside the recency window", async () => { + const { prisma, findMany } = makePrisma(["a", "b"]); + const ids = await findRecoveryScoreCandidates(prisma, NOW, 100); + + expect(ids).toEqual(["a", "b"]); + const where = findMany.mock.calls[0][0].where; + expect(where.type.in).toEqual( + expect.arrayContaining([ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + ]), + ); + expect(where.deletedAt).toBeNull(); + // recency floor is `now - RECENCY_DAYS`. + const expectedSince = new Date( + NOW.getTime() - RECOVERY_SCORE_RECENCY_DAYS * 24 * 60 * 60 * 1000, + ); + expect(where.measuredAt.gte.getTime()).toBe(expectedSince.getTime()); + expect(findMany.mock.calls[0][0].distinct).toEqual(["userId"]); + }); +}); + +describe("runRecoveryScore", () => { + it("tallies stored vs insufficient across the cohort", async () => { + const { prisma } = makePrisma(["a", "b", "c"]); + persistMock + .mockResolvedValueOnce({ outcome: "stored", score: 70 }) + .mockResolvedValueOnce({ outcome: "insufficient", score: null }) + .mockResolvedValueOnce({ outcome: "stored", score: 55 }); + + const result = await runRecoveryScore(prisma, { now: NOW }); + + expect(result).toEqual({ + considered: 3, + stored: 2, + insufficient: 1, + errored: 0, + }); + expect(persistMock).toHaveBeenCalledTimes(3); + }); + + it("counts a per-user error and keeps processing the rest", async () => { + const { prisma } = makePrisma(["a", "b"]); + persistMock + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ outcome: "stored", score: 80 }); + + const result = await runRecoveryScore(prisma, { now: NOW }); + + expect(result).toEqual({ + considered: 2, + stored: 1, + insufficient: 0, + errored: 1, + }); + }); + + it("annotates the pass with the tally", async () => { + const { prisma } = makePrisma(["a"]); + persistMock.mockResolvedValue({ outcome: "stored", score: 90 }); + + await runRecoveryScore(prisma, { now: NOW }); + + expect(annotateMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "insights.recovery.compute" }), + }), + ); + }); +}); diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts new file mode 100644 index 000000000..316d2bec4 --- /dev/null +++ b/src/lib/jobs/recovery-score.ts @@ -0,0 +1,130 @@ +/** + * v1.10.0 — computed scores (WX-C). Nightly Recovery-score computation + + * store. + * + * Once a night this cron computes each eligible user's Recovery score from + * their already-stored nightly signals (RHR / HRV / sleep / respiratory + * deviation from the personal baseline) and persists it as a `COMPUTED` + * `RECOVERY_SCORE` Measurement row (see `src/lib/insights/recovery-score.ts`). + * The score is NEVER computed on a page visit — the read surfaces query the + * stored row like any other daily series. + * + * Idempotent per user per day: the per-user persist upserts on + * `(userId, type, source, externalId)` with `externalId = recovery:YYYY-MM-DD`, + * so a re-fired cron tick (or a manual re-run) overwrites the day's row in + * place rather than duplicating it. A user whose readiness blend is below the + * minimum-component floor gets NO row that night — the series stays honest. + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an + * unregistered queue silently never drains (the recurring past bug this + * comment guards against). + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { persistRecoveryScore } from "@/lib/insights/recovery-score"; + +export const RECOVERY_SCORE_QUEUE = "recovery-score-compute"; + +/** + * Daily at 04:45 Europe/Berlin — inside the existing 03:xx–04:xx maintenance + * window, after the cumulative drain (03:45) + the daily-mean consolidation + * and the insight pre-generation (04:30) so the nightly signals it reads are + * already folded into the rollup tier by the time it runs. + */ +export const RECOVERY_SCORE_CRON = "45 4 * * *"; + +/** + * Per-run user cap. A nightly fan-out across every user could starve the + * pool; the discovery query is ordered so users with the most recent vital + * data are served first and the long tail catches up over successive nights. + */ +export const RECOVERY_SCORE_BATCH_CAP = 500; + +/** Trailing window (days) a user must have a recent recovery-input row in to be a candidate. */ +export const RECOVERY_SCORE_RECENCY_DAYS = 2; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface RecoveryScoreRunResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Discover users worth scoring tonight: anyone with at least one LIVE + * recovery-input row (RHR / HRV / sleep / respiratory) in the recency window. + * A user with no recent nightly signal cannot produce a score, so skipping + * them keeps the pass cheap. Bounded by `cap`, newest-data first. + */ +export async function findRecoveryScoreCandidates( + prisma: PrismaClient, + now: Date, + cap: number, +): Promise { + const since = new Date(now.getTime() - RECOVERY_SCORE_RECENCY_DAYS * MS_PER_DAY); + // The input-type set is a closed compile-time list of enum members — + // splice-free; Prisma binds the `type IN (...)` array as parameters. + const rows = await prisma.measurement.findMany({ + where: { + type: { + in: [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + ], + }, + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }); + return rows.map((r) => r.userId); +} + +/** + * Run one Recovery-score pass. Pure of pg-boss so the unit test drives it + * directly. Each user is scored independently; a single user's error is + * recorded and the pass continues (one bad account never blocks the cohort). + */ +export async function runRecoveryScore( + prisma: PrismaClient, + options: { now?: Date; cap?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const cap = options.cap ?? RECOVERY_SCORE_BATCH_CAP; + + const userIds = await findRecoveryScoreCandidates(prisma, now, cap); + + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistRecoveryScore(prisma, userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: "insights.recovery.compute", + details: { + considered: userIds.length, + stored, + insufficient, + errored, + }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 2a3cf76d5..4bf3ad5ad 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -68,6 +68,11 @@ import { forceWarmUser, type InsightPregeneratePayload, } from "@/lib/jobs/insight-pregenerate"; +import { + RECOVERY_SCORE_QUEUE, + RECOVERY_SCORE_CRON, + runRecoveryScore, +} from "@/lib/jobs/recovery-score"; import { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_GENERATE_CONCURRENCY, @@ -2128,6 +2133,11 @@ export async function startReminderWorker() { // schedule silently no-ops and pruned-past-retention tombstones pile // up forever. MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, + // v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute + + // store. The cron tick fans out one stored `COMPUTED RECOVERY_SCORE` row + // per eligible user. The queue MUST be registered here or pg-boss never + // provisions it and the 04:45 schedule silently never fires. + RECOVERY_SCORE_QUEUE, ]; for (const q of allQueues) { @@ -2218,6 +2228,10 @@ export async function startReminderWorker() { // v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement // tombstones. [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], + // v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin + // Recovery-score compute + store, after the rollup-feeding consolidation + // + drain so the signals it reads are already folded. + [RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON], ]; for (const [name, cron] of schedules) { @@ -2374,6 +2388,28 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleInsightPregenerateJob, ); + // v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute + + // store. The cron tick carries an empty payload; the runner iterates every + // eligible user and upserts one `COMPUTED RECOVERY_SCORE` row per scored + // day (idempotent — a re-fire overwrites in place). Single-flight so two + // ticks never double-walk the cohort. + await boss.work( + RECOVERY_SCORE_QUEUE, + { localConcurrency: 1 }, + async () => { + try { + const summary = await runRecoveryScore(getWorkerPrisma()); + workerLog( + "info", + `[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[recovery-score] pass failed", err); + throw err; + } + }, + ); // v1.8.3 — on-demand per-metric status generation enqueued by the // read-only status route on a cold card. Low concurrency so a first // visit that cold-misses several cards can't saturate the Prisma pool From 4ddf385211bde5c2ae08cf9e023b1720eee6da37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:37:55 +0200 Subject: [PATCH 21/49] docs(readme): add wearable-ecosystem positioning and v1.10 derived metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an honest "Versus WHOOP / Oura / Fitbit ecosystems" subsection that positions HealthLog on self-hosting, data ownership, multi-provider aggregation, and the blood-pressure / glucose / medication context the wrist bands don't hold — without claiming to be or match a wearable device. Refresh the comparison table with BP+glucose, medication compliance, derived metrics, and FHIR rows. Fold the v1.10 derived-metrics tier into the status line, Key Features, and roadmap: fitness-age, vascular-age delta, HRV balance, sleep score, readiness and the stored recovery score, and the coincident-deviation early-strain flag — each computed from the user's own measurements, citing its inputs, degrading to "not enough data" rather than fabricating a value. Stress and strain scores stay out of the copy: the types are reserved but no engine computes them in v1.10. --- README.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5ea334b1e..c8479c9f3 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0. -> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.7 — health-record export (PDF + FHIR R4), flexible medication schedules, full HealthKit metric coverage, and a first-paint dashboard snapshot. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). +> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.10 — a derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), each computed from your own measurements and citing its inputs, on top of the v1.7 health-record export (PDF + FHIR R4), flexible medication schedules, and full HealthKit metric coverage. The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). > **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper. @@ -60,12 +60,26 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a | Open source | AGPL-3.0 | No | No | No | n/a | | Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No | | Apple Health import | Yes (`export.zip`) | No | Native | No | Manual | +| Blood pressure + glucose | First-class | BP only | Manual | No | Manual | +| Medication compliance | Cadence-aware | No | No | No | Manual | +| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| No | | Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a | -| Doctor-report PDF | Yes (client-side) | No | No | No | n/a | +| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | n/a | | AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a | | Subscription required | No | For some metrics| No | Yes | No | | Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends | +### Versus WHOOP / Oura / Fitbit ecosystems + +The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run. + +- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS. +- **It aggregates across providers instead of locking you to one.** Withings devices sync over OAuth2, an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, an Apple Watch, and a glucometer land on one timeline instead of three apps. +- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone. +- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present. + +HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data. + --- ## Key Features @@ -86,6 +100,8 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a **AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network. +**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments. + **Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis. **Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`. @@ -488,7 +504,8 @@ For a single-process default the same container hosts both the web and worker (` | ------------ | ----- | | **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. | | **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. | -| **v1.6 – v1.7** (current) | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). | +| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). | +| **v1.8 – v1.10** (current) | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. | | **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). | The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md). From 56fc9cefbe84144c94619c00b3dbf52adcfc3ffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:38:00 +0200 Subject: [PATCH 22/49] docs(meta): sharpen the base SEO description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic page description with the self-hosted positioning — Withings + Apple Health sync, transparent derived wellness metrics, and AI Insights you own — so the search-result snippet matches the README and landing copy. OpenGraph and Twitter card descriptions are unchanged. --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ea7f03881..9bae20a19 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,7 +35,7 @@ const inter = Inter({ export const metadata: Metadata = { title: "HealthLog", description: - "Personal health tracking — weight, blood pressure, pulse, mood, medication compliance", + "Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.", manifest: "/manifest.json", icons: { icon: "/favicon.svg", From ce2b745cc3cc7073b188c44114b3ded06d7ca60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:47:58 +0200 Subject: [PATCH 23/49] feat(measurements): add a bounded dense intra-day retention tier for HRV/HR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daytime heart-rate-variability (SDNN) and heart-rate samples need to keep their intra-day shape long enough for the stress engine to read them, but the raw per-sample volume must stay bounded. Add a retention tier that keeps the last 14 days of raw HRV/HR samples and folds everything older to one daily-mean row via the proven mean-consolidation drain (same idempotent stats:: fold, soft-delete, and rollup recompute). The two dense-tier types are deliberately EXEMPT from the destructive daily-mean drain allowlist (HIGH_FREQUENCY_MEAN_TYPES) — that drain would collapse the intra-day shape and soft-delete the raw rows. A regression test pins both exclusions so a future edit that adds either type to the drain allowlist fails loudly. Migration 0108 adds only the partial index that keeps the retention drain's discovery and per-user scan cheap; the retention bound lives in code so tuning it never needs a migration. --- .../migration.sql | 54 ++++ prisma/schema.prisma | 5 + src/lib/jobs/dense-intraday-retention.ts | 163 +++++++++++ .../dense-intraday-drain-exemption.test.ts | 71 +++++ .../dense-intraday-retention.test.ts | 168 ++++++++++++ .../measurements/dense-intraday-retention.ts | 254 ++++++++++++++++++ 6 files changed, 715 insertions(+) create mode 100644 prisma/migrations/0108_v1100_dense_intraday_retention/migration.sql create mode 100644 src/lib/jobs/dense-intraday-retention.ts create mode 100644 src/lib/measurements/__tests__/dense-intraday-drain-exemption.test.ts create mode 100644 src/lib/measurements/__tests__/dense-intraday-retention.test.ts create mode 100644 src/lib/measurements/dense-intraday-retention.ts diff --git a/prisma/migrations/0108_v1100_dense_intraday_retention/migration.sql b/prisma/migrations/0108_v1100_dense_intraday_retention/migration.sql new file mode 100644 index 000000000..8d8bcd543 --- /dev/null +++ b/prisma/migrations/0108_v1100_dense_intraday_retention/migration.sql @@ -0,0 +1,54 @@ +-- v1.10.0 — dense intra-day retention tier for daytime HRV / heart-rate. +-- +-- The Stress engine reads the intra-day SDNN (and daytime heart-rate) +-- SHAPE across the day, not a single daily mean. Apple Health ships those +-- as discrete per-sample rows, so the engine needs the raw samples to +-- survive long enough to read. Two facts make that safe: +-- +-- 1. `HEART_RATE_VARIABILITY` and `PULSE` are NOT in the destructive +-- daily-mean drain's allowlist (`HIGH_FREQUENCY_MEAN_TYPES` in +-- src/lib/measurements/apple-health-mapping.ts), so the nightly +-- mean-consolidation never collapses them — the intra-day shape is +-- preserved by construction. (PULSE was already excluded for the +-- correlation/scatter readers; HRV is exempted for the same reason +-- the Stress proxy needs it.) +-- +-- 2. A BOUNDED retention window contains the volume: per-sample HRV / HR +-- rows older than the window are folded to one daily-mean `stats:` row +-- (the same idempotent fold + soft-delete the mean-consolidation uses) +-- and the raw rows are tombstoned. Within the window the raw samples +-- stay, so the Stress engine always has its intra-day inputs for the +-- days it scores. +-- +-- This migration adds ONLY the partial index that keeps the dense-tier +-- drain's discovery + per-user scan cheap. No new column, no backfill, no +-- table rewrite. The retention bound itself lives in application code +-- (DENSE_INTRADAY_RETENTION_DAYS in +-- src/lib/measurements/dense-intraday-retention.ts) so tuning it never +-- needs a migration. +-- +-- The index is keyed on `(user_id, measured_at)` and partial over the two +-- dense-tier types on live rows only, so it shrinks toward the in-window +-- working set as out-of-window samples are folded away — it carries no +-- steady-state cost once a tenant's history is past the retention window. +-- +-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only. +-- +-- Lock note (conscious trade-off): a plain, non-`CONCURRENTLY` build, so +-- it runs inside Prisma's migration transaction and holds an ACCESS +-- EXCLUSIVE lock on `measurements` for the build. The partial predicate +-- (two types, `deleted_at IS NULL`) keeps the index small on a typical +-- self-host, so the build is fast and the lock is not felt; the largest +-- multi-year HealthKit tenants should deploy at a low-traffic window. A +-- `CONCURRENTLY` build cannot run inside the migration transaction and is +-- not warranted given the partial-index size on the vast majority of +-- deployments. +-- +-- Reversibility: +-- DROP INDEX IF EXISTS "measurements_dense_intraday_retention_idx"; +-- A roll-back loses only the scan acceleration; no data is touched. + +CREATE INDEX IF NOT EXISTS "measurements_dense_intraday_retention_idx" + ON "measurements" ("user_id", "measured_at") + WHERE "type" IN ('HEART_RATE_VARIABILITY', 'PULSE') + AND "deleted_at" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a8787dec8..f47c76dd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -736,6 +736,11 @@ model Measurement { // migration 0087. It accelerates the v1.5.6 legacy step-consolidation // discovery scan. Prisma can't express a partial-predicate index, so // it lives in raw migration SQL and is intentionally omitted here. + // `measurements_dense_intraday_retention_idx` is a partial index over + // `type IN ('HEART_RATE_VARIABILITY','PULSE') AND deleted_at IS NULL` + // — see migration 0108. It accelerates the v1.10.0 dense intra-day + // retention drain's discovery + per-user scan. Same Prisma-can't- + // express-a-partial-predicate reason it is omitted here. @@map("measurements") } diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts new file mode 100644 index 000000000..24fd395eb --- /dev/null +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -0,0 +1,163 @@ +/** + * v1.10.0 — pg-boss queue + boot-time converging backfill for the dense + * intra-day retention drain of daytime HRV / heart-rate samples. + * + * Modelled on `mean-consolidation.ts`: a discovery query enqueues one job + * per user still holding live per-sample dense-tier rows OLDER than the + * retention window, the per-user handler runs `runDenseIntradayRetention`, + * and the pass is idempotent across reboots (folded rows are soft-deleted + * so they drop off the discovery list). + * + * Unlike the daily-mean consolidation, this pass scopes to the dense-tier + * types (`HEART_RATE_VARIABILITY`, `PULSE`) and keeps the last + * `DENSE_INTRADAY_RETENTION_DAYS` of raw per-sample rows so the Stress + * engine still has its intra-day SDNN shape; only out-of-window samples + * fold to a daily mean. + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an + * unregistered queue silently never drains. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + runDenseIntradayRetention, + DENSE_INTRADAY_RETENTION_TYPES, + DENSE_INTRADAY_RETENTION_DAYS, +} from "@/lib/measurements/dense-intraday-retention"; + +export const DENSE_INTRADAY_RETENTION_QUEUE = "dense-intraday-retention"; + +/** + * Serial concurrency — the drain walks every out-of-window per-sample + * dense-tier row for a user and writes a transaction per day; + * concurrency-1 keeps it from crowding the request pool, matching the + * mean-consolidation + step-consolidation convention. + */ +export const DENSE_INTRADAY_RETENTION_CONCURRENCY = 1; + +/** + * Daily at 03:50 Europe/Berlin — inside the existing 03:xx maintenance + * window, after the daily-mean consolidation that the cumulative + mean + * drains run, and before the 04:30 insight pre-generation + 04:45 + * recovery-score so the rollup tier reflects the fold before the nightly + * scores read it. + */ +export const DENSE_INTRADAY_RETENTION_CRON = "50 3 * * *"; + +export interface DenseIntradayRetentionPayload { + userId: string; + enqueuedAt: string; +} + +/** + * Per-user queue handler. Runs the dense-tier retention drain for one + * account and returns the summary totals so the worker can log them. + */ +export async function runDenseIntradayRetentionForUser( + userId: string, +): Promise<{ daysConsolidated: number; perSampleRowsSoftDeleted: number }> { + const summary = await runDenseIntradayRetention(prisma, { + userId, + log: () => { + // Silent inside the queue handler — the worker logs the totals. + }, + }); + annotate({ + action: { + name: "measurement.dense_intraday.retain", + details: { + days: summary.totals.daysConsolidated, + per_sample_rows_soft_deleted: summary.totals.perSampleRowsSoftDeleted, + daily_rows_upserted: summary.totals.dailyRowsUpserted, + }, + }, + }); + return { + daysConsolidated: summary.totals.daysConsolidated, + perSampleRowsSoftDeleted: summary.totals.perSampleRowsSoftDeleted, + }; +} + +/** + * Boot-time discovery. Finds every user holding at least one LIVE + * per-sample dense-tier row (an `APPLE_HEALTH` row of a + * `DENSE_INTRADAY_RETENTION_TYPES` type whose externalId does NOT start + * with the daily-stats prefix, that is not tombstoned, AND whose + * `measuredAt` is older than the retention window) and enqueues one + * retention job per account. + * + * Idempotent across reboots: once a user's out-of-window per-sample rows + * are soft-deleted, the predicate drops them from the discovery set. + * pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors + * are returned through the result value so worker boot never fails because + * of a retention miss. + */ +export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + // The dense-tier type set is a closed compile-time list of enum + // members; splice-free — Prisma binds the `type IN (...)` array as + // parameters. + const types = Array.from(DENSE_INTRADAY_RETENTION_TYPES); + if (types.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + const windowStart = new Date( + Date.now() - DENSE_INTRADAY_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ); + + const users = await prisma.measurement.findMany({ + where: { + source: "APPLE_HEALTH", + type: { in: types }, + deletedAt: null, + NOT: { externalId: { startsWith: "stats:" } }, + measuredAt: { lt: windowStart }, + }, + select: { userId: true }, + distinct: ["userId"], + }); + + if (users.length === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const { userId } of users) { + const payload: DenseIntradayRetentionPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send(DENSE_INTRADAY_RETENTION_QUEUE, payload, { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + singletonKey: `dense-intraday-retention|${userId}`, + }); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/measurements/__tests__/dense-intraday-drain-exemption.test.ts b/src/lib/measurements/__tests__/dense-intraday-drain-exemption.test.ts new file mode 100644 index 000000000..1f9d82faa --- /dev/null +++ b/src/lib/measurements/__tests__/dense-intraday-drain-exemption.test.ts @@ -0,0 +1,71 @@ +/** + * v1.10.0 — computed scores (WX-E). Dense intra-day retention DRAIN-EXEMPTION + * regression guard. + * + * LOAD-BEARING — a regression here is silent and irreversible. + * + * The Stress engine reads the intra-day SDNN (and daytime HR) SHAPE across + * the day, not a single daily mean. The destructive daily-mean drain + * (`consolidateDailyMean`) collapses a day's per-sample rows for every type + * in its allowlist (`HIGH_FREQUENCY_MEAN_TYPES`) to ONE daily mean and + * SOFT-DELETES the raw rows. If `HEART_RATE_VARIABILITY` (or the daytime + * `PULSE` the engine relies on) were ever added to that allowlist, the + * intra-day shape Stress needs would be destroyed the next night the drain + * ran — and the raw rows would be gone for good. + * + * This test pins both exclusions so a future edit that adds either type to + * the drain's allowlist fails CI loudly. The dense intra-day retention tier + * (`dense-intraday-retention.ts`) is the ONLY pass allowed to fold these + * types, and it does so behind a bounded window that preserves the recent + * intra-day samples. + */ +import { describe, expect, it } from "vitest"; +import type { MeasurementType } from "@/generated/prisma/client"; + +import { + CUMULATIVE_HK_TYPES, + HIGH_FREQUENCY_MEAN_TYPES, +} from "../apple-health-mapping"; +import { DENSE_INTRADAY_RETENTION_TYPES } from "../dense-intraday-retention"; + +describe("dense intra-day retention — drain exemption (WX-E)", () => { + it("EXEMPTS HEART_RATE_VARIABILITY from the destructive daily-mean drain", () => { + // If this fails, the nightly daily-mean drain will collapse intra-day + // SDNN to a single daily mean + soft-delete the raw rows, destroying the + // shape the Stress engine reads. The dense-tier retention drain is the + // only pass allowed to fold HRV, and only out-of-window samples. + expect( + HIGH_FREQUENCY_MEAN_TYPES.has( + "HEART_RATE_VARIABILITY" as MeasurementType, + ), + ).toBe(false); + }); + + it("EXEMPTS PULSE from the destructive daily-mean drain", () => { + expect(HIGH_FREQUENCY_MEAN_TYPES.has("PULSE" as MeasurementType)).toBe( + false, + ); + }); + + it("EXEMPTS both dense-tier types from the cumulative (SUM) drain too", () => { + // HRV / HR are spot signals, never cumulative — they must not be in the + // SUM drain either, which would also collapse + hard-delete them. + for (const type of DENSE_INTRADAY_RETENTION_TYPES) { + expect(CUMULATIVE_HK_TYPES.has(type)).toBe(false); + } + }); + + it("scopes the dense tier to exactly HEART_RATE_VARIABILITY + PULSE", () => { + expect(Array.from(DENSE_INTRADAY_RETENTION_TYPES).sort()).toEqual( + ["HEART_RATE_VARIABILITY", "PULSE"].sort(), + ); + }); + + it("keeps the dense tier strictly disjoint from the daily-mean drain", () => { + // The two passes must never overlap: the daily-mean drain would destroy + // the intra-day shape; the dense tier preserves it behind a window. + for (const type of DENSE_INTRADAY_RETENTION_TYPES) { + expect(HIGH_FREQUENCY_MEAN_TYPES.has(type)).toBe(false); + } + }); +}); diff --git a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts new file mode 100644 index 000000000..2f039b49f --- /dev/null +++ b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts @@ -0,0 +1,168 @@ +/** + * v1.10.0 — computed scores (WX-E). Dense intra-day retention drain. + * + * Pins the retention BOUND: the scan only folds per-sample dense-tier rows + * OLDER than the retention window (`measuredAt < now - retentionDays`), so + * the recent intra-day shape the Stress engine reads is never collapsed. + * Also pins the dense-tier scope (HRV + PULSE), the APPLE_HEALTH source + * scope, the MEAN reduction + soft-delete, and the rollup recompute. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { recomputeBucketsForMeasurement } = vi.hoisted(() => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement, +})); + +import { + runDenseIntradayRetention, + DENSE_INTRADAY_RETENTION_DAYS, + DENSE_INTRADAY_RETENTION_TYPES, +} from "../dense-intraday-retention"; +import type { PerSampleRow } from "../drain-per-sample-cumulative"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; + +function row( + id: string, + value: number, + iso: string, + type: MeasurementType, + unit = "ms", + externalId: string | null = null, +): PerSampleRow { + return { id, type, value, measuredAt: new Date(iso), externalId, unit }; +} + +function buildPrismaMock(rowsByType: Record) { + const upsert = vi.fn().mockResolvedValue({}); + const updateMany = vi.fn().mockResolvedValue({ count: 0 }); + const findManyMeasurement = vi.fn( + async (args: { where: { type: string } }) => + rowsByType[args.where.type] ?? [], + ); + const tx = { measurement: { upsert, updateMany } }; + return { + mock: { + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-1", timezone: "Europe/Berlin" }]), + }, + measurement: { findMany: findManyMeasurement }, + $transaction: vi.fn(async (cb: (t: unknown) => Promise) => + cb(tx), + ), + } as unknown as PrismaClient, + upsert, + updateMany, + findManyMeasurement, + }; +} + +beforeEach(() => { + recomputeBucketsForMeasurement.mockClear(); +}); + +describe("runDenseIntradayRetention — retention bound", () => { + it("only scans rows OLDER than the retention window", async () => { + const { mock, findManyMeasurement } = buildPrismaMock({}); + await runDenseIntradayRetention(mock, { log: () => {} }); + + // Every scan carries a `measuredAt < cutoff` predicate — the retention + // boundary. Without it, in-window intra-day samples would be folded and + // the Stress engine would lose its shape. + for (const call of findManyMeasurement.mock.calls) { + const where = (call[0] as unknown as { where: { measuredAt?: { lt: Date } } }) + .where; + expect(where.measuredAt?.lt).toBeInstanceOf(Date); + } + // The cutoff is ~DENSE_INTRADAY_RETENTION_DAYS in the past. + const firstCutoff = ( + findManyMeasurement.mock.calls[0]?.[0] as unknown as { + where: { measuredAt: { lt: Date } }; + } + ).where.measuredAt.lt.getTime(); + const expected = + Date.now() - DENSE_INTRADAY_RETENTION_DAYS * 24 * 60 * 60 * 1000; + // Within a generous 60s of the expected boundary. + expect(Math.abs(firstCutoff - expected)).toBeLessThan(60_000); + }); + + it("folds everything when retentionDays = 0 (no window)", async () => { + const { mock, findManyMeasurement } = buildPrismaMock({}); + await runDenseIntradayRetention(mock, { retentionDays: 0, log: () => {} }); + for (const call of findManyMeasurement.mock.calls) { + const where = (call[0] as unknown as { where: { measuredAt?: { lt: Date } } }) + .where; + // No cutoff predicate → the whole history is in scope. + expect(where.measuredAt).toBeUndefined(); + } + }); + + it("scans exactly the dense-tier types, source-scoped to APPLE_HEALTH", async () => { + const { mock, findManyMeasurement } = buildPrismaMock({}); + await runDenseIntradayRetention(mock, { log: () => {} }); + + const scannedTypes = findManyMeasurement.mock.calls.map( + (c) => (c[0] as { where: { type: string } }).where.type, + ); + expect(scannedTypes.sort()).toEqual( + Array.from(DENSE_INTRADAY_RETENTION_TYPES).sort(), + ); + for (const call of findManyMeasurement.mock.calls) { + const where = (call[0] as unknown as { where: { source: string } }).where; + expect(where.source).toBe("APPLE_HEALTH"); + } + }); +}); + +describe("runDenseIntradayRetention — fold flow", () => { + it("upserts the day MEAN and soft-deletes the out-of-window rows", async () => { + // Two HRV samples on an out-of-window day. + const hrvRows = [ + row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"), + row("b", 60, "2026-05-01T09:00:00.000Z", "HEART_RATE_VARIABILITY"), + ]; + const { mock, upsert, updateMany } = buildPrismaMock({ + HEART_RATE_VARIABILITY: hrvRows, + }); + + const summary = await runDenseIntradayRetention(mock, { + retentionDays: 0, + log: () => {}, + }); + + const upsertArg = upsert.mock.calls[0]?.[0] as { + create: { value: number; source: string; type: string }; + }; + // value is the MEAN (50), not the sum (100). + expect(upsertArg.create.value).toBeCloseTo(50, 6); + expect(upsertArg.create.source).toBe("APPLE_HEALTH"); + expect(upsertArg.create.type).toBe("HEART_RATE_VARIABILITY"); + + // soft-delete, never hard delete. + const updArg = updateMany.mock.calls[0]?.[0] as { + data: { deletedAt: Date }; + }; + expect(updArg.data.deletedAt).toBeInstanceOf(Date); + expect(summary.totals.daysConsolidated).toBe(1); + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); + }); + + it("does not write on a dry-run", async () => { + const { mock, upsert } = buildPrismaMock({ + HEART_RATE_VARIABILITY: [ + row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"), + ], + }); + await runDenseIntradayRetention(mock, { + retentionDays: 0, + dryRun: true, + log: () => {}, + }); + expect(upsert).not.toHaveBeenCalled(); + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts new file mode 100644 index 000000000..da61b9f6b --- /dev/null +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -0,0 +1,254 @@ +/** + * v1.10.0 — dense intra-day retention tier for daytime HRV / heart-rate. + * + * The Stress engine (`src/lib/insights/stress-score.ts`) reads the + * intra-day SDNN SHAPE across the day — the spread of a day's + * `HEART_RATE_VARIABILITY` (and daytime `PULSE`) samples, not a single + * daily mean. Apple Health ships those as discrete per-sample rows, so the + * raw samples must survive long enough for the engine to read them. + * + * Two facts make a bounded raw-retention window the right shape rather than + * a new storage paradigm: + * + * 1. `HEART_RATE_VARIABILITY` and `PULSE` are NOT in the destructive + * daily-mean drain's allowlist (`HIGH_FREQUENCY_MEAN_TYPES` in + * `apple-health-mapping.ts`). The nightly `mean-consolidation` never + * collapses them, so the intra-day shape is preserved by construction + * — the drain-exemption the Stress engine relies on. (PULSE was + * already excluded for the correlation/scatter readers; HRV is + * exempted here for the same reason the Stress proxy needs it.) A + * regression test pins both exclusions. + * + * 2. A BOUNDED retention window contains the volume the exemption would + * otherwise let grow forever. This pass REUSES the proven + * mean-consolidation drain (same `runConsolidation` base, same + * idempotent `stats::` fold, same soft-delete + rollup + * recompute) but with the grace cutoff widened from 36 hours to the + * retention window: per-sample HRV / HR rows OLDER than the window are + * folded to one daily-mean row and the raw rows are tombstoned; rows + * INSIDE the window stay raw so the Stress engine always has its + * intra-day inputs for the days it scores. + * + * The net effect: the last `DENSE_INTRADAY_RETENTION_DAYS` carry the dense + * intra-day samples; everything older folds to the same daily-mean grain + * the rest of the high-frequency metrics already use, and the + * `MeasurementRollup` DAY/WEEK/MONTH/YEAR tiers keep serving the long + * history. No new table, no new value column — the retention bound lives + * here in code so tuning it never needs a migration. + * + * Runs on pg-boss (`DENSE_INTRADAY_RETENTION_QUEUE`), modelled on the + * `mean-consolidation` boot-time converging-backfill pattern — the + * production standalone image strips `tsx`, so this can never be a CLI. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; + +import { + dailyStatsExternalId, + hkIdentifierForType, +} from "./apple-health-mapping"; +import { + runConsolidation, + type DayWriteOutcome, +} from "./consolidation-base"; +import { meanBucketValue } from "./consolidate-daily-mean"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +/** + * The dense-tier types. These are the daytime intra-day signals the Stress + * engine reads as a shape. Both are deliberately EXEMPT from the + * destructive daily-mean drain (`HIGH_FREQUENCY_MEAN_TYPES`); the + * disjointness from that set is the load-bearing invariant a regression + * test pins. + */ +export const DENSE_INTRADAY_RETENTION_TYPES: ReadonlySet = + new Set(["HEART_RATE_VARIABILITY", "PULSE"]); + +/** + * Retention bound (days). Per-sample HRV / HR rows older than this window + * fold to one daily-mean row and the raw rows tombstone; rows inside the + * window stay raw so the Stress engine has its intra-day inputs. + * + * 14 days covers the Stress engine's 7-day reference window plus a margin + * for late watch syncs and a backfilled gap, while keeping the raw + * per-sample volume bounded to roughly two weeks of samples per dense type. + */ +export const DENSE_INTRADAY_RETENTION_DAYS = 14; + +/** The daily-stats externalId prefix marks an already-collapsed row. */ +const DAILY_STATS_PREFIX = "stats:"; + +export interface DenseIntradayRetentionSummary { + dryRun: boolean; + totals: { + usersScanned: number; + daysConsolidated: number; + perSampleRowsSoftDeleted: number; + dailyRowsUpserted: number; + }; +} + +export interface DenseIntradayRetentionOptions { + /** Limit the pass to a single user. Default = every user. */ + userId?: string; + /** Preview-only mode — no DB writes. */ + dryRun?: boolean; + /** Logger sink — defaults to `console.log`. */ + log?: (line: string) => void; + /** + * Retention window in days. Per-sample rows OLDER than `now() - + * retentionDays` are folded; rows inside the window stay raw. Defaults to + * `DENSE_INTRADAY_RETENTION_DAYS`. An explicit one-shot can pass `0` to + * fold everything (no protection window) — used only by tests / a manual + * full-collapse, never by the scheduled pass. + */ + retentionDays?: number; +} + +/** + * Run the dense intra-day retention drain. Folds per-sample HRV / HR rows + * older than the retention window into one daily-mean `stats:` row per user + * × type × day and soft-deletes the raw rows; rows inside the window stay + * raw. Idempotent — re-invocation after a successful pass converges to zero + * work because the folded rows are soft-deleted and so excluded from the + * live scan, and the minted stats row is excluded by the `NOT + * startsWith('stats:')` predicate. + * + * Does NOT enforce any auth gate — the queue handler owns that concern. + */ +export async function runDenseIntradayRetention( + prismaClient: PrismaClient, + options: DenseIntradayRetentionOptions = {}, +): Promise { + const log = options.log ?? ((line) => console.log(line)); + const retentionDays = options.retentionDays ?? DENSE_INTRADAY_RETENTION_DAYS; + // The shared base resolves the cutoff from `cutoffHours`; the retention + // window is the same mechanism expressed in days, so the rows newer than + // it stay raw exactly like the mean-consolidation grace window keeps + // today's syncs raw — only the width differs. + const cutoffHours = retentionDays > 0 ? retentionDays * 24 : undefined; + + const summary: DenseIntradayRetentionSummary = { + dryRun: options.dryRun ?? false, + totals: { + usersScanned: 0, + daysConsolidated: 0, + perSampleRowsSoftDeleted: 0, + dailyRowsUpserted: 0, + }, + }; + + const { usersScanned } = await runConsolidation({ + prismaClient, + options: { ...options, cutoffHours }, + types: DENSE_INTRADAY_RETENTION_TYPES, + hkIdentifierForType, + dailyStatsExternalId, + statsPrefix: DAILY_STATS_PREFIX, + // Mean is the correct out-of-window reduction — the same reducer the + // daily-mean consolidation uses for the other high-frequency spot + // metrics. Reused verbatim so the two surfaces can never drift. + reduce: meanBucketValue, + // Live per-sample rows for the type, source-scoped to APPLE_HEALTH so + // manual + Withings spot rows survive. The single minted stats row is + // excluded by the NOT-startsWith predicate so it is never re-folded; + // soft-deleted rows are excluded so a re-run converges. `cutoffAt` is + // the retention boundary — rows newer than it (inside the window) are + // NOT scanned, so the dense intra-day shape is preserved. + buildScanWhere: ({ userId, type, cutoffAt, statsPrefix }) => ({ + userId, + source: "APPLE_HEALTH", + type, + deletedAt: null, + NOT: { externalId: { startsWith: statsPrefix } }, + ...(cutoffAt ? { measuredAt: { lt: cutoffAt } } : {}), + }), + scanSelect: { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, + unit: true, + }, + writeDay: async ({ + prismaClient: pc, + userId, + type, + externalId, + canonicalTimestamp, + reducedValue, + dayRows, + sourceRowIds, + }): Promise => { + const unit = dayRows[0]?.unit ?? "unknown"; + let removed = 0; + await pc.$transaction(async (tx) => { + // Mint / refresh the canonical daily-mean row first. The unique + // index (userId, type, source, externalId) makes the upsert + // idempotent across re-runs. Built field-by-field (no spread) per + // the no-mass-assignment convention. + await tx.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type, + source: "APPLE_HEALTH", + externalId, + }, + }, + create: { + userId, + type, + value: reducedValue, + unit, + source: "APPLE_HEALTH", + measuredAt: canonicalTimestamp, + externalId, + }, + update: { + value: reducedValue, + measuredAt: canonicalTimestamp, + deletedAt: null, + }, + }); + + // Soft-delete the out-of-window per-sample rows in the same + // transaction — tombstone, never hard-delete; they remain as an + // audit trail and drop off the live read + this pass's re-run + // discovery. + const del = await tx.measurement.updateMany({ + where: { id: { in: sourceRowIds }, deletedAt: null }, + data: { deletedAt: new Date() }, + }); + removed = del.count; + }); + + // Recompute the affected (user, type, day) rollup buckets after the + // fold commits — the rollup tier reads only `deleted_at IS NULL`, so + // the stale DAY bucket must re-aggregate against the single + // consolidated row, exactly as the mean-consolidation drain does. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + + return { kind: "written", sourceRowsRemoved: removed }; + }, + recordBucket: ({ dayRows, outcome }) => { + summary.totals.daysConsolidated += 1; + summary.totals.dailyRowsUpserted += 1; + summary.totals.perSampleRowsSoftDeleted += + outcome?.kind === "written" ? outcome.sourceRowsRemoved : dayRows.length; + }, + onUserComplete: ({ userId, tz, dryRun }) => { + log( + `[dense-intraday-retention] user=${userId} tz=${tz}${dryRun ? " (dry-run)" : ""}`, + ); + }, + }); + + summary.totals.usersScanned = usersScanned; + + log( + `[dense-intraday-retention] done — usersScanned=${summary.totals.usersScanned} daysConsolidated=${summary.totals.daysConsolidated} perSampleRowsSoftDeleted=${summary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${options.dryRun ? " (dry-run)" : ""}`, + ); + + return summary; +} From 57d9cc16740a0db62a047a546c286de714499fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:48:07 +0200 Subject: [PATCH 24/49] feat(insights): compute and store a nightly stress score from intra-day HRV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Watch has no electrodermal sensor, so there is no direct stress measurement to read — what HealthKit does give is discrete SDNN samples through the day. Derive an honest HRV-through-day PROXY: a suppressed, below-baseline intra-day SDNN is a recognised marker of higher sympathetic load. The provenance and label say "HRV-derived proxy" and never imply a galvanic/EDA reading. The engine reuses the shared vitals-baseline math (the same SDNN baseline the wellness index reads), maps the day's below-baseline suppression to a 0-100 score, and stores it as a COMPUTED STRESS_SCORE row — server-owned, a client can never POST it. It gates and writes nothing below the minimum intra-day sample floor or without a usable baseline. Standard: Kim et al. 2018 (HRV and stress meta-analysis); Thayer et al. 2012 (neurovisceral integration). Idempotent per user per day via the nightly job. --- .../insights/__tests__/stress-score.test.ts | 158 ++++++++++ src/lib/insights/stress-score.ts | 283 ++++++++++++++++++ src/lib/jobs/stress-score.ts | 109 +++++++ 3 files changed, 550 insertions(+) create mode 100644 src/lib/insights/__tests__/stress-score.test.ts create mode 100644 src/lib/insights/stress-score.ts create mode 100644 src/lib/jobs/stress-score.ts diff --git a/src/lib/insights/__tests__/stress-score.test.ts b/src/lib/insights/__tests__/stress-score.test.ts new file mode 100644 index 000000000..8d8ce2e8b --- /dev/null +++ b/src/lib/insights/__tests__/stress-score.test.ts @@ -0,0 +1,158 @@ +/** + * v1.10.0 — computed scores (WX-E). Stress-score engine + persistence. + * + * The Stress score is an HONEST HRV-derived proxy (Apple Watch has no EDA + * sensor). These tests pin: + * - the proxy math (HRV suppression below baseline → higher stress), + * - the per-day idempotency-key + canonical-timestamp helpers, + * - the insufficient-data gates (too few intra-day samples; no baseline), + * - the "store on ok, write nothing on gate" persistence + idempotency. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const computeVitalsBaselineMock = vi.fn(); +vi.mock("@/lib/insights/derived/baseline", () => ({ + computeVitalsBaseline: (...args: unknown[]) => + computeVitalsBaselineMock(...args), +})); + +import { + stressDayKey, + stressExternalId, + stressMeasuredAt, + stressProxyFromSdnn, + persistStressScore, + STRESS_SCORE_EXTERNAL_ID_PREFIX, + STRESS_MIN_INTRADAY_SAMPLES, +} from "../stress-score"; + +const NOW = new Date("2026-06-02T08:30:00Z"); + +function okBaseline(center: number, spread: number) { + return { + status: "ok" as const, + value: { center, low: center - spread, high: center + spread, spread }, + }; +} + +function makePrisma(sdnnValues: number[]) { + const sdnnRows = sdnnValues.map((value, i) => ({ value, id: `s${i}` })); + const findMany = vi.fn().mockResolvedValue(sdnnRows); + const upsert = vi.fn().mockResolvedValue({}); + const findUnique = vi + .fn() + .mockResolvedValue({ dateOfBirth: null, gender: "MALE", heightCm: 180 }); + return { + prisma: { + measurement: { findMany, upsert }, + user: { findUnique }, + } as unknown as Parameters[0], + findMany, + upsert, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("stress-score helpers", () => { + it("keys the day + externalId by the UTC calendar day", () => { + expect(stressDayKey(NOW)).toBe("2026-06-02"); + expect(stressExternalId(NOW)).toBe( + `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + ); + }); + + it("anchors the canonical timestamp at noon UTC on the scored day", () => { + expect(stressMeasuredAt(NOW).toISOString()).toBe( + "2026-06-02T12:00:00.000Z", + ); + }); +}); + +describe("stressProxyFromSdnn — HRV suppression → higher stress", () => { + it("scores 0 when SDNN is at or above baseline (no inferred stress)", () => { + expect(stressProxyFromSdnn(60, 60, 10)).toBe(0); + expect(stressProxyFromSdnn(80, 60, 10)).toBe(0); + }); + + it("rises as SDNN drops below baseline", () => { + // One spread below baseline → ~50 stress (recovery scorer gives ~50). + expect(stressProxyFromSdnn(50, 60, 10)).toBeGreaterThanOrEqual(45); + expect(stressProxyFromSdnn(50, 60, 10)).toBeLessThanOrEqual(55); + // Two spreads below → ~100 stress. + expect(stressProxyFromSdnn(40, 60, 10)).toBeGreaterThanOrEqual(95); + }); + + it("is monotone — lower SDNN never yields lower stress", () => { + const a = stressProxyFromSdnn(55, 60, 10); + const b = stressProxyFromSdnn(45, 60, 10); + expect(b).toBeGreaterThanOrEqual(a); + }); +}); + +describe("persistStressScore", () => { + it("stores a COMPUTED STRESS_SCORE row when inputs are sufficient", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); // mean 40.25 + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.score).toBeGreaterThan(0); + expect(upsert).toHaveBeenCalledTimes(1); + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "user-1", + type: "STRESS_SCORE", + source: "COMPUTED", + externalId: "stress:2026-06-02", + }); + expect(arg.create).toMatchObject({ + type: "STRESS_SCORE", + source: "COMPUTED", + unit: "score", + externalId: "stress:2026-06-02", + }); + }); + + it("writes NOTHING when there are too few intra-day samples", async () => { + const { prisma, upsert } = makePrisma( + Array(STRESS_MIN_INTRADAY_SAMPLES - 1).fill(50), + ); + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_intraday_samples"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("writes NOTHING when the SDNN baseline is not usable", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); + computeVitalsBaselineMock.mockResolvedValue({ status: "insufficient" }); + + const result = await persistStressScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_baseline"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("is idempotent per user per day — re-runs upsert the same key", async () => { + const { prisma, upsert } = makePrisma([40, 42, 38, 41]); + computeVitalsBaselineMock.mockResolvedValue(okBaseline(60, 10)); + + await persistStressScore(prisma, "user-1", NOW); + await persistStressScore(prisma, "user-1", NOW); + + expect(upsert).toHaveBeenCalledTimes(2); + const firstKey = + upsert.mock.calls[0][0].where.userId_type_source_externalId; + const secondKey = + upsert.mock.calls[1][0].where.userId_type_source_externalId; + expect(secondKey).toEqual(firstKey); + }); +}); diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts new file mode 100644 index 000000000..46eecc18d --- /dev/null +++ b/src/lib/insights/stress-score.ts @@ -0,0 +1,283 @@ +/** + * v1.10.0 — computed scores (WX-E). The Stress score ENGINE + persistence. + * + * HONEST FRAMING (load-bearing — read before changing the label or copy): + * Apple Watch has NO electrodermal-activity (EDA / galvanic) sensor. There + * is no continuous stress measurement in HealthKit. What HealthKit DOES + * give is discrete heart-rate-variability (SDNN) samples through the day. + * This engine derives a defensible *proxy* from the intra-day SDNN signal — + * autonomic-balance research treats a SUPPRESSED, less-variable HRV through + * the day as a marker of higher sympathetic ("stress") load. It is NOT a + * direct stress measurement and the provenance/label must always say + * "HRV-derived proxy", never imply a galvanic / EDA reading. + * + * The proxy (0–100, higher = more inferred stress): + * - Read the day's intra-day `HEART_RATE_VARIABILITY` (SDNN, ms) samples + * (the dense intra-day retention tier keeps these raw within its + * window — see `dense-intraday-retention.ts`). + * - Take the day's MEAN SDNN. + * - Compare it to the user's personal SDNN baseline (the robust + * median/MAD band from the shared vitals-baseline engine over a 7-day + * reference window). HRV is "higher-better" for recovery; a day mean + * BELOW baseline = HRV suppression = higher inferred stress. + * - Map the suppression deviation to 0–100 via the same MAD-scaled + * deviation scorer the readiness blend uses (one spread below baseline + * → ~50, two spreads → ~100), then INVERT so suppression raises stress. + * + * Honest confidence: the score is only stored when the day has at least + * `STRESS_MIN_INTRADAY_SAMPLES` SDNN samples AND the SDNN baseline is + * usable. Below that the engine returns `insufficient` and NO row is + * written — the series never carries a headline from a single spot reading + * or an unestablished baseline. + * + * Standard / framing: HRV (SDNN) suppression + reduced variability as a + * sympathetic-load marker — Kim et al. 2018, "Stress and Heart Rate + * Variability: A Meta-Analysis", Psychiatry Investigation 15(3):235–245; + * Thayer et al. 2012, Neuroscience & Biobehavioral Reviews 36(2):747–756. + * The score is descriptive — a daily wellness proxy, NOT a clinical + * assessment, and it is excluded from the doctor PDF. + * + * Row shape (identical posture to the Recovery score): + * - `type = STRESS_SCORE` + * - `source = COMPUTED` (server-owned; a client can never POST it) + * - `unit = "score"` + * - `value = 0..100` (plausibility-pinned to {min:0, max:100}) + * - `externalId = stress:YYYY-MM-DD` (the per-day idempotency key) + * + * Server-only — runs from the nightly pg-boss job in + * `src/lib/jobs/stress-score.ts`. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { computeVitalsBaseline } from "@/lib/insights/derived/baseline"; +import { scoreDeviation } from "@/lib/insights/derived/readiness"; +import type { BaselineProfile } from "@/lib/insights/derived/baseline"; + +/** The per-day idempotency-key prefix for a stored Stress score row. */ +export const STRESS_SCORE_EXTERNAL_ID_PREFIX = "stress:"; + +/** SDNN reference window (days) for the personal baseline. */ +export const STRESS_BASELINE_WINDOW_DAYS = 7; + +/** + * Minimum intra-day SDNN samples in the scored day before a headline is + * produced. A single spot SDNN reading is not an intra-day shape; below + * this floor the engine gates and writes nothing. + */ +export const STRESS_MIN_INTRADAY_SAMPLES = 3; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +export function stressDayKey(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** The full `externalId` for a given day's Stress score row. */ +export function stressExternalId(now: Date): string { + return `${STRESS_SCORE_EXTERNAL_ID_PREFIX}${stressDayKey(now)}`; +} + +/** + * The canonical timestamp a stored Stress row carries: noon UTC on the + * scored day — same day-keying convention as the Recovery score so the + * series lines up with the buckets it summarises. + */ +export function stressMeasuredAt(now: Date): Date { + return new Date(`${stressDayKey(now)}T12:00:00.000Z`); +} + +/** + * Map a day-mean SDNN below the personal baseline to a 0–100 stress proxy. + * Reuses the readiness deviation scorer (HRV is "higher-better" for + * recovery, so a suppression scores LOW there) and INVERTS it so HRV + * suppression raises the stress proxy. On-or-above baseline → 0 (no + * inferred stress); one spread below → ~50; two spreads below → ~100. + * + * Pure — exported for unit testing without Prisma. + */ +export function stressProxyFromSdnn( + dayMeanSdnn: number, + baselineCenter: number, + baselineSpread: number, +): number { + // `scoreDeviation(... "higher-better")` returns 100 at/above baseline and + // falls toward 0 as SDNN drops; invert so suppression → high stress. + const recoveryComponent = scoreDeviation( + dayMeanSdnn, + baselineCenter, + baselineSpread, + "higher-better", + ); + return Math.max(0, Math.min(100, Math.round(100 - recoveryComponent))); +} + +/** + * Read the scored day's intra-day SDNN samples (raw `HEART_RATE_VARIABILITY` + * rows on the scored UTC day). The dense intra-day retention tier keeps + * these raw within its window, so for a recently-synced day the engine sees + * the full intra-day set. Returns the per-sample values for the day. + */ +async function readIntradaySdnn( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const dayKey = stressDayKey(now); + const dayStart = new Date(`${dayKey}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "HEART_RATE_VARIABILITY" as MeasurementType, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + // Only count raw per-sample rows toward the intra-day floor — a single + // already-folded `stats:` daily-mean row is one point, not a shape. + NOT: { externalId: { startsWith: "stats:" } }, + }, + select: { value: true }, + take: 2000, + }); + return rows.map((r) => r.value); +} + +export interface StressComputeResult { + /** The 0..100 score to persist, or null when the inputs gate. */ + score: number | null; + /** Why a null score gated (exposed for the job log + tests). */ + reason: + | "ok" + | "insufficient_intraday_samples" + | "insufficient_baseline"; + /** Number of intra-day SDNN samples found for the scored day. */ + intradaySamples: number; +} + +/** + * Compute the Stress score for one user as of `now`. Pure of persistence — + * the job calls this, then writes the row. Reuses the shared vitals-baseline + * engine (the same SDNN baseline the readiness blend reads) so the two + * surfaces can never drift. + */ +export async function computeStressScore( + prisma: PrismaClient, + userId: string, + profile: BaselineProfile, + now: Date, +): Promise { + const sdnn = await readIntradaySdnn(prisma, userId, now); + if (sdnn.length < STRESS_MIN_INTRADAY_SAMPLES) { + return { + score: null, + reason: "insufficient_intraday_samples", + intradaySamples: sdnn.length, + }; + } + + const baseline = await computeVitalsBaseline(userId, profile, { + type: "HEART_RATE_VARIABILITY", + windowDays: STRESS_BASELINE_WINDOW_DAYS, + now, + }); + if (baseline.status !== "ok") { + return { + score: null, + reason: "insufficient_baseline", + intradaySamples: sdnn.length, + }; + } + + const dayMean = sdnn.reduce((s, v) => s + v, 0) / sdnn.length; + const score = stressProxyFromSdnn( + dayMean, + baseline.value.center, + baseline.value.spread, + ); + return { score, reason: "ok", intradaySamples: sdnn.length }; +} + +/** + * Build the `BaselineProfile` the SDNN baseline needs from the user row — + * the same shape the live `/api/insights/derived` route builds. + */ +export async function loadStressProfile( + prisma: PrismaClient, + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + return { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + heightCm: user?.heightCm ?? null, + }; +} + +export interface PersistStressResult { + /** "stored" when a row was upserted; "insufficient" when gated. */ + outcome: "stored" | "insufficient"; + /** The persisted score, or null when insufficient. */ + score: number | null; + /** The gate reason (mirrors the compute result). */ + reason: StressComputeResult["reason"]; +} + +/** + * Compute + persist one user's Stress score for the `now` day. Upserts on + * the `(userId, type, source, externalId)` key so a re-run overwrites the + * day's row in place rather than duplicating it. Writes NOTHING when the + * inputs gate — no phantom proxy from a single reading or an unestablished + * baseline. The baseline engine reads `@/lib/db` directly; the nightly job + * passes that same shared client so both reads hit one connection. + */ +export async function persistStressScore( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const profile = await loadStressProfile(prisma, userId); + const { score, reason } = await computeStressScore( + prisma, + userId, + profile, + now, + ); + + if (score === null) { + return { outcome: "insufficient", score: null, reason }; + } + + const externalId = stressExternalId(now); + const measuredAt = stressMeasuredAt(now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "STRESS_SCORE", + source: "COMPUTED", + externalId, + }, + }, + create: { + userId, + type: "STRESS_SCORE", + source: "COMPUTED", + value: score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: score, + measuredAt, + }, + }); + + return { outcome: "stored", score, reason }; +} diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts new file mode 100644 index 000000000..9f7accffb --- /dev/null +++ b/src/lib/jobs/stress-score.ts @@ -0,0 +1,109 @@ +/** + * v1.10.0 — computed scores (WX-E). Nightly Stress-score computation + store. + * + * Once a night this cron computes each eligible user's Stress score from + * their intra-day `HEART_RATE_VARIABILITY` (SDNN) samples and persists it as + * a `COMPUTED STRESS_SCORE` Measurement row (see + * `src/lib/insights/stress-score.ts`). The score is an HONEST HRV-derived + * proxy — Apple Watch has no EDA sensor; it is NEVER a direct stress + * measurement. The score is never computed on a page visit. + * + * Idempotent per user per day: the per-user persist upserts on + * `(userId, type, source, externalId)` with `externalId = stress:YYYY-MM-DD`, + * so a re-fired cron tick (or a manual re-run) overwrites the day's row in + * place. A user without enough intra-day SDNN samples or a usable baseline + * gets NO row that night — the series stays honest. + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an + * unregistered queue silently never drains. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { persistStressScore } from "@/lib/insights/stress-score"; + +export const STRESS_SCORE_QUEUE = "stress-score-compute"; + +/** + * Daily at 04:50 Europe/Berlin — inside the existing maintenance window, + * after the dense intra-day retention drain (03:50) and the recovery-score + * pass (04:45) so the HRV inputs it reads are settled. + */ +export const STRESS_SCORE_CRON = "50 4 * * *"; + +/** Per-run user cap — same starvation guard the recovery-score pass uses. */ +export const STRESS_SCORE_BATCH_CAP = 500; + +/** Trailing window (days) a user must have a recent HRV row in to be a candidate. */ +export const STRESS_SCORE_RECENCY_DAYS = 2; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface StressScoreRunResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Discover users worth scoring tonight: anyone with at least one LIVE + * `HEART_RATE_VARIABILITY` row in the recency window. A user with no recent + * HRV cannot produce a Stress proxy, so skipping them keeps the pass cheap. + * Bounded by `cap`. + */ +export async function findStressScoreCandidates( + prisma: PrismaClient, + now: Date, + cap: number, +): Promise { + const since = new Date(now.getTime() - STRESS_SCORE_RECENCY_DAYS * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { + type: "HEART_RATE_VARIABILITY", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }); + return rows.map((r) => r.userId); +} + +/** + * Run one Stress-score pass. Pure of pg-boss so the unit test drives it + * directly. Each user is scored independently; a single user's error is + * recorded and the pass continues. + */ +export async function runStressScore( + prisma: PrismaClient, + options: { now?: Date; cap?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const cap = options.cap ?? STRESS_SCORE_BATCH_CAP; + + const userIds = await findStressScoreCandidates(prisma, now, cap); + + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistStressScore(prisma, userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: "insights.stress.compute", + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +} From 6935feda15bb9921bd321fcd4e3fdd463e6c05a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:48:14 +0200 Subject: [PATCH 25/49] feat(insights): compute and store a nightly strain score from workout load Derive a daily cardio-load proxy from the per-workout heart-rate series plus active energy. Each workout's HR series scores via Banister's gender-weighted exponential TRIMP over the fractional heart-rate reserve (Tanaka age-predicted HRmax, the user's resting HR); the day's TRIMP totals map to a 0-100 saturating scale. Days with no usable HR series but a non-trivial active-energy total fall back to an energy-only proxy, with the path recorded in the result. Stored as a COMPUTED STRAIN_SCORE row, idempotent per user per day, gated when neither a usable HR series nor active energy is present. Standard: Banister 1991; Morton, Fitz-Clarke & Banister 1990 (TRIMP); Tanaka, Monahan & Seals 2001 (HRmax). --- .../insights/__tests__/strain-score.test.ts | 258 +++++++++++++ src/lib/insights/strain-score.ts | 361 ++++++++++++++++++ src/lib/jobs/strain-score.ts | 119 ++++++ 3 files changed, 738 insertions(+) create mode 100644 src/lib/insights/__tests__/strain-score.test.ts create mode 100644 src/lib/insights/strain-score.ts create mode 100644 src/lib/jobs/strain-score.ts diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts new file mode 100644 index 000000000..1b26a91f5 --- /dev/null +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -0,0 +1,258 @@ +/** + * v1.10.0 — computed scores (WX-E). Strain-score engine + persistence. + * + * The Strain score is a Banister-TRIMP cardio-load proxy from the + * per-workout HR series + active energy. These tests pin: + * - the Banister gender-weighted exponential TRIMP math, + * - the saturating 0–100 map, + * - the TRIMP path + the active-energy fallback, + * - the per-day idempotency key + insufficient-data gate. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + strainDayKey, + strainExternalId, + strainMeasuredAt, + tanakaHrMax, + banisterTrimp, + saturateToScore, + persistStrainScore, + STRAIN_SCORE_EXTERNAL_ID_PREFIX, +} from "../strain-score"; + +const NOW = new Date("2026-06-02T08:30:00Z"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("strain-score helpers", () => { + it("keys the day + externalId by the UTC calendar day", () => { + expect(strainDayKey(NOW)).toBe("2026-06-02"); + expect(strainExternalId(NOW)).toBe( + `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + ); + }); + + it("anchors the canonical timestamp at noon UTC", () => { + expect(strainMeasuredAt(NOW).toISOString()).toBe( + "2026-06-02T12:00:00.000Z", + ); + }); + + it("uses the Tanaka HRmax formula", () => { + expect(tanakaHrMax(40)).toBeCloseTo(208 - 0.7 * 40, 6); // 180 + }); +}); + +describe("banisterTrimp", () => { + const hrRest = 50; + const hrMax = 190; + const sex = "MALE" as const; + + it("returns 0 for a series with fewer than two usable HR samples", () => { + expect(banisterTrimp([{ t: NOW.toISOString(), hr: 150 }], hrRest, hrMax, sex)).toBe( + 0, + ); + }); + + it("accumulates a positive TRIMP for a sustained elevated series", () => { + const samples = [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T08:30:00.000Z", hr: 150 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ]; + const trimp = banisterTrimp(samples, hrRest, hrMax, sex); + expect(trimp).toBeGreaterThan(0); + }); + + it("scores a harder hour above an easier hour", () => { + const easy = [ + { t: "2026-06-02T08:00:00.000Z", hr: 100 }, + { t: "2026-06-02T09:00:00.000Z", hr: 100 }, + ]; + const hard = [ + { t: "2026-06-02T08:00:00.000Z", hr: 170 }, + { t: "2026-06-02T09:00:00.000Z", hr: 170 }, + ]; + expect(banisterTrimp(hard, hrRest, hrMax, sex)).toBeGreaterThan( + banisterTrimp(easy, hrRest, hrMax, sex), + ); + }); + + it("ignores samples below resting HR (no negative impulse)", () => { + const below = [ + { t: "2026-06-02T08:00:00.000Z", hr: 45 }, + { t: "2026-06-02T09:00:00.000Z", hr: 45 }, + ]; + expect(banisterTrimp(below, hrRest, hrMax, sex)).toBe(0); + }); + + it("returns 0 when HRmax <= HRrest (degenerate profile)", () => { + const samples = [ + { t: "2026-06-02T08:00:00.000Z", hr: 100 }, + { t: "2026-06-02T09:00:00.000Z", hr: 100 }, + ]; + expect(banisterTrimp(samples, 190, 190, sex)).toBe(0); + }); +}); + +describe("saturateToScore", () => { + it("is 0 at zero load and rises monotonically toward 100", () => { + expect(saturateToScore(0, 150)).toBe(0); + const a = saturateToScore(50, 150); + const b = saturateToScore(150, 150); + const c = saturateToScore(600, 150); + expect(b).toBeGreaterThan(a); + expect(c).toBeGreaterThan(b); + expect(c).toBeLessThanOrEqual(100); + }); +}); + +function makePrisma(opts: { + energy?: number[]; + workouts?: Array<{ id: string; samples: { samples: unknown } | null }>; + rhr?: number | null; + gender?: string; + dateOfBirth?: Date | null; +}) { + const energyRows = (opts.energy ?? []).map((value, i) => ({ + value, + id: `e${i}`, + })); + const measurementFindMany = vi.fn().mockResolvedValue(energyRows); + const measurementFindFirst = vi + .fn() + .mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr }); + const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []); + const upsert = vi.fn().mockResolvedValue({}); + const findUnique = vi.fn().mockResolvedValue({ + dateOfBirth: + "dateOfBirth" in opts + ? opts.dateOfBirth + : new Date("1986-01-01T00:00:00Z"), + gender: opts.gender ?? "MALE", + }); + return { + prisma: { + measurement: { + findMany: measurementFindMany, + findFirst: measurementFindFirst, + upsert, + }, + workout: { findMany: workoutFindMany }, + user: { findUnique }, + } as unknown as Parameters[0], + upsert, + }; +} + +describe("persistStrainScore", () => { + it("stores a COMPUTED STRAIN_SCORE row via the TRIMP path", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + workouts: [ + { + id: "w1", + samples: { + samples: [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T08:30:00.000Z", hr: 155 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ], + }, + }, + ], + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.reason).toBe("trimp"); + expect(result.score).toBeGreaterThan(0); + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "user-1", + type: "STRAIN_SCORE", + source: "COMPUTED", + externalId: "strain:2026-06-02", + }); + expect(arg.create).toMatchObject({ + type: "STRAIN_SCORE", + source: "COMPUTED", + unit: "score", + }); + }); + + it("falls back to the active-energy proxy when no HR series is usable", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + energy: [300, 250], + workouts: [], // no workouts → no TRIMP + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("stored"); + expect(result.reason).toBe("active_energy_fallback"); + expect(result.score).toBeGreaterThan(0); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("gates when there is a workout but the profile yields no HRmax", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + dateOfBirth: null, // no age → no Tanaka HRmax + workouts: [ + { + id: "w1", + samples: { + samples: [ + { t: "2026-06-02T08:00:00.000Z", hr: 150 }, + { t: "2026-06-02T09:00:00.000Z", hr: 150 }, + ], + }, + }, + ], + energy: [], // no active-energy fallback either + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_profile"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("writes NOTHING when there is no usable input at all", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + workouts: [], + energy: [], + }); + + const result = await persistStrainScore(prisma, "user-1", NOW); + + expect(result.outcome).toBe("insufficient"); + expect(result.reason).toBe("insufficient_inputs"); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("is idempotent per user per day — re-runs upsert the same key", async () => { + const { prisma, upsert } = makePrisma({ + rhr: 50, + energy: [400], + }); + + await persistStrainScore(prisma, "user-1", NOW); + await persistStrainScore(prisma, "user-1", NOW); + + expect(upsert).toHaveBeenCalledTimes(2); + const firstKey = + upsert.mock.calls[0][0].where.userId_type_source_externalId; + const secondKey = + upsert.mock.calls[1][0].where.userId_type_source_externalId; + expect(secondKey).toEqual(firstKey); + }); +}); diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts new file mode 100644 index 000000000..f572a2540 --- /dev/null +++ b/src/lib/insights/strain-score.ts @@ -0,0 +1,361 @@ +/** + * v1.10.0 — computed scores (WX-E). The Strain score ENGINE + persistence. + * + * A daily cardio-load proxy (0–100, higher = more training strain) built + * from the per-workout heart-rate series (`WorkoutSamples`, WX-D) plus + * active-energy burned (`ACTIVE_ENERGY_BURNED`). + * + * CARDIO-LOAD MODEL — Banister's TRIMP (Training Impulse). + * For each workout's HR series we compute the gender-weighted exponential + * TRIMP: + * + * TRIMP = Σ (Δt_min · HRr · 0.64 · e^(1.92 · HRr)) (men) + * TRIMP = Σ (Δt_min · HRr · 0.86 · e^(1.67 · HRr)) (women) + * + * where HRr = (HR − HRrest) / (HRmax − HRrest) is the fractional + * heart-rate reserve per sample, Δt_min the minutes that sample + * represents, HRmax the age-predicted maximum (Tanaka 2001: + * 208 − 0.7·age), and HRrest the user's resting heart rate. + * + * Standard: Banister EW, "Modeling Elite Athletic Performance" (1991); + * the gender-weighted exponential form is from Morton, Fitz-Clarke & + * Banister 1990, J. Appl. Physiol. 69(3):1171–1177. Tanaka HRmax: + * Tanaka, Monahan & Seals 2001, J. Am. Coll. Cardiol. 37(1):153–156. + * + * The per-workout TRIMP values are summed across the scored day to a + * day-total TRIMP, then mapped to the 0–100 scale by a saturating curve + * anchored at a reference daily load (`STRAIN_TRIMP_REFERENCE` ≈ a hard + * hour). Days with NO usable HR series but WITH active energy fall back to + * an active-energy-only proxy so a "logged but no series" workout still + * registers some strain (clearly the weaker signal — the provenance records + * which path produced the score). + * + * Honest confidence: the score is only stored when the day carried at least + * one workout HR series with usable samples OR a non-trivial active-energy + * total AND the user profile yields an HRmax (needs age) + a resting HR. + * Below that the engine returns `insufficient` and NO row is written. + * + * Row shape (identical posture to the Recovery + Stress scores): + * - `type = STRAIN_SCORE` + * - `source = COMPUTED` + * - `unit = "score"` + * - `value = 0..100` + * - `externalId = strain:YYYY-MM-DD` + * + * The score is descriptive — a daily training-load proxy, NOT a clinical + * assessment, and it is excluded from the doctor PDF. Server-only — runs + * from the nightly pg-boss job in `src/lib/jobs/strain-score.ts`. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; + +/** The per-day idempotency-key prefix for a stored Strain score row. */ +export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Reference day-total TRIMP that maps to a high (but not pinned) strain. + * ~150 TRIMP is a hard ~hour of threshold work; the saturating map sends a + * day at this load near the top of the band, with very heavy days pinned at + * 100. + */ +export const STRAIN_TRIMP_REFERENCE = 150; + +/** + * Reference daily active-energy (kcal) for the HR-series-less fallback. A + * busy active day (~600 kcal active) maps near the top of the band. The + * fallback is intentionally the weaker signal — it has no intensity + * distribution, only a volume total. + */ +export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600; + +/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +export function strainDayKey(now: Date): string { + return now.toISOString().slice(0, 10); +} + +/** The full `externalId` for a given day's Strain score row. */ +export function strainExternalId(now: Date): string { + return `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}${strainDayKey(now)}`; +} + +/** Noon UTC on the scored day — same convention as the other scores. */ +export function strainMeasuredAt(now: Date): Date { + return new Date(`${strainDayKey(now)}T12:00:00.000Z`); +} + +/** Tanaka 2001 age-predicted maximum heart rate. */ +export function tanakaHrMax(ageYears: number): number { + return 208 - 0.7 * ageYears; +} + +interface HrSample { + t: string; + hr?: number; +} + +/** + * Banister gender-weighted exponential TRIMP for one workout HR series. + * Integrates fractional-HR-reserve over the inter-sample intervals. Samples + * with no `hr`, or with an out-of-range fractional reserve, contribute + * zero. Returns 0 for a series with fewer than two usable HR samples (no + * interval to integrate). Pure — exported for unit testing. + */ +export function banisterTrimp( + samples: readonly HrSample[], + hrRest: number, + hrMax: number, + sex: "MALE" | "FEMALE" | null, +): number { + if (hrMax <= hrRest) return 0; + // Morton/Fitz-Clarke/Banister gender weights; the unisex default uses the + // male coefficients (the more common watch-cohort assumption) when sex is + // unknown. + const a = sex === "FEMALE" ? 0.86 : 0.64; + const b = sex === "FEMALE" ? 1.67 : 1.92; + + const usable = samples + .filter((s) => typeof s.hr === "number" && Number.isFinite(s.hr)) + .map((s) => ({ t: Date.parse(s.t), hr: s.hr as number })) + .filter((s) => Number.isFinite(s.t)) + .sort((x, y) => x.t - y.t); + if (usable.length < 2) return 0; + + let trimp = 0; + for (let i = 1; i < usable.length; i++) { + const dtMin = (usable[i].t - usable[i - 1].t) / 60_000; + if (dtMin <= 0) continue; + // Use the interval's mean HR for the reserve fraction. + const meanHr = (usable[i].hr + usable[i - 1].hr) / 2; + let hrr = (meanHr - hrRest) / (hrMax - hrRest); + if (!Number.isFinite(hrr)) continue; + if (hrr <= 0) continue; // below resting → no training impulse. + if (hrr > 1) hrr = 1; // clamp a spurious over-max sample. + trimp += dtMin * hrr * a * Math.exp(b * hrr); + } + return trimp; +} + +/** + * Saturating map of a non-negative load to 0–100 against a reference. Uses + * `1 − e^(−x/ref)` scaled to 100 so the score rises steeply at low load and + * saturates toward 100 for very heavy days — never pinned exactly at 100 + * until the load is well above the reference. Pure. + */ +export function saturateToScore(load: number, reference: number): number { + if (!(load > 0) || !(reference > 0)) return 0; + const score = 100 * (1 - Math.exp(-load / reference)); + return Math.max(0, Math.min(100, Math.round(score))); +} + +export interface StrainComputeResult { + /** The 0..100 score to persist, or null when the inputs gate. */ + score: number | null; + /** Why the result gated / which path produced the score. */ + reason: + | "trimp" + | "active_energy_fallback" + | "insufficient_profile" + | "insufficient_inputs"; + /** Day-total TRIMP (0 when no usable HR series). */ + dayTrimp: number; + /** Day-total active energy (kcal). */ + dayActiveEnergy: number; + /** Workouts with a usable HR series on the day. */ + workoutsWithSeries: number; +} + +export interface StrainProfile { + ageYears: number | null; + sex: "MALE" | "FEMALE" | null; +} + +/** + * Compute the Strain score for one user as of `now`. Reads the day's + * workouts (+ HR series) and active-energy total, computes day-total TRIMP, + * and maps it to 0–100. Falls back to an active-energy-only proxy when no + * HR series is usable. Pure of persistence. Gates (null score) when the + * profile yields no HRmax / resting HR, or there is no usable input at all. + */ +export async function computeStrainScore( + prisma: PrismaClient, + userId: string, + profile: StrainProfile, + hrRest: number | null, + now: Date, +): Promise { + const dayKey = strainDayKey(now); + const dayStart = new Date(`${dayKey}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + MS_PER_DAY); + + // Day-total active energy first — it is the fallback signal and cheap. + const energyRows = await prisma.measurement.findMany({ + where: { + userId, + type: "ACTIVE_ENERGY_BURNED" as MeasurementType, + deletedAt: null, + measuredAt: { gte: dayStart, lt: dayEnd }, + }, + select: { value: true }, + take: 5000, + }); + const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); + + // The day's workouts + their HR series. + const workouts = await prisma.workout.findMany({ + where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, + select: { id: true, samples: { select: { samples: true } } }, + take: 100, + }); + + const hrMax = + profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; + const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; + + let dayTrimp = 0; + let workoutsWithSeries = 0; + if (canTrimp) { + for (const w of workouts) { + const raw = w.samples?.samples; + if (!Array.isArray(raw)) continue; + const series = raw as unknown as HrSample[]; + const trimp = banisterTrimp(series, hrRest!, hrMax!, profile.sex); + if (trimp > 0) { + dayTrimp += trimp; + workoutsWithSeries += 1; + } + } + } + + if (dayTrimp > 0) { + return { + score: saturateToScore(dayTrimp, STRAIN_TRIMP_REFERENCE), + reason: "trimp", + dayTrimp, + dayActiveEnergy, + workoutsWithSeries, + }; + } + + // No usable HR series. Fall back to the active-energy-only proxy when + // there is a non-trivial active-energy total. A profile with no HRmax / + // resting HR can still get this fallback because it needs neither. + if (dayActiveEnergy > 0) { + return { + score: saturateToScore(dayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE), + reason: "active_energy_fallback", + dayTrimp: 0, + dayActiveEnergy, + workoutsWithSeries: 0, + }; + } + + // Neither a usable HR series nor active energy → nothing to score. If the + // only reason TRIMP was unavailable is the profile, say so for the log. + return { + score: null, + reason: + workouts.length > 0 && !canTrimp + ? "insufficient_profile" + : "insufficient_inputs", + dayTrimp: 0, + dayActiveEnergy, + workoutsWithSeries: 0, + }; +} + +/** + * Build the Strain profile (age + sex) and resting HR from the user row + + * the latest resting-HR reading. `prisma` is the (worker) client. + */ +export async function loadStrainInputs( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise<{ profile: StrainProfile; hrRest: number | null }> { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + const profile: StrainProfile = { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + }; + + // Most recent resting-HR reading within a 30-day window — the HRrest input + // for the TRIMP reserve fraction. Null when the user has no recent RHR. + const since = new Date(now.getTime() - 30 * MS_PER_DAY); + const rhr = await prisma.measurement.findFirst({ + where: { + userId, + type: "RESTING_HEART_RATE" as MeasurementType, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + return { profile, hrRest: rhr?.value ?? null }; +} + +export interface PersistStrainResult { + outcome: "stored" | "insufficient"; + score: number | null; + reason: StrainComputeResult["reason"]; +} + +/** + * Compute + persist one user's Strain score for the `now` day. Upserts on + * the `(userId, type, source, externalId)` key (idempotent per user per + * day). Writes NOTHING when the inputs gate. + */ +export async function persistStrainScore( + prisma: PrismaClient, + userId: string, + now: Date, +): Promise { + const { profile, hrRest } = await loadStrainInputs(prisma, userId, now); + const { score, reason } = await computeStrainScore( + prisma, + userId, + profile, + hrRest, + now, + ); + + if (score === null) { + return { outcome: "insufficient", score: null, reason }; + } + + const externalId = strainExternalId(now); + const measuredAt = strainMeasuredAt(now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId, + type: "STRAIN_SCORE", + source: "COMPUTED", + externalId, + }, + }, + create: { + userId, + type: "STRAIN_SCORE", + source: "COMPUTED", + value: score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: score, + measuredAt, + }, + }); + + return { outcome: "stored", score, reason }; +} diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts new file mode 100644 index 000000000..364f306c9 --- /dev/null +++ b/src/lib/jobs/strain-score.ts @@ -0,0 +1,119 @@ +/** + * v1.10.0 — computed scores (WX-E). Nightly Strain-score computation + store. + * + * Once a night this cron computes each eligible user's Strain score from + * the day's per-workout heart-rate series (`WorkoutSamples`, WX-D) plus + * active-energy burned, via Banister's TRIMP cardio-load model (see + * `src/lib/insights/strain-score.ts`), and persists it as a `COMPUTED + * STRAIN_SCORE` Measurement row. The score is never computed on a page + * visit. + * + * Idempotent per user per day: the per-user persist upserts on + * `(userId, type, source, externalId)` with `externalId = strain:YYYY-MM-DD`, + * so a re-fired cron tick (or a manual re-run) overwrites the day's row in + * place. A user with no usable cardio-load input that day gets NO row. + * + * The queue name MUST be registered in `allQueues` in + * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an + * unregistered queue silently never drains. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; +import { persistStrainScore } from "@/lib/insights/strain-score"; + +export const STRAIN_SCORE_QUEUE = "strain-score-compute"; + +/** + * Daily at 04:55 Europe/Berlin — inside the existing maintenance window, + * after the recovery-score (04:45) + stress-score (04:50) passes so the + * nightly score writes stay ordered and don't pile on one boss poll. + */ +export const STRAIN_SCORE_CRON = "55 4 * * *"; + +/** Per-run user cap — same starvation guard the other score passes use. */ +export const STRAIN_SCORE_BATCH_CAP = 500; + +/** Trailing window (days) a user must have a recent strain-input row in. */ +export const STRAIN_SCORE_RECENCY_DAYS = 2; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface StrainScoreRunResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Discover users worth scoring tonight: anyone with a workout started in the + * recency window OR an `ACTIVE_ENERGY_BURNED` row in it. A user with neither + * cannot produce a Strain score, so skipping them keeps the pass cheap. + * Bounded by `cap`. + */ +export async function findStrainScoreCandidates( + prisma: PrismaClient, + now: Date, + cap: number, +): Promise { + const since = new Date(now.getTime() - STRAIN_SCORE_RECENCY_DAYS * MS_PER_DAY); + const [workoutUsers, energyUsers] = await Promise.all([ + prisma.workout.findMany({ + where: { startedAt: { gte: since } }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }), + prisma.measurement.findMany({ + where: { + type: "ACTIVE_ENERGY_BURNED", + deletedAt: null, + measuredAt: { gte: since }, + }, + select: { userId: true }, + distinct: ["userId"], + take: cap, + }), + ]); + const ids = new Set(); + for (const r of workoutUsers) ids.add(r.userId); + for (const r of energyUsers) ids.add(r.userId); + return Array.from(ids).slice(0, cap); +} + +/** + * Run one Strain-score pass. Pure of pg-boss so the unit test drives it + * directly. Each user is scored independently; a single user's error is + * recorded and the pass continues. + */ +export async function runStrainScore( + prisma: PrismaClient, + options: { now?: Date; cap?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const cap = options.cap ?? STRAIN_SCORE_BATCH_CAP; + + const userIds = await findStrainScoreCandidates(prisma, now, cap); + + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistStrainScore(prisma, userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: "insights.strain.compute", + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +} From c5ba38cebbd1f286309b0ca8662023a66a7e7faa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:48:21 +0200 Subject: [PATCH 26/49] feat(insights): register the stress, strain, and dense-retention jobs Wire the two nightly score passes and the dense intra-day retention drain into the worker. The stress (04:50) and strain (04:55) crons each get a queue, a schedule, and a single-flight handler; the dense-retention drain gets a per-user backfill queue with boot discovery (like mean-consolidation) and folds its steady-state nightly walk onto the existing drain-cumulative tick, after the daily-mean fold. A queue not registered in allQueues silently never drains, so a source-grep guard pins every registration site for all three jobs. --- .../stress-strain-retention-queue.test.ts | 101 ++++++++++ src/lib/jobs/reminder-worker.ts | 174 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts diff --git a/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts b/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts new file mode 100644 index 000000000..4daea0d98 --- /dev/null +++ b/src/lib/jobs/__tests__/stress-strain-retention-queue.test.ts @@ -0,0 +1,101 @@ +/** + * v1.10.0 — computed scores (WX-E). Queue-registration guards for the + * Stress + Strain nightly score jobs and the dense intra-day retention + * drain. + * + * Same source-text-grep approach as the WX-C recovery-score guard: assert + * each queue is imported, registered in `allQueues`, scheduled (the two + * cron-driven score jobs), and wired to a `boss.work` handler — without + * booting pg-boss + Prisma. An unregistered queue silently never drains + * (the recurring past bug this guards against). + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts"); +const source = readFileSync(REMINDER_WORKER_PATH, "utf8"); + +function allQueuesBlock(): string { + const m = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(m).not.toBeNull(); + return m![1]; +} + +describe("reminder-worker — stress-score wiring", () => { + it("imports the queue symbols from the stress-score module", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/stress-score["']/); + expect(source).toMatch(/\bSTRESS_SCORE_QUEUE\b/); + expect(source).toMatch(/\bSTRESS_SCORE_CRON\b/); + expect(source).toMatch(/\brunStressScore\b/); + }); + + it("registers the stress-score queue in allQueues", () => { + expect(allQueuesBlock()).toMatch(/\bSTRESS_SCORE_QUEUE\b/); + }); + + it("schedules the stress-score cron", () => { + expect(source).toMatch(/\[STRESS_SCORE_QUEUE,\s*STRESS_SCORE_CRON\]/); + }); + + it("registers a boss.work handler that runs the stress-score pass", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}STRESS_SCORE_QUEUE[\s\S]{0,400}runStressScore/, + ); + }); +}); + +describe("reminder-worker — strain-score wiring", () => { + it("imports the queue symbols from the strain-score module", () => { + expect(source).toMatch(/from\s*["']@\/lib\/jobs\/strain-score["']/); + expect(source).toMatch(/\bSTRAIN_SCORE_QUEUE\b/); + expect(source).toMatch(/\bSTRAIN_SCORE_CRON\b/); + expect(source).toMatch(/\brunStrainScore\b/); + }); + + it("registers the strain-score queue in allQueues", () => { + expect(allQueuesBlock()).toMatch(/\bSTRAIN_SCORE_QUEUE\b/); + }); + + it("schedules the strain-score cron", () => { + expect(source).toMatch(/\[STRAIN_SCORE_QUEUE,\s*STRAIN_SCORE_CRON\]/); + }); + + it("registers a boss.work handler that runs the strain-score pass", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,200}STRAIN_SCORE_QUEUE[\s\S]{0,400}runStrainScore/, + ); + }); +}); + +describe("reminder-worker — dense intra-day retention wiring", () => { + it("imports the queue symbols from the dense-intraday-retention module", () => { + expect(source).toMatch( + /from\s*["']@\/lib\/jobs\/dense-intraday-retention["']/, + ); + expect(source).toMatch(/\bDENSE_INTRADAY_RETENTION_QUEUE\b/); + expect(source).toMatch(/\brunDenseIntradayRetentionForUser\b/); + expect(source).toMatch(/\benqueueBootTimeDenseIntradayRetention\b/); + }); + + it("registers the dense-intraday-retention queue in allQueues", () => { + expect(allQueuesBlock()).toMatch(/\bDENSE_INTRADAY_RETENTION_QUEUE\b/); + }); + + it("registers a boss.work handler against the dense-intraday-retention queue", () => { + expect(source).toMatch( + /boss\.work[\s\S]{0,250}DENSE_INTRADAY_RETENTION_QUEUE[\s\S]{0,500}runDenseIntradayRetentionForUser/, + ); + }); + + it("fires the boot-discovery enqueue helper", () => { + expect(source).toMatch(/await enqueueBootTimeDenseIntradayRetention\(\)/); + }); + + it("folds the nightly dense-retention walk onto the drain-cumulative tick", () => { + // The steady-state nightly walk runs the global (no-userId) pass on the + // same tick as the cumulative + mean drains. + expect(source).toMatch(/runDenseIntradayRetention\(\s*getWorkerPrisma\(\)/); + }); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 4bf3ad5ad..86bf7ebb1 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -73,6 +73,24 @@ import { RECOVERY_SCORE_CRON, runRecoveryScore, } from "@/lib/jobs/recovery-score"; +import { + STRESS_SCORE_QUEUE, + STRESS_SCORE_CRON, + runStressScore, +} from "@/lib/jobs/stress-score"; +import { + STRAIN_SCORE_QUEUE, + STRAIN_SCORE_CRON, + runStrainScore, +} from "@/lib/jobs/strain-score"; +import { + DENSE_INTRADAY_RETENTION_QUEUE, + DENSE_INTRADAY_RETENTION_CONCURRENCY, + runDenseIntradayRetentionForUser, + enqueueBootTimeDenseIntradayRetention, + type DenseIntradayRetentionPayload, +} from "@/lib/jobs/dense-intraday-retention"; +import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; import { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_GENERATE_CONCURRENCY, @@ -2138,6 +2156,20 @@ export async function startReminderWorker() { // per eligible user. The queue MUST be registered here or pg-boss never // provisions it and the 04:45 schedule silently never fires. RECOVERY_SCORE_QUEUE, + // v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived + // proxy) compute + store. Same createQueue contract as the recovery + // score; without this entry the 04:50 schedule silently never fires. + STRESS_SCORE_QUEUE, + // v1.10.0 — computed scores (WX-E). Nightly Strain-score (Banister TRIMP + // cardio-load) compute + store. Same createQueue contract; without this + // entry the 04:55 schedule silently never fires. + STRAIN_SCORE_QUEUE, + // v1.10.0 — computed scores (WX-E). Dense intra-day retention drain of + // daytime HRV / HR samples (per-user backfill queue, boot-discovery + // driven like mean-consolidation; the steady-state nightly walk folds + // onto the drain-cumulative tick). The queue MUST be registered here or + // the boot enqueue silently never drains. + DENSE_INTRADAY_RETENTION_QUEUE, ]; for (const q of allQueues) { @@ -2232,6 +2264,14 @@ export async function startReminderWorker() { // Recovery-score compute + store, after the rollup-feeding consolidation // + drain so the signals it reads are already folded. [RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON], + // v1.10.0 — computed scores (WX-E). Nightly 04:50 Europe/Berlin + // Stress-score compute + store, after the dense intra-day retention + // drain so the HRV inputs it reads are settled. + [STRESS_SCORE_QUEUE, STRESS_SCORE_CRON], + // v1.10.0 — computed scores (WX-E). Nightly 04:55 Europe/Berlin + // Strain-score compute + store, after the recovery + stress passes so + // the nightly score writes stay ordered. + [STRAIN_SCORE_QUEUE, STRAIN_SCORE_CRON], ]; for (const [name, cron] of schedules) { @@ -2410,6 +2450,48 @@ export async function startReminderWorker() { } }, ); + // v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived + // proxy) compute + store. Single-flight so two ticks never double-walk + // the cohort. The runner iterates every eligible user and upserts one + // `COMPUTED STRESS_SCORE` row per scored day (idempotent — a re-fire + // overwrites in place). + await boss.work( + STRESS_SCORE_QUEUE, + { localConcurrency: 1 }, + async () => { + try { + const summary = await runStressScore(getWorkerPrisma()); + workerLog( + "info", + `[stress-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[stress-score] pass failed", err); + throw err; + } + }, + ); + // v1.10.0 — computed scores (WX-E). Nightly Strain-score (Banister TRIMP + // cardio-load) compute + store. Single-flight; upserts one `COMPUTED + // STRAIN_SCORE` row per scored day (idempotent). + await boss.work( + STRAIN_SCORE_QUEUE, + { localConcurrency: 1 }, + async () => { + try { + const summary = await runStrainScore(getWorkerPrisma()); + workerLog( + "info", + `[strain-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`, + ); + } catch (err) { + recordError(); + workerLog("error", "[strain-score] pass failed", err); + throw err; + } + }, + ); // v1.8.3 — on-demand per-metric status generation enqueued by the // read-only status route on a cold card. Low concurrency so a first // visit that cold-misses several cards can't saturate the Prisma pool @@ -2527,6 +2609,38 @@ export async function startReminderWorker() { }, ); + // v1.10.0 WX-E — dense intra-day retention per-user backfill worker. The + // boot enqueue helper below sends one job per user holding live per-sample + // dense-tier (HRV / HR) rows older than the retention window; this handler + // folds those out-of-window samples to a daily mean and soft-deletes the + // originals, keeping the in-window intra-day shape intact for the Stress + // engine. Serial concurrency so the backfill never crowds the request pool. + await boss.work( + DENSE_INTRADAY_RETENTION_QUEUE, + { localConcurrency: DENSE_INTRADAY_RETENTION_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + try { + const { daysConsolidated, perSampleRowsSoftDeleted } = + await runDenseIntradayRetentionForUser(userId); + workerLog( + "info", + `[dense-intraday-retention] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[dense-intraday-retention] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + // v1.8.2 — duplicate dose-slot cleanup worker. The boot enqueue helper // below sends one job per user holding two live intake rows that snap // to the same canonical slot; this handler keeps the winner, soft- @@ -2693,6 +2807,38 @@ export async function startReminderWorker() { recordError(); workerLog("error", "[mean-consolidation] nightly run failed", err); } + + // v1.10.0 WX-E — fold the dense intra-day retention drain onto the + // same nightly tick. Unlike the daily-mean drain, this scopes to the + // dense-tier types (HEART_RATE_VARIABILITY, PULSE) and keeps the last + // DENSE_INTRADAY_RETENTION_DAYS of raw per-sample rows so the Stress + // engine still sees the intra-day SDNN shape; only out-of-window + // samples fold to a daily mean. These two types are NEVER in the + // destructive HIGH_FREQUENCY_MEAN_TYPES allowlist — the drain + // exemption the intra-day shape depends on. The global (no `userId`) + // signature drains every user. Boot discovery (below) back-fills + // accounts that accumulated out-of-window raw rows before this + // shipped. + try { + const denseSummary = await runDenseIntradayRetention( + getWorkerPrisma(), + { + dryRun: false, + log: (line) => workerLog("info", line), + }, + ); + workerLog( + "info", + `[dense-intraday-retention] triggeredAt=${job.data.triggeredAt} usersScanned=${denseSummary.totals.usersScanned} daysConsolidated=${denseSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${denseSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${denseSummary.totals.dailyRowsUpserted}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + "[dense-intraday-retention] nightly run failed", + err, + ); + } } }, ); @@ -2789,6 +2935,34 @@ export async function startReminderWorker() { ); } + // v1.10.0 WX-E — fire-and-forget boot discovery for the dense intra-day + // retention drain. Finds every user holding live per-sample dense-tier + // (HRV / HR) rows OLDER than the retention window and enqueues one job per + // account. Idempotent across reboots: folded rows are soft-deleted, so the + // discovery predicate drops them. Errors are returned through the helper's + // result value — the worker boot never fails on a miss. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeDenseIntradayRetention(); + if (error) { + workerLog( + "error", + `[dense-intraday-retention] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[dense-intraday-retention] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[dense-intraday-retention] boot discovery threw an unexpected error", + err, + ); + } + // v1.8.2 — fire-and-forget boot discovery for the duplicate dose-slot // cleanup. Finds every user holding two live intake rows within the // drift window on the same medication and enqueues one dedup job per From 02c50acd46e2a75a2393941f229f178f627be9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:55:53 +0200 Subject: [PATCH 27/49] feat(insights): unify the derived-metric query hook onto one factory key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel loaders for the generic derived-metric route had grown — a components-local hook keyed on a 4-element tuple and a hooks/ hook keyed on a 3-or-4-element tuple. Both shared the ["insights","derived",metric,…] prefix with different tuple shapes, which silently poisons the TanStack cache between surfaces. Collapse to the single components-local hook (auth-gated, 8 s client ceiling, retry-free) on the one insightsDerived factory key; the fourth slot is always present so the metric prefix can no longer carry two shapes. --- .../derived/composite-score-anatomy.tsx | 2 +- .../insights/derived/use-derived-metric.ts | 57 ++++++++++----- src/hooks/use-derived-metric.ts | 72 ------------------- src/lib/query-keys.ts | 29 +++----- 4 files changed, 52 insertions(+), 108 deletions(-) delete mode 100644 src/hooks/use-derived-metric.ts diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index 09c9bcadb..b6fb4ac7e 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; -import { useDerivedMetric } from "@/hooks/use-derived-metric"; +import { useDerivedMetric } from "./use-derived-metric"; import { Skeleton } from "@/components/ui/skeleton"; import type { ReadinessValue, diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts index 2230fb180..9d686b9e2 100644 --- a/src/components/insights/derived/use-derived-metric.ts +++ b/src/components/insights/derived/use-derived-metric.ts @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import type { DerivedCoverage, @@ -9,14 +10,22 @@ import type { } from "@/lib/insights/derived/types"; /** - * v1.10.0 — TanStack Query hook for the generic derived-metric route. + * v1.10.0 — the single TanStack Query hook for the generic derived-metric + * route, shared by every client surface (vitals dashboard tiles, composite + * score-anatomy, the home wellness strip). * * `GET /api/insights/derived?metric=[&type=]` returns the - * compute-once flattened `Derived` the W2b dashboard tiles + per-metric - * detail cards read. The queryKey comes from the centralised factory - * (`queryKeys.insightsDerived`) so the bare-array ESLint rule stays - * satisfied and the cache never poisons across metrics; the read unwraps - * `(await res.json()).data` per the envelope convention. + * compute-once flattened `Derived` the surfaces read. The queryKey comes + * from the centralised factory (`queryKeys.insightsDerived`) so the bare-array + * ESLint rule stays satisfied and the cache never poisons across metrics; the + * read unwraps `(await res.json()).data` per the envelope convention. + * + * Pure compute on the server (sub-second on a warm rollup tenant) — so the + * hook bounds the fetch on an 8 s client ceiling (a slow response means a + * degraded network, not a provider call), keeps a 60 s `staleTime`, gates on + * auth, and never inline-retries. These reads NEVER warm/generate on visit — + * they read already-computed rollup values (the v1.4.36 / v1.9.1 lesson: a + * warm-on-mount fan-out looks like the app hanging). * * Type-only import of the `Derived` facets — no server module enters the * client bundle (the v1.9.0 lesson). The value `T` is left generic so each @@ -24,6 +33,8 @@ import type { * imported as `import type`. */ +const DERIVED_TIMEOUT_MS = 8_000; + /** The flattened wire shape the route emits in the `data` envelope slot. */ export interface DerivedMetricResponse { metric: string; @@ -38,7 +49,7 @@ export interface DerivedMetricResponse { export interface UseDerivedMetricOptions { /** Optional sub-target (the chosen vital for `VITALS_BASELINE`). */ type?: string | null; - /** Gate the fetch (e.g. on auth). Defaults to enabled. */ + /** Gate the fetch (e.g. on a surface flag). Defaults to enabled. */ enabled?: boolean; } @@ -47,21 +58,33 @@ export function useDerivedMetric( options: UseDerivedMetricOptions = {}, ) { const { type = null, enabled = true } = options; + const { isAuthenticated } = useAuth(); + const gated = enabled && isAuthenticated && metric !== ""; + return useQuery({ queryKey: queryKeys.insightsDerived(metric, type), queryFn: async (): Promise> => { - const params = new URLSearchParams({ metric }); - if (type) params.set("type", type); - const res = await fetch(`/api/insights/derived?${params.toString()}`); - if (!res.ok) { - throw new Error(`derived ${metric} request failed (${res.status})`); + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metric }); + if (type) params.set("type", type); + const res = await fetch(`/api/insights/derived?${params.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`derived ${metric} request failed (${res.status})`); + } + return (await res.json()).data as DerivedMetricResponse; + } finally { + clearTimeout(timeoutHandle); } - return (await res.json()).data as DerivedMetricResponse; }, - enabled, - // Pure compute over the rollup tier — cheap to recompute, but the - // numbers only move on a fresh ingest. A short stale window keeps the - // dashboard snappy without hammering the route on every focus. + enabled: gated, staleTime: 60_000, + retry: 0, }); } diff --git a/src/hooks/use-derived-metric.ts b/src/hooks/use-derived-metric.ts deleted file mode 100644 index 2da6959dd..000000000 --- a/src/hooks/use-derived-metric.ts +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { useAuth } from "@/hooks/use-auth"; -import { queryKeys } from "@/lib/query-keys"; -import type { - DerivedConfidence, - DerivedCoverage, - DerivedProvenance, -} from "@/lib/insights/derived/types"; - -/** - * v1.10.0 — client loader for one derived wellness metric. - * - * Reads the flat `Derived` envelope off `/api/insights/derived?metric=…` - * (the same shape the score-anatomy view + iOS decode). Pure compute on the - * server — sub-second on a warm rollup tenant — so the hook bounds the fetch - * on an 8 s client ceiling (a slow response means a degraded network, not a - * provider call), keeps a 60 s `staleTime`, and never inline-retries. - * - * `T` is the metric-specific value shape (e.g. `ReadinessValue`, - * `SleepScoreValue`); the caller supplies it so the anatomy view stays - * strongly typed without a second cast. - */ - -const DERIVED_TIMEOUT_MS = 8_000; - -/** The flat wire shape the derived route returns inside `{ data }`. */ -export interface DerivedMetricEnvelope { - metric: string; - status: "ok" | "insufficient"; - value: T | null; - coverage: DerivedCoverage; - confidence: DerivedConfidence | null; - provenance: DerivedProvenance; - reason: string | null; -} - -export function useDerivedMetric( - metric: string, - options: { type?: string; enabled?: boolean } = {}, -) { - const { isAuthenticated } = useAuth(); - const enabled = (options.enabled ?? true) && isAuthenticated && metric !== ""; - - return useQuery({ - queryKey: queryKeys.insightsDerivedMetric(metric, options.type), - queryFn: async (): Promise> => { - const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - DERIVED_TIMEOUT_MS, - ); - try { - const params = new URLSearchParams({ metric }); - if (options.type) params.set("type", options.type); - const res = await fetch(`/api/insights/derived?${params.toString()}`, { - signal: controller.signal, - }); - if (!res.ok) throw new Error("Failed"); - const json = (await res.json()) as { data: DerivedMetricEnvelope }; - return json.data; - } finally { - clearTimeout(timeoutHandle); - } - }, - enabled, - staleTime: 60 * 1000, - retry: 0, - }); -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 8504391d0..b6c8af25a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -144,27 +144,20 @@ export const queryKeys = { /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals - * dashboard tiles + per-metric detail cards, so the key is - * parameterised by the derived-metric id and the optional sub-type - * (the chosen vital for `VITALS_BASELINE`). Pure compute, locale-free - * (numbers + bands; surfaces localise their own prose), so no locale - * segment — unlike the narrative `*-status` keys above. + * dashboard tiles, the composite score-anatomy view, and the home + * wellness strip — all through the one `useDerivedMetric` hook. + * + * One generic route backs every derived metric (sleep score, readiness, + * coincident-deviation, vitals baseline, …); the optional sub-type is the + * chosen vital for `VITALS_BASELINE`. The fourth tuple slot is always + * present (`null` when no sub-type) so the cache never poisons between a + * 3-element and a 4-element shape for the same metric prefix. Pure compute + * over the rollup tier — a measurement write keeps it fresh by construction + * (next read sees new buckets), so the `["insights"]` prefix carries the + * invalidation fan-out. */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, - /** - * v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`). - * One generic route backs every derived composite (sleep score, - * readiness, coincident-deviation, vitals baseline, …) so the key is - * parameterised by the metric id alongside the optional sub-type. Pure - * compute over the rollup tier — a measurement write keeps it fresh by - * construction (next read sees new buckets), so the `["insights"]` prefix - * is enough for invalidation fan-out. - */ - insightsDerivedMetric: (metric: string, type?: string) => - type - ? (["insights", "derived", metric, type] as const) - : (["insights", "derived", metric] as const), /** * v1.10.0 — FDR-controlled correlation discovery * (`/api/insights/correlations`). Read-only descriptive surface; the From a8daef7376b51dfa3e15690e22baf0c211e2f9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:58:52 +0200 Subject: [PATCH 28/49] feat(insights): read the persisted wellness scores through the derived contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE as derived metrics that read the nightly-persisted COMPUTED Measurement rows — a passthrough, never a recompute. The dispatcher routes the three ids to a thin reader that returns the latest score, a trailing-window trend, and a direction-aware band (stress and strain invert, so a high stress reads red). This lets every surface — the dashboard, the Coach snapshot, the doctor report — consume the scores through the one Derived route + hook rather than each growing its own read path. Regenerate the OpenAPI enum, which picks up the three ids from the registry. --- docs/api/openapi.yaml | 6 + .../derived/__tests__/dispatch.test.ts | 8 +- .../derived/__tests__/wellness-scores.test.ts | 80 +++++++++ src/lib/insights/derived/dispatch.ts | 15 ++ src/lib/insights/derived/index.ts | 13 ++ src/lib/insights/derived/registry.ts | 35 +++- src/lib/insights/derived/wellness-scores.ts | 153 ++++++++++++++++++ 7 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/wellness-scores.test.ts create mode 100644 src/lib/insights/derived/wellness-scores.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8fbe742ce..63590ac05 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2445,6 +2445,9 @@ paths: - COINCIDENT_DEVIATION - HRV_BALANCE - BMI + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE description: "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`." @@ -7592,6 +7595,9 @@ components: - COINCIDENT_DEVIATION - HRV_BALANCE - BMI + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE description: Echoes the requested derived-metric id (tags the union). status: type: string diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 2f595b115..8e0c1b987 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -36,6 +36,9 @@ describe("registry", () => { expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true); expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true); expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true); + expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -53,7 +56,10 @@ describe("registry", () => { expect(DERIVED_METRIC_IDS).toContain("READINESS"); expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE"); expect(DERIVED_METRIC_IDS).toContain("BMI"); - expect(DERIVED_METRIC_IDS.length).toBe(8); + expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE"); + expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE"); + expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE"); + expect(DERIVED_METRIC_IDS.length).toBe(11); }); }); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts new file mode 100644 index 000000000..438b67f9c --- /dev/null +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { + computeWellnessScore, + bandWellnessScore, + type WellnessScoreValue, +} from "../wellness-scores"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const findMany = prisma.measurement.findMany as ReturnType; + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("bandWellnessScore", () => { + it("higher is better for recovery", () => { + expect(bandWellnessScore("RECOVERY_SCORE", 80)).toBe("green"); + expect(bandWellnessScore("RECOVERY_SCORE", 50)).toBe("yellow"); + expect(bandWellnessScore("RECOVERY_SCORE", 20)).toBe("red"); + }); + + it("higher is worse for stress (band inverts)", () => { + expect(bandWellnessScore("STRESS_SCORE", 80)).toBe("red"); + expect(bandWellnessScore("STRESS_SCORE", 50)).toBe("yellow"); + expect(bandWellnessScore("STRESS_SCORE", 20)).toBe("green"); + }); +}); + +describe("computeWellnessScore", () => { + it("returns insufficient with no_score_in_window when the job hasn't run", async () => { + findMany.mockResolvedValue([]); + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.reason).toBe("no_score_in_window"); + } + }); + + it("reads the latest persisted score and a trailing trend", async () => { + findMany.mockResolvedValue([ + { value: 72, measuredAt: new Date("2026-06-02T06:00:00Z") }, + { value: 60, measuredAt: new Date("2026-06-01T06:00:00Z") }, + { value: 64, measuredAt: new Date("2026-05-31T06:00:00Z") }, + ]); + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + const v = r.value as WellnessScoreValue; + expect(v.score).toBe(72); + expect(v.band).toBe("green"); + // 72 - mean(60, 64) = 72 - 62 = 10 + expect(v.trendDelta).toBe(10); + expect(v.daysInWindow).toBe(3); + } + }); + + it("null trend when only one score exists", async () => { + findMany.mockResolvedValue([ + { value: 40, measuredAt: new Date("2026-06-02T06:00:00Z") }, + ]); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).trendDelta).toBeNull(); + } + }); +}); diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index d09346df2..a5080a56a 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -29,6 +29,10 @@ import { computeBmi } from "./bmi"; import { computeSleepScore } from "./sleep-score"; import { computeReadiness } from "./readiness"; import { computeCoincidentDeviation } from "./coincident-deviation"; +import { + computeWellnessScore, + type WellnessScoreType, +} from "./wellness-scores"; import type { Derived } from "./types"; export interface DerivedComputeArgs { @@ -142,6 +146,17 @@ export async function computeDerivedMetric( windowDays: args.windowDays, now, }) as Promise>; + case "RECOVERY_SCORE": + case "STRESS_SCORE": + case "STRAIN_SCORE": + // Passthrough read of the nightly-persisted COMPUTED rows — never a + // recompute. The same `Derived` contract carries the scores. + return computeWellnessScore( + args.metric as WellnessScoreType, + args.userId, + args.profile, + { windowDays: args.windowDays, now }, + ) as Promise>; default: // Registered + implemented but no dispatch arm — treat as a stub. return notImplemented(meta.inputs.map(String), now); diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index d0db86158..30807e1e4 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -130,3 +130,16 @@ export type { CoincidentDeviationOpts, VitalDeviation, } from "./coincident-deviation"; + +// ── persisted nightly wellness scores (passthrough read) ───────────── +export { + computeWellnessScore, + bandWellnessScore, + WELLNESS_SCORE_TYPES, +} from "./wellness-scores"; +export type { + WellnessScoreValue, + WellnessScoreBand, + WellnessScoreType, + WellnessScoreOpts, +} from "./wellness-scores"; diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index e14c5a5b5..73620d5ef 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -40,7 +40,13 @@ export type DerivedMetricId = /** W2: HRV (SDNN) personal-trend balance band (reuses the baseline engine). */ | "HRV_BALANCE" /** W2: BMI from weight + height (fallback when no device BMI). */ - | "BMI"; + | "BMI" + /** Persisted nightly recovery composite (passthrough read of COMPUTED rows). */ + | "RECOVERY_SCORE" + /** Persisted nightly stress composite (passthrough read of COMPUTED rows). */ + | "STRESS_SCORE" + /** Persisted nightly strain composite (passthrough read of COMPUTED rows). */ + | "STRAIN_SCORE"; /** Archetype of a derived metric — drives shaping + the QA inventory. */ export type DerivedArchetype = @@ -177,6 +183,33 @@ const REGISTRY: Record = { minInputs: 2, implemented: true, }, + RECOVERY_SCORE: { + id: "RECOVERY_SCORE", + displayName: "Recovery score", + archetype: "passthrough-reframe", + inputs: ["RECOVERY_SCORE"], + minHistoryDays: 1, + minInputs: 1, + implemented: true, + }, + STRESS_SCORE: { + id: "STRESS_SCORE", + displayName: "Stress score", + archetype: "passthrough-reframe", + inputs: ["STRESS_SCORE"], + minHistoryDays: 1, + minInputs: 1, + implemented: true, + }, + STRAIN_SCORE: { + id: "STRAIN_SCORE", + displayName: "Strain score", + archetype: "passthrough-reframe", + inputs: ["STRAIN_SCORE"], + minHistoryDays: 1, + minInputs: 1, + implemented: true, + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts new file mode 100644 index 000000000..7aa7e460f --- /dev/null +++ b/src/lib/insights/derived/wellness-scores.ts @@ -0,0 +1,153 @@ +/** + * v1.10.0 — passthrough read of the persisted nightly wellness scores + * (`RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE`). + * + * These three 0–100 composites are NOT recomputed here. A nightly job + * (`src/lib/jobs/{recovery,stress,strain}-score.ts`) writes them as + * `COMPUTED`-source Measurement rows; this engine simply reads the most + * recent persisted value plus a short trend so the SAME `Derived` + * contract every other derived metric speaks also carries the scores. No + * surface ever recomputes — the dashboard, Coach, and doctor report all + * pattern-match the `Derived` this returns. + * + * Server-only — reads `@/lib/db`. + */ +import { prisma } from "@/lib/db"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived } from "./types"; + +/** A 0–100 wellness score band. Higher is better for recovery; for stress a + * higher score is worse, so the band direction flips (see `WELLNESS_DIR`). */ +export type WellnessScoreBand = "green" | "yellow" | "red"; + +export interface WellnessScoreValue { + /** The latest persisted 0–100 score. */ + score: number; + band: WellnessScoreBand; + /** Score minus the trailing-window mean (excluding today), or null. */ + trendDelta: number | null; + /** Distinct days with a score in the window — drives the trend confidence. */ + daysInWindow: number; + /** ISO timestamp of the latest score's `measuredAt`. */ + asOf: string; +} + +/** The three persisted score types this engine serves. */ +export const WELLNESS_SCORE_TYPES = { + RECOVERY_SCORE: "RECOVERY_SCORE", + STRESS_SCORE: "STRESS_SCORE", + STRAIN_SCORE: "STRAIN_SCORE", +} as const; + +export type WellnessScoreType = keyof typeof WELLNESS_SCORE_TYPES; + +/** `true` when a higher score is the healthier direction. Stress + strain + * invert (more = worse / harder), so the band flips for them. */ +const HIGHER_IS_BETTER: Record = { + RECOVERY_SCORE: true, + STRESS_SCORE: false, + STRAIN_SCORE: false, +}; + +/** Band a 0–100 score honouring the metric's direction. */ +export function bandWellnessScore( + type: WellnessScoreType, + score: number, +): WellnessScoreBand { + const good = HIGHER_IS_BETTER[type] + ? score + : // Invert for stress/strain so 80 stress reads red, not green. + 100 - score; + if (good >= 70) return "green"; + if (good >= 40) return "yellow"; + return "red"; +} + +export interface WellnessScoreOpts { + /** Trailing window for the trend mean (days). Defaults to 14. */ + windowDays?: number; + now?: Date; +} + +/** + * Read the latest persisted wellness score + a trailing trend. Returns + * `insufficient` with `reason: "no_score_in_window"` when the nightly job + * has not yet written one (e.g. a brand-new account, or no underlying + * signals) — never a fabricated value. + */ +export async function computeWellnessScore( + type: WellnessScoreType, + userId: string, + _profile: BaselineProfile, + opts: WellnessScoreOpts = {}, +): Promise> { + const now = opts.now ?? new Date(); + const windowDays = opts.windowDays ?? 14; + const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const computedAt = nowProvenanceTimestamp(now); + const measurementType = WELLNESS_SCORE_TYPES[type] as MeasurementType; + + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: measurementType, + source: "COMPUTED", + deletedAt: null, + measuredAt: { gte: cutoff, lte: now }, + }, + select: { value: true, measuredAt: true }, + orderBy: { measuredAt: "desc" }, + }); + + if (rows.length === 0) { + return buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [type], + }, + provenance: { inputs: [type], source: "none", windowDays, computedAt }, + reason: "no_score_in_window", + }); + } + + const latest = rows[0]; + const score = Math.round(latest.value); + // Trend = latest vs the mean of the prior rows in the window. + const prior = rows.slice(1); + const trendDelta = + prior.length > 0 + ? Math.round( + score - prior.reduce((s, r) => s + r.value, 0) / prior.length, + ) + : null; + + return buildOk({ + value: { + score, + band: bandWellnessScore(type, score), + trendDelta, + daysInWindow: rows.length, + asOf: latest.measuredAt.toISOString(), + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + }, + // The score is a persisted, already-computed composite — high + // confidence by construction; the trailing-day count carries the + // trend's strength via the coverage meter rather than the band. + confidence: { score: 90, band: "high" }, + provenance: { + inputs: [type], + source: "DAY", + windowDays, + computedAt, + }, + }); +} From d9405e17b9bf90a7d4066d52871d05be500098be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:00:41 +0200 Subject: [PATCH 29/49] feat(dashboard): surface the wellness scores as a data-gated ring strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a wellness-scores section to the vitals dashboard: a row of score rings for Readiness, Recovery, Sleep, Stress, and Strain. Every ring is data-availability-gated — a score with no underlying data simply does not render, so the strip never shows an apologetic empty ring. Readiness and Sleep are tappable and route to their existing score-anatomy detail pages; the persisted nightly scores render read-only. Every ring reads the one /api/insights/derived route through the shared useDerivedMetric hook. --- 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/components/insights/derived/index.ts | 1 + .../insights/derived/vitals-dashboard.tsx | 48 ++--- .../insights/derived/wellness-scores.tsx | 174 ++++++++++++++++++ 9 files changed, 237 insertions(+), 22 deletions(-) create mode 100644 src/components/insights/derived/wellness-scores.tsx diff --git a/messages/de.json b/messages/de.json index f24eb7f65..851afd6a4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2576,6 +2576,12 @@ "mood": "Stimmungsstabilität" } } + }, + "scores": { + "sectionTitle": "Wellness-Werte", + "recovery": "Erholung", + "stress": "Stress", + "strain": "Belastung" } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index d0cf7cd26..c13a39e48 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2576,6 +2576,12 @@ "mood": "Mood stability" } } + }, + "scores": { + "sectionTitle": "Wellness scores", + "recovery": "Recovery", + "stress": "Stress", + "strain": "Strain" } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index f37f81050..2b5f9085e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2576,6 +2576,12 @@ "mood": "Estabilidad del ánimo" } } + }, + "scores": { + "sectionTitle": "Puntuaciones de bienestar", + "recovery": "Recuperación", + "stress": "Estrés", + "strain": "Esfuerzo" } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index df15b2e71..9e5eb8456 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2576,6 +2576,12 @@ "mood": "Stabilité de l’humeur" } } + }, + "scores": { + "sectionTitle": "Scores de bien-être", + "recovery": "Récupération", + "stress": "Stress", + "strain": "Effort" } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index ca367ad3b..9131ec561 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2576,6 +2576,12 @@ "mood": "Stabilità dell’umore" } } + }, + "scores": { + "sectionTitle": "Punteggi di benessere", + "recovery": "Recupero", + "stress": "Stress", + "strain": "Sforzo" } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index c39329307..cf5ffd105 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2576,6 +2576,12 @@ "mood": "Stabilność nastroju" } } + }, + "scores": { + "sectionTitle": "Wyniki dobrostanu", + "recovery": "Regeneracja", + "stress": "Stres", + "strain": "Obciążenie" } }, "cardioFitness": { diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index a86a06aea..9fe957b53 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -35,6 +35,7 @@ export { // ── W2b vitals dashboard surface + query hook ──────────────────────── export { VitalsDashboard } from "./vitals-dashboard"; +export { WellnessScores } from "./wellness-scores"; export { useDerivedMetric, type DerivedMetricResponse, diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 1f33cc834..d1ec79f6c 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -11,6 +11,7 @@ import { } from "@/components/measurements/measurement-list-meta"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; +import { WellnessScores } from "./wellness-scores"; import { useDerivedMetric } from "./use-derived-metric"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. @@ -286,28 +287,31 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { const vitals = useMemo(() => SECTION_VITALS, []); return ( -
    -

    - {t("insights.derived.vitals.sectionTitle")} -

    -
    + +
    - - - - - {vitals - .filter((type) => type !== "HEART_RATE_VARIABILITY") - .map((type) => ( - - ))} -
    -
    +

    + {t("insights.derived.vitals.sectionTitle")} +

    +
    + + + + + {vitals + .filter((type) => type !== "HEART_RATE_VARIABILITY") + .map((type) => ( + + ))} +
    + +
    ); } diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx new file mode 100644 index 000000000..9b91b09b7 --- /dev/null +++ b/src/components/insights/derived/wellness-scores.tsx @@ -0,0 +1,174 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { ScoreRing } from "./score-ring"; +import { useDerivedMetric } from "./use-derived-metric"; +import type { ScoreBand } from "./band-tokens"; +// Type-only — the compute payloads never drag the server graph into the bundle. +import type { ReadinessValue } from "@/lib/insights/derived/readiness"; +import type { SleepScoreValue } from "@/lib/insights/derived/sleep-score"; +import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores"; + +/** + * v1.10.0 — the wellness-scores strip. + * + * A row of score rings for the composite + persisted wellness scores, each + * data-availability-gated: a score the user has no data for simply does not + * render (never an apologetic empty ring). The two composites with a detail + * surface (Readiness, Sleep) are tappable and route to their existing + * `ScoreAnatomyView` page (`/insights/scores/`); the persisted nightly + * scores (Recovery, Stress, Strain) render as read-only rings. + * + * Every ring reads the SAME `/api/insights/derived` route through the one + * `useDerivedMetric` hook — no per-screen recompute, no warm-on-mount fan-out + * (the reads are already-computed rollup/persisted values). + */ + +interface ScoreTileProps { + enabled: boolean; +} + +/** A score ring optionally wrapped in a link to its anatomy detail page. */ +function RingTile({ + score, + band, + label, + href, + metricSlot, +}: { + score: number; + band: ScoreBand; + label: string; + href?: string; + metricSlot: string; +}) { + const ring = ( +
    + +
    + ); + if (href) { + return ( + + {ring} + + ); + } + return ( +
    + {ring} +
    + ); +} + +function ReadinessTile({ enabled }: ScoreTileProps) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("READINESS", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +function SleepTile({ enabled }: ScoreTileProps) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric("SLEEP_SCORE", { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +function PersistedScoreTile({ + metric, + labelKey, + enabled, +}: { + metric: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE"; + labelKey: string; + enabled: boolean; +}) { + const { t } = useTranslations(); + const { data, isLoading } = useDerivedMetric(metric, { + enabled, + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + return ( + + ); +} + +export function WellnessScores({ + enabled = true, + className, +}: { + enabled?: boolean; + className?: string; +}) { + const { t } = useTranslations(); + return ( +
    +

    + {t("insights.derived.scores.sectionTitle")} +

    +
    + + + + + +
    +
    + ); +} From 3487bdaec89446dcebadfb81d304b0e1172cc20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:04:28 +0200 Subject: [PATCH 30/49] feat(coach): fold the derived wellness layer into the snapshot as compact summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "derived" block to the Coach snapshot: one tiny object per high-signal derived metric (readiness, recovery, sleep, stress, strain, HRV balance, cardio-fitness band, vascular-age delta) carrying value + band + confidence + historyDays — never the raw series. A fully-populated block is ~250 tokens, comfortably inside the snapshot budget. Insufficient metrics are omitted so the model never sees no-data noise; each metric's compute is fault-isolated so a transient failure on one never sinks the turn. Gated on the underlying signals staying in scope. The system prompt now documents the block and frames the bands as descriptive composites, not clinical findings. --- .../coach/__tests__/derived-snapshot.test.ts | 75 ++++++++++ src/lib/ai/coach/derived-snapshot.ts | 128 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 41 ++++++ src/lib/ai/coach/system-prompt.ts | 11 ++ 4 files changed, 255 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/derived-snapshot.test.ts create mode 100644 src/lib/ai/coach/derived-snapshot.ts diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts new file mode 100644 index 000000000..a43175f29 --- /dev/null +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, computeDerivedMetric: vi.fn() }; +}); + +import { computeDerivedMetric } from "@/lib/insights/derived"; +import { buildDerivedSnapshotBlock } from "../derived-snapshot"; + +const compute = computeDerivedMetric as unknown as ReturnType; +const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; +const NOW = new Date("2026-06-02T08:00:00Z"); + +function ok(value: Record, historyDays = 14) { + return { + status: "ok" as const, + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays, missing: [] }, + confidence: { score: 88, band: "high" as const }, + provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" }, + }; +} +const insufficient = { + status: "insufficient" as const, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" }, + reason: "no_score_in_window", +}; + +beforeEach(() => compute.mockReset()); + +describe("buildDerivedSnapshotBlock", () => { + it("returns null when every metric is insufficient", async () => { + compute.mockResolvedValue(insufficient); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).toBeNull(); + }); + + it("emits compact value + band + confidence per ok metric, omits insufficient", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); + if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).not.toBeNull(); + expect(Object.keys(block!).sort()).toEqual(["READINESS", "RECOVERY_SCORE"]); + expect(block!.READINESS).toEqual({ + value: 64, + band: "yellow", + confidence: 88, + historyDays: 14, + }); + // No raw series leaked — only the four compact facets. + expect(Object.keys(block!.READINESS).sort()).toEqual([ + "band", + "confidence", + "historyDays", + "value", + ]); + }); + + it("isolates a per-metric compute failure", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") throw new Error("boom"); + if (args?.metric === "SLEEP_SCORE") return ok({ score: 80, band: "green" }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).toEqual({ + SLEEP_SCORE: { value: 80, band: "green", confidence: 88, historyDays: 14 }, + }); + }); +}); diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts new file mode 100644 index 000000000..dc78d673d --- /dev/null +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -0,0 +1,128 @@ +/** + * v1.10.0 — derived-metrics block for the Coach prompt. + * + * Folds the v1.10 derived wellness layer into the Coach snapshot as + * COMPACT summaries — one small object per metric carrying value + band + + * coverage, never the raw series. The Coach narrates the same numbers the + * dashboard rings render (single source of truth) without ballooning the + * ~6k-token snapshot budget: a fully-populated block is ~8 tiny objects. + * + * Every entry reads the one `computeDerivedMetric` contract — no recompute. + * A metric whose value is `insufficient` is OMITTED entirely (we never feed + * the model "no data" noise; the snapshot's compactSections pass would drop + * an empty key anyway). + * + * Server-only — calls the derived dispatcher, which reads `@/lib/db`. + */ +import { + computeDerivedMetric, + isDerivedOk, + type DerivedMetricId, + type BaselineProfile, +} from "@/lib/insights/derived"; + +/** The high-signal derived metrics worth a Coach prompt slot. The vitals + * baseline is omitted — the per-vital aggregate block already carries those + * numbers; here we add the composites + scores the aggregates can't express. */ +const SNAPSHOT_METRICS: DerivedMetricId[] = [ + "READINESS", + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", + "SLEEP_SCORE", + "HRV_BALANCE", + "FITNESS_AGE", + "VASCULAR_AGE_DELTA", +]; + +/** One compact line per derived metric the model can ground a reply in. */ +interface DerivedSnapshotEntry { + /** 0–100 score, the recent average, or the re-framed device value. */ + value: number; + /** The metric's band/category in plain words (green/yellow/red, etc.). */ + band?: string; + /** Confidence 0–100 from the coverage model. */ + confidence?: number; + /** Days of history backing the value. */ + historyDays: number; +} + +/** Pull the headline number + band off each metric's value shape. */ +function summariseValue( + metric: DerivedMetricId, + value: unknown, +): { value: number; band?: string } | null { + const v = value as Record; + switch (metric) { + case "READINESS": + case "SLEEP_SCORE": + case "RECOVERY_SCORE": + case "STRESS_SCORE": + case "STRAIN_SCORE": + return typeof v.score === "number" + ? { value: v.score, band: typeof v.band === "string" ? v.band : undefined } + : null; + case "HRV_BALANCE": + return typeof v.recentAvg === "number" + ? { value: Math.round(v.recentAvg), band: typeof v.band === "string" ? v.band : undefined } + : null; + case "FITNESS_AGE": + return typeof v.vo2Max === "number" + ? { + value: Math.round(v.vo2Max * 10) / 10, + band: + typeof v.fitnessAgeDeltaYears === "number" + ? `${v.fitnessAgeDeltaYears >= 0 ? "+" : ""}${v.fitnessAgeDeltaYears}yr vs age` + : undefined, + } + : null; + case "VASCULAR_AGE_DELTA": + return typeof v.vascularAge === "number" + ? { + value: Math.round(v.vascularAge), + band: + typeof v.deltaYears === "number" + ? `${v.deltaYears >= 0 ? "+" : ""}${Math.round(v.deltaYears)}yr vs age` + : undefined, + } + : null; + default: + return null; + } +} + +/** + * Build the compact derived block, or `null` when no derived metric resolved + * to `ok`. Each entry is value + band + confidence + historyDays — the model + * never sees the underlying series. Computes the metrics sequentially off the + * one shared profile (no per-metric profile re-read). + */ +export async function buildDerivedSnapshotBlock( + userId: string, + profile: BaselineProfile, + now: Date, +): Promise | null> { + const block: Record = {}; + + for (const metric of SNAPSHOT_METRICS) { + // Per-metric fault isolation: a transient compute failure on one metric + // must never sink the whole Coach turn — drop it and carry on. + let derived; + try { + derived = await computeDerivedMetric({ metric, userId, profile, now }); + } catch { + continue; + } + if (!isDerivedOk(derived)) continue; // omit insufficient — no noise + const summary = summariseValue(metric, derived.value); + if (!summary) continue; + block[metric] = { + value: summary.value, + band: summary.band, + confidence: derived.confidence.score, + historyDays: derived.coverage.historyDays, + }; + } + + return Object.keys(block).length > 0 ? block : null; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 0f5dcfcaf..7fdcf7867 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -26,6 +26,8 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; +import { buildDerivedSnapshotBlock } from "./derived-snapshot"; +import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, clusterSourcesFromPrefs, @@ -1185,6 +1187,45 @@ async function buildCoachSnapshotImpl( } } + // ── v1.10.0 — derived wellness layer (compact summaries) ───────────── + // + // The composites + persisted scores the dashboard rings render, folded + // in as one tiny object per metric (value + band + coverage), NOT the + // raw series. So the Coach can say "your readiness is 64, low band" and + // ground it in the same number the user sees. Insufficient metrics are + // omitted (no "no data" noise). Reads the same `computeDerivedMetric` + // contract every surface uses — no recompute. Gated on at least one of + // the signals the composites are built from staying in-scope (HRV / + // resting HR / sleep / VO₂max), so a user who excludes those doesn't see + // the block. + const derivedSources: CoachScopeSource[] = [ + "hrv", + "resting_hr", + "sleep", + "vo2_max", + ]; + if (derivedSources.some((s) => sources.has(s))) { + const ctx = features.context; + const derivedProfile: BaselineProfile = { + ageYears: ctx?.ageYears ?? null, + sex: + ctx?.gender === "MALE" || ctx?.gender === "FEMALE" + ? (ctx.gender as "MALE" | "FEMALE") + : null, + heightCm: ctx?.heightCm ?? null, + }; + const derivedBlock = await buildDerivedSnapshotBlock( + userId, + derivedProfile, + now, + ); + if (derivedBlock) { + snapshot.derived = derivedBlock; + metrics.add("hrv"); + registerBlock("derived", "hrv"); + } + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 4499ab8f2..35253bffe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -178,6 +178,17 @@ ISO-week means. cluster the SNAPSHOT does not contain. The "workouts" block carries the recent sessions plus a per-sport rollup, not every session. +- The SNAPSHOT MAY carry a "derived" block: compact wellness summaries + the app already computed (readiness, recovery, sleep score, stress, + strain, HRV balance, cardio-fitness band, vascular-age delta). Each + entry is { value, band, confidence, historyDays } — a single number + plus its band ("green"/"yellow"/"red") and how many days back it. + Cite these as the user sees them ("your readiness is 64, in the + yellow band") to ground a reply. These are DESCRIPTIVE composites, + not clinical assessments or diagnoses — never frame a band as a + medical finding, and lean on lower-confidence / few-day entries + cautiously. Never recompute or second-guess the number. + EVIDENCE BLOCK After your prose reply, append the load-bearing numbers as a sentinel From 8c24f3ec2a9d11bd9acdb6f2aca28d05f9e842b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:07:37 +0200 Subject: [PATCH 31/49] feat(insights): add a wellness-score summary to the doctor report and FHIR export Surface the server-derived nightly scores (recovery / stress / strain) in the health-record export without polluting the clinical layer. The aggregator now emits a wellnessScores summary (latest / avg / min / max / count per score over the window) built from the already-fetched COMPUTED rows. The PDF renders them in a clearly-labelled "Wellness summary" section, separate from the clinical vitals table, with a "descriptive, not a clinical assessment" disclaimer. The FHIR bundle emits one Observation per score under the survey category with a text-only concept and the same disclaimer as an Annotation note, so a viewer never mistakes a band for a diagnosis. --- messages/de.json | 7 ++- messages/en.json | 7 ++- messages/es.json | 7 ++- messages/fr.json | 7 ++- messages/it.json | 7 ++- messages/pl.json | 7 ++- src/lib/doctor-report-data.ts | 53 ++++++++++++++++++++ src/lib/doctor-report-pdf-core.ts | 82 +++++++++++++++++++++++++++++++ src/lib/fhir/build-bundle.ts | 41 ++++++++++++++++ src/lib/fhir/types.ts | 7 +++ 10 files changed, 219 insertions(+), 6 deletions(-) diff --git a/messages/de.json b/messages/de.json index 851afd6a4..d77e2a7d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -158,7 +158,12 @@ "chartsTitle": "Trend-Diagramme", "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert", "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", - "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog." + "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.", + "wellnessTitle": "Wellness-Zusammenfassung", + "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", + "wellnessRecovery": "Erholung", + "wellnessStress": "Stress", + "wellnessStrain": "Belastung" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", diff --git a/messages/en.json b/messages/en.json index c13a39e48..d43358aae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -158,7 +158,12 @@ "chartsTitle": "Trend charts", "aiSummaryTitle": "AI summary — not clinically validated", "aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.", - "packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog." + "packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.", + "wellnessTitle": "Wellness summary", + "wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.", + "wellnessRecovery": "Recovery", + "wellnessStress": "Stress", + "wellnessStrain": "Strain" }, "errorBoundary": { "title": "Something went wrong", diff --git a/messages/es.json b/messages/es.json index 2b5f9085e..24aaf3011 100644 --- a/messages/es.json +++ b/messages/es.json @@ -158,7 +158,12 @@ "chartsTitle": "Gráficos de tendencia", "aiSummaryTitle": "Resumen de IA — no validado clínicamente", "aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.", - "packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog." + "packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.", + "wellnessTitle": "Resumen de bienestar", + "wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.", + "wellnessRecovery": "Recuperación", + "wellnessStress": "Estrés", + "wellnessStrain": "Esfuerzo" }, "errorBoundary": { "title": "Algo ha salido mal", diff --git a/messages/fr.json b/messages/fr.json index 9e5eb8456..fcbf04373 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -158,7 +158,12 @@ "chartsTitle": "Graphiques de tendance", "aiSummaryTitle": "Résumé IA — non validé cliniquement", "aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.", - "packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog." + "packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.", + "wellnessTitle": "Résumé de bien-être", + "wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.", + "wellnessRecovery": "Récupération", + "wellnessStress": "Stress", + "wellnessStrain": "Effort" }, "errorBoundary": { "title": "Une erreur est survenue", diff --git a/messages/it.json b/messages/it.json index 9131ec561..2faf81e83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -158,7 +158,12 @@ "chartsTitle": "Grafici di tendenza", "aiSummaryTitle": "Riepilogo IA — non validato clinicamente", "aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.", - "packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog." + "packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.", + "wellnessTitle": "Riepilogo del benessere", + "wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.", + "wellnessRecovery": "Recupero", + "wellnessStress": "Stress", + "wellnessStrain": "Sforzo" }, "errorBoundary": { "title": "Qualcosa è andato storto", diff --git a/messages/pl.json b/messages/pl.json index cf5ffd105..5cd74f5aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -158,7 +158,12 @@ "chartsTitle": "Wykresy trendów", "aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie", "aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.", - "packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog." + "packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.", + "wellnessTitle": "Podsumowanie dobrostanu", + "wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.", + "wellnessRecovery": "Regeneracja", + "wellnessStress": "Stres", + "wellnessStrain": "Obciążenie" }, "errorBoundary": { "title": "Coś poszło nie tak", diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 68e72d47a..8e5cb0f23 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -170,8 +170,36 @@ export interface DoctorReportData { weightEndKg: number | null; sideEffects: Array<{ tag: string; count: number }>; } | null; + /** + * v1.10.0 — the server-derived nightly wellness scores + * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the + * report window. These are 0–100 DESCRIPTIVE composites, NOT clinical + * vitals — they are rendered in a clearly-labelled "Wellness summary" + * section separate from the clinical vitals table, each with a + * "descriptive, not a clinical assessment" note. Null/absent when the + * user has no computed scores in the window (the renderer + FHIR builder + * then skip the section). Optional so pre-v1.10 fixtures still typecheck. + */ + wellnessScores?: Array<{ + /** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */ + type: string; + latest: number; + avg: number; + min: number; + max: number; + count: number; + /** ISO timestamp of the latest score. */ + latestAt: string; + }> | null; } +/** The three persisted score types surfaced in the wellness summary. */ +export const WELLNESS_SCORE_REPORT_TYPES = [ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", +] as const; + const GLUCOSE_CONTEXTS: GlucoseContext[] = [ "FASTING", "POSTPRANDIAL", @@ -752,6 +780,30 @@ export async function collectDoctorReportData( }; } + // v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE` + // rows are already in `byType`/`stats` (they're only filtered out of the + // clinical vitals table). Summarise each present score type for the + // separate "Wellness summary" section. Empty array → null so the renderer + // + FHIR builder skip the section entirely. + const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { + const s = stats[type]; + const rows = byType[type]; + if (!s || !rows || rows.length === 0) return []; + return [ + { + type, + latest: Math.round(s.latest), + avg: Math.round(s.avg), + min: Math.round(s.min), + max: Math.round(s.max), + count: s.count, + latestAt: rows[rows.length - 1].measuredAt, + }, + ]; + }); + const wellnessScores = + wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null; + return { period: { days, @@ -798,6 +850,7 @@ export async function collectDoctorReportData( : null, mood, glp1, + wellnessScores, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index b640da16b..c21a43327 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -208,6 +208,13 @@ const MOOD_LABEL_KEYS: Record = { 5: "doctorReport.moodGreat", }; +/** v1.10.0 — display-label key per persisted wellness-score type. */ +const WELLNESS_SCORE_LABEL_KEYS: Record = { + RECOVERY_SCORE: "doctorReport.wellnessRecovery", + STRESS_SCORE: "doctorReport.wellnessStress", + STRAIN_SCORE: "doctorReport.wellnessStrain", +}; + const GLUCOSE_LABEL_KEYS = { FASTING: "doctorReport.typeGlucoseFasting", POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", @@ -1140,6 +1147,81 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.10.0 — wellness summary. The server-derived nightly scores + // (recovery / stress / strain) are 0–100 DESCRIPTIVE composites, NOT + // clinical vitals — they render in their own clearly-labelled section, + // separate from the vitals table, with a "descriptive, not a clinical + // assessment" disclaimer so a physician never mistakes a band for a + // finding. Skipped entirely when the aggregator emitted no scores. + if (data.wellnessScores && data.wellnessScores.length > 0) { + const scoreRows = data.wellnessScores.map((s) => [ + t(WELLNESS_SCORE_LABEL_KEYS[s.type] ?? "doctorReport.wellnessTitle"), + String(s.latest), + String(s.avg), + String(s.min), + String(s.max), + String(s.count), + ]); + + // Keep the heading + disclaimer with the first table row. + y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1)); + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.wellnessTitle"), margin, y); + y += 6; + + doc.setFontSize(8); + doc.setFont("helvetica", "italic"); + const wellnessDisclaimer = doc.splitTextToSize( + t("doctorReport.wellnessDisclaimer"), + pageWidth - margin * 2, + ); + for (const line of wellnessDisclaimer) { + doc.text(line, margin, y); + y += 4; + } + y += 2; + doc.setFont("helvetica", "normal"); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.colParameter"), + t("doctorReport.colCurrent"), + t("doctorReport.colAvgPeriod"), + t("doctorReport.colMin"), + t("doctorReport.colMax"), + t("doctorReport.colN"), + ], + ], + body: scoreRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 0636ca0a2..bdafc2d66 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -243,6 +243,14 @@ function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { return { text: m.display }; } +/** v1.10.0 — English display per persisted wellness-score type (FHIR + * text-only concept; the score has no published LOINC term). */ +const WELLNESS_SCORE_DISPLAY: Record = { + RECOVERY_SCORE: "Recovery score", + STRESS_SCORE: "Stress score", + STRAIN_SCORE: "Strain score", +}; + function categoryConcept(category: string): FhirCodeableConcept { return { coding: [ @@ -540,6 +548,39 @@ export function buildFhirDocumentBundle( }); } + // --- Wellness-score Observations (descriptive composites) -------------- + // v1.10.0 — the server-derived nightly scores (recovery / stress / + // strain). They have no published LOINC term and are NOT clinical + // findings, so each is emitted under the `survey` category with a + // text-only concept and an explicit "descriptive, not a clinical + // assessment" note — a physician's FHIR viewer never mistakes a band for + // a diagnosis. Absent when the aggregator emitted no scores. + if (data.wellnessScores && data.wellnessScores.length > 0) { + for (const s of data.wellnessScores) { + obsSeq += 1; + pushObservation({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("survey")], + code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" }, + subject: patientRef, + effectiveDateTime: s.latestAt, + valueQuantity: { + value: s.latest, + unit: "{score}", + system: UCUM_SYSTEM, + code: "{score}", + }, + note: [ + { + text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.", + }, + ], + }); + } + } + // --- MedicationStatement per active medication ------------------------- let medSeq = 0; for (const med of data.medications) { diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index df65bbc80..3c05fb84d 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -63,6 +63,11 @@ export interface FhirPatient { birthDate?: string; // YYYY-MM-DD } +/** R4 `Annotation` — a free-text note attached to a resource. */ +export interface FhirAnnotation { + text: string; +} + export interface FhirObservation { resourceType: "Observation"; id: string; @@ -75,6 +80,8 @@ export interface FhirObservation { valueInteger?: number; valueString?: string; component?: FhirObservationComponent[]; + /** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */ + note?: FhirAnnotation[]; } export interface FhirDosage { From 78c8177cd3493ecddf356b3d6842abeaf2f4d730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:10:39 +0200 Subject: [PATCH 32/49] feat(insights): fold a confidence-gated derived signal into the daily briefing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a derived wellness score (readiness or recovery) crosses a notability gate — outside the green band AND confidence at least medium — the insights generator now appends a SYSTEM CONTEXT block so the briefing can narrate the shift observationally. Mirrors the GLP-1 plateau detector's append mechanism: reads the one derived contract, returns null (the common case) at zero token cost, and instructs the model to frame the signal as an observed pattern, never a diagnosis or a dose recommendation. Add the readiness/recovery sourceMetric enum values plus their briefing href + icon + trend-chart mappings. --- src/app/api/insights/generate/route.ts | 24 +++ src/components/insights/daily-briefing.tsx | 8 + src/lib/ai/schema.ts | 7 + .../__tests__/derived-briefing.test.ts | 92 +++++++++++ src/lib/insights/derived-briefing.ts | 146 ++++++++++++++++++ src/lib/insights/trend-chart-select.ts | 5 + 6 files changed, 282 insertions(+) create mode 100644 src/lib/insights/__tests__/derived-briefing.test.ts create mode 100644 src/lib/insights/derived-briefing.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index f8f9bf91f..73b33cd9a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,11 @@ import { detectGlp1Plateau, buildGlp1PlateauPrompt, } from "@/lib/insights/glp1-plateau"; +import { + detectDerivedBriefingSignals, + buildDerivedBriefingPrompt, +} from "@/lib/insights/derived-briefing"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { buildUserPrompt, type ComparisonSnapshot, @@ -199,6 +204,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // LLM never sees the excluded blocks. insightsExcludeMetrics: true, locale: true, + // v1.10.0 — profile for the derived-signal briefing detector. + dateOfBirth: true, + gender: true, + heightCm: true, }, }); @@ -397,6 +406,21 @@ export const POST = apiHandler(async (request: NextRequest) => { if (plateauContext) { userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); } + // v1.10.0 — fold a notable derived wellness signal (readiness / recovery + // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. + // Reads the one derived contract; null (the common case) costs nothing. + const derivedSex = + dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" + ? (dbUser.gender as "MALE" | "FEMALE") + : null; + const derivedBriefing = await detectDerivedBriefingSignals(userId, { + ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), + sex: derivedSex, + heightCm: dbUser?.heightCm ?? null, + }); + if (derivedBriefing) { + userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); + } let result; let workingProviderType: string; diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 7c83dfc13..bd2cea693 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -56,6 +56,11 @@ const METRIC_HREF: Record = { diff --git a/src/lib/ai/schema.ts b/src/lib/ai/schema.ts index a86614d44..92cf8bc26 100644 --- a/src/lib/ai/schema.ts +++ b/src/lib/ai/schema.ts @@ -277,6 +277,13 @@ export const dailyBriefingKeyFindingSchema = z.object({ // user's clinician (no dose recommendation); the enum value // exists so the UI can pin the right icon + filter. "glp1_plateau", + // ── v1.10.0 derived-wellness additive ── + // Findings emitted when a notable derived signal (a readiness / + // recovery shift gated on confidence) crosses the briefing's + // notability gate. Descriptive, never a clinical attribution; the + // enum value lets the UI pin the right icon + filter. + "readiness", + "recovery", ]), }); diff --git a/src/lib/insights/__tests__/derived-briefing.test.ts b/src/lib/insights/__tests__/derived-briefing.test.ts new file mode 100644 index 000000000..88215e6c1 --- /dev/null +++ b/src/lib/insights/__tests__/derived-briefing.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, computeDerivedMetric: vi.fn() }; +}); + +import { computeDerivedMetric } from "@/lib/insights/derived"; +import { + detectDerivedBriefingSignals, + buildDerivedBriefingPrompt, +} from "../derived-briefing"; + +const compute = computeDerivedMetric as unknown as ReturnType; +const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; +const NOW = new Date("2026-06-02T08:00:00Z"); + +function ok(score: number, band: string, confidence: number) { + return { + status: "ok" as const, + value: { score, band }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 14, missing: [] }, + confidence: { score: confidence, band: "medium" as const }, + provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" }, + }; +} +const insufficient = { + status: "insufficient" as const, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" }, + reason: "x", +}; + +beforeEach(() => compute.mockReset()); + +describe("detectDerivedBriefingSignals", () => { + it("returns null when nothing is notable", async () => { + // green band → not notable even at high confidence + compute.mockResolvedValue(ok(82, "green", 90)); + expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull(); + }); + + it("gates out low-confidence signals", async () => { + compute.mockResolvedValue(ok(35, "red", 30)); + expect(await detectDerivedBriefingSignals("u1", PROFILE, NOW)).toBeNull(); + }); + + it("surfaces a notable, confident yellow/red signal", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") return ok(48, "yellow", 70); + return insufficient; + }); + const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW); + expect(ctx).not.toBeNull(); + expect(ctx!.signals).toHaveLength(1); + expect(ctx!.signals[0]).toMatchObject({ + sourceMetric: "readiness", + score: 48, + band: "yellow", + }); + }); + + it("isolates a per-metric compute failure", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") throw new Error("boom"); + if (args?.metric === "RECOVERY_SCORE") return ok(30, "red", 88); + return insufficient; + }); + const ctx = await detectDerivedBriefingSignals("u1", PROFILE, NOW); + expect(ctx!.signals.map((s) => s.sourceMetric)).toEqual(["recovery"]); + }); +}); + +describe("buildDerivedBriefingPrompt", () => { + it("renders an EN system-context block citing the value + band", () => { + const prompt = buildDerivedBriefingPrompt( + { signals: [{ sourceMetric: "readiness", label: "readiness", score: 48, band: "yellow", confidence: 70 }] }, + "en", + ); + expect(prompt).toContain("DERIVED WELLNESS SIGNALS"); + expect(prompt).toContain("readiness: 48/100"); + expect(prompt).toContain("NEVER recommend"); + }); + + it("renders a DE block for the de locale", () => { + const prompt = buildDerivedBriefingPrompt( + { signals: [{ sourceMetric: "recovery", label: "recovery", score: 30, band: "red", confidence: 88 }] }, + "de", + ); + expect(prompt).toContain("ABGELEITETE WELLNESS-SIGNALE"); + }); +}); diff --git a/src/lib/insights/derived-briefing.ts b/src/lib/insights/derived-briefing.ts new file mode 100644 index 000000000..1c38b0519 --- /dev/null +++ b/src/lib/insights/derived-briefing.ts @@ -0,0 +1,146 @@ +/** + * v1.10.0 — derived-signal context for the daily briefing. + * + * Picks the most relevant 1–2 derived wellness signals (a notable + * readiness / recovery shift) and folds them into the insight-generator + * prompt as a SYSTEM CONTEXT block — the same mechanism the GLP-1 plateau + * detector uses. Gated on confidence: a signal only surfaces when its + * derived value is `ok` AND its confidence band is `high` or `medium`, so + * the briefing never narrates a shaky few-day number. + * + * Reads the one `computeDerivedMetric` contract — no recompute. Returns + * `null` (the common case) when nothing notable crosses the gate, so the + * generate route pays zero token cost for users with no derived signal. + * + * Server-only. + */ +import { + computeDerivedMetric, + isDerivedOk, + type BaselineProfile, +} from "@/lib/insights/derived"; +import type { Locale } from "@/lib/i18n/config"; + +/** One derived signal worth a sentence in the briefing. */ +export interface DerivedBriefingSignal { + /** Sentinel id the model echoes into `sourceMetric` (e.g. "readiness"). */ + sourceMetric: string; + /** Human label ("readiness", "recovery"). */ + label: string; + /** Latest 0–100 score. */ + score: number; + /** Band ("green" / "yellow" / "red"). */ + band: string; + /** Confidence 0–100. */ + confidence: number; +} + +export interface DerivedBriefingContext { + signals: DerivedBriefingSignal[]; +} + +/** The notable-signal candidates, in priority order. */ +const CANDIDATES: Array<{ + metric: "READINESS" | "RECOVERY_SCORE"; + sourceMetric: string; + label: string; +}> = [ + { metric: "READINESS", sourceMetric: "readiness", label: "readiness" }, + { metric: "RECOVERY_SCORE", sourceMetric: "recovery", label: "recovery" }, +]; + +/** Confidence floor — only `high`/`medium` bands surface in the briefing. */ +const CONFIDENCE_FLOOR = 50; +/** A signal is notable when it sits outside the comfortable green band. */ +function isNotable(band: string, confidence: number): boolean { + return confidence >= CONFIDENCE_FLOOR && (band === "yellow" || band === "red"); +} + +/** + * Detect the 1–2 most relevant derived signals for the briefing, or `null` + * when nothing crosses the confidence + notability gate. Computes the + * candidates off the one shared profile (no per-metric profile re-read). + */ +export async function detectDerivedBriefingSignals( + userId: string, + profile: BaselineProfile, + now: Date = new Date(), +): Promise { + const signals: DerivedBriefingSignal[] = []; + + for (const cand of CANDIDATES) { + let derived; + try { + derived = await computeDerivedMetric({ + metric: cand.metric, + userId, + profile, + now, + }); + } catch { + continue; + } + if (!isDerivedOk(derived)) continue; + const v = derived.value as { score?: number; band?: string }; + if (typeof v.score !== "number" || typeof v.band !== "string") continue; + if (!isNotable(v.band, derived.confidence.score)) continue; + signals.push({ + sourceMetric: cand.sourceMetric, + label: cand.label, + score: v.score, + band: v.band, + confidence: derived.confidence.score, + }); + if (signals.length >= 2) break; + } + + return signals.length > 0 ? { signals } : null; +} + +/** + * Build the SYSTEM CONTEXT block appended to the user prompt. Mirrors the + * GLP-1 plateau detector's append mechanism. DE + EN bodies; other locales + * fall through to EN (the same chain the message bundles use). + */ +export function buildDerivedBriefingPrompt( + ctx: DerivedBriefingContext, + locale: Locale, +): string { + const lines = ctx.signals + .map( + (s) => + `- ${s.label}: ${s.score}/100 (${s.band} band, confidence ${s.confidence})`, + ) + .join("\n"); + const sentinels = ctx.signals.map((s) => `"${s.sourceMetric}"`).join(" / "); + + if (locale === "de") { + return ` + +SYSTEM CONTEXT — ABGELEITETE WELLNESS-SIGNALE + +Die App hat folgende abgeleitete Wellness-Werte berechnet (beschreibend, keine klinische Beurteilung): +${lines} + +Wenn du einen dailyBriefing-keyFinding zu einem dieser Signale emittierst: +- sourceMetric: ${sentinels} +- tone: "info" (gelb) bzw. "watch" (rot) +- Rahmen es als beobachtetes Muster aus den eigenen Daten des Nutzers, keine Diagnose +- nenne den Wert und das Band; verweise NICHT auf eine medizinische Ursache +- KEINE Empfehlung zu Medikamenten oder Dosierung`; + } + + return ` + +SYSTEM CONTEXT — DERIVED WELLNESS SIGNALS + +The app computed these derived wellness scores (descriptive, not a clinical assessment): +${lines} + +If you emit a dailyBriefing keyFinding for one of these signals: +- sourceMetric: ${sentinels} +- tone: "info" (yellow) or "watch" (red) +- frame as an observed pattern from the user's own data, never a diagnosis +- state the value and the band; do NOT attribute it to a medical cause +- NEVER recommend a medication or dose change`; +} diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index 25f4ff7eb..8bcb85dd2 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -225,6 +225,11 @@ export const TREND_CHART_CONFIG: Record< // the next finding (or the default fallback) takes the slot. compliance: null, glp1_plateau: null, + // ── v1.10.0 derived-wellness additive ── + // The wellness scores are 0–100 composites whose deeper view is the + // score-anatomy page, not a single plottable trend series in this row. + readiness: null, + recovery: null, }; /** From 148e6cd6002cf749751e75a30cf3b14cde990d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:38:27 +0200 Subject: [PATCH 33/49] fix(insights): score the previous day in the nightly score engines The Recovery/Stress/Strain crons fire in the small hours (04:45-04:55 Europe/Berlin, ~03 UTC) but scored the CURRENT UTC day, only a few hours old: Strain missed the just-completed day's workouts and Stress saw a near-empty intra-day SDNN set and falsely gated as insufficient. Score the day that just ended (now - 1 day) across all three engines and their measuredAt / externalId day stamps. Resolve the previous-day key in one place by extracting the shared score-row helpers the three engines each duplicated: scoreDayKey / scoreMeasuredAt / scoreExternalId, the idempotent upsertScoreRow, and the cohort runScoreBatch tally loop into src/lib/insights/score-row.ts; and loadBaselineProfile into the baseline engine (the three byte-identical profile loaders + the partial strain variant now route through it). The derived route should adopt loadBaselineProfile too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../insights/__tests__/recovery-score.test.ts | 15 +- src/lib/insights/__tests__/score-row.test.ts | 148 +++++++++++++++++ .../insights/__tests__/strain-score.test.ts | 14 +- .../insights/__tests__/stress-score.test.ts | 19 ++- src/lib/insights/derived/baseline.ts | 32 +++- src/lib/insights/recovery-score.ts | 85 ++++------ src/lib/insights/score-row.ts | 149 ++++++++++++++++++ src/lib/insights/strain-score.ts | 71 ++++----- src/lib/insights/stress-score.ts | 89 ++++------- src/lib/jobs/recovery-score.ts | 34 +--- src/lib/jobs/strain-score.ts | 29 +--- src/lib/jobs/stress-score.ts | 29 +--- 12 files changed, 472 insertions(+), 242 deletions(-) create mode 100644 src/lib/insights/__tests__/score-row.test.ts create mode 100644 src/lib/insights/score-row.ts diff --git a/src/lib/insights/__tests__/recovery-score.test.ts b/src/lib/insights/__tests__/recovery-score.test.ts index a705d9191..ac2b39a51 100644 --- a/src/lib/insights/__tests__/recovery-score.test.ts +++ b/src/lib/insights/__tests__/recovery-score.test.ts @@ -68,16 +68,17 @@ beforeEach(() => { }); describe("recovery-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(recoveryDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01. + expect(recoveryDayKey(NOW)).toBe("2026-06-01"); expect(recoveryExternalId(NOW)).toBe( - `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC on the scored day", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(recoveryMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); }); @@ -96,7 +97,7 @@ describe("persistRecoveryScore", () => { userId: "user-1", type: "RECOVERY_SCORE", source: "COMPUTED", - externalId: "recovery:2026-06-02", + externalId: "recovery:2026-06-01", }); expect(arg.create).toMatchObject({ userId: "user-1", @@ -104,7 +105,7 @@ describe("persistRecoveryScore", () => { source: "COMPUTED", value: 72, unit: "score", - externalId: "recovery:2026-06-02", + externalId: "recovery:2026-06-01", }); expect(arg.update).toMatchObject({ value: 72 }); }); diff --git a/src/lib/insights/__tests__/score-row.test.ts b/src/lib/insights/__tests__/score-row.test.ts new file mode 100644 index 000000000..63c4fb4f6 --- /dev/null +++ b/src/lib/insights/__tests__/score-row.test.ts @@ -0,0 +1,148 @@ +/** + * v1.10.0 QA — shared score-row helpers. + * + * Pins the "score the PREVIOUS UTC day" contract (the cron fires ~03 UTC, so + * scoring the current UTC day would miss a just-completed day's data), the + * noon-UTC measuredAt, the prefixed externalId, the idempotent upsert, and + * the cohort tally / one-bad-user-does-not-block-the-rest guarantee of + * `runScoreBatch`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const annotateMock = vi.fn(); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...args: unknown[]) => annotateMock(...args), +})); + +import { + scoreDayKey, + scoreMeasuredAt, + scoreExternalId, + upsertScoreRow, + runScoreBatch, +} from "../score-row"; + +beforeEach(() => vi.clearAllMocks()); + +describe("scoreDayKey — previous UTC day", () => { + it("a 03:00 UTC run scores the prior calendar day", () => { + // 04:45 Europe/Berlin (summer) ≈ 02:45 UTC; a 03:00 UTC tick is the cron + // moment. The scored day must be the day that just ended. + const now = new Date("2026-06-02T03:00:00Z"); + expect(scoreDayKey(now)).toBe("2026-06-01"); + }); + + it("crosses a month boundary correctly", () => { + const now = new Date("2026-07-01T02:50:00Z"); + expect(scoreDayKey(now)).toBe("2026-06-30"); + }); + + it("crosses a year boundary correctly", () => { + const now = new Date("2027-01-01T03:00:00Z"); + expect(scoreDayKey(now)).toBe("2026-12-31"); + }); +}); + +describe("scoreMeasuredAt / scoreExternalId", () => { + const now = new Date("2026-06-02T03:00:00Z"); + it("anchors the row at noon UTC on the scored (previous) day", () => { + expect(scoreMeasuredAt(now).toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + it("builds the prefixed externalId over the scored day", () => { + expect(scoreExternalId("recovery:", now)).toBe("recovery:2026-06-01"); + expect(scoreExternalId("strain:", now)).toBe("strain:2026-06-01"); + }); +}); + +describe("upsertScoreRow", () => { + it("upserts on the (userId, type, COMPUTED, externalId) key with the prev-day stamp", async () => { + const upsert = vi.fn().mockResolvedValue({}); + const prisma = { + measurement: { upsert }, + } as unknown as Parameters[0]; + const now = new Date("2026-06-02T03:00:00Z"); + + await upsertScoreRow(prisma, { + userId: "u1", + type: "RECOVERY_SCORE", + externalIdPrefix: "recovery:", + score: 77, + now, + }); + + const arg = upsert.mock.calls[0][0]; + expect(arg.where.userId_type_source_externalId).toEqual({ + userId: "u1", + type: "RECOVERY_SCORE", + source: "COMPUTED", + externalId: "recovery:2026-06-01", + }); + expect(arg.create).toMatchObject({ + type: "RECOVERY_SCORE", + source: "COMPUTED", + value: 77, + unit: "score", + externalId: "recovery:2026-06-01", + }); + expect(arg.create.measuredAt.toISOString()).toBe( + "2026-06-01T12:00:00.000Z", + ); + expect(arg.update).toEqual({ + value: 77, + measuredAt: arg.create.measuredAt, + }); + }); +}); + +describe("runScoreBatch", () => { + const NOW = new Date("2026-06-02T03:00:00Z"); + + it("tallies stored vs insufficient and annotates", async () => { + const persist = vi + .fn() + .mockResolvedValueOnce({ outcome: "stored" }) + .mockResolvedValueOnce({ outcome: "insufficient" }) + .mockResolvedValueOnce({ outcome: "stored" }); + + const result = await runScoreBatch( + ["a", "b", "c"], + NOW, + persist, + "insights.recovery.compute", + ); + + expect(result).toEqual({ + considered: 3, + stored: 2, + insufficient: 1, + errored: 0, + }); + expect(annotateMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "insights.recovery.compute" }), + }), + ); + }); + + it("counts a per-user error and keeps processing the cohort", async () => { + const persist = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ outcome: "stored" }); + + const result = await runScoreBatch(["a", "b"], NOW, persist, "x.compute"); + + expect(result).toEqual({ + considered: 2, + stored: 1, + insufficient: 0, + errored: 1, + }); + }); + + it("passes the run `now` through to the persist function", async () => { + const persist = vi.fn().mockResolvedValue({ outcome: "stored" }); + await runScoreBatch(["a"], NOW, persist, "x.compute"); + expect(persist).toHaveBeenCalledWith("a", NOW); + }); +}); diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 1b26a91f5..4be24fe01 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -28,16 +28,18 @@ beforeEach(() => { }); describe("strain-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(strainDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01, + // whose workouts + active-energy total are settled. + expect(strainDayKey(NOW)).toBe("2026-06-01"); expect(strainExternalId(NOW)).toBe( - `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(strainMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); @@ -176,7 +178,7 @@ describe("persistStrainScore", () => { userId: "user-1", type: "STRAIN_SCORE", source: "COMPUTED", - externalId: "strain:2026-06-02", + externalId: "strain:2026-06-01", }); expect(arg.create).toMatchObject({ type: "STRAIN_SCORE", diff --git a/src/lib/insights/__tests__/stress-score.test.ts b/src/lib/insights/__tests__/stress-score.test.ts index 8d8ce2e8b..1427c4723 100644 --- a/src/lib/insights/__tests__/stress-score.test.ts +++ b/src/lib/insights/__tests__/stress-score.test.ts @@ -14,6 +14,9 @@ const computeVitalsBaselineMock = vi.fn(); vi.mock("@/lib/insights/derived/baseline", () => ({ computeVitalsBaseline: (...args: unknown[]) => computeVitalsBaselineMock(...args), + loadBaselineProfile: vi + .fn() + .mockResolvedValue({ ageYears: null, sex: "MALE", heightCm: 180 }), })); import { @@ -57,16 +60,18 @@ beforeEach(() => { }); describe("stress-score helpers", () => { - it("keys the day + externalId by the UTC calendar day", () => { - expect(stressDayKey(NOW)).toBe("2026-06-02"); + it("scores the PREVIOUS UTC day (cron fires in the small hours)", () => { + // NOW is 2026-06-02 → the scored day is the just-completed 2026-06-01, + // whose intra-day SDNN set is complete rather than a few hours old. + expect(stressDayKey(NOW)).toBe("2026-06-01"); expect(stressExternalId(NOW)).toBe( - `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-02`, + `${STRESS_SCORE_EXTERNAL_ID_PREFIX}2026-06-01`, ); }); - it("anchors the canonical timestamp at noon UTC on the scored day", () => { + it("anchors the canonical timestamp at noon UTC on the scored (previous) day", () => { expect(stressMeasuredAt(NOW).toISOString()).toBe( - "2026-06-02T12:00:00.000Z", + "2026-06-01T12:00:00.000Z", ); }); }); @@ -107,13 +112,13 @@ describe("persistStressScore", () => { userId: "user-1", type: "STRESS_SCORE", source: "COMPUTED", - externalId: "stress:2026-06-02", + externalId: "stress:2026-06-01", }); expect(arg.create).toMatchObject({ type: "STRESS_SCORE", source: "COMPUTED", unit: "score", - externalId: "stress:2026-06-02", + externalId: "stress:2026-06-01", }); }); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts index 3da330ec4..aa46ba120 100644 --- a/src/lib/insights/derived/baseline.ts +++ b/src/lib/insights/derived/baseline.ts @@ -28,8 +28,9 @@ * statistics helpers below are exported so the unit test can assert the * composed-bucket baseline matches the raw-DAY baseline within tolerance. */ -import type { MeasurementType } from "@/generated/prisma/client"; +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { probeRollupCoverage, type RollupCoverageMap, @@ -79,6 +80,33 @@ export interface BaselineProfile { heightCm?: number | null; } +/** + * Build the `BaselineProfile` the baseline engine + readiness blend need from + * the `User` row. This is the one loader the persisting score engines and the + * live `/api/insights/derived` route share — previously each inlined a + * byte-identical `findUnique` + gender-narrow + `getAgeFromDateOfBirth`. The + * `prisma` arg is the caller's client (the nightly worker passes its shared + * connection; the route passes the request client) so both reads hit one + * pool. `now` is unused here but accepted so a future age-as-of-date refinement + * is a one-line change in this single place. + */ +export async function loadBaselineProfile( + prisma: PrismaClient, + userId: string, +): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dateOfBirth: true, gender: true, heightCm: true }, + }); + const sex = + user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + return { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex, + heightCm: user?.heightCm ?? null, + }; +} + export interface VitalsBaselineOpts { /** Which vital to baseline. */ type: MeasurementType; @@ -155,7 +183,7 @@ export function buildBaselineBand( * live-SQL fallback on a coverage miss. Returns the series plus the * provenance source the read resolved against. */ -async function readDayMeanSeries( +export async function readDayMeanSeries( userId: string, type: MeasurementType, windowDays: number, diff --git a/src/lib/insights/recovery-score.ts b/src/lib/insights/recovery-score.ts index aa470df68..40db18a88 100644 --- a/src/lib/insights/recovery-score.ts +++ b/src/lib/insights/recovery-score.ts @@ -36,40 +36,43 @@ * pg-boss job in `src/lib/jobs/recovery-score.ts`. */ import type { PrismaClient } from "@/generated/prisma/client"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeReadiness, type ReadinessValue, } from "@/lib/insights/derived/readiness"; -import type { BaselineProfile } from "@/lib/insights/derived/baseline"; +import { + loadBaselineProfile, + type BaselineProfile, +} from "@/lib/insights/derived/baseline"; +import { + scoreDayKey, + scoreExternalId, + scoreMeasuredAt, + upsertScoreRow, +} from "@/lib/insights/score-row"; import type { Derived } from "@/lib/insights/derived/types"; /** The per-day idempotency-key prefix for a stored Recovery score row. */ export const RECOVERY_SCORE_EXTERNAL_ID_PREFIX = "recovery:"; /** - * The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. The Recovery - * series is one row per UTC day — the same day-keying the rollup tier and - * the daily-mean consolidation use, so the score lines up with the buckets - * it summarises. + * The UTC calendar day a Recovery run scores — the PREVIOUS day relative to + * `now` (the cron fires in the small hours; the just-completed day is the one + * with a full signal set). Delegates to the shared `scoreDayKey` so all three + * score engines agree on the day stamp. */ export function recoveryDayKey(now: Date): string { - return now.toISOString().slice(0, 10); + return scoreDayKey(now); } -/** The full `externalId` for a given day's Recovery score row. */ +/** The full `externalId` for a given run's Recovery score row. */ export function recoveryExternalId(now: Date): string { - return `${RECOVERY_SCORE_EXTERNAL_ID_PREFIX}${recoveryDayKey(now)}`; + return scoreExternalId(RECOVERY_SCORE_EXTERNAL_ID_PREFIX, now); } -/** - * The canonical timestamp a stored Recovery row carries: noon UTC on the - * scored day. Noon (not midnight) keeps the row inside its own UTC day under - * any reasonable display-timezone offset, mirroring the daily-stats - * convention so a chart never mis-buckets the point at a day boundary. - */ +/** The canonical timestamp a stored Recovery row carries (noon UTC, scored day). */ export function recoveryMeasuredAt(now: Date): Date { - return new Date(`${recoveryDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } export interface RecoveryComputeResult { @@ -101,25 +104,16 @@ export async function computeRecoveryScore( } /** - * Build the `BaselineProfile` the readiness blend needs from the user row — - * the same shape the live `/api/insights/derived` route builds. `prisma` is - * the (worker) client so the job shares one connection. + * Build the `BaselineProfile` the readiness blend needs from the user row. + * Thin alias over the shared `loadBaselineProfile` so the existing callers / + * tests keep their name; `prisma` is the (worker) client so the job shares one + * connection. */ export async function loadRecoveryProfile( prisma: PrismaClient, userId: string, ): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; - return { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, - heightCm: user?.heightCm ?? null, - }; + return loadBaselineProfile(prisma, userId); } export interface PersistRecoveryResult { @@ -147,31 +141,12 @@ export async function persistRecoveryScore( return { outcome: "insufficient", score: null }; } - const externalId = recoveryExternalId(now); - const measuredAt = recoveryMeasuredAt(now); - - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: "RECOVERY_SCORE", - source: "COMPUTED", - externalId, - }, - }, - create: { - userId, - type: "RECOVERY_SCORE", - source: "COMPUTED", - value: score, - unit: "score", - measuredAt, - externalId, - }, - update: { - value: score, - measuredAt, - }, + await upsertScoreRow(prisma, { + userId, + type: "RECOVERY_SCORE", + externalIdPrefix: RECOVERY_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score }; diff --git a/src/lib/insights/score-row.ts b/src/lib/insights/score-row.ts new file mode 100644 index 000000000..09b6ebce6 --- /dev/null +++ b/src/lib/insights/score-row.ts @@ -0,0 +1,149 @@ +/** + * v1.10.0 — computed scores (WX-C/E). Shared score-row helpers. + * + * The three nightly score engines (Recovery / Stress / Strain) all file one + * `COMPUTED` Measurement row per user per scored day, keyed by the same + * day-stamp triplet (a `prefix:YYYY-MM-DD` externalId, a noon-UTC + * `measuredAt`, the UTC day key) and written through the same idempotent + * `(userId, type, source, externalId)` upsert. Before this module each engine + * carried a byte-identical copy of that triplet + upsert, differing only by + * the `prefix` / `type` literal. + * + * SCORED DAY = THE PREVIOUS UTC DAY. The crons fire ~03:xx UTC (04:45–04:55 + * Europe/Berlin). At that moment the CURRENT UTC day is only a few hours old: + * the Strain engine would miss the just-completed day's workouts and the + * Stress engine would see a near-empty intra-day SDNN set and gate as + * `insufficient`. Every engine therefore scores the day that just ENDED — + * `now − 1 day` — resolved once, here, so the externalId / measuredAt / day + * key can never drift between the three. + * + * `scoreDayKey` / `scoreMeasuredAt` / `scoreExternalId` are pure (no Prisma) + * so the engines' day-stamp tests can drive them directly; `upsertScoreRow` + * takes the (worker) client. + */ +import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; +import { annotate } from "@/lib/logging/context"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * The UTC calendar day a score run scores — the PREVIOUS day relative to + * `now`. The cron runs in the small hours; the just-completed day is the one + * with a full set of signals, so we resolve `now − 1 day` and take its UTC + * `YYYY-MM-DD`. Resolved in ONE place so all three engines agree. + */ +export function scoreDayKey(now: Date): string { + return new Date(now.getTime() - MS_PER_DAY).toISOString().slice(0, 10); +} + +/** + * The canonical timestamp a stored score row carries: noon UTC on the scored + * (previous) day. Noon (not midnight) keeps the row inside its own UTC day + * under any reasonable display-timezone offset, mirroring the daily-stats + * convention so a chart never mis-buckets the point at a day boundary. + */ +export function scoreMeasuredAt(now: Date): Date { + return new Date(`${scoreDayKey(now)}T12:00:00.000Z`); +} + +/** + * The full per-day idempotency-key `externalId` for a score row: + * `prefix:YYYY-MM-DD` over the scored (previous) day. `prefix` carries its + * own trailing colon (`recovery:` / `stress:` / `strain:`). + */ +export function scoreExternalId(prefix: string, now: Date): string { + return `${prefix}${scoreDayKey(now)}`; +} + +/** + * Upsert one user's score row for the scored day. Idempotent on the + * `(userId, type, source = COMPUTED, externalId)` key so a re-fired cron tick + * (or a manual re-run) overwrites the day's row in place rather than minting a + * duplicate. The three engines differ only by `type` + `externalId` prefix; + * everything else (source, unit, the noon-UTC `measuredAt`) is shared. + */ +export async function upsertScoreRow( + prisma: PrismaClient, + args: { + userId: string; + type: MeasurementType; + externalIdPrefix: string; + score: number; + now: Date; + }, +): Promise { + const externalId = scoreExternalId(args.externalIdPrefix, args.now); + const measuredAt = scoreMeasuredAt(args.now); + + await prisma.measurement.upsert({ + where: { + userId_type_source_externalId: { + userId: args.userId, + type: args.type, + source: "COMPUTED", + externalId, + }, + }, + create: { + userId: args.userId, + type: args.type, + source: "COMPUTED", + value: args.score, + unit: "score", + measuredAt, + externalId, + }, + update: { + value: args.score, + measuredAt, + }, + }); +} + +/** The tally every score pass returns. */ +export interface ScoreBatchResult { + considered: number; + stored: number; + insufficient: number; + errored: number; +} + +/** + * Run one score pass over a candidate cohort. Each user is scored + * independently through `persistFn`; a single user's error is recorded and + * the pass continues (one bad account never blocks the cohort). Emits the + * pass tally under `actionName`. This is the identical loop the three nightly + * jobs each carried — only the persist function + the annotate action name + * differed. + */ +export async function runScoreBatch( + userIds: string[], + now: Date, + persistFn: ( + userId: string, + now: Date, + ) => Promise<{ outcome: "stored" | "insufficient" }>, + actionName: string, +): Promise { + let stored = 0; + let insufficient = 0; + let errored = 0; + for (const userId of userIds) { + try { + const result = await persistFn(userId, now); + if (result.outcome === "stored") stored += 1; + else insufficient += 1; + } catch { + errored += 1; + } + } + + annotate({ + action: { + name: actionName, + details: { considered: userIds.length, stored, insufficient, errored }, + }, + }); + + return { considered: userIds.length, stored, insufficient, errored }; +} diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index f572a2540..3bc69f476 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -47,7 +47,13 @@ * from the nightly pg-boss job in `src/lib/jobs/strain-score.ts`. */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { + scoreDayKey, + scoreExternalId, + scoreMeasuredAt, + upsertScoreRow, +} from "@/lib/insights/score-row"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -70,19 +76,25 @@ export const STRAIN_TRIMP_REFERENCE = 150; */ export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600; -/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ +/** + * The UTC calendar day a Strain run scores — the PREVIOUS day relative to + * `now`. The cron fires in the small hours; scoring the just-ended day is what + * lets the engine see that day's completed workouts + active-energy total + * rather than a few hours of the current day. Delegates to the shared + * `scoreDayKey` so all three engines agree. + */ export function strainDayKey(now: Date): string { - return now.toISOString().slice(0, 10); + return scoreDayKey(now); } -/** The full `externalId` for a given day's Strain score row. */ +/** The full `externalId` for a given run's Strain score row. */ export function strainExternalId(now: Date): string { - return `${STRAIN_SCORE_EXTERNAL_ID_PREFIX}${strainDayKey(now)}`; + return scoreExternalId(STRAIN_SCORE_EXTERNAL_ID_PREFIX, now); } -/** Noon UTC on the scored day — same convention as the other scores. */ +/** Noon UTC on the scored (previous) day — same convention as the other scores. */ export function strainMeasuredAt(now: Date): Date { - return new Date(`${strainDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } /** Tanaka 2001 age-predicted maximum heart rate. */ @@ -274,15 +286,13 @@ export async function loadStrainInputs( userId: string, now: Date, ): Promise<{ profile: StrainProfile; hrRest: number | null }> { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { dateOfBirth: true, gender: true }, - }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; + // Reuse the shared profile loader, then narrow to the Strain profile + // (age + sex; height is not a TRIMP input). One loader for all three + // engines + the derived route. + const base = await loadBaselineProfile(prisma, userId); const profile: StrainProfile = { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, + ageYears: base.ageYears, + sex: base.sex, }; // Most recent resting-HR reading within a 30-day window — the HRrest input @@ -330,31 +340,12 @@ export async function persistStrainScore( return { outcome: "insufficient", score: null, reason }; } - const externalId = strainExternalId(now); - const measuredAt = strainMeasuredAt(now); - - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: "STRAIN_SCORE", - source: "COMPUTED", - externalId, - }, - }, - create: { - userId, - type: "STRAIN_SCORE", - source: "COMPUTED", - value: score, - unit: "score", - measuredAt, - externalId, - }, - update: { - value: score, - measuredAt, - }, + await upsertScoreRow(prisma, { + userId, + type: "STRAIN_SCORE", + externalIdPrefix: STRAIN_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score, reason }; diff --git a/src/lib/insights/stress-score.ts b/src/lib/insights/stress-score.ts index 46eecc18d..471371608 100644 --- a/src/lib/insights/stress-score.ts +++ b/src/lib/insights/stress-score.ts @@ -48,10 +48,18 @@ * `src/lib/jobs/stress-score.ts`. */ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; -import { computeVitalsBaseline } from "@/lib/insights/derived/baseline"; +import { + computeVitalsBaseline, + loadBaselineProfile, +} from "@/lib/insights/derived/baseline"; import { scoreDeviation } from "@/lib/insights/derived/readiness"; import type { BaselineProfile } from "@/lib/insights/derived/baseline"; +import { + scoreDayKey, + scoreExternalId, + scoreMeasuredAt, + upsertScoreRow, +} from "@/lib/insights/score-row"; /** The per-day idempotency-key prefix for a stored Stress score row. */ export const STRESS_SCORE_EXTERNAL_ID_PREFIX = "stress:"; @@ -68,23 +76,24 @@ export const STRESS_MIN_INTRADAY_SAMPLES = 3; const MS_PER_DAY = 24 * 60 * 60 * 1000; -/** The UTC calendar day a `now` falls in, as `YYYY-MM-DD`. */ -export function stressDayKey(now: Date): string { - return now.toISOString().slice(0, 10); -} - -/** The full `externalId` for a given day's Stress score row. */ -export function stressExternalId(now: Date): string { - return `${STRESS_SCORE_EXTERNAL_ID_PREFIX}${stressDayKey(now)}`; -} - /** - * The canonical timestamp a stored Stress row carries: noon UTC on the - * scored day — same day-keying convention as the Recovery score so the - * series lines up with the buckets it summarises. + * The UTC calendar day a Stress run scores — the PREVIOUS day relative to + * `now`. The cron fires in the small hours; scoring the just-ended day is what + * gives the intra-day SDNN set a full day of samples instead of a near-empty + * few hours. Delegates to the shared `scoreDayKey` so all three engines agree. */ +export function stressDayKey(now: Date): string { + return scoreDayKey(now); +} + +/** The full `externalId` for a given run's Stress score row. */ +export function stressExternalId(now: Date): string { + return scoreExternalId(STRESS_SCORE_EXTERNAL_ID_PREFIX, now); +} + +/** The canonical timestamp a stored Stress row carries (noon UTC, scored day). */ export function stressMeasuredAt(now: Date): Date { - return new Date(`${stressDayKey(now)}T12:00:00.000Z`); + return scoreMeasuredAt(now); } /** @@ -198,24 +207,15 @@ export async function computeStressScore( } /** - * Build the `BaselineProfile` the SDNN baseline needs from the user row — - * the same shape the live `/api/insights/derived` route builds. + * Build the `BaselineProfile` the SDNN baseline needs from the user row. Thin + * alias over the shared `loadBaselineProfile` so callers / tests keep the + * name. */ export async function loadStressProfile( prisma: PrismaClient, userId: string, ): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null; - return { - ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), - sex, - heightCm: user?.heightCm ?? null, - }; + return loadBaselineProfile(prisma, userId); } export interface PersistStressResult { @@ -252,31 +252,12 @@ export async function persistStressScore( return { outcome: "insufficient", score: null, reason }; } - const externalId = stressExternalId(now); - const measuredAt = stressMeasuredAt(now); - - await prisma.measurement.upsert({ - where: { - userId_type_source_externalId: { - userId, - type: "STRESS_SCORE", - source: "COMPUTED", - externalId, - }, - }, - create: { - userId, - type: "STRESS_SCORE", - source: "COMPUTED", - value: score, - unit: "score", - measuredAt, - externalId, - }, - update: { - value: score, - measuredAt, - }, + await upsertScoreRow(prisma, { + userId, + type: "STRESS_SCORE", + externalIdPrefix: STRESS_SCORE_EXTERNAL_ID_PREFIX, + score, + now, }); return { outcome: "stored", score, reason }; diff --git a/src/lib/jobs/recovery-score.ts b/src/lib/jobs/recovery-score.ts index 316d2bec4..08918f274 100644 --- a/src/lib/jobs/recovery-score.ts +++ b/src/lib/jobs/recovery-score.ts @@ -21,8 +21,8 @@ * comment guards against). */ import type { PrismaClient } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; import { persistRecoveryScore } from "@/lib/insights/recovery-score"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const RECOVERY_SCORE_QUEUE = "recovery-score-compute"; @@ -101,30 +101,10 @@ export async function runRecoveryScore( const userIds = await findRecoveryScoreCandidates(prisma, now, cap); - let stored = 0; - let insufficient = 0; - let errored = 0; - for (const userId of userIds) { - try { - const result = await persistRecoveryScore(prisma, userId, now); - if (result.outcome === "stored") stored += 1; - else insufficient += 1; - } catch { - errored += 1; - } - } - - annotate({ - action: { - name: "insights.recovery.compute", - details: { - considered: userIds.length, - stored, - insufficient, - errored, - }, - }, - }); - - return { considered: userIds.length, stored, insufficient, errored }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistRecoveryScore(prisma, userId, runNow), + "insights.recovery.compute", + ); } diff --git a/src/lib/jobs/strain-score.ts b/src/lib/jobs/strain-score.ts index 364f306c9..4415ffb48 100644 --- a/src/lib/jobs/strain-score.ts +++ b/src/lib/jobs/strain-score.ts @@ -18,8 +18,8 @@ * unregistered queue silently never drains. */ import type { PrismaClient } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; import { persistStrainScore } from "@/lib/insights/strain-score"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const STRAIN_SCORE_QUEUE = "strain-score-compute"; @@ -95,25 +95,10 @@ export async function runStrainScore( const userIds = await findStrainScoreCandidates(prisma, now, cap); - let stored = 0; - let insufficient = 0; - let errored = 0; - for (const userId of userIds) { - try { - const result = await persistStrainScore(prisma, userId, now); - if (result.outcome === "stored") stored += 1; - else insufficient += 1; - } catch { - errored += 1; - } - } - - annotate({ - action: { - name: "insights.strain.compute", - details: { considered: userIds.length, stored, insufficient, errored }, - }, - }); - - return { considered: userIds.length, stored, insufficient, errored }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistStrainScore(prisma, userId, runNow), + "insights.strain.compute", + ); } diff --git a/src/lib/jobs/stress-score.ts b/src/lib/jobs/stress-score.ts index 9f7accffb..2064a9ddc 100644 --- a/src/lib/jobs/stress-score.ts +++ b/src/lib/jobs/stress-score.ts @@ -19,8 +19,8 @@ * unregistered queue silently never drains. */ import type { PrismaClient } from "@/generated/prisma/client"; -import { annotate } from "@/lib/logging/context"; import { persistStressScore } from "@/lib/insights/stress-score"; +import { runScoreBatch } from "@/lib/insights/score-row"; export const STRESS_SCORE_QUEUE = "stress-score-compute"; @@ -85,25 +85,10 @@ export async function runStressScore( const userIds = await findStressScoreCandidates(prisma, now, cap); - let stored = 0; - let insufficient = 0; - let errored = 0; - for (const userId of userIds) { - try { - const result = await persistStressScore(prisma, userId, now); - if (result.outcome === "stored") stored += 1; - else insufficient += 1; - } catch { - errored += 1; - } - } - - annotate({ - action: { - name: "insights.stress.compute", - details: { considered: userIds.length, stored, insufficient, errored }, - }, - }); - - return { considered: userIds.length, stored, insufficient, errored }; + return runScoreBatch( + userIds, + now, + (userId, runNow) => persistStressScore(prisma, userId, runNow), + "insights.stress.compute", + ); } From cd8e34dac7ca365cbd34a114b953046f698aa7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:38:33 +0200 Subject: [PATCH 34/49] fix(insights): place HRV balance against the recent trend, not the band center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeHrvBalance read recentAvg = baseline.value.center, which is the band midpoint by construction and therefore always inside [low, high] — so the "low" and "unbalanced" arms were dead and the card could only ever read "balanced". Compute recentAvg from the last <= 7 DAY means (the recent trend) and place that against the longer-window personal band, so a suppressed recent trend flags "low" and an elevated one flags "unbalanced". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../derived/__tests__/hrv-balance.test.ts | 84 ++++++++++++++++++- src/lib/insights/derived/hrv-balance.ts | 47 +++++++++-- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/lib/insights/derived/__tests__/hrv-balance.test.ts b/src/lib/insights/derived/__tests__/hrv-balance.test.ts index b293eb19a..ddfde8842 100644 --- a/src/lib/insights/derived/__tests__/hrv-balance.test.ts +++ b/src/lib/insights/derived/__tests__/hrv-balance.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const computeVitalsBaseline = vi.fn(); +const readDayMeanSeries = vi.fn(); vi.mock("../baseline", () => ({ computeVitalsBaseline: (...a: unknown[]) => computeVitalsBaseline(...a), + readDayMeanSeries: (...a: unknown[]) => readDayMeanSeries(...a), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), })); import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; @@ -10,7 +15,20 @@ import { computeHrvBalance, placeHrvBalance } from "../hrv-balance"; const NOW = new Date("2026-06-02T07:00:00Z"); const PROFILE = { ageYears: 40, sex: "MALE" as const }; -beforeEach(() => vi.clearAllMocks()); +/** Build a `readDayMeanSeries` return from a list of recent DAY means. */ +function recentSeries(means: number[]) { + return { + points: means.map((mean, i) => ({ day: `2026-05-${20 + i}`, mean })), + source: "DAY" as const, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Default: the recent trend sits on the band center so unrelated tests stay + // "balanced" unless they override the series. + readDayMeanSeries.mockResolvedValue(recentSeries([60, 60, 60])); +}); describe("placeHrvBalance", () => { it("low below the band floor", () => { @@ -40,7 +58,7 @@ describe("computeHrvBalance", () => { } }); - it("builds a balanced band off the baseline center", async () => { + it("builds a balanced band when the recent trend sits inside the band", async () => { computeVitalsBaseline.mockResolvedValueOnce({ status: "ok", value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, @@ -48,14 +66,74 @@ describe("computeHrvBalance", () => { confidence: { score: 70, band: "medium" }, provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, }); + // Recent trend right around the center → balanced. + readDayMeanSeries.mockResolvedValueOnce(recentSeries([58, 62, 60])); const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); expect(r.status).toBe("ok"); if (r.status === "ok") { expect(r.value.band).toBe("balanced"); - expect(r.value.recentAvg).toBe(60); + expect(r.value.recentAvg).toBeCloseTo(60, 5); expect(r.value.baselineLow).toBe(40); expect(r.value.baselineHigh).toBe(80); expect(r.value.sampleDays).toBe(21); } }); + + it("flags 'low' when the RECENT trend is suppressed below the band, not the band center", async () => { + // The band center sits inside [low,high] by construction; the previous + // implementation read it directly so the recentAvg could never go low. + // Drive a suppressed recent 7-day trend and assert it places "low". + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + readDayMeanSeries.mockResolvedValueOnce(recentSeries([30, 28, 32])); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("low"); + expect(r.value.recentAvg).toBeCloseTo(30, 5); + } + }); + + it("flags 'unbalanced' when the recent trend runs above the band high edge", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + readDayMeanSeries.mockResolvedValueOnce(recentSeries([95, 100, 98])); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.band).toBe("unbalanced"); + expect(r.value.recentAvg).toBeGreaterThan(80); + } + }); + + it("uses only the last ≤ 7 DAY means for the recent trend", async () => { + computeVitalsBaseline.mockResolvedValueOnce({ + status: "ok", + value: { type: "HEART_RATE_VARIABILITY", center: 60, low: 40, high: 80, spread: 20, sampleDays: 21, k: 3 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 21, missing: [] }, + confidence: { score: 70, band: "medium" }, + provenance: { inputs: ["HEART_RATE_VARIABILITY"], source: "DAY", windowDays: 30, computedAt: "x" }, + }); + // 10 days: the first three (high) must NOT pull the recent mean up — only + // the last 7 (all ~30) count, so the trend is suppressed → "low". + readDayMeanSeries.mockResolvedValueOnce( + recentSeries([90, 90, 90, 30, 30, 30, 30, 30, 30, 30]), + ); + const r = await computeHrvBalance("u1", PROFILE, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.recentAvg).toBeCloseTo(30, 5); + expect(r.value.band).toBe("low"); + } + }); }); diff --git a/src/lib/insights/derived/hrv-balance.ts b/src/lib/insights/derived/hrv-balance.ts index 8cb67fc9d..abff1c479 100644 --- a/src/lib/insights/derived/hrv-balance.ts +++ b/src/lib/insights/derived/hrv-balance.ts @@ -29,19 +29,29 @@ * placement helper is exported pure for the unit tests. */ import type { MeasurementType } from "@/generated/prisma/client"; -import type { RollupCoverageMap } from "@/lib/rollups/measurement-coverage"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; import { buildInsufficient, buildOk, deriveCoverage, nowProvenanceTimestamp, } from "./coverage"; -import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { + computeVitalsBaseline, + readDayMeanSeries, + type BaselineProfile, +} from "./baseline"; import { isDerivedOk } from "./types"; import type { Derived } from "./types"; const HRV_TYPE: MeasurementType = "HEART_RATE_VARIABILITY"; +/** The recent trend rides the last ≤ this many DAY means. */ +const RECENT_TREND_DAYS = 7; + /** Personal-trend balance placement. */ export type HrvBalanceBand = "balanced" | "unbalanced" | "low"; @@ -89,12 +99,16 @@ export async function computeHrvBalance( ): Promise> { const now = opts?.now ?? new Date(); const computedAt = nowProvenanceTimestamp(now); + // One coverage probe shared between the baseline read below and the + // recent-trend read (the pool-contention mitigation; the route passes a + // pre-probed map). + const coverageMap = opts?.coverage ?? (await probeRollupCoverage(userId)); const baseline = await computeVitalsBaseline(userId, profile, { type: HRV_TYPE, windowDays: opts?.windowDays, now, - coverage: opts?.coverage, + coverage: coverageMap, }); // Below the band floor (or no data) — surface the baseline engine's gated @@ -107,11 +121,28 @@ export async function computeHrvBalance( }); } - // The baseline already computed the robust center+band over DAY means; the - // recent average rides the same center for a stable, sparse-cadence-safe - // placement (per-day SDNN is noisy; the median center is the honest - // "recent typical"). - const recentAvg = baseline.value.center; + // The recent average is the mean of the last ≤ 7 DAY means — the RECENT + // TREND, placed against the longer-window personal band. Reading the band + // center here (the previous behaviour) made `recentAvg` identical to the + // band midpoint by construction, so it could never fall outside the band: + // the "low" / "unbalanced" arms were dead and the card always read + // "balanced". The recent-trend mean can drift below the band's low edge + // (suppressed HRV) or above its high edge, which is the whole point of a + // trend-vs-baseline placement. Falls back to the band center only when the + // recent series is somehow empty (the baseline being `ok` already implies + // ≥ 7 backing days, so this is defensive). + const recent = await readDayMeanSeries( + userId, + HRV_TYPE, + baseline.provenance.windowDays, + now, + coverageMap, + ); + const recentPoints = recent.points.slice(-RECENT_TREND_DAYS); + const recentAvg = + recentPoints.length > 0 + ? recentPoints.reduce((s, p) => s + p.mean, 0) / recentPoints.length + : baseline.value.center; const band = placeHrvBalance( recentAvg, baseline.value.low, From fc6924d52ff434d50f2b0de493e8339f11e51bf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:38:42 +0200 Subject: [PATCH 35/49] fix(insights): circular sleep-midpoint distance and explicit efficiency overlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep-score consistency + timing sub-scores measured the midpoint as a linear minutes-of-day difference, so a sleeper whose midpoint straddles midnight (and any night across a DST shift) read a spurious ~24-hour spread that inflated the SD and timing distance. Measure the midpoint on the circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint and a circular distance for the SD and timing offset. Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to 100, masking a data problem. Handle the overlap explicitly: a small overshoot caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross overshoot drops the sub-score because the in-bed denominator is not usable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../derived/__tests__/sleep-score.test.ts | 44 +++++++++ src/lib/insights/derived/sleep-score.ts | 94 +++++++++++++++---- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index 1dc05154a..335ceabae 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -15,6 +15,8 @@ import { scoreComposition, scoreConsistency, scoreTiming, + circularMinuteDistance, + circularMeanMinutes, type SleepSubScoreKey, } from "../sleep-score"; @@ -51,6 +53,15 @@ describe("pure sleep scorers", () => { expect(scoreEfficiency(420, 480)).toBe(88); }); + it("efficiency handles asleep > in-bed overlap explicitly, not via a blind clamp", () => { + // A small overshoot (≤ 5 %, overlapping HK stages) caps at the AASM + // ceiling of 100 rather than reporting > 100 %. + expect(scoreEfficiency(485, 480)).toBe(100); // ~101 % → 100 + // A gross overshoot means the in-bed denominator is not usable — drop the + // sub-score (null) rather than masking the data problem with a fake 100. + expect(scoreEfficiency(600, 480)).toBeNull(); // 125 % → unusable + }); + it("composition is null without a stage breakdown, 100 inside the band", () => { expect(scoreComposition(0, 0, 420, false)).toBeNull(); // REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48]. @@ -66,6 +77,39 @@ describe("pure sleep scorers", () => { expect(scoreTiming(180, null, 1)).toBeNull(); expect(scoreTiming(180, 180, 5)).toBe(100); }); + + it("circular distance wraps across midnight (mod 1440)", () => { + // 23:50 (1430) and 00:10 (10) are 20 min apart, not 1420. + expect(circularMinuteDistance(1430, 10)).toBe(20); + expect(circularMinuteDistance(10, 1430)).toBe(20); + // Within-day distance unchanged. + expect(circularMinuteDistance(100, 160)).toBe(60); + }); + + it("circular mean of midnight-straddling midpoints stays near midnight", () => { + // 23:50, 00:10, 00:00 cluster around midnight; a linear mean would + // collapse to ~08:00 (480). The circular mean stays near 0/1440. + const mean = circularMeanMinutes([1430, 10, 0])!; + const distFromMidnight = Math.min(mean, 1440 - mean); + expect(distFromMidnight).toBeLessThan(20); + }); + + it("consistency does NOT penalise a midnight-straddling sleeper", () => { + // Tight cluster around midnight: circular SD is small → high score. A + // linear SD would read a spurious ~24-h spread → 0. + const score = scoreConsistency([1430, 10, 0, 1435, 5])!; + expect(score).toBeGreaterThan(80); + }); + + it("timing uses circular distance across the wrap", () => { + // Night midpoint 00:05 (5) vs habitual 23:55 (1435) is 10 min off (NOT + // 1430), so the score stays high. A linear distance would read ~1430 → + // clamp to 0. + const wrapped = scoreTiming(5, 1435, 5)!; + expect(wrapped).toBeGreaterThan(85); + // 10 min off the habitual midpoint within the day scores identically. + expect(scoreTiming(100, 110, 5)).toBe(wrapped); + }); }); describe("blendSleepSubScores reweighting", () => { diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index 600734e31..e3e3359ff 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -121,9 +121,20 @@ export function scoreSufficiency( } /** - * Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor - * maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy - * nights with only asleep stages and no IN_BED row). + * Efficiency: asleep ÷ in-bed (AASM convention; ≥ 85 % is the clinical + * "good" floor). Null when in-bed is unknown (legacy nights with only asleep + * stages and no IN_BED row). + * + * Overlap handling: HealthKit can report ASLEEP-class stages whose summed + * minutes exceed the explicit IN_BED total when stages overlap (the watch + * writes per-sample stage rows and an IN_BED block that don't tile cleanly). + * Treating that as a real ratio yields efficiency > 100 %, which a blind + * `clamp(…, 100)` would silently swallow as a perfect night and hide the + * data problem. Instead, treat asleep > in-bed as the impossible-overlap case + * it is: cap efficiency at 100 % (asleep cannot exceed time in bed) but only + * when the overshoot is within a small tolerance — a gross overshoot signals + * a malformed night with no trustworthy in-bed denominator, so we drop the + * sub-score (null) rather than report a fabricated 100. */ export function scoreEfficiency( asleepMinutes: number, @@ -131,6 +142,14 @@ export function scoreEfficiency( ): number | null { if (inBedMinutes == null || inBedMinutes <= 0) return null; const pct = (asleepMinutes / inBedMinutes) * 100; + if (pct > 100) { + // A small overshoot (≤ 5 %) is rounding / stage-boundary noise — asleep + // cannot truly exceed in-bed, so cap at the AASM ceiling of 100. A larger + // overshoot means the in-bed total is not a usable denominator; the night + // has no honest efficiency, so it drops from the blend. + const EFFICIENCY_OVERSHOOT_TOLERANCE = 105; + return pct <= EFFICIENCY_OVERSHOOT_TOLERANCE ? 100 : null; + } return clamp100(pct); } @@ -158,27 +177,67 @@ export function scoreComposition( return clamp100(100 * (1 - dist / halfWidth)); } +const MINUTES_PER_DAY = 1440; + /** - * Consistency: lower SD of the sleep midpoint (minutes-of-day) across the - * window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null - * below 3 nights (no variance signal). + * Shortest distance between two minutes-of-day on the 24-hour clock, treating + * the day as circular (mod 1440). A midpoint at 23:50 (1430) and one at 00:10 + * (10) are 20 minutes apart, NOT 1420 — the linear difference inflates timing + * SD and distance for sleepers whose midpoint straddles midnight (and on a DST + * shift). Pure. + */ +export function circularMinuteDistance(a: number, b: number): number { + const raw = Math.abs(a - b) % MINUTES_PER_DAY; + return Math.min(raw, MINUTES_PER_DAY - raw); +} + +/** + * Circular mean of minutes-of-day via the mean resultant vector (the standard + * directional-statistics circular mean) — averaging clock minutes linearly + * collapses a midnight-straddling cluster to noon. Returns null on an empty + * input or a near-zero resultant (no defined mean direction). Pure. + */ +export function circularMeanMinutes(minutes: number[]): number | null { + if (minutes.length === 0) return null; + let sumSin = 0; + let sumCos = 0; + for (const m of minutes) { + const angle = (m / MINUTES_PER_DAY) * 2 * Math.PI; + sumSin += Math.sin(angle); + sumCos += Math.cos(angle); + } + if (Math.abs(sumSin) < 1e-9 && Math.abs(sumCos) < 1e-9) return null; + let meanAngle = Math.atan2(sumSin, sumCos); + if (meanAngle < 0) meanAngle += 2 * Math.PI; + return (meanAngle / (2 * Math.PI)) * MINUTES_PER_DAY; +} + +/** + * Consistency: lower circular SD of the sleep midpoint (minutes-of-day) + * across the window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. + * Null below 3 nights (no variance signal). The SD is computed against the + * CIRCULAR mean using circular distance so a midnight-straddling sleeper is + * not penalised for a spurious ~24-hour spread. */ export function scoreConsistency(midpointMinutes: number[]): number | null { if (midpointMinutes.length < 3) return null; - const mean = - midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length; + const mean = circularMeanMinutes(midpointMinutes); + if (mean == null) return null; const variance = - midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) / - midpointMinutes.length; + midpointMinutes.reduce( + (s, v) => s + circularMinuteDistance(v, mean) ** 2, + 0, + ) / midpointMinutes.length; const sd = Math.sqrt(variance); const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic. return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE)); } /** - * Timing: this night's midpoint vs the user's habitual midpoint (the - * window mean). On-target → 100; 90 min off → 0. Null when the habitual - * window is not yet established (< 3 nights). + * Timing: this night's midpoint vs the user's habitual midpoint (the window + * circular mean). On-target → 100; 90 min off → 0. Null when the habitual + * window is not yet established (< 3 nights). Uses circular distance so a + * midnight-straddling night is measured by its true clock offset. */ export function scoreTiming( nightMidpoint: number | null, @@ -189,7 +248,7 @@ export function scoreTiming( return null; } const FULL_SCALE = 90; - const dist = Math.abs(nightMidpoint - habitualMidpoint); + const dist = circularMinuteDistance(nightMidpoint, habitualMidpoint); return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE)); } @@ -406,10 +465,9 @@ export async function computeSleepScore( const midpoints = scorableNights .map((n) => n.midpoint) .filter((m): m is number => m != null); - const habitualMidpoint = - midpoints.length > 0 - ? midpoints.reduce((s, v) => s + v, 0) / midpoints.length - : null; + // Circular mean (mod 1440) so a midnight-straddling sleeper's habitual + // midpoint is not dragged to noon by a linear average. + const habitualMidpoint = circularMeanMinutes(midpoints); const needMinutes = sleepNeedMinutes(profile.ageYears); From dabdfdf68e807a70c1ad3493c07f5b3f2d00b323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:38:48 +0200 Subject: [PATCH 36/49] fix(insights): confidence reflects real history depth; readiness source folds sleep + mood The readiness and coincident-deviation composites passed historyDays = windowDays (a constant) into deriveCoverage, pinning historyFraction to 1 so a 7-day and a 30-day blend reported identical confidence. Pass the real distinct-history-days backing the composite (the depth of the best-supported present contributor) so confidence tracks actual depth. The readiness provenance source was derived only from RHR/HRV/respiratory, so a blend backed solely by sleep + mood could mislabel as DAY. Fold the present sleep + mood provenance in so the source chip matches what actually backed the headline. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../derived/__tests__/readiness.test.ts | 84 +++++++++++++++++++ .../insights/derived/coincident-deviation.ts | 11 ++- src/lib/insights/derived/readiness.ts | 78 ++++++++++++++--- 3 files changed, 160 insertions(+), 13 deletions(-) diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index 44dfd345f..0dece59aa 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -114,4 +114,88 @@ describe("computeReadiness gating", () => { expect(result.value.score).toBeLessThanOrEqual(100); } }); + + it("reports confidence reflecting REAL history depth, not the constant window", async () => { + // 10 distinct backing days on a 30-day window. Pre-fix `historyDays` was + // pinned to `windowDays` so `historyFraction` was always 1 and confidence + // hit the ceiling; post-fix it reflects the 10/30 depth and lands below a + // full-history blend. + const DAY_MS = 24 * 60 * 60 * 1000; + const days = (type: string, base: number, count: number) => + Array.from({ length: count }, (_, i) => ({ + value: base, + // i+1 days before NOW so each row lands on a distinct prior UTC day + // inside the 30-day window (valid calendar dates across the month). + measuredAt: new Date(NOW.getTime() - (i + 1) * DAY_MS), + type, + })); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58, 10); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65, 10); + return []; + }, + ); + const shallow = await computeReadiness("u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58, 30); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65, 30); + return []; + }, + ); + const deep = await computeReadiness("u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + + expect(shallow.status).toBe("ok"); + expect(deep.status).toBe("ok"); + if (shallow.status === "ok" && deep.status === "ok") { + expect(shallow.coverage.historyDays).toBe(10); + expect(deep.coverage.historyDays).toBe(30); + // A 10-day blend must report lower confidence than a 30-day one. + expect(shallow.confidence.score).toBeLessThan(deep.confidence.score); + } + }); + + it("provenance source is 'live' for a blend backed only by sleep + mood", async () => { + // No vital readings (RHR/HRV/resp all gate). Mood entries present + a + // scorable sleep night → the two present components are both live reads, + // so the source chip must read 'live', never DAY. + moodFindMany.mockResolvedValue( + Array.from({ length: 8 }, (_, i) => ({ + score: 4, + moodLoggedAt: new Date(`2026-05-${String(20 + i).padStart(2, "0")}T20:00:00Z`), + })), + ); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "SLEEP_DURATION") { + // Three scorable nights so the sleep score lights up. + return [ + { value: 420, measuredAt: new Date("2026-05-31T06:00:00Z"), sleepStage: "ASLEEP" }, + { value: 430, measuredAt: new Date("2026-06-01T06:10:00Z"), sleepStage: "ASLEEP" }, + { value: 410, measuredAt: new Date("2026-06-02T06:05:00Z"), sleepStage: "ASLEEP" }, + ]; + } + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const present = result.value.components.filter((c) => c.value !== null); + const keys = present.map((c) => c.key).sort(); + expect(keys).toEqual(["mood", "sleep"]); + expect(result.provenance.source).toBe("live"); + } + }); }); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 74359d7f5..518857dda 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -152,6 +152,7 @@ export async function computeCoincidentDeviation( const vitals: VitalDeviation[] = []; let latestDay = ""; let anyDaySource = false; + let maxHistoryDays = 0; // Fan the baseline engine across the supported vitals. Each baseline read // shares the one coverage probe (no per-vital re-probe). @@ -167,6 +168,9 @@ export async function computeCoincidentDeviation( const latest = await readLatestDayMean(userId, type, windowDays, now); if (!latest) continue; if (latest.day > latestDay) latestDay = latest.day; + if (baseline.coverage.historyDays > maxHistoryDays) { + maxHistoryDays = baseline.coverage.historyDays; + } vitals.push( classifyDeviation( type, @@ -209,7 +213,12 @@ export async function computeCoincidentDeviation( // The coverage axis is "how many vitals could have coincided". requiredInputs: vitals.length, presentInputs: vitals.length, - historyDays: windowDays, + // v1.10.0 QA: the REAL distinct-history-days backing the deepest + // contributing vital, not the constant `windowDays` (which pinned + // `historyFraction` to 1 so a 7-day and a 30-day blend reported the same + // confidence). The composite is at least as well-backed as its + // best-supported vital. + historyDays: maxHistoryDays, missing: [], fullHistoryDays: windowDays, }); diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts index 1a2d4ccb2..e1908a9c7 100644 --- a/src/lib/insights/derived/readiness.ts +++ b/src/lib/insights/derived/readiness.ts @@ -196,7 +196,12 @@ async function scoreVitalDeviation( windowDays: number, now: Date, coverage: RollupCoverageMap, -): Promise<{ value: number | null; source: DerivedProvenanceSource }> { +): Promise<{ + value: number | null; + source: DerivedProvenanceSource; + /** Distinct history days backing this component's baseline (0 when gated). */ + historyDays: number; +}> { const baseline = await computeVitalsBaseline(userId, profile, { type, windowDays, @@ -204,11 +209,19 @@ async function scoreVitalDeviation( coverage, }); if (baseline.status !== "ok") { - return { value: null, source: baseline.provenance.source }; + return { + value: null, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } const today = await readLatestDayMean(userId, type, windowDays, now); if (today == null) { - return { value: null, source: baseline.provenance.source }; + return { + value: null, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } const value = scoreDeviation( today, @@ -216,7 +229,11 @@ async function scoreVitalDeviation( baseline.value.spread, direction, ); - return { value, source: baseline.provenance.source }; + return { + value, + source: baseline.provenance.source, + historyDays: baseline.coverage.historyDays, + }; } // ── compute ───────────────────────────────────────────────────────────── @@ -285,10 +302,13 @@ export async function computeReadiness( // null when there is no scorable night, dropping the component). const sleep = await computeSleepScore(userId, profile, { windowDays, now }); const sleepValue = sleep.status === "ok" ? sleep.value.score : null; + const sleepHistoryDays = + sleep.status === "ok" ? sleep.coverage.historyDays : 0; // Mood stability — reuse the shipped mood-aggregate stability score over // the window's daily means. - const moodValue = await readMoodStability(userId, windowDays, now); + const mood = await readMoodStability(userId, windowDays, now); + const moodValue = mood.value; const raw: Record = { rhr: rhr.value, @@ -304,10 +324,41 @@ export async function computeReadiness( .filter((c) => c.value === null) .map((c) => c.key); - // Provenance source: DAY when any vital read the rollup tier, else live. - const anyDay = [rhr.source, hrv.source, resp.source].includes("DAY"); + // The REAL history depth backing the composite. v1.10.0 QA: this used to be + // pinned to `windowDays` (the constant), so `historyFraction` was always 1 + // and a 7-day blend reported the same confidence as a 30-day one. Take the + // history of the deepest PRESENT component — the blend is at least as + // well-backed as its best-supported contributor — so confidence reflects + // actual depth. + const presentHistories: number[] = []; + if (rhr.value !== null) presentHistories.push(rhr.historyDays); + if (hrv.value !== null) presentHistories.push(hrv.historyDays); + if (resp.value !== null) presentHistories.push(resp.historyDays); + if (sleepValue !== null) presentHistories.push(sleepHistoryDays); + if (moodValue !== null) presentHistories.push(mood.historyDays); + const compositeHistoryDays = + presentHistories.length > 0 ? Math.max(...presentHistories) : 0; + + // Provenance source (#6): DAY when any PRESENT input read the rollup tier, + // else live. The previous form looked only at RHR/HRV/resp; a blend backed + // solely by sleep + mood (both live reads) mislabelled as DAY whenever one + // of the three gated vitals happened to resolve to DAY despite contributing + // nothing. Fold sleep + mood provenance in so the chip matches what + // actually backed the headline. + const presentSources: DerivedProvenanceSource[] = []; + if (rhr.value !== null) presentSources.push(rhr.source); + if (hrv.value !== null) presentSources.push(hrv.source); + if (resp.value !== null) presentSources.push(resp.source); + // Sleep + mood are live-SQL reads (no rollup tier for per-stage sleep rows + // or mood entries), so a present sleep/mood component contributes "live". + if (sleepValue !== null) presentSources.push(sleep.provenance.source); + if (moodValue !== null) presentSources.push("live"); const source: DerivedProvenanceSource = - presentCount === 0 ? "none" : anyDay ? "DAY" : "live"; + presentCount === 0 + ? "none" + : presentSources.includes("DAY") + ? "DAY" + : "live"; if (presentCount < READINESS_MIN_COMPONENTS) { const { coverage: cov } = deriveCoverage({ @@ -327,7 +378,7 @@ export async function computeReadiness( const { coverage: cov, confidence } = deriveCoverage({ requiredInputs: components.length, presentInputs: presentCount, - historyDays: windowDays, + historyDays: compositeHistoryDays, missing, fullHistoryDays: windowDays, }); @@ -361,7 +412,7 @@ async function readMoodStability( userId: string, windowDays: number, now: Date, -): Promise { +): Promise<{ value: number | null; historyDays: number }> { const since = new Date(now.getTime() - windowDays * MS_PER_DAY); const rows = await prisma.moodEntry.findMany({ where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, @@ -369,7 +420,7 @@ async function readMoodStability( take: 2000, select: { score: true, moodLoggedAt: true }, }); - if (rows.length === 0) return null; + if (rows.length === 0) return { value: null, historyDays: 0 }; // Collapse to daily means keyed by dayOffset (newest = 0), matching the // mood-aggregate DailyPoint shape. const byDay = new Map(); @@ -387,5 +438,8 @@ async function readMoodStability( value: acc.sum / acc.count, })); const stability = computeMoodStability(daily); - return stability ? stability.score : null; + return { + value: stability ? stability.score : null, + historyDays: daily.length, + }; } From f170198a8368a9c9f57ecbad9ec250d7382233fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:38:56 +0200 Subject: [PATCH 37/49] fix(measurements): validate single-POST source against the writable allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it gated the single-entry POST, but the schema only refined out COMPUTED — WITHINGS and IMPORT stayed client-writable, so a client could attribute a row to a source the server owns (the Withings webhook / CSV importer) and pollute the per-source canonical picker. Wire createMeasurementSchema.source to a Zod enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum subsumes the old COMPUTED refine. Regenerate the OpenAPI contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/api/openapi.yaml | 3 - .../validations/__tests__/measurement.test.ts | 57 ++++++++++++------- src/lib/validations/measurement.ts | 34 ++++++----- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 63590ac05..9fbc47346 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -555,10 +555,7 @@ paths: type: string enum: - MANUAL - - WITHINGS - - IMPORT - APPLE_HEALTH - - COMPUTED glucoseContext: type: string enum: diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 1ab1bca1a..3b95a933e 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -287,34 +287,49 @@ describe("measurement validation", () => { expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull(); }); - it("rejects the server-owned COMPUTED source on a client write", () => { - const parsed = createMeasurementSchema.safeParse({ - type: "RECOVERY_SCORE", - value: 72, - measuredAt: "2026-06-02T12:00:00Z", - source: "COMPUTED", - }); - expect(parsed.success).toBe(false); - if (!parsed.success) { - const issue = parsed.error.issues.find((i) => - i.path.includes("source"), - ); - expect(issue, "expected a source-path rejection").toBeDefined(); - expect(issue!.message).toMatch(/server-owned/i); + it("rejects every non-writable source on a client write", () => { + // v1.10.0 QA — the single-POST validates `source` against the + // client-writable subset {MANUAL, APPLE_HEALTH}. COMPUTED (server + // scores), WITHINGS (the Withings webhook), and IMPORT (the CSV + // importer) are all server-owned and must be rejected so a client + // cannot forge a row attributed to a source it does not own. + for (const source of ["COMPUTED", "WITHINGS", "IMPORT"]) { + const parsed = createMeasurementSchema.safeParse({ + type: "WEIGHT", + value: 80, + measuredAt: "2026-06-02T12:00:00Z", + source, + }); + expect(parsed.success, `expected ${source} to be rejected`).toBe(false); + if (!parsed.success) { + const issue = parsed.error.issues.find((i) => + i.path.includes("source"), + ); + expect(issue, `expected a source-path rejection for ${source}`).toBeDefined(); + } } }); - it("still accepts a client-writable source for a score-typed manual row", () => { - // The COMPUTED source is the only one this refine blocks; a MANUAL - // write of any type stays valid (the source guard is orthogonal to - // whether a UI ever offers manual score entry). + it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => { + for (const source of ["MANUAL", "APPLE_HEALTH"]) { + const parsed = createMeasurementSchema.safeParse({ + type: "WEIGHT", + value: 80, + measuredAt: "2026-06-02T12:00:00Z", + source, + }); + expect(parsed.success, `expected ${source} to be accepted`).toBe(true); + } + }); + + it("defaults source to MANUAL when omitted", () => { const parsed = createMeasurementSchema.safeParse({ - type: "RECOVERY_SCORE", - value: 72, + type: "WEIGHT", + value: 80, measuredAt: "2026-06-02T12:00:00Z", - source: "MANUAL", }); expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.source).toBe("MANUAL"); }); }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7c17e4ed1..48c80c76c 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -147,11 +147,24 @@ export const measurementSourceEnum = z.enum([ */ export const WRITABLE_MEASUREMENT_SOURCES = [ "MANUAL", - "WITHINGS", - "IMPORT", "APPLE_HEALTH", ] as const; +/** + * v1.10.0 QA — the Zod enum the single-entry POST validates `source` against. + * Built from `WRITABLE_MEASUREMENT_SOURCES` so the allowlist has one source of + * truth. A client may only attribute a row it actually owns: `MANUAL` (a + * hand-entered reading) or `APPLE_HEALTH` (the HealthKit batch's per-row + * source). `WITHINGS` is owned by the Withings webhook, `IMPORT` by the CSV + * importer, and `COMPUTED` by the nightly score engines — letting a client + * forge any of those would pollute the per-source canonical picker with rows + * the server never produced. The batch route mirrors the same exclusion with + * its own narrower `{APPLE_HEALTH, MANUAL}` `batchSourceEnum`. + */ +export const writableMeasurementSourceEnum = z.enum( + WRITABLE_MEASUREMENT_SOURCES, +); + const unitMap: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", @@ -428,7 +441,11 @@ export const createMeasurementSchema = z value: z.number(), measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(), - source: measurementSourceEnum.optional().default("MANUAL"), + // v1.10.0 QA — validate against the client-writable subset, not the full + // `MeasurementSource` enum. A client may attribute only `MANUAL` or + // `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and + // forging them would pollute the per-source canonical picker. + source: writableMeasurementSourceEnum.optional().default("MANUAL"), // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK // constraint in Postgres (see migration 0021). glucoseContext: glucoseContextEnum.optional(), @@ -453,16 +470,7 @@ export const createMeasurementSchema = z "Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.", path: ["glucoseContext"], }, - ) - // v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned: - // a nightly engine mints those rows, a client never does. Reject it here - // so the single-entry POST cannot forge a server-derived row (the batch - // route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}` - // allowlist). - .refine((data) => data.source !== "COMPUTED", { - message: "The COMPUTED source is server-owned and cannot be set by a client.", - path: ["source"], - }); + ); export const updateMeasurementSchema = z.object({ value: z.number().min(0).max(500000).optional(), From ecf93317ddc7f03b9b81ba9680c10566e3f27aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:43:22 +0200 Subject: [PATCH 38/49] docs: document the wellness-summary report section and correct the computed-scores note --- docs/doctor-report.md | 19 +++++++++++++++++++ .../0106_v1100_computed_scores/migration.sql | 8 ++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/doctor-report.md b/docs/doctor-report.md index bb69e84b5..c3743191e 100644 --- a/docs/doctor-report.md +++ b/docs/doctor-report.md @@ -44,6 +44,25 @@ PDF in a new tab without preserving the filename or fails the blob save). `500` if rendering fails. - Audit action: `doctor-report.pdf.generate`. +## Wellness summary (derived scores) + +Since v1.10.0 the report carries a **Wellness summary** section below the +clinical content, holding the server-derived wellness scores (Recovery, +Stress, Strain) and the other derived metrics where present. It is kept +deliberately separate from the clinical vitals table: + +- Each entry is labelled **descriptive, not a clinical assessment**, and the + scores never enter the vitals table or the clinical aggregations. +- The Stress score is shown as an HRV-derived proxy, not a sensor-measured + stress value. +- In the FHIR export the same scores are emitted as `survey`-category + `Observation`s (text-only `code`, no fabricated LOINC) with a per-Observation + non-clinical note — so a downstream system never mistakes them for a vital + sign or a diagnosis. + +Each derived metric states its inputs, method, and the cited standard it is +grounded in (the same provenance the in-app explainer shows). + ## Implementation notes - `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer. diff --git a/prisma/migrations/0106_v1100_computed_scores/migration.sql b/prisma/migrations/0106_v1100_computed_scores/migration.sql index 6ca88ebd8..3c1f9507f 100644 --- a/prisma/migrations/0106_v1100_computed_scores/migration.sql +++ b/prisma/migrations/0106_v1100_computed_scores/migration.sql @@ -13,10 +13,10 @@ -- -- 2. Three new `measurement_type` values — the server-derived wellness -- scores `RECOVERY_SCORE`, `STRESS_SCORE`, `STRAIN_SCORE` (0–100, unit --- `score`). Only RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + --- STRAIN_SCORE are defined now so the later engines that compute them --- need no schema change. These are NOT clinical vitals — they never --- appear in the doctor-report PDF. +-- `score`), each minted nightly by its own engine. These are NOT +-- clinical vitals: they stay out of the clinical vitals table and +-- surface only in a clearly-labelled, descriptive-not-clinical wellness +-- summary (and as FHIR `survey`-category observations). -- -- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. -- From 60c0ba4483820008cb1b2c39ef05c9ec709959e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:46:45 +0200 Subject: [PATCH 39/49] fix(insights): point derived score-band tokens at the AA-corrected semantic colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The band primitives (score ring, coverage dots, contributor bars, provenance chips + standard link, sparkline tint, trend arrow/delta) painted straight from the raw `--dracula-*` variables, which carry no `:root.light` override. On the white card the Alucard light theme uses, the green/orange/yellow cleared roughly 1.1-1.5:1 — under AA — and the `bg-dracula-yellow` confidence dot read near-invisible. The file comment claimed they 'track automatically'; they did not. Point BAND_NUMBER/PROGRESS/BORDER/VAR, the coverage-meter dot tint, the provenance cyan chips, the sparkline stroke, and sentimentColorClass at the semantic `--success`/`--warning`/`--destructive`/`--info` tokens instead. Those alias the same Dracula tones in dark mode (pixel-identical there, the primary target) but carry the darker AA-safe values under `:root.light`. Clamp the score-ring provisional caption to one line so a longer localised string cannot wrap over the ring arc. --- .../insights/derived/band-tokens.ts | 44 ++++++++++++------- .../insights/derived/coverage-meter.tsx | 19 +++++--- .../insights/derived/provenance-explainer.tsx | 4 +- .../insights/derived/score-ring.tsx | 2 +- .../insights/derived/sparkline-delta-tile.tsx | 6 ++- src/lib/insights/trend-sentiment.ts | 8 +++- 6 files changed, 54 insertions(+), 29 deletions(-) diff --git a/src/components/insights/derived/band-tokens.ts b/src/components/insights/derived/band-tokens.ts index 0204d56f6..381ce9fa2 100644 --- a/src/components/insights/derived/band-tokens.ts +++ b/src/components/insights/derived/band-tokens.ts @@ -5,44 +5,54 @@ * exact same band colour grammar the shipped `health-score-card` already * uses, without each component redeclaring the map (concept cohesion). * - * No new tokens — these point at the existing `--dracula-*` variables in - * `globals.css`. A green/yellow/red band is the one semantic the whole - * v1.10 score language extends. + * These point at the *semantic* feedback tokens (`--success` / `--warning` + * / `--destructive`), NOT the raw `--dracula-*` primitives. That matters + * for contrast: `:root.light` (Alucard) overrides the semantic tokens to + * AA-safe darker tones (`--success: #14720a`, `--warning: #a34d14`, + * `--destructive: #cb3a2a`) precisely because straight Dracula + * green/orange on a white `--card` clears only ~1.1–1.5:1 — under AA. + * Painting the band off the semantic token means every score surface + * tracks that light-mode correction automatically, in both themes; the + * raw `--dracula-*` primitives do NOT carry a light override. A + * green/yellow/red band is the one semantic the whole v1.10 score + * language extends. */ export type ScoreBand = "green" | "yellow" | "red"; /** Tailwind text-colour class per band (the headline number colour). */ export const BAND_NUMBER_CLASS: Record = { - green: "text-dracula-green", - yellow: "text-dracula-orange", - red: "text-dracula-red", + green: "text-success", + yellow: "text-warning", + red: "text-destructive", }; /** Tailwind background class per band (progress/fill bars). */ export const BAND_PROGRESS_CLASS: Record = { - green: "bg-dracula-green", - yellow: "bg-dracula-orange", - red: "bg-dracula-red", + green: "bg-success", + yellow: "bg-warning", + red: "bg-destructive", }; /** Tailwind border-colour class per band (card / chip outlines). */ export const BAND_BORDER_CLASS: Record = { - green: "border-dracula-green/40", - yellow: "border-dracula-orange/40", - red: "border-dracula-red/40", + green: "border-success/40", + yellow: "border-warning/40", + red: "border-destructive/40", }; /** * The raw CSS variable per band — used where a Recharts `fill` needs an * actual colour string rather than a Tailwind class (the score ring's - * ``). Reads the same `--dracula-*` tokens so dark and - * Alucard light modes track automatically. + * ``). Reads the semantic tokens so dark and Alucard light + * modes track the AA-safe light overrides automatically (the + * `--dracula-*` primitives carry no light override and would fail AA on + * the white card). */ export const BAND_VAR: Record = { - green: "var(--dracula-green)", - yellow: "var(--dracula-orange)", - red: "var(--dracula-red)", + green: "var(--success)", + yellow: "var(--warning)", + red: "var(--destructive)", }; /** diff --git a/src/components/insights/derived/coverage-meter.tsx b/src/components/insights/derived/coverage-meter.tsx index 5cf194da4..500913833 100644 --- a/src/components/insights/derived/coverage-meter.tsx +++ b/src/components/insights/derived/coverage-meter.tsx @@ -48,12 +48,21 @@ export interface CoverageMeterProps { className?: string; } -/** Dot tint per confidence band — reuses the existing dracula vocabulary. */ +/** + * Dot tint per confidence band. Points at the *semantic* feedback tokens + * (`--success` / `--warning` / `--destructive`), not the raw `--dracula-*` + * primitives, so the dots track the AA-safe `:root.light` overrides on the + * white card — straight `bg-dracula-yellow` cleared barely 1.1:1 on white + * and read near-invisible. The four bands collapse onto the three semantic + * severities (medium + low both ride the caution `--warning`); the lit-dot + * count + the percent label carry the medium↔low distinction, never colour + * alone. + */ const BAND_DOT_CLASS: Record = { - high: "bg-dracula-green", - medium: "bg-dracula-yellow", - low: "bg-dracula-orange", - draft: "bg-dracula-red", + high: "bg-success", + medium: "bg-warning", + low: "bg-warning", + draft: "bg-destructive", }; function clampInt(value: number, min: number, max: number): number { diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx index 64c217beb..6112c9211 100644 --- a/src/components/insights/derived/provenance-explainer.tsx +++ b/src/components/insights/derived/provenance-explainer.tsx @@ -100,7 +100,7 @@ export function ProvenanceExplainer({
  • {input}
  • @@ -141,7 +141,7 @@ export function ProvenanceExplainer({ href={standard.url} target="_blank" rel="noopener noreferrer" - className="text-dracula-cyan hover:text-dracula-cyan/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none" + className="text-info hover:text-info/80 focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded text-[11px] font-medium focus-visible:ring-2 focus-visible:outline-none" > {t("insights.derived.provenance.standardLink", { name: standard.name, diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 06514dc1e..0f75c40e7 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -188,7 +188,7 @@ export function ScoreRing({ {!hasScore && ( {t("insights.derived.scoreRing.provisionalCaption")} diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index d95a2fef6..dda91b6ca 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -94,11 +94,13 @@ export function SparklineDeltaTile({ // The sparkline tints with the trend sentiment so it reads "same signal" // as the arrow; neutral metrics ride the muted-foreground line. + // Semantic tokens, not raw --dracula-*, so the sparkline tint tracks the + // AA-safe :root.light overrides on the white card (the band-token fix). const strokeVar = arrowSentiment === "positive" - ? "var(--dracula-green)" + ? "var(--success)" : arrowSentiment === "negative" - ? "var(--dracula-orange)" + ? "var(--warning)" : "var(--muted-foreground)"; const fillId = `spark-${label.replace(/[^a-zA-Z0-9]/g, "")}`; diff --git a/src/lib/insights/trend-sentiment.ts b/src/lib/insights/trend-sentiment.ts index 5933f0b14..8a2fa5863 100644 --- a/src/lib/insights/trend-sentiment.ts +++ b/src/lib/insights/trend-sentiment.ts @@ -44,8 +44,12 @@ export function getTrendSentiment( export function sentimentColorClass( direction: TrendSentimentDirection, ): string { - if (direction === "positive") return "text-dracula-green"; - if (direction === "negative") return "text-dracula-orange"; + // Semantic tokens, not raw --dracula-*: `--success` / `--warning` alias + // the same Dracula green/orange in dark mode (pixel-identical there) but + // carry the AA-safe `:root.light` overrides, so the trend arrow + delta + // clear AA on the white card instead of the ~1.2:1 the raw primitives hit. + if (direction === "positive") return "text-success"; + if (direction === "negative") return "text-warning"; return "text-muted-foreground"; } From 3f8c8e750cba593cc2de04a701a1d3301d6a985a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:47:18 +0200 Subject: [PATCH 40/49] fix(i18n): translate the device-notification namespace for es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entire insights.rhythmEvents namespace — section title, intro, the load-error, and the medical-adjacent device-detection disclaimer — shipped the English string verbatim in es/fr/it/pl. The locale-integrity guard only checks key shape + placeholders, so the copy-paste passed CI while rendering English on a Spanish/French/Italian/Polish device-notifications card, disclaimer included. Translate the four keys into real localised copy. --- messages/es.json | 8 ++++---- messages/fr.json | 8 ++++---- messages/it.json | 8 ++++---- messages/pl.json | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/messages/es.json b/messages/es.json index 24aaf3011..acc539147 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2670,10 +2670,10 @@ }, "navBreathingDisturbances": "Respiración", "rhythmEvents": { - "sectionTitle": "Device health notifications", - "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", - "loadError": "Could not load your device notifications.", - "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "sectionTitle": "Notificaciones de salud del dispositivo", + "sectionIntro": "Notificaciones que tu reloj envió por su cuenta. HealthLog solo las muestra — no las detecta, confirma ni interpreta.", + "loadError": "No se pudieron cargar las notificaciones de tu dispositivo.", + "disclaimer": "Estas son alertas que tu dispositivo generó con su propia detección integrada. Se muestran aquí solo para tu información. No es una evaluación médica de HealthLog, y HealthLog no diagnostica ninguna afección. Si una notificación te preocupa, habla con tu médico.", "event": { "irregularRhythm": "Irregular rhythm notification", "highHeartRate": "High heart rate notification", diff --git a/messages/fr.json b/messages/fr.json index fcbf04373..98d0674ac 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2670,10 +2670,10 @@ }, "navBreathingDisturbances": "Respiration", "rhythmEvents": { - "sectionTitle": "Device health notifications", - "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", - "loadError": "Could not load your device notifications.", - "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "sectionTitle": "Notifications de santé de l’appareil", + "sectionIntro": "Notifications que votre montre a envoyées d’elle-même. HealthLog se contente de les afficher — il ne les détecte, ne les confirme ni ne les interprète.", + "loadError": "Impossible de charger les notifications de votre appareil.", + "disclaimer": "Ce sont des alertes que votre appareil a produites avec sa propre détection intégrée. Elles sont affichées ici uniquement à titre informatif. Il ne s’agit pas d’une évaluation médicale de HealthLog, et HealthLog ne pose aucun diagnostic. Si une notification vous inquiète, parlez-en à votre médecin.", "event": { "irregularRhythm": "Irregular rhythm notification", "highHeartRate": "High heart rate notification", diff --git a/messages/it.json b/messages/it.json index 2faf81e83..697eca6a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2670,10 +2670,10 @@ }, "navBreathingDisturbances": "Respirazione", "rhythmEvents": { - "sectionTitle": "Device health notifications", - "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", - "loadError": "Could not load your device notifications.", - "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "sectionTitle": "Notifiche sulla salute del dispositivo", + "sectionIntro": "Notifiche che il tuo orologio ha inviato autonomamente. HealthLog si limita a mostrarle — non le rileva, conferma né interpreta.", + "loadError": "Impossibile caricare le notifiche del dispositivo.", + "disclaimer": "Questi sono avvisi generati dal tuo dispositivo con il proprio rilevamento integrato. Sono mostrati qui solo a scopo informativo. Non è una valutazione medica da parte di HealthLog, e HealthLog non diagnostica alcuna condizione. Se una notifica ti preoccupa, parla con il tuo medico.", "event": { "irregularRhythm": "Irregular rhythm notification", "highHeartRate": "High heart rate notification", diff --git a/messages/pl.json b/messages/pl.json index 5cd74f5aa..2b850259b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2670,10 +2670,10 @@ }, "navBreathingDisturbances": "Oddychanie", "rhythmEvents": { - "sectionTitle": "Device health notifications", - "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", - "loadError": "Could not load your device notifications.", - "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "sectionTitle": "Powiadomienia zdrowotne urządzenia", + "sectionIntro": "Powiadomienia, które zegarek wysłał samodzielnie. HealthLog jedynie je wyświetla — nie wykrywa ich, nie potwierdza ani nie interpretuje.", + "loadError": "Nie udało się wczytać powiadomień urządzenia.", + "disclaimer": "To alerty wygenerowane przez Twoje urządzenie za pomocą jego wbudowanego wykrywania. Są tu pokazywane wyłącznie dla Twojej informacji. To nie jest ocena medyczna wykonana przez HealthLog, a HealthLog nie diagnozuje żadnych schorzeń. Jeśli powiadomienie Cię niepokoi, skontaktuj się z lekarzem.", "event": { "irregularRhythm": "Irregular rhythm notification", "highHeartRate": "High heart rate notification", From b92d5cec820ae57358f6722e48948026d4bdbd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:00:03 +0200 Subject: [PATCH 41/49] feat(insights): batch the derived-metric reads behind one route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Insights cold mount fired 14+ independent GET /api/insights/derived requests — Readiness, Sleep and the vitals baselines are compute-on-visit, and all of them share one Prisma pool. That is the pool-starvation class that surfaces as the app hanging then recovering. Add GET /api/insights/derived/batch?metrics=a,b,c[:type] that resolves the requested metrics server-side under a bounded p-limit(4) with the profile loaded once via the shared loadBaselineProfile, returning a map of the same flat Derived envelope keyed by the request token. Add the useDerivedBatch hook + the insightsDerivedBatch query-key factory entry; the single-metric route + hook stay for the per-score detail pages. The single route now also adopts loadBaselineProfile instead of re-inlining the profile read. Honors the existing 8s ceiling, retry:0, replace-with-fallback, and warms nothing on visit. Register the new route in the OpenAPI contract. --- docs/api/openapi.yaml | 69 +++++++ .../derived/batch/__tests__/route.test.ts | 146 ++++++++++++++ src/app/api/insights/derived/batch/route.ts | 190 ++++++++++++++++++ src/app/api/insights/derived/route.ts | 22 +- .../insights/derived/use-derived-metric.ts | 84 ++++++++ src/lib/insights/derived/index.ts | 1 + src/lib/openapi/routes.ts | 55 +++++ src/lib/query-keys.ts | 10 + 8 files changed, 561 insertions(+), 16 deletions(-) create mode 100644 src/app/api/insights/derived/batch/__tests__/route.test.ts create mode 100644 src/app/api/insights/derived/batch/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9fbc47346..5d64a1842 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2484,6 +2484,40 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/derived/batch: + get: + tags: + - Insights + summary: Derived wellness metrics (batched compute-once) + description: v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a + `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the + profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out + of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The + single-metric route stays for the per-score detail pages. Auth via cookie or Bearer. + parameters: + - in: query + name: metrics + schema: + type: string + minLength: 1 + maxLength: 1024 + description: Comma-separated derived-metric tokens. Each is a `` or + `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id + 422s; at most 24 tokens; duplicates collapse. + required: true + description: Comma-separated derived-metric tokens. Each is a `` or + `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; + at most 24 tokens; duplicates collapse. + responses: + "200": + description: The map of derived-metric values, keyed by token. + content: + application/json: + schema: + $ref: "#/components/schemas/DerivedBatchResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/correlations: get: tags: @@ -7720,6 +7754,41 @@ components: - windowDays - computedAt additionalProperties: false + DerivedBatchResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DerivedBatchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DerivedBatchResponse: + type: object + properties: + metrics: + type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/DerivedMetricResponse" + description: Map keyed by the per-request token (`` or `:`). Each value is the same flat + `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly + the tokens it asked for. + required: + - metrics + additionalProperties: false + description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the + derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma + pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. CorrelationDiscoveryResponseEnvelope: type: object properties: diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts new file mode 100644 index 000000000..58992b16f --- /dev/null +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { + findUnique: vi.fn().mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + heightCm: 180, + }), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the compute layer; keep the real registry + loadBaselineProfile. +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({ + status: "ok", + value: { metric: args.metric, n: 1 }, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 100, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + })), + }; +}); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { computeDerivedMetric } from "@/lib/insights/derived"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metrics?: string): NextRequest { + const url = new URL("http://localhost/api/insights/derived/batch"); + if (metrics !== undefined) url.searchParams.set("metrics", metrics); + return new NextRequest(url); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: "MALE", + heightCm: 180, + } as never); +}); + +describe("GET /api/insights/derived/batch", () => { + it("resolves several metrics into one keyed map", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet( + makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Record }; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(Object.keys(body.data.metrics).sort()).toEqual([ + "BMI", + "READINESS", + "VITALS_BASELINE:WEIGHT", + ]); + expect(body.data.metrics["VITALS_BASELINE:WEIGHT"].status).toBe("ok"); + expect(body.data.metrics.READINESS.metric).toBe("READINESS"); + }); + + it("loads the profile once regardless of metric count", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("READINESS,BMI,FITNESS_AGE,HRV_BALANCE")); + // One User read for the whole batch — the pool-contention mitigation. + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(computeDerivedMetric).toHaveBeenCalledTimes(4); + }); + + it("narrows userId from the session into every compute call", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("READINESS")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", metric: "READINESS" }), + ); + }); + + it("collapses a duplicate token so it cannot inflate the fan-out", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("BMI,BMI,BMI")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + }); + + it("422s when any token is an unknown metric id", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("READINESS,NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a missing metrics param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on an empty metrics list", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq(" , , ")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("READINESS")); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts new file mode 100644 index 000000000..ea0c8e22d --- /dev/null +++ b/src/app/api/insights/derived/batch/route.ts @@ -0,0 +1,190 @@ +/** + * v1.10.0 — batched derived-wellness-metric route. + * + * `GET /api/insights/derived/batch?metrics=[&types=]` + * resolves several derived metrics in ONE request and returns a map keyed + * by the per-request key (`` or `:` for a + * VITALS_BASELINE sub-target). It exists to collapse the Insights cold + * mount's 14+ independent `GET /api/insights/derived` requests — each + * sharing one Prisma pool — into a single request that fans out + * server-side under a bounded `p-limit`, the exact pool-starvation class + * that bit v1.4.49.1 / v1.4.40 (the "app hangs then recovers" symptom). + * + * Honors the same contract as the single route: closed registry enum + * (unknown id → 422), cookie OR Bearer auth, `userId` narrowed from the + * session (never a query field), pure compute over the rollup tier (no + * provider call, no cache table). The profile is loaded ONCE via the + * shared `loadBaselineProfile` and threaded into every compute, so the + * batch never re-reads the `User` row per metric. + * + * The single-metric route stays for the anatomy detail pages (one metric + * per page); this route backs the dashboard's one-shot grid read. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import pLimit from "p-limit"; +import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { + computeDerivedMetric, + loadBaselineProfile, + isDerivedMetricId, + type DerivedMetricId, +} from "@/lib/insights/derived"; + +export const dynamic = "force-dynamic"; + +// Bounded internal fan-out — matches the W-POOL `p-limit(4)` discipline +// v1.4.40 applied to the per-type analytics walk so a batch of 14 metrics +// never floods the shared Prisma pool. +const BATCH_CONCURRENCY = 4; +// Defensive ceiling — the dashboard requests ~11 metrics + ~6 baseline +// sub-targets; cap the list so a crafted query can't fan out unbounded. +const MAX_METRICS = 24; + +/** + * A single batch request item: a registered metric id with an optional + * VITALS_BASELINE sub-target. The wire form is `metric` or `metric:type` + * inside the `metrics` CSV; an entry's `type` is parsed off the colon. + */ +interface BatchItem { + metric: DerivedMetricId; + type: string | null; + /** The map key the response is keyed by (mirrors the request token). */ + key: string; +} + +const batchQuerySchema = z.object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe("CSV of derived-metric tokens (`metric` or `metric:type`)."), +}); + +/** + * Parse the `metrics` CSV into validated items. An entry `metric:type` + * sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type. + * Unknown ids are returned as `invalid` so the route 422s the whole + * request rather than silently dropping a metric (the closed-enum rule). + * Duplicate keys collapse to one item (last write wins) so a repeated + * token can't inflate the fan-out. + */ +function parseBatchItems(csv: string): { + items: BatchItem[]; + invalid: string[]; +} { + const seen = new Map(); + const invalid: string[] = []; + for (const raw of csv.split(",")) { + const token = raw.trim(); + if (token === "") continue; + const colon = token.indexOf(":"); + const metric = colon === -1 ? token : token.slice(0, colon); + const type = colon === -1 ? null : token.slice(colon + 1) || null; + if (!isDerivedMetricId(metric)) { + invalid.push(metric); + continue; + } + const key = type ? `${metric}:${type}` : metric; + seen.set(key, { metric, type, key }); + } + return { items: [...seen.values()], invalid }; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = batchQuerySchema.safeParse({ + metrics: request.nextUrl.searchParams.get("metrics"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.derived.batch.invalid-query" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { items, invalid } = parseBatchItems(parsed.data.metrics); + if (invalid.length > 0) { + // Mirror the single route's closed-enum rule — an unknown id is a + // contract violation, not a silent drop. The id name is the caller's + // own input, never user health data, so it is safe to echo. + annotate({ + action: { name: "insights.derived.batch.invalid-metric" }, + meta: { invalid_count: invalid.length }, + }); + return apiError( + `Unknown derived metric id(s): ${invalid.join(", ")}`, + 422, + ); + } + if (items.length === 0 || items.length > MAX_METRICS) { + annotate({ + action: { name: "insights.derived.batch.invalid-count" }, + meta: { requested: items.length, max: MAX_METRICS }, + }); + return apiError( + items.length === 0 + ? "metrics must name at least one registered derived metric" + : `metrics must name at most ${MAX_METRICS} entries`, + 422, + ); + } + + // Profile read ONCE — the pool-contention mitigation; the same shared + // loader the nightly score jobs use, so the batch never re-reads the + // User row per metric. + const profile = await loadBaselineProfile(prisma, user.id); + + // Fan out under a bounded limiter so a 17-item dashboard read resolves + // four computes at a time against the shared pool, not seventeen. + const limit = pLimit(BATCH_CONCURRENCY); + const now = new Date(); + const results = await Promise.all( + items.map((item) => + limit(async () => { + const derived = await computeDerivedMetric({ + metric: item.metric, + userId: user.id, + profile, + type: item.type, + now, + }); + return { + key: item.key, + payload: { + metric: item.metric, + status: derived.status, + value: derived.status === "ok" ? derived.value : null, + coverage: derived.coverage, + confidence: derived.status === "ok" ? derived.confidence : null, + provenance: derived.provenance, + reason: + derived.status === "insufficient" ? derived.reason : null, + }, + }; + }), + ), + ); + + const map: Record = {}; + for (const r of results) map[r.key] = r.payload; + + annotate({ + action: { name: "insights.derived.batch" }, + meta: { + requested: items.length, + ok: results.filter((r) => r.payload.status === "ok").length, + }, + }); + + // `metrics` is a map keyed by the per-request token so the client reads + // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). + return apiSuccess({ metrics: map }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 65356e615..c14536150 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -25,9 +25,9 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; -import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; import { computeDerivedMetric, + loadBaselineProfile, DERIVED_METRIC_IDS, type DerivedMetricId, } from "@/lib/insights/derived"; @@ -63,25 +63,15 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as DerivedMetricId; - // Profile read once, passed into the pure compute function (never - // re-fetched per metric — the pool-contention mitigation). - const profile = await prisma.user.findUnique({ - where: { id: user.id }, - select: { dateOfBirth: true, gender: true, heightCm: true }, - }); - const sex = - profile?.gender === "MALE" || profile?.gender === "FEMALE" - ? profile.gender - : null; + // Profile read once via the shared loader (the same one the batch route + // and the nightly score jobs use), passed into the pure compute function + // — never re-fetched per metric. + const profile = await loadBaselineProfile(prisma, user.id); const derived = await computeDerivedMetric({ metric, userId: user.id, - profile: { - ageYears: getAgeFromDateOfBirth(profile?.dateOfBirth ?? null), - sex, - heightCm: profile?.heightCm ?? null, - }, + profile, type: parsed.data.type ?? null, }); diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts index 9d686b9e2..555af48dc 100644 --- a/src/components/insights/derived/use-derived-metric.ts +++ b/src/components/insights/derived/use-derived-metric.ts @@ -88,3 +88,87 @@ export function useDerivedMetric( retry: 0, }); } + +/** + * A batch request token: a metric id with an optional VITALS_BASELINE + * sub-target. Wire form is `metric` or `metric:type` inside the CSV. + */ +export interface DerivedBatchToken { + metric: string; + type?: string | null; +} + +/** Build the `metric` / `metric:type` wire token for one batch item. */ +function tokenString(token: DerivedBatchToken): string { + return token.type ? `${token.metric}:${token.type}` : token.metric; +} + +/** The batch route's `data` shape — a map keyed by the per-request token. */ +export interface DerivedBatchResponse { + metrics: Record>; +} + +/** + * The typed `read` selector `useDerivedBatch` returns — a tile narrows its + * own metric value out of the resolved map exactly as it did per-metric. + */ +export type DerivedBatchRead = ( + token: DerivedBatchToken, +) => DerivedMetricResponse | null; + +/** + * v1.10.0 — ONE batched read for a set of derived metrics, the dashboard's + * cold-mount fan-out fix. Replaces N independent `useDerivedMetric` queries + * (each a separate request sharing the Prisma pool — the v1.9.1 "hangs then + * recovers" symptom) with a single `GET /api/insights/derived/batch`. The + * server fans out under a bounded limiter and loads the profile once. + * + * Returns a typed `read(token)` selector so a tile pulls its own + * `DerivedMetricResponse` out of the map with the same narrowing it had + * per-metric. Same 8 s ceiling + `retry:0` + 60 s `staleTime` as the single + * hook; these reads never warm/generate on visit. + */ +export function useDerivedBatch( + tokens: DerivedBatchToken[], + options: { enabled?: boolean } = {}, +) { + const { enabled = true } = options; + const { isAuthenticated } = useAuth(); + const wireTokens = tokens.map(tokenString); + const gated = enabled && isAuthenticated && wireTokens.length > 0; + + const query = useQuery({ + queryKey: queryKeys.insightsDerivedBatch(wireTokens), + queryFn: async (): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metrics: wireTokens.join(",") }); + const res = await fetch( + `/api/insights/derived/batch?${params.toString()}`, + { signal: controller.signal }, + ); + if (!res.ok) { + throw new Error(`derived batch request failed (${res.status})`); + } + return (await res.json()).data as DerivedBatchResponse; + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: gated, + staleTime: 60_000, + retry: 0, + }); + + /** Pull one metric's value out of the resolved map, narrowed to `T`. */ + function read(token: DerivedBatchToken): DerivedMetricResponse | null { + const entry = query.data?.metrics[tokenString(token)]; + return (entry as DerivedMetricResponse | undefined) ?? null; + } + + return { ...query, read }; +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 30807e1e4..658f7c972 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -55,6 +55,7 @@ export type { NormRange, NormSex } from "./norms"; // ── server-only compute engines (do NOT value-import from a client component) ── export { computeVitalsBaseline, + loadBaselineProfile, buildBaselineBand, median, medianAbsoluteDeviation, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 49574c5d2..ed4a8b3de 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1406,6 +1406,36 @@ const derivedMetricResponse = z "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", }); +// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one +// or more `metric` / `metric:type` tokens; the route fans out server-side +// under a bounded limiter with the profile loaded once, collapsing the +// dashboard's cold-mount fan-out of N single-metric requests into one. +const derivedBatchQuery = z + .object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe( + "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.", + ), + }) + .meta({ id: "DerivedBatchQuery" }); + +const derivedBatchResponse = z + .object({ + metrics: z + .record(z.string(), derivedMetricResponse) + .describe( + "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", + ), + }) + .meta({ + id: "DerivedBatchResponse", + description: + "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", + }); + // v1.10.0 — FDR-controlled correlation discovery result. One discovered, // statistically-defensible behaviour → next-day-outcome pair. const discoveredCorrelation = z @@ -3190,6 +3220,31 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/derived/batch": { + get: { + tags: ["Insights"], + summary: "Derived wellness metrics (batched compute-once)", + description: + "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", + requestParams: { + query: derivedBatchQuery, + }, + responses: { + "200": { + description: "The map of derived-metric values, keyed by token.", + content: { + "application/json": { + schema: dataEnvelope( + derivedBatchResponse, + "DerivedBatchResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/correlations": { get: { tags: ["Insights"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index b6c8af25a..2c61d640e 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -158,6 +158,16 @@ export const queryKeys = { */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, + /** + * v1.10.0 — batched derived-metric read (`/api/insights/derived/batch`). + * One request resolves the whole dashboard grid server-side instead of N + * concurrent single-metric reads sharing the Prisma pool. Keyed by the + * sorted token list so the same grid always hits one cache entry; sits + * under the `["insights", "derived"]` prefix so a measurement-write + * invalidation fan-out reaches it exactly like the single-metric reads. + */ + insightsDerivedBatch: (tokens: readonly string[]) => + ["insights", "derived", "batch", [...tokens].sort().join(",")] as const, /** * v1.10.0 — FDR-controlled correlation discovery * (`/api/insights/correlations`). Read-only descriptive surface; the From 551d3525810b5058eb729d97e5065b05c4f68fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:00:14 +0200 Subject: [PATCH 42/49] feat(insights): surface inputs, method and a cited standard for all 11 derived metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Sleep and Readiness reached a provenance surface; the other nine shipped a number with the cited standard buried in a code comment. Give every derived metric a path to its inputs + method + cited standard: - Recovery, Stress and Strain get a ScoreAnatomyView detail page and a tappable ring (they were read-only dead-ends). Stress surfaces its 'HRV-derived proxy, NOT an EDA/galvanic measurement' caveat; Strain cites the TRIMP load model; Recovery the HRV-guided recovery literature. - The vitals tiles (baseline, HRV balance, BMI, fitness age, vascular age) gain an info explainer affordance carrying the method + standard, since they render as tiles with no detail page. - The coincident-deviation flag gains a dashboard tile (shown only when it fired) with the same explainer (median ± k·MAD basis). - A single standards map (src/components/insights/derived/standards.ts) pins each metric's method copy key, standard name and a real linkable URL (DOI / WHO / Wikipedia) so the citation a metric exposes stays one source. Add the per-metric method/caveat/description copy across all six locales. Map storage units to display symbols so a baseline tile reads '36.6 °C', not '36.6 celsius'. Show the band word under the strip rings so a value reads 'low 62', not a bare reddish 62. Drop the empty dashed sparkline placeholder when no trailing series exists rather than reserving dead space, and give the tile a series + provenance slot for when one does. --- messages/de.json | 44 +++- messages/en.json | 44 +++- messages/es.json | 44 +++- messages/fr.json | 44 +++- messages/it.json | 44 +++- messages/pl.json | 44 +++- src/app/insights/scores/[metric]/page.tsx | 57 ++++- .../derived/__tests__/coverage-meter.test.tsx | 8 +- .../__tests__/sparkline-delta-tile.test.tsx | 33 ++- .../__tests__/vitals-dashboard.test.tsx | 129 +++++++--- .../derived/composite-score-anatomy.tsx | 105 +++++--- .../insights/derived/sparkline-delta-tile.tsx | 34 ++- src/components/insights/derived/standards.ts | 131 ++++++++++ .../insights/derived/vitals-dashboard.tsx | 224 ++++++++++++++--- .../insights/derived/wellness-scores.tsx | 225 +++++++++--------- 15 files changed, 960 insertions(+), 250 deletions(-) create mode 100644 src/components/insights/derived/standards.ts diff --git a/messages/de.json b/messages/de.json index d77e2a7d0..78bf46e5b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2579,7 +2579,44 @@ "sleep": "Schlaf", "respiratory": "Atemfrequenz", "mood": "Stimmungsstabilität" - } + }, + "caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert." + }, + "VITALS_BASELINE": { + "method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich." + }, + "HRV_BALANCE": { + "method": "Dein jüngster HRV-(SDNN-)Durchschnitt im Vergleich zu deiner eigenen gleitenden Baseline (Median ± robuste Streuung). Ausgeglichen heißt: er liegt in deinem persönlichen Bereich; das Band stammt aus deinen Daten, nie aus einem Bevölkerungswert." + }, + "BMI": { + "method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis — es unterscheidet nicht Muskel von Fett." + }, + "FITNESS_AGE": { + "method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts — hier nie neu berechnet." + }, + "VASCULAR_AGE_DELTA": { + "method": "Die Gefäßalter- bzw. Pulswellengeschwindigkeits-Schätzung deines Geräts im Vergleich zu deinem chronologischen Alter, als Differenz in Jahren. Die Schätzung stammt vom Gerät; HealthLog ordnet sie nur ein.", + "caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet — beschreibend, keine klinische Gefäßbewertung.", + "description": "Wie die Gefäßalter-Schätzung deines Geräts zu deinem chronologischen Alter steht." + }, + "COINCIDENT_DEVIATION": { + "method": "Ein Hinweis, wenn zwei oder mehr deiner Vitalwerte am selben Tag außerhalb ihres persönlichen Normalbereichs liegen. Jedes Band ist der robuste Median ± k·MAD deiner eigenen Verlaufsdaten und passt sich an dich an, nicht an einen Bevölkerungswert.", + "caveat": "Ein Hinweis aus deinen eigenen Baselines — beschreibend, keine Diagnose und kein klinischer Alarm." + }, + "RECOVERY_SCORE": { + "method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.", + "caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.", + "description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf." + }, + "STRESS_SCORE": { + "method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.", + "caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.", + "description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV." + }, + "STRAIN_SCORE": { + "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", + "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Erholung", "stress": "Stress", "strain": "Belastung" + }, + "coincident": { + "label": "Mehrere Vitalwerte außerhalb der Baseline", + "summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.", + "vitals": "Außerhalb des Bereichs: {list}" } }, "cardioFitness": { diff --git a/messages/en.json b/messages/en.json index d43358aae..054d61a83 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2579,7 +2579,44 @@ "sleep": "Sleep", "respiratory": "Respiratory rate", "mood": "Mood stability" - } + }, + "caveat": "A wellness proxy, not a recovery or clinical score." + }, + "VITALS_BASELINE": { + "method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you." + }, + "HRV_BALANCE": { + "method": "Your recent HRV (SDNN) average placed against your own rolling baseline (median ± a robust spread). Balanced means it sits inside your personal range; the band is built from your data, never a population cut-off." + }, + "BMI": { + "method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only — it does not distinguish muscle from fat." + }, + "FITNESS_AGE": { + "method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate — never recomputed here." + }, + "VASCULAR_AGE_DELTA": { + "method": "Your device's vascular-age / pulse-wave-velocity estimate compared with your chronological age, expressed as a delta in years. The estimate is the device's; HealthLog only re-frames it.", + "caveat": "Your device's own estimate, re-framed — descriptive, not a clinical vascular assessment.", + "description": "How your device's vascular-age estimate sits against your chronological age." + }, + "COINCIDENT_DEVIATION": { + "method": "A flag raised when two or more of your vitals sit outside their personal typical range on the same day. Each band is the robust median ± k·MAD of your own history, so it adapts to you rather than to a population cut-off.", + "caveat": "An awareness flag from your own baselines — descriptive, not a diagnosis or a clinical alert." + }, + "RECOVERY_SCORE": { + "method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.", + "caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.", + "description": "A nightly recovery proxy from your HRV, resting heart rate and sleep." + }, + "STRESS_SCORE": { + "method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.", + "caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.", + "description": "A nightly stress proxy from the shape of your intra-day HRV." + }, + "STRAIN_SCORE": { + "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", + "caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.", + "description": "A nightly activity-load proxy from your heart-rate-weighted effort." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recovery", "stress": "Stress", "strain": "Strain" + }, + "coincident": { + "label": "Several vitals off baseline", + "summary": "{count} of your vitals sat outside their personal range today.", + "vitals": "Outside range: {list}" } }, "cardioFitness": { diff --git a/messages/es.json b/messages/es.json index acc539147..b16140457 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2579,7 +2579,44 @@ "sleep": "Sueño", "respiratory": "Frecuencia respiratoria", "mood": "Estabilidad del ánimo" - } + }, + "caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica." + }, + "VITALS_BASELINE": { + "method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti." + }, + "HRV_BALANCE": { + "method": "Tu media reciente de VFC (SDNN) frente a tu propia línea base móvil (mediana ± dispersión robusta). Equilibrada significa que está dentro de tu rango personal; la banda se construye con tus datos, nunca con un umbral poblacional." + }, + "BMI": { + "method": "Peso ÷ altura², clasificado según las categorías de la OMS para adultos (bajo peso / normal / sobrepeso / obesidad). Solo es una proporción de cribado: no distingue músculo de grasa." + }, + "FITNESS_AGE": { + "method": "Tu VO₂máx situado frente a percentiles de forma cardiorrespiratoria por edad y sexo, expresado como una diferencia de edad de forma física. El VO₂máx es la estimación de tu dispositivo, nunca se recalcula aquí." + }, + "VASCULAR_AGE_DELTA": { + "method": "La estimación de edad vascular / velocidad de onda de pulso de tu dispositivo comparada con tu edad cronológica, expresada como una diferencia en años. La estimación es del dispositivo; HealthLog solo la reinterpreta.", + "caveat": "La propia estimación de tu dispositivo, solo reinterpretada: descriptiva, no una evaluación vascular clínica.", + "description": "Cómo se sitúa la estimación de edad vascular de tu dispositivo frente a tu edad cronológica." + }, + "COINCIDENT_DEVIATION": { + "method": "Un aviso que aparece cuando dos o más de tus constantes quedan fuera de su rango típico personal el mismo día. Cada banda es la mediana robusta ± k·MAD de tu propio historial, así que se adapta a ti y no a un umbral poblacional.", + "caveat": "Un aviso a partir de tus propias líneas base: descriptivo, no un diagnóstico ni una alerta clínica." + }, + "RECOVERY_SCORE": { + "method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.", + "caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.", + "description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño." + }, + "STRESS_SCORE": { + "method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.", + "caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.", + "description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día." + }, + "STRAIN_SCORE": { + "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", + "caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.", + "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recuperación", "stress": "Estrés", "strain": "Esfuerzo" + }, + "coincident": { + "label": "Varias constantes fuera de la base", + "summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.", + "vitals": "Fuera de rango: {list}" } }, "cardioFitness": { diff --git a/messages/fr.json b/messages/fr.json index 98d0674ac..6479aa6df 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2579,7 +2579,44 @@ "sleep": "Sommeil", "respiratory": "Fréquence respiratoire", "mood": "Stabilité de l’humeur" - } + }, + "caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique." + }, + "VITALS_BASELINE": { + "method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous." + }, + "HRV_BALANCE": { + "method": "Votre moyenne récente de VFC (SDNN) comparée à votre propre référence glissante (médiane ± dispersion robuste). Équilibrée signifie qu’elle se situe dans votre plage personnelle ; la bande est construite à partir de vos données, jamais d’un seuil de population." + }, + "BMI": { + "method": "Poids ÷ taille², classé selon les catégories adultes de l’OMS (maigreur / normal / surpoids / obésité). Un simple ratio de dépistage : il ne distingue pas le muscle de la graisse." + }, + "FITNESS_AGE": { + "method": "Votre VO₂max situé par rapport aux percentiles de condition cardiorespiratoire selon l’âge et le sexe, exprimé comme un écart d’âge de forme. Le VO₂max est l’estimation de votre appareil, jamais recalculée ici." + }, + "VASCULAR_AGE_DELTA": { + "method": "L’estimation d’âge vasculaire / vitesse de l’onde de pouls de votre appareil comparée à votre âge chronologique, exprimée comme un écart en années. L’estimation vient de l’appareil ; HealthLog ne fait que la reformuler.", + "caveat": "La propre estimation de votre appareil, simplement reformulée : descriptive, pas une évaluation vasculaire clinique.", + "description": "Comment l’estimation d’âge vasculaire de votre appareil se situe par rapport à votre âge chronologique." + }, + "COINCIDENT_DEVIATION": { + "method": "Un indicateur qui se déclenche lorsque deux de vos constantes ou plus sortent de leur plage typique personnelle le même jour. Chaque bande est la médiane robuste ± k·MAD de votre propre historique : elle s’adapte à vous et non à un seuil de population.", + "caveat": "Un indicateur issu de vos propres références : descriptif, ni un diagnostic ni une alerte clinique." + }, + "RECOVERY_SCORE": { + "method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.", + "caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.", + "description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil." + }, + "STRESS_SCORE": { + "method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.", + "caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.", + "description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée." + }, + "STRAIN_SCORE": { + "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", + "caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.", + "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Récupération", "stress": "Stress", "strain": "Effort" + }, + "coincident": { + "label": "Plusieurs constantes hors référence", + "summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.", + "vitals": "Hors plage : {list}" } }, "cardioFitness": { diff --git a/messages/it.json b/messages/it.json index 697eca6a7..e65233fa0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2579,7 +2579,44 @@ "sleep": "Sonno", "respiratory": "Frequenza respiratoria", "mood": "Stabilità dell’umore" - } + }, + "caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico." + }, + "VITALS_BASELINE": { + "method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te." + }, + "HRV_BALANCE": { + "method": "La tua media recente di VFC (SDNN) confrontata con la tua linea di base mobile (mediana ± dispersione robusta). Equilibrata significa che rientra nel tuo intervallo personale; la banda è costruita sui tuoi dati, mai su una soglia di popolazione." + }, + "BMI": { + "method": "Peso ÷ altezza², classificato secondo le categorie OMS per adulti (sottopeso / normale / sovrappeso / obesità). Solo un rapporto di screening: non distingue il muscolo dal grasso." + }, + "FITNESS_AGE": { + "method": "Il tuo VO₂max collocato rispetto ai percentili di fitness cardiorespiratoria per età e sesso, espresso come scarto di età di forma. Il VO₂max è la stima del tuo dispositivo, qui mai ricalcolata." + }, + "VASCULAR_AGE_DELTA": { + "method": "La stima di età vascolare / velocità dell’onda di polso del tuo dispositivo confrontata con la tua età anagrafica, espressa come scarto in anni. La stima è del dispositivo; HealthLog si limita a reinquadrarla.", + "caveat": "La stima del tuo dispositivo, solo reinquadrata: descrittiva, non una valutazione vascolare clinica.", + "description": "Come la stima di età vascolare del tuo dispositivo si colloca rispetto alla tua età anagrafica." + }, + "COINCIDENT_DEVIATION": { + "method": "Un avviso che compare quando due o più dei tuoi parametri vitali escono dal loro intervallo tipico personale nello stesso giorno. Ogni banda è la mediana robusta ± k·MAD del tuo storico, quindi si adatta a te e non a una soglia di popolazione.", + "caveat": "Un avviso dalle tue linee di base: descrittivo, non una diagnosi né un allarme clinico." + }, + "RECOVERY_SCORE": { + "method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.", + "caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.", + "description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno." + }, + "STRESS_SCORE": { + "method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.", + "caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.", + "description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata." + }, + "STRAIN_SCORE": { + "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", + "caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.", + "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Recupero", "stress": "Stress", "strain": "Sforzo" + }, + "coincident": { + "label": "Più parametri fuori dalla base", + "summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.", + "vitals": "Fuori intervallo: {list}" } }, "cardioFitness": { diff --git a/messages/pl.json b/messages/pl.json index 2b850259b..c70c1c764 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2579,7 +2579,44 @@ "sleep": "Sen", "respiratory": "Częstość oddechu", "mood": "Stabilność nastroju" - } + }, + "caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny." + }, + "VITALS_BASELINE": { + "method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie." + }, + "HRV_BALANCE": { + "method": "Twoja niedawna średnia HRV (SDNN) na tle Twojej własnej ruchomej linii bazowej (mediana ± odporne rozproszenie). Zrównoważone oznacza, że mieści się w Twoim osobistym zakresie; pasmo budowane jest z Twoich danych, nigdy z progu populacyjnego." + }, + "BMI": { + "method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy — nie odróżnia mięśni od tłuszczu." + }, + "FITNESS_AGE": { + "method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia — tutaj nigdy nie jest przeliczany ponownie." + }, + "VASCULAR_AGE_DELTA": { + "method": "Szacunek wieku naczyniowego / prędkości fali tętna z Twojego urządzenia porównany z Twoim wiekiem metrykalnym, wyrażony jako różnica w latach. Szacunek pochodzi z urządzenia; HealthLog jedynie go przedstawia.", + "caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony — opisowy, nie kliniczna ocena naczyniowa.", + "description": "Jak szacunek wieku naczyniowego Twojego urządzenia wypada na tle Twojego wieku metrykalnego." + }, + "COINCIDENT_DEVIATION": { + "method": "Sygnał pojawiający się, gdy co najmniej dwa Twoje parametry życiowe wykraczają tego samego dnia poza swój osobisty typowy zakres. Każde pasmo to odporna mediana ± k·MAD Twojej historii, więc dostosowuje się do Ciebie, a nie do progu populacyjnego.", + "caveat": "Sygnał z Twoich własnych linii bazowych — opisowy, nie diagnoza ani alarm kliniczny." + }, + "RECOVERY_SCORE": { + "method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.", + "caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.", + "description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu." + }, + "STRESS_SCORE": { + "method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.", + "caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.", + "description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia." + }, + "STRAIN_SCORE": { + "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", + "caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.", + "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem." } }, "scores": { @@ -2587,6 +2624,11 @@ "recovery": "Regeneracja", "stress": "Stres", "strain": "Obciążenie" + }, + "coincident": { + "label": "Kilka parametrów poza bazą", + "summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.", + "vitals": "Poza zakresem: {list}" } }, "cardioFitness": { diff --git a/src/app/insights/scores/[metric]/page.tsx b/src/app/insights/scores/[metric]/page.tsx index 71802fdcc..fe7c630de 100644 --- a/src/app/insights/scores/[metric]/page.tsx +++ b/src/app/insights/scores/[metric]/page.tsx @@ -6,35 +6,68 @@ import { useTranslations } from "@/lib/i18n/context"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { CompositeScoreAnatomy, - type CompositeMetricId, + type AnatomyMetricId, } from "@/components/insights/derived/composite-score-anatomy"; /** - * v1.10.0 — `/insights/scores/` — the composite score-anatomy - * detail surface. + * v1.10.0 — `/insights/scores/` — the score-anatomy detail surface. * - * Reached on tap from a composite's dashboard / insights entry. Renders the - * Oura-style anatomy view (big ring + ranked contributor rows + cited - * standard) for one composite. The `[metric]` param is validated against the - * two W3 composites — an unknown slug falls back to the readiness view's - * insufficient state rather than 404-ing, so a stale link never dead-ends. + * Reached on tap from a score's dashboard / insights entry. Renders the + * Oura-style anatomy view (big ring + ranked contributor rows where the + * score decomposes + cited standard + method + honesty caveat) for one + * derived score — the two composites (sleep, readiness) AND the three + * persisted nightly scores (recovery, stress, strain), which previously had + * no detail page. The `[metric]` slug is validated against the known set; an + * unknown slug falls back to the readiness view's insufficient state rather + * than 404-ing, so a stale link never dead-ends. */ -const SLUG_TO_METRIC: Record = { +const SLUG_TO_METRIC: Record = { sleep: "SLEEP_SCORE", readiness: "READINESS", + recovery: "RECOVERY_SCORE", + stress: "STRESS_SCORE", + strain: "STRAIN_SCORE", +}; + +/** Localised page header per metric (title + one-line description). */ +const HEADER_KEYS: Record< + AnatomyMetricId, + { title: string; description: string } +> = { + SLEEP_SCORE: { + title: "insights.derived.composite.SLEEP_SCORE.title", + description: "insights.derived.composite.SLEEP_SCORE.description", + }, + READINESS: { + title: "insights.derived.composite.READINESS.title", + description: "insights.derived.composite.READINESS.description", + }, + RECOVERY_SCORE: { + title: "insights.derived.scores.recovery", + description: "insights.derived.composite.RECOVERY_SCORE.description", + }, + STRESS_SCORE: { + title: "insights.derived.scores.stress", + description: "insights.derived.composite.STRESS_SCORE.description", + }, + STRAIN_SCORE: { + title: "insights.derived.scores.strain", + description: "insights.derived.composite.STRAIN_SCORE.description", + }, }; export default function CompositeScorePage() { const params = useParams<{ metric: string }>(); const { t } = useTranslations(); const slug = typeof params.metric === "string" ? params.metric : ""; - const metric: CompositeMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + const metric: AnatomyMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + const header = HEADER_KEYS[metric]; return ( diff --git a/src/components/insights/derived/__tests__/coverage-meter.test.tsx b/src/components/insights/derived/__tests__/coverage-meter.test.tsx index f58ab2a51..b73ca2a1b 100644 --- a/src/components/insights/derived/__tests__/coverage-meter.test.tsx +++ b/src/components/insights/derived/__tests__/coverage-meter.test.tsx @@ -48,7 +48,8 @@ describe("", () => { const html = render(); const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; expect(lit).toBe(5); - expect(html).toContain("bg-dracula-green"); + // Semantic --success token — AA-safe on the white card in light mode. + expect(html).toContain("bg-success"); }); it("lights a partial number of dots and tints by band (provisional)", () => { @@ -56,7 +57,10 @@ describe("", () => { const lit = (html.match(/data-dot-state="lit"/g) ?? []).length; expect(lit).toBeGreaterThan(0); expect(lit).toBeLessThan(5); - expect(html).toContain("bg-dracula-orange"); + // The four confidence bands collapse onto three semantic severities; + // low rides the caution --warning (medium↔low told apart by lit-dot + // count + percent, never colour alone). + expect(html).toContain("bg-warning"); expect(html).toContain('data-percent="35"'); }); diff --git a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx index 484f45509..b051869b6 100644 --- a/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx +++ b/src/components/insights/derived/__tests__/sparkline-delta-tile.test.tsx @@ -47,23 +47,44 @@ describe("", () => { />, ); expect(html).toContain('data-slot="sparkline-delta-tile-delta"'); - expect(html).toContain("text-dracula-green"); + // Semantic --success token (AA-safe in light mode, identical in dark). + expect(html).toContain("text-success"); }); - it("renders the empty state for a null value (no value, dashed sparkline slot)", () => { + it("renders the empty state for a null value with no sparkline slot", () => { const html = render(); expect(html).toContain('data-slot="sparkline-delta-tile-value"'); expect(html).toContain("—"); - // No series → dashed placeholder, not an SVG. - expect(html).toContain("border-dashed"); + // No series → the sparkline row collapses entirely (no reserved empty + // dashed placeholder, which read as visual dead space across the grid). + expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"'); + expect(html).not.toContain("border-dashed"); }); - it("reserves the sparkline slot but omits the chart when series < 2 points", () => { + it("omits the sparkline row when series < 2 points", () => { const html = render( , ); + expect(html).not.toContain('data-slot="sparkline-delta-tile-spark"'); + }); + + it("renders the sparkline chart when a series of >= 2 points is passed", () => { + const html = render( + , + ); expect(html).toContain('data-slot="sparkline-delta-tile-spark"'); - expect(html).toContain("border-dashed"); + }); + + it("renders the provenance affordance in the label row when passed", () => { + const html = render( + i} + />, + ); + expect(html).toContain('data-slot="sparkline-delta-tile-provenance"'); + expect(html).toContain('data-slot="test-prov"'); }); it("surfaces the stale caption when staleDays > 7", () => { diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index 7ca853cd1..e30240d7e 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -2,13 +2,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; -import type { DerivedMetricResponse } from "../use-derived-metric"; +import type { + DerivedMetricResponse, + DerivedBatchToken, +} from "../use-derived-metric"; -// Drive the grid by stubbing the per-tile query hook; each tile renders the -// state the mock returns for its (metric, type) pair. -const useDerivedMetric = vi.fn(); +// The dashboard now reads the whole grid through ONE batched query. Drive it +// by stubbing `useDerivedBatch` and returning a `read(token)` selector that +// resolves each (metric, type) pair from a scenario map. +const useDerivedBatch = vi.fn(); vi.mock("../use-derived-metric", () => ({ - useDerivedMetric: (...a: unknown[]) => useDerivedMetric(...a), + useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a), +})); +// The auth hook gates the (mocked-away) query — stub it authenticated. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), })); import { VitalsDashboard } from "../vitals-dashboard"; @@ -21,41 +29,55 @@ function render(node: React.ReactNode) { type Resp = DerivedMetricResponse>; -function ok(value: Record): { data: Resp; isLoading: false } { +function ok(value: Record): Resp { return { - isLoading: false, - data: { - metric: "X", - status: "ok", - value, - coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, - confidence: { score: 90, band: "high" }, - provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, - reason: null, - }, + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, }; } -function insufficient(reason: string, historyDays = 0): { data: Resp; isLoading: false } { +function insufficient(reason: string, historyDays = 0): Resp { return { - isLoading: false, - data: { - metric: "X", - status: "insufficient", - value: null, - coverage: { requiredInputs: 1, presentInputs: reason === "no_readings_in_window" ? 0 : 1, historyDays, missing: [] }, - confidence: null, - provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, - reason, + metric: "X", + status: "insufficient", + value: null, + coverage: { + requiredInputs: 1, + presentInputs: reason === "no_readings_in_window" ? 0 : 1, + historyDays, + missing: [], }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason, }; } +/** + * Install a batch mock that resolves each requested token from `resolve`, + * defaulting unmatched tokens to the absent state so a scenario only has to + * name the tiles it cares about. + */ +function mockBatch( + resolve: (token: DerivedBatchToken) => Resp, + isLoading = false, +) { + useDerivedBatch.mockReturnValue({ + isLoading, + read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, + }); +} + beforeEach(() => vi.clearAllMocks()); describe("", () => { it("renders an available baseline vital as an ok tile", () => { - useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { - if (metric === "VITALS_BASELINE" && opts?.type === "RESTING_HEART_RATE") { + mockBatch((token) => { + if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") { return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); } return insufficient("no_readings_in_window"); @@ -69,7 +91,7 @@ describe("", () => { }); it("hides an absent vital entirely (no tile)", () => { - useDerivedMetric.mockReturnValue(insufficient("no_readings_in_window")); + mockBatch(() => insufficient("no_readings_in_window")); const html = render(); // The grid + heading still render, but no value tile. expect(html).toContain('data-slot="vitals-dashboard-grid"'); @@ -78,8 +100,8 @@ describe("", () => { }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { - useDerivedMetric.mockImplementation((metric: string, opts?: { type?: string }) => { - if (metric === "VITALS_BASELINE" && opts?.type === "WEIGHT") { + mockBatch((token) => { + if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") { return insufficient("insufficient_history_for_band", 4); } return insufficient("no_readings_in_window"); @@ -92,8 +114,8 @@ describe("", () => { }); it("renders the fitness-age tile with its age framing", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "FITNESS_AGE") { + mockBatch((token) => { + if (token.metric === "FITNESS_AGE") { return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); } return insufficient("no_readings_in_window"); @@ -104,8 +126,8 @@ describe("", () => { }); it("renders the BMI tile with its WHO category", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "BMI") { + mockBatch((token) => { + if (token.metric === "BMI") { return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); } return insufficient("no_readings_in_window"); @@ -116,11 +138,44 @@ describe("", () => { }); it("hides BMI when there is no height/weight", () => { - useDerivedMetric.mockImplementation((metric: string) => { - if (metric === "BMI") return insufficient("no_height_on_profile"); + mockBatch((token) => { + if (token.metric === "BMI") return insufficient("no_height_on_profile"); return insufficient("no_readings_in_window"); }); const html = render(); expect(html).not.toContain('data-metric="BMI"'); }); + + it("surfaces the coincident-deviation flag when it fired", () => { + mockBatch((token) => { + if (token.metric === "COINCIDENT_DEVIATION") { + return ok({ + fired: true, + day: "2026-06-02", + vitals: [], + contributing: [ + { type: "RESTING_HEART_RATE", value: 70, center: 55, low: 48, high: 62, outside: true, direction: "above" }, + { type: "RESPIRATORY_RATE", value: 20, center: 14, low: 12, high: 16, outside: true, direction: "above" }, + ], + }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).toContain('data-metric="COINCIDENT_DEVIATION"'); + expect(html).toContain('data-state="fired"'); + // The provenance affordance reaches the flag. + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + }); + + it("hides the coincident-deviation flag when it did not fire", () => { + mockBatch((token) => { + if (token.metric === "COINCIDENT_DEVIATION") { + return ok({ fired: false, day: "2026-06-02", vitals: [], contributing: [] }); + } + return insufficient("no_readings_in_window"); + }); + const html = render(); + expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"'); + }); }); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index b6fb4ac7e..f253b9f0c 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -6,44 +6,48 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { ReadinessValue, SleepScoreValue, + WellnessScoreValue, } from "@/lib/insights/derived"; import { ScoreAnatomyView, type AnatomyContributor, } from "./score-anatomy-view"; -import type { ProvenanceStandard } from "./provenance-explainer"; +import { METRIC_PROVENANCE } from "./standards"; /** - * v1.10.0 — the data-bound wrapper that fetches a composite derived metric - * and renders the reusable `ScoreAnatomyView`. It owns the per-metric + * v1.10.0 — the data-bound wrapper that fetches a composite/persisted derived + * score and renders the reusable `ScoreAnatomyView`. It owns the per-metric * presentation map (title, contributor labels, the cited standard, the - * plain-language method) so the anatomy view itself stays metric-agnostic. + * plain-language method + honesty caveat) so the anatomy view itself stays + * metric-agnostic. * - * Supports the W3 composites: `SLEEP_SCORE` and `READINESS`. Each maps its - * `value` (sub-scores / components) onto the normalised `AnatomyContributor` - * list and supplies its cited standard + method copy from the i18n bundle. + * Supports the two decomposable W3 composites (`SLEEP_SCORE`, `READINESS` — + * each maps its sub-scores onto ranked `AnatomyContributor` rows) AND the + * three persisted nightly scores (`RECOVERY_SCORE`, `STRESS_SCORE`, + * `STRAIN_SCORE`). The persisted scores carry no sub-decomposition, so they + * render the ring + coverage + the provenance surface (method + cited + * standard + caveat) with no contributor rows — the acceptance-criterion fix + * that gives every wellness ring a provenance surface instead of a read-only + * dead-end. STRESS surfaces its "HRV-derived proxy, not an EDA/galvanic + * measurement" caveat via the standards map. + * + * The standard + method/caveat keys come from the single `METRIC_PROVENANCE` + * source map so the citation a metric exposes never drifts across surfaces. * * Client-only: `import type` for the value shapes (no server graph leaks); * the fetch rides the same `/api/insights/derived` route + 8 s client * ceiling as the rest of the surface. */ -export type CompositeMetricId = "SLEEP_SCORE" | "READINESS"; - -/** The cited standard per composite (the explainer's external link). */ -const STANDARDS: Record = { - SLEEP_SCORE: { - name: "Hirshkowitz et al. 2015, Sleep Health", - url: "https://doi.org/10.1016/j.sleh.2014.12.010", - }, - READINESS: { - name: "Plews et al. 2013, Sports Medicine", - url: "https://doi.org/10.1007/s40279-013-0071-8", - }, -}; +export type AnatomyMetricId = + | "SLEEP_SCORE" + | "READINESS" + | "RECOVERY_SCORE" + | "STRESS_SCORE" + | "STRAIN_SCORE"; export interface CompositeScoreAnatomyProps { - metric: CompositeMetricId; + metric: AnatomyMetricId; className?: string; } @@ -53,14 +57,9 @@ export function CompositeScoreAnatomy({ }: CompositeScoreAnatomyProps) { const { t } = useTranslations(); - const sleep = useDerivedMetric("SLEEP_SCORE", { - enabled: metric === "SLEEP_SCORE", - }); - const readiness = useDerivedMetric("READINESS", { - enabled: metric === "READINESS", - }); - - const query = metric === "SLEEP_SCORE" ? sleep : readiness; + const query = useDerivedMetric< + SleepScoreValue | ReadinessValue | WellnessScoreValue + >(metric); if (query.isLoading) { return ( @@ -71,8 +70,22 @@ export function CompositeScoreAnatomy({ } const data = query.data; - const title = t(`insights.derived.composite.${metric}.title`); - const standard = STANDARDS[metric]; + const meta = METRIC_PROVENANCE[metric]; + const standard = meta.standard; + const title = titleFor(metric, t); + // Method copy carries an optional honesty caveat above it (STRESS proxy, + // descriptive-not-clinical, …) so the caveat reaches the user, not just + // the engine header. + const method = ( + <> + {meta.caveatKey ? ( + + {t(meta.caveatKey)} + + ) : null} + {t(meta.methodKey)} + + ); if (!data) { // Network/abort fallback — render the insufficient state honestly. @@ -94,7 +107,7 @@ export function CompositeScoreAnatomy({ windowDays: 0, computedAt: new Date().toISOString(), }} - method={t(`insights.derived.composite.${metric}.method`)} + method={method} standard={standard} insufficient className={className} @@ -120,7 +133,7 @@ export function CompositeScoreAnatomy({ weight: s.weight, })) .sort(rankByImpact); - } else { + } else if (metric === "READINESS") { const v = data.value as ReadinessValue; score = v.score; caption = t(`insights.derived.scoreRing.band.${v.band}`); @@ -132,6 +145,11 @@ export function CompositeScoreAnatomy({ weight: c.weight, })) .sort(rankByImpact); + } else { + // Persisted nightly score — no sub-decomposition; ring + provenance. + const v = data.value as WellnessScoreValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); } } @@ -144,7 +162,7 @@ export function CompositeScoreAnatomy({ coverage={data.coverage} confidence={data.confidence} provenance={data.provenance} - method={t(`insights.derived.composite.${metric}.method`)} + method={method} standard={standard} insufficient={insufficient} className={className} @@ -152,6 +170,25 @@ export function CompositeScoreAnatomy({ ); } +/** Localised title per metric — composites use their existing copy keys; + * the persisted scores reuse the wellness-strip labels. */ +function titleFor( + metric: AnatomyMetricId, + t: (key: string) => string, +): string { + switch (metric) { + case "SLEEP_SCORE": + case "READINESS": + return t(`insights.derived.composite.${metric}.title`); + case "RECOVERY_SCORE": + return t("insights.derived.scores.recovery"); + case "STRESS_SCORE": + return t("insights.derived.scores.stress"); + case "STRAIN_SCORE": + return t("insights.derived.scores.strain"); + } +} + /** Rank contributors by impact: present-and-higher-weight first. */ function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number { const aPresent = a.value != null ? 1 : 0; diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index dda91b6ca..ddc1dada6 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -53,6 +53,14 @@ export interface SparklineDeltaTileProps { staleDays?: number | null; /** Decimal places for the value + delta. Defaults to 1. */ precision?: number; + /** + * Optional provenance affordance — the `ProvenanceExplainer` ⓘ trigger + * rendered in the label row so the method + cited standard reach the user + * on a tile that has no detail page (vitals baseline, HRV, BMI, + * fitness/vascular age). Plain element children — the caller wires the + * explainer; the tile just gives it a slot. + */ + provenance?: React.ReactNode; className?: string; } @@ -67,6 +75,7 @@ export function SparklineDeltaTile({ framing, staleDays = null, precision = 1, + provenance, className, }: SparklineDeltaTileProps) { const { t } = useTranslations(); @@ -119,6 +128,14 @@ export function SparklineDeltaTile({ > {label} + {provenance ? ( + + {provenance} + + ) : null}
    @@ -149,9 +166,11 @@ export function SparklineDeltaTile({ {/* Inline sparkline — fixed 40px height so the tile never reflows. - Suppressed (with the height reserved) when there is no series. */} -
    - {sparkData ? ( + When there is no trailing series the row collapses entirely rather + than reserving an empty dashed placeholder (which read as visual + dead space across the whole grid). */} + {sparkData ? ( +
    - ) : ( - +
    + ) : null} {/* Framing + delta caption row. */}
    diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts new file mode 100644 index 000000000..f701fca1b --- /dev/null +++ b/src/components/insights/derived/standards.ts @@ -0,0 +1,131 @@ +/** + * v1.10.0 — the single derived-metric provenance source map. + * + * One place that pins, per derived metric, the cited standard (a real, + * linkable primary source — DOI / WHO / professional-society standard) and + * the i18n keys for the plain-language method copy + the honesty caveat the + * `ProvenanceExplainer` / `ScoreAnatomyView` surface. Every score surface + * (the dashboard tiles, the wellness rings, the anatomy detail pages, the + * coincident-deviation flag) reads from here so the method + cited standard + * a metric exposes stays one source — never a comment buried in the engine + * module, never two surfaces disagreeing on the citation. + * + * Client-safe: a string-keyed metadata map + i18n key names, no compute, no + * server imports — a `"use client"` surface value-imports it freely. + */ +import type { ProvenanceStandard } from "./provenance-explainer"; +import type { DerivedMetricId } from "@/lib/insights/derived/registry"; + +export interface MetricProvenanceMeta { + /** i18n key for the plain-language method copy (under composite.*.method). */ + methodKey: string; + /** The cited primary standard rendered as an external link. */ + standard: ProvenanceStandard; + /** + * Optional i18n key for an honesty caveat surfaced above the method — + * e.g. STRESS_SCORE must state it is an HRV-derived proxy, not an + * EDA/galvanic measurement. + */ + caveatKey?: string; +} + +/** + * The cited standard + method/caveat keys for every derived metric. The + * `method`/`caveat` keys live under `insights.derived.composite..*` + * (extending the shape SLEEP_SCORE + READINESS already use) so the i18n + * bundle carries one consistent namespace. + */ +export const METRIC_PROVENANCE: Record = { + VITALS_BASELINE: { + methodKey: "insights.derived.composite.VITALS_BASELINE.method", + standard: { + // Robust central-tendency / dispersion via the median ± k·MAD. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + }, + HRV_BALANCE: { + methodKey: "insights.derived.composite.HRV_BALANCE.method", + standard: { + // Task Force of the ESC / NASPE — HRV standards of measurement. + name: "Task Force ESC/NASPE 1996, Circulation", + url: "https://doi.org/10.1161/01.CIR.93.5.1043", + }, + }, + BMI: { + methodKey: "insights.derived.composite.BMI.method", + standard: { + // WHO Technical Report Series 894 — BMI classification. + name: "WHO Technical Report Series 894", + url: "https://iris.who.int/handle/10665/42330", + }, + }, + FITNESS_AGE: { + methodKey: "insights.derived.composite.FITNESS_AGE.method", + standard: { + // FRIEND registry CRF reference standards (Kaminsky et al.). + name: "Kaminsky et al. 2015, Mayo Clin. Proc. (FRIEND)", + url: "https://doi.org/10.1016/j.mayocp.2015.07.026", + }, + }, + VASCULAR_AGE_DELTA: { + methodKey: "insights.derived.composite.VASCULAR_AGE_DELTA.method", + standard: { + // Reference values for arterial stiffness (PWV) collaboration. + name: "Reference Values for Arterial Stiffness 2010, Eur. Heart J.", + url: "https://doi.org/10.1093/eurheartj/ehq165", + }, + caveatKey: "insights.derived.composite.VASCULAR_AGE_DELTA.caveat", + }, + COINCIDENT_DEVIATION: { + methodKey: "insights.derived.composite.COINCIDENT_DEVIATION.method", + standard: { + // Hampel-identifier / median ± k·MAD outlier flagging. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.COINCIDENT_DEVIATION.caveat", + }, + SLEEP_SCORE: { + methodKey: "insights.derived.composite.SLEEP_SCORE.method", + standard: { + name: "Hirshkowitz et al. 2015, Sleep Health", + url: "https://doi.org/10.1016/j.sleh.2014.12.010", + }, + }, + READINESS: { + methodKey: "insights.derived.composite.READINESS.method", + standard: { + name: "Plews et al. 2013, Sports Medicine", + url: "https://doi.org/10.1007/s40279-013-0071-8", + }, + caveatKey: "insights.derived.composite.READINESS.caveat", + }, + RECOVERY_SCORE: { + methodKey: "insights.derived.composite.RECOVERY_SCORE.method", + standard: { + // HRV-guided recovery / training-readiness framing. + name: "Buchheit 2014, Front. Physiol.", + url: "https://doi.org/10.3389/fphys.2014.00073", + }, + caveatKey: "insights.derived.composite.RECOVERY_SCORE.caveat", + }, + STRESS_SCORE: { + methodKey: "insights.derived.composite.STRESS_SCORE.method", + standard: { + // Neurovisceral-integration / HRV-as-stress-index basis. + name: "Kim et al. 2018, Psychiatry Investig.", + url: "https://doi.org/10.30773/pi.2017.08.17", + }, + caveatKey: "insights.derived.composite.STRESS_SCORE.caveat", + }, + STRAIN_SCORE: { + methodKey: "insights.derived.composite.STRAIN_SCORE.method", + standard: { + // Banister TRIMP training-load model (HR-reserve weighting). + name: "Banister 1991, TRIMP model", + url: "https://en.wikipedia.org/wiki/Training_Impulse", + }, + caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat", + }, +}; diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index d1ec79f6c..c73c5656a 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { Gauge, HeartPulse, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; import { @@ -12,7 +13,13 @@ import { import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; import { WellnessScores } from "./wellness-scores"; -import { useDerivedMetric } from "./use-derived-metric"; +import { ProvenanceExplainer } from "./provenance-explainer"; +import { METRIC_PROVENANCE } from "./standards"; +import { + useDerivedBatch, + type DerivedBatchRead, + type DerivedBatchToken, +} from "./use-derived-metric"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -20,6 +27,9 @@ import type { FitnessAgeValue } from "@/lib/insights/derived/fitness-age"; import type { VascularAgeDeltaValue } from "@/lib/insights/derived/vascular-age"; import type { HrvBalanceValue } from "@/lib/insights/derived/hrv-balance"; import type { BmiValue } from "@/lib/insights/derived/bmi"; +import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation"; +import type { DerivedProvenance } from "@/lib/insights/derived/types"; +import { AlertTriangle } from "lucide-react"; /** * v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid). @@ -33,10 +43,13 @@ import type { BmiValue } from "@/lib/insights/derived/bmi"; * - provisional (`insufficient_history_for_band`) → the tile renders with a * `CoverageMeter` that shows the honest "N of 7 days" state, never a * headline number from too-little history. - * - ok → the full value + framing line + sparkline placeholder. + * - ok → the full value + framing line + a provenance ⓘ affordance. * - * Each tile owns its own `useDerivedMetric` query (keyed off the centralised - * factory) so a sparse vital simply un-mounts rather than blocking the grid. + * The whole grid (every vital tile AND the wellness-score strip) reads from + * ONE batched `/api/insights/derived/batch` request, fanned out server-side + * under a bounded limiter with the profile loaded once. This replaces the + * cold-mount fan-out of 14+ independent requests sharing the Prisma pool — + * the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1). */ const SECTION_VITALS: string[] = [ @@ -59,6 +72,23 @@ const UP_BAD_VITALS = new Set([ /** Up-is-good for the vitals where a rise is favourable. */ const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); +/** + * Storage-unit → display-symbol overrides. `getUnitForType` returns the + * canonical *storage* string (e.g. the literal "celsius"); a tile must show + * the symbol a user reads ("°C"). Only the units whose storage string is not + * already a display symbol need an entry — kg / bpm / ms / % / mg/dL render + * fine as-is. + */ +const DISPLAY_UNIT_SYMBOL: Record = { + celsius: "°C", + fahrenheit: "°F", +}; + +function displayUnit(type: string): string { + const storage = getUnitForType(type); + return DISPLAY_UNIT_SYMBOL[storage] ?? storage; +} + function vitalSentiment(type: string): TrendDirectionSentiment { if (UP_BAD_VITALS.has(type)) return "up-bad"; if (UP_GOOD_VITALS.has(type)) return "up-good"; @@ -70,14 +100,47 @@ interface DashboardProps { className?: string; } +/** The provenance ⓘ explainer for one derived metric, wired from the map. */ +function MetricProvenance({ + metric, + provenance, +}: { + metric: keyof typeof METRIC_PROVENANCE; + provenance: DerivedProvenance; +}) { + const { t } = useTranslations(); + const meta = METRIC_PROVENANCE[metric]; + const method = ( + <> + {meta.caveatKey ? ( + {t(meta.caveatKey)} + ) : null} + {t(meta.methodKey)} + + ); + return ( + + ); +} + +interface TileProps { + read: DerivedBatchRead; + isLoading: boolean; +} + /** A single personal-typical-range tile for one vital. */ -function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { +function BaselineTile({ + type, + read, + isLoading, +}: TileProps & { type: string }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading } = useDerivedMetric( - "VITALS_BASELINE", - { type, enabled }, - ); + const data = read({ metric: "VITALS_BASELINE", type }); if (isLoading || !data) return null; // Absent → don't render the tile at all. @@ -88,7 +151,7 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge; const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; const label = labelKey ? t(labelKey) : type; - const unit = getUnitForType(type); + const unit = displayUnit(type); // Provisional — building the band; show value-less coverage state. if (data.status === "insufficient") { @@ -131,17 +194,18 @@ function BaselineTile({ type, enabled }: { type: string; enabled: boolean }) { icon={Icon} framing={framing} directionSentiment={vitalSentiment(type)} + provenance={ + + } />
    ); } /** Cardio-fitness band tile (VO₂max passthrough re-frame). */ -function FitnessAgeTile({ enabled }: { enabled: boolean }) { +function FitnessAgeTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("FITNESS_AGE", { - enabled, - }); + const data = read({ metric: "FITNESS_AGE" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = @@ -167,18 +231,18 @@ function FitnessAgeTile({ enabled }: { enabled: boolean }) { directionSentiment="up-good" framing={framing} precision={1} + provenance={ + + } />
    ); } /** Vascular-age delta tile (Withings passthrough re-frame). */ -function VascularAgeTile({ enabled }: { enabled: boolean }) { +function VascularAgeTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric( - "VASCULAR_AGE_DELTA", - { enabled }, - ); + const data = read({ metric: "VASCULAR_AGE_DELTA" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = @@ -204,17 +268,21 @@ function VascularAgeTile({ enabled }: { enabled: boolean }) { directionSentiment="up-bad" framing={framing} precision={0} + provenance={ + + } /> ); } /** HRV (SDNN) balance tile (reuses the baseline engine). */ -function HrvBalanceTile({ enabled }: { enabled: boolean }) { +function HrvBalanceTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("HRV_BALANCE", { - enabled, - }); + const data = read({ metric: "HRV_BALANCE" }); if (isLoading || !data) return null; if (data.status === "insufficient" && data.reason === "no_readings_in_window") { return null; @@ -255,15 +323,18 @@ function HrvBalanceTile({ enabled }: { enabled: boolean }) { directionSentiment="up-good" framing={framing} precision={0} + provenance={ + + } /> ); } /** BMI tile (weight + height fallback). */ -function BmiTile({ enabled }: { enabled: boolean }) { +function BmiTile({ read, isLoading }: TileProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("BMI", { enabled }); + const data = read({ metric: "BMI" }); if (isLoading || !data || data.status !== "ok" || !data.value) return null; const v = data.value; const framing = t(`insights.derived.vitals.bmiCategory.${v.category}`); @@ -277,18 +348,108 @@ function BmiTile({ enabled }: { enabled: boolean }) { directionSentiment="neutral" framing={framing} precision={1} + provenance={ + + } /> ); } +/** + * Coincident-deviation flag tile — surfaces ONLY when the flag fired today + * (≥ N vitals outside their personal band on the same day). Lists the + * contributing vitals as plain text and carries the provenance affordance + * (the Hampel/Leys median ± k·MAD basis + the descriptive-not-clinical + * caveat). When the flag has not fired the tile un-mounts entirely — never + * an alarming empty card. + */ +function CoincidentDeviationTile({ read, isLoading }: TileProps) { + const { t } = useTranslations(); + const data = read({ + metric: "COINCIDENT_DEVIATION", + }); + if (isLoading || !data || data.status !== "ok" || !data.value) return null; + const v = data.value; + if (!v.fired || v.contributing.length === 0) return null; + + const names = v.contributing + .map((d) => { + const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[d.type]; + return labelKey ? t(labelKey) : d.type; + }) + .join(", "); + + return ( +
    +
    + + {t("insights.derived.coincident.label")} + + + +
    +

    + {t("insights.derived.coincident.summary", { + count: v.contributing.length, + })} +

    +

    + {t("insights.derived.coincident.vitals", { list: names })} +

    +
    + ); +} + +/** + * The full set of tokens the dashboard reads in one batch — the five + * wellness scores + the four derived re-frames + the coincident-deviation + * flag + one baseline per vital (minus HRV, which has its own balance tile). + */ +function dashboardTokens(): DerivedBatchToken[] { + const tokens: DerivedBatchToken[] = [ + { metric: "READINESS" }, + { metric: "SLEEP_SCORE" }, + { metric: "RECOVERY_SCORE" }, + { metric: "STRESS_SCORE" }, + { metric: "STRAIN_SCORE" }, + { metric: "FITNESS_AGE" }, + { metric: "VASCULAR_AGE_DELTA" }, + { metric: "HRV_BALANCE" }, + { metric: "BMI" }, + { metric: "COINCIDENT_DEVIATION" }, + ]; + for (const type of SECTION_VITALS) { + if (type === "HEART_RATE_VARIABILITY") continue; + tokens.push({ metric: "VITALS_BASELINE", type }); + } + return tokens; +} + export function VitalsDashboard({ enabled = true, className }: DashboardProps) { const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); const vitals = useMemo(() => SECTION_VITALS, []); + const tokens = useMemo(() => dashboardTokens(), []); + + const batch = useDerivedBatch(tokens, { + enabled: enabled && isAuthenticated, + }); + const read = batch.read; + const isLoading = batch.isLoading; + const tileProps: TileProps = { read, isLoading }; return (
    - +
    - - - - + + + + + {vitals .filter((type) => type !== "HEART_RATE_VARIABILITY") .map((type) => ( - + ))}
    diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 9b91b09b7..c39c9d75b 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { ScoreRing } from "./score-ring"; -import { useDerivedMetric } from "./use-derived-metric"; +import type { DerivedBatchRead } from "./use-derived-metric"; import type { ScoreBand } from "./band-tokens"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { ReadinessValue } from "@/lib/insights/derived/readiness"; @@ -16,128 +16,145 @@ import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores" * * A row of score rings for the composite + persisted wellness scores, each * data-availability-gated: a score the user has no data for simply does not - * render (never an apologetic empty ring). The two composites with a detail - * surface (Readiness, Sleep) are tappable and route to their existing - * `ScoreAnatomyView` page (`/insights/scores/`); the persisted nightly - * scores (Recovery, Stress, Strain) render as read-only rings. + * render (never an apologetic empty ring). All five composites/scores now + * route to a `ScoreAnatomyView` detail page (`/insights/scores/`) so + * every ring carries its inputs + method + cited standard one tap away — the + * three persisted scores (Recovery, Stress, Strain) are no longer read-only + * dead-ends. The band word renders under each ring so a less-technical user + * reads "low 62", not a bare reddish 62. * - * Every ring reads the SAME `/api/insights/derived` route through the one - * `useDerivedMetric` hook — no per-screen recompute, no warm-on-mount fan-out - * (the reads are already-computed rollup/persisted values). + * Every ring reads from the ONE batched `/api/insights/derived/batch` + * request the parent dashboard owns (passed in via `read`) — no per-ring + * request, no cold-mount fan-out. The reads are already-computed + * rollup/persisted values; nothing warms on visit. */ -interface ScoreTileProps { - enabled: boolean; +interface ScoreStripProps { + /** The batched-read selector from the parent dashboard's one query. */ + read: DerivedBatchRead; + isLoading: boolean; + className?: string; } -/** A score ring optionally wrapped in a link to its anatomy detail page. */ +/** A score ring wrapped in a link to its anatomy detail page. */ function RingTile({ score, band, label, + bandWord, href, metricSlot, }: { score: number; band: ScoreBand; label: string; - href?: string; + bandWord: string; + href: string; metricSlot: string; }) { - const ring = ( -
    - -
    - ); - if (href) { - return ( - - {ring} - - ); - } return ( -
    - {ring} -
    +
    + + + {bandWord} + +
    + ); } -function ReadinessTile({ enabled }: ScoreTileProps) { +export function WellnessScores({ read, isLoading, className }: ScoreStripProps) { const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("READINESS", { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} -function SleepTile({ enabled }: ScoreTileProps) { - const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric("SLEEP_SCORE", { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} + const readiness = read({ metric: "READINESS" }); + const sleep = read({ metric: "SLEEP_SCORE" }); + const recovery = read({ metric: "RECOVERY_SCORE" }); + const stress = read({ metric: "STRESS_SCORE" }); + const strain = read({ metric: "STRAIN_SCORE" }); -function PersistedScoreTile({ - metric, - labelKey, - enabled, -}: { - metric: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE"; - labelKey: string; - enabled: boolean; -}) { - const { t } = useTranslations(); - const { data, isLoading } = useDerivedMetric(metric, { - enabled, - }); - if (isLoading || !data || data.status !== "ok" || !data.value) return null; - return ( - - ); -} + const bandWord = (band: ScoreBand) => + t(`insights.derived.scoreRing.band.${band}`); + + const tiles: React.ReactNode[] = []; + + if (!isLoading && readiness?.status === "ok" && readiness.value) { + tiles.push( + , + ); + } + if (!isLoading && recovery?.status === "ok" && recovery.value) { + tiles.push( + , + ); + } + if (!isLoading && sleep?.status === "ok" && sleep.value) { + tiles.push( + , + ); + } + if (!isLoading && stress?.status === "ok" && stress.value) { + tiles.push( + , + ); + } + if (!isLoading && strain?.status === "ok" && strain.value) { + tiles.push( + , + ); + } + + // Whole strip un-mounts when no score has data — never an empty section. + if (tiles.length === 0) return null; -export function WellnessScores({ - enabled = true, - className, -}: { - enabled?: boolean; - className?: string; -}) { - const { t } = useTranslations(); return (
    - - - - - + {tiles}
    ); From d369b406ac8412929550e35705adc8fa04614efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:02:28 +0200 Subject: [PATCH 43/49] test(insights): align trend-sentiment + gate-inventory tests with the changes The semantic-token swap means the trend arrow/delta now emit text-success/text-warning (pixel-identical in dark, AA-safe in light), and the batched derived route joins the insightStatus allowlist alongside the single route. Update the trend-card sentiment-colour assertions and the coach-route gate inventory accordingly. --- .../coach-route-gate-inventory.test.ts | 3 +++ .../__tests__/trend-card-7d-trend.test.tsx | 12 +++++----- .../__tests__/trend-card-comparison.test.tsx | 8 +++---- .../charts/__tests__/trend-card.test.tsx | 24 +++++++++---------- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 37ea6fd23..fbbbe4f92 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -43,6 +43,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // the rollup tier; gates on the same `insightStatus` sub-flag as the // assessment routes (no Coach prose). "src/app/api/insights/derived/route.ts", + // v1.10.0 — batched derived-metric route (the dashboard fan-out fix). + // Same pure compute + `insightStatus` sub-flag as the single route. + "src/app/api/insights/derived/batch/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the // same `insightStatus` sub-flag as the seven specialised status routes. diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index 35ff4c0b7..2622c9a9c 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -112,8 +112,8 @@ describe(" 7-day trend label", () => { expect(html).toContain("±0"); // Tiny absolute deltas never paint sentiment colour — they read // as "no movement" regardless of metric type. - expect(html).not.toContain("text-dracula-green"); - expect(html).not.toContain("text-dracula-orange"); + expect(html).not.toContain("text-success"); + expect(html).not.toContain("text-warning"); }); it("colors a +delta orange on an up-bad metric (weight rising = bad)", () => { @@ -129,7 +129,7 @@ describe(" 7-day trend label", () => { // HTML, so we assert orange is present in a delta-bearing render. // (Tile arrow is muted for stable slope or omitted icon — but the // delta paints regardless, so orange must appear.) - expect(html).toContain("text-dracula-orange"); + expect(html).toContain("text-warning"); }); it("colors a +delta green on an up-good metric (mood rising = good)", () => { @@ -146,7 +146,7 @@ describe(" 7-day trend label", () => { trend7Delta={0.5} />, ); - expect(html).toContain("text-dracula-green"); + expect(html).toContain("text-success"); }); it("paints neutral metrics muted regardless of delta direction", () => { @@ -159,7 +159,7 @@ describe(" 7-day trend label", () => { ); expect(html).toContain('data-slot="trend7-delta"'); expect(html).toContain("text-muted-foreground"); - expect(html).not.toContain("text-dracula-green"); - expect(html).not.toContain("text-dracula-orange"); + expect(html).not.toContain("text-success"); + expect(html).not.toContain("text-warning"); }); }); diff --git a/src/components/charts/__tests__/trend-card-comparison.test.tsx b/src/components/charts/__tests__/trend-card-comparison.test.tsx index 052af79bc..03436a920 100644 --- a/src/components/charts/__tests__/trend-card-comparison.test.tsx +++ b/src/components/charts/__tests__/trend-card-comparison.test.tsx @@ -79,7 +79,7 @@ describe("", () => { , ); // Down-trend on an up-bad metric is "improvement" → green. - expect(html).toMatch(/text-dracula-green/); + expect(html).toMatch(/text-success/); }); it("paints an up-good metric worsening (negative delta) with the orange sentiment class", () => { @@ -99,7 +99,7 @@ describe("", () => { /> , ); - expect(html).toMatch(/text-dracula-orange/); + expect(html).toMatch(/text-warning/); }); it("renders muted color when delta is exactly zero (stable)", () => { @@ -122,8 +122,8 @@ describe("", () => { // Stable + neutral both push to muted-foreground. expect(html).toMatch(/text-muted-foreground/); // Sentiment colour classes must not appear for a stable delta. - expect(html).not.toMatch(/text-dracula-green.*tile-compare-delta/); - expect(html).not.toMatch(/text-dracula-orange.*tile-compare-delta/); + expect(html).not.toMatch(/text-success.*tile-compare-delta/); + expect(html).not.toMatch(/text-warning.*tile-compare-delta/); }); it("suppresses the callout when compareDelta is null (insufficient prior-period data)", () => { diff --git a/src/components/charts/__tests__/trend-card.test.tsx b/src/components/charts/__tests__/trend-card.test.tsx index 926480a78..316055747 100644 --- a/src/components/charts/__tests__/trend-card.test.tsx +++ b/src/components/charts/__tests__/trend-card.test.tsx @@ -42,7 +42,7 @@ describe(" directionSentiment", () => { const html = render( , ); - expect(html).toContain("text-dracula-orange"); + expect(html).toContain("text-warning"); }); it("paints up-bad metric ↓ as green (improvement)", () => { @@ -53,7 +53,7 @@ describe(" directionSentiment", () => { directionSentiment="up-bad" />, ); - expect(html).toContain("text-dracula-green"); + expect(html).toContain("text-success"); }); it("paints up-good metric ↑ as green", () => { @@ -64,7 +64,7 @@ describe(" directionSentiment", () => { directionSentiment="up-good" />, ); - expect(html).toContain("text-dracula-green"); + expect(html).toContain("text-success"); }); it("paints up-good metric ↓ as orange", () => { @@ -75,7 +75,7 @@ describe(" directionSentiment", () => { directionSentiment="up-good" />, ); - expect(html).toContain("text-dracula-orange"); + expect(html).toContain("text-warning"); }); it("keeps neutral metric arrows muted regardless of direction", () => { @@ -94,11 +94,11 @@ describe(" directionSentiment", () => { />, ); expect(up).toContain("text-muted-foreground"); - expect(up).not.toContain("text-dracula-green"); - expect(up).not.toContain("text-dracula-orange"); + expect(up).not.toContain("text-success"); + expect(up).not.toContain("text-warning"); expect(down).toContain("text-muted-foreground"); - expect(down).not.toContain("text-dracula-green"); - expect(down).not.toContain("text-dracula-orange"); + expect(down).not.toContain("text-success"); + expect(down).not.toContain("text-warning"); }); it("keeps stable-trend arrows muted even when sentiment is set", () => { @@ -111,15 +111,15 @@ describe(" directionSentiment", () => { ); // Stable slope = no value judgement; the arrow points sideways. expect(html).toContain("text-muted-foreground"); - expect(html).not.toContain("text-dracula-green"); - expect(html).not.toContain("text-dracula-orange"); + expect(html).not.toContain("text-success"); + expect(html).not.toContain("text-warning"); }); it("defaults to neutral when sentiment is omitted (back-compat)", () => { const html = render(); expect(html).toContain("text-muted-foreground"); - expect(html).not.toContain("text-dracula-green"); - expect(html).not.toContain("text-dracula-orange"); + expect(html).not.toContain("text-success"); + expect(html).not.toContain("text-warning"); }); }); From d43cbf3a3b49fd5cbbae27ec0a0ea9de9ef2827a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 00:08:22 +0200 Subject: [PATCH 44/49] =?UTF-8?q?chore(release):=20v1.10.0=20=E2=80=94=20d?= =?UTF-8?q?erived=20wellness=20metrics,=20device-event=20awareness,=20deep?= =?UTF-8?q?er=20ingestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8de28c26..5cfd4ae66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.10.0] — 2026-06-03 — Derived wellness metrics, device-event awareness, deeper ingestion + +### Added + +- **Derived wellness metrics, each shown with how it is worked out.** From data you already sync, the Insights surface now derives a personal typical range for a vital, a cardio-fitness band, a vascular-age delta, an HRV (SDNN) balance read, body-mass index, a sleep score, a readiness index, and a coincident-deviation flag that notices when several vitals sit off their personal range at once. Every metric states its inputs, its method in plain language, and the published standard it is grounded in, with an honest read of how much data backs it — never a bare number. A metric appears only when there is enough history to mean something. +- **Recovery, Stress, and Strain scores, computed each night.** Recovery blends your resting-heart-rate, HRV, sleep and respiratory trends against your own baseline; Strain reads the heart-rate load of your workouts; Stress is an HRV-derived proxy and is labelled as exactly that — not a sensor-measured stress reading. Each is stored as a first-class value you can chart, and each opens to a breakdown of its contributors and its cited basis. +- **More of what your watch already measures.** Cardio recovery, overnight wrist temperature, fall count, six-minute-walk distance, stair ascent and descent speed, and the sleep breathing-disturbance index are now ingested and surfaced. +- **An awareness timeline for device-flagged events.** Irregular-rhythm, high- and low-heart-rate, walking-steadiness, and breathing-disturbance notifications your device produced are shown as a timeline — strictly as awareness of your own device's certified result, never re-assessed and never a HealthLog diagnosis, with a permanent disclaimer to that effect. The section stays hidden until there is something to show. +- **A per-workout heart-rate series**, so an indoor workout with no GPS still yields a training-load signal. +- **A wellness summary in the doctor report.** The derived scores and metrics appear in their own section, kept out of the clinical vitals table, labelled descriptive rather than clinical, and exported to FHIR as `survey`-category observations with the same note — so a clinician's system never mistakes them for a vital sign or a diagnosis. + +### Changed + +- **The health-record export carries richer medication codings.** A `MedicationAdministration` now adds a SNOMED route and injection-site coding alongside the existing plain text, a German-locale export can append the German BfArM ATC coding next to the unchanged WHO ATC entry, and the number of administrations an export can carry is raised and made configurable by the operator. +- **The Insights overview reads its metrics through a single request** instead of one per tile, so opening the page no longer fans out into a burst of concurrent reads. +- **Score-band colours meet contrast on the light theme**, and the device-notification copy — including its medical disclaimer — is translated across every supported language. + ## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5d64a1842..708980948 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.9.2 + version: 1.10.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 6bf1c3470..fe05f1d38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.9.2", + "version": "1.10.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", From 8dbfc6989a4d0702996e528473ec49442bd7fe89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 01:00:04 +0200 Subject: [PATCH 45/49] refactor(measurements): drop the unused score-type set and rhythm-classification enum --- src/lib/validations/measurement.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 48c80c76c..83f831067 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -78,19 +78,6 @@ export const measurementTypeEnum = z.enum([ "STRAIN_SCORE", ]); -/** - * v1.10.0 — computed scores (WX-C). The closed set of server-derived - * `*_SCORE` MeasurementTypes. Each is a 0–100 wellness score persisted with - * `source = COMPUTED` by a nightly engine — never ingested from a client. - * Kept as a `Set` so read/write paths can branch on "is this a computed - * score?" the same way `EVENT_MEASUREMENT_TYPES` gates the event rows. - */ -export const SCORE_MEASUREMENT_TYPES: ReadonlySet = new Set([ - "RECOVERY_SCORE", - "STRESS_SCORE", - "STRAIN_SCORE", -]); - /** * v1.10.0 — categorical events (WX-B). The closed set of EVENT-class * MeasurementTypes. An EVENT row carries `value = 1`, an optional @@ -106,15 +93,6 @@ export const EVENT_MEASUREMENT_TYPES: ReadonlySet = new Set([ "BREATHING_DISTURBANCE_EVENT", ]); -export const rhythmClassificationEnum = z.enum([ - "IRREGULAR", - "NOT_DETECTED", - "INCONCLUSIVE", - "LOW", - "VERY_LOW", - "FIRED", -]); - export const glucoseContextEnum = z.enum([ "FASTING", "POSTPRANDIAL", From e1d4182a0309dd8175f1ae17a63d528453e3cde3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 01:00:04 +0200 Subject: [PATCH 46/49] test(medications): pin the clock for the next-due day-label assertion --- .../medication-card-next-due.test.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index 8fdf0c629..bb4fd48f5 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -153,10 +153,20 @@ describe(" — next-due reads from server nextDueAt", () => { // A daily / weekly medication whose server next-due is tomorrow // still renders the relative "tomorrow" label — proving the ordinary // single-time path keeps working. + // + // Pin the clock to a midday UTC instant and express `due` as a fixed + // UTC instant ~20h later: it lands on the next calendar day in every + // timezone the card might label in (UTC and the Berlin default alike), + // so the relative label is deterministic regardless of the CI host + // clock / timezone (the late-UTC run made an unpinned "tomorrow" read + // as "today" in the Berlin frame). + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); const now = new Date(); const due = new Date(now); - due.setDate(due.getDate() + 1); - due.setHours(8, 0, 0, 0); + due.setUTCDate(due.getUTCDate() + 1); + due.setUTCHours(8, 0, 0, 0); const med = { id: "med-daily-1", @@ -198,5 +208,8 @@ describe(" — next-due reads from server nextDueAt", () => { expect(html).toContain("Next intake:"); // Relative day label from the server instant (capitalised "Tomorrow"). expect(html).toContain("Tomorrow,"); + } finally { + vi.useRealTimers(); + } }); }); From d105849026facec1ab11bd0f04ae6c0f54130236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 01:00:04 +0200 Subject: [PATCH 47/49] fix(fhir): resolve the medication-administration cap per call so an operator override takes effect --- src/lib/doctor-report-data.ts | 29 +++++++++---------- .../doctor-report-sections.test.ts | 7 ++++- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 8e5cb0f23..e2c71a303 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -130,8 +130,9 @@ export interface DoctorReportData { deliveryForm: string | null; }>; /** - * v1.9.0 — set when {@link MAX_MEDICATION_ADMINISTRATIONS} trimmed the - * administration set. `total` is the full acted-intake count over the + * v1.9.0 — set when the administration cap (see + * {@link resolveMaxMedicationAdministrations}) trimmed the administration + * set. `total` is the full acted-intake count over the * window; `included` is how many (the most-recent) survived the cap. * Null/absent when nothing was trimmed. The FHIR builder discloses * this in the document narrative so the export is honest about the @@ -265,17 +266,6 @@ export function resolveMaxMedicationAdministrations( : DEFAULT_MAX_MEDICATION_ADMINISTRATIONS; } -/** - * The cap is a coarse safety ceiling resolved once at module load, not a - * per-request knob — the report window is the natural bound; this only - * guards a pathological multi-year, many-medication export. When it trims - * rows the aggregator flags it so the FHIR narrative discloses the - * truncation rather than silently dropping history. - */ -const MAX_MEDICATION_ADMINISTRATIONS = resolveMaxMedicationAdministrations( - process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS, -); - export interface DoctorReportRange { /** Start of the reporting window (UTC, inclusive). */ start: Date; @@ -594,13 +584,20 @@ export async function collectDoctorReportData( // `scheduledFor: asc`, so the most-recent acted rows are at the tail; // keep the last N and flag the trim so the FHIR narrative can disclose // it. The omitted rows are the OLDEST in the window — recent adherence - // is preserved intact. + // is preserved intact. The cap is a coarse safety ceiling (the report + // window is the natural bound; this only guards a pathological + // multi-year, many-medication export) — resolved per call from + // `FHIR_MAX_MEDICATION_ADMINISTRATIONS` so an operator override takes + // effect without a code change. + const maxMedicationAdministrations = resolveMaxMedicationAdministrations( + process.env.FHIR_MAX_MEDICATION_ADMINISTRATIONS, + ); const totalAdministrations = medicationAdministrations.length; const medicationAdministrationsTruncated = - totalAdministrations > MAX_MEDICATION_ADMINISTRATIONS; + totalAdministrations > maxMedicationAdministrations; const cappedAdministrations = medicationAdministrationsTruncated ? medicationAdministrations.slice( - totalAdministrations - MAX_MEDICATION_ADMINISTRATIONS, + totalAdministrations - maxMedicationAdministrations, ) : medicationAdministrations; diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 73ecdf2da..a905ce4f6 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -55,6 +55,7 @@ beforeEach(async () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); const RANGE = { @@ -241,7 +242,11 @@ describe("doctor-report — per-section toggles", () => { }); describe("doctor-report — MedicationAdministration export cap", () => { - it("caps the administration set at 1000 most-recent rows and flags the truncation", async () => { + it("caps the administration set at the configured ceiling and flags the truncation", async () => { + // The default ceiling is 5000; pin the operator-configurable + // `FHIR_MAX_MEDICATION_ADMINISTRATIONS` to 1000 so the cap is exercised + // with a tractable row count (the cap is resolved per call). + vi.stubEnv("FHIR_MAX_MEDICATION_ADMINISTRATIONS", "1000"); const prisma = getPrismaClient(); const user = await prisma.user.create({ data: { From fbab8cfd30527e39842c04cdb13e44fbe9665b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 01:00:39 +0200 Subject: [PATCH 48/49] fix(jobs): break consolidation import cycle that crashed the worker at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder worker failed to start in the production bundle with `ReferenceError: Cannot access 'l' before initialization`, taking every nightly pg-boss job down with it (score jobs + dense-intraday retention). Root cause is a value-level import cycle between `consolidation-base.ts` and `drain-per-sample-cumulative.ts`: each imported values from the other. The bundler merges the tightly-cyclic pair into one module body and hoists `DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` ahead of the constant it reads, so the eager read hits a temporal dead zone. It only surfaced in the production build because dev/test run the modules unbundled. Hoist the shared leaf — `CONSOLIDATION_GRACE_CUTOFF_HOURS`, the `PerSampleRow` shape, and the timezone day-math helpers — into a new dependency-free module `consolidation-tz.ts`. Both sides now import from the leaf, removing the cycle at its root. Re-export the moved names from their former homes so every existing import site resolves unchanged. --- src/lib/measurements/consolidation-base.ts | 24 ++- src/lib/measurements/consolidation-tz.ts | 158 ++++++++++++++++++ .../drain-per-sample-cumulative.ts | 152 +++-------------- 3 files changed, 191 insertions(+), 143 deletions(-) create mode 100644 src/lib/measurements/consolidation-tz.ts diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 6da1a4594..bd06f6d1b 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -19,10 +19,12 @@ * supplies its reducer/types and keeps its own public summary shape. * * The timezone day-math helpers (`dayKeyForUserTz`, - * `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`) live in - * `drain-per-sample-cumulative.ts` and are imported here to avoid a - * cyclic dependency — that module is the historical home the other two - * already import them from. + * `canonicalDailyTimestamp`, `localStartOfDay`, `localDayWindow`), the + * `PerSampleRow` shape, and `CONSOLIDATION_GRACE_CUTOFF_HOURS` all live + * in the dependency-free leaf `consolidation-tz.ts`. Both this module + * and `drain-per-sample-cumulative.ts` import them from there, so there + * is no value-level cycle between the two — a cycle would otherwise trip + * a module-init TDZ in the production bundle. */ import type { MeasurementType, @@ -31,19 +33,15 @@ import type { } from "@/generated/prisma/client"; import { + CONSOLIDATION_GRACE_CUTOFF_HOURS, canonicalDailyTimestamp, dayKeyForUserTz, type PerSampleRow, -} from "./drain-per-sample-cumulative"; +} from "./consolidation-tz"; -/** - * Canonical grace window shared by the cumulative + mean drains. Rows - * whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS` - * stay raw so today's still-in-flight watch syncs surface in the live - * "today" view. 36 hours covers the previous calendar day plus a - * trailing sync window for watches that weren't worn at midnight. - */ -export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36; +// Re-export the shared grace constant so the established +// `consolidation-base` import surface stays unchanged for callers. +export { CONSOLIDATION_GRACE_CUTOFF_HOURS }; /** Fallback timezone for users with no `User.timezone` set. */ const DEFAULT_TIMEZONE = "Europe/Berlin"; diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts new file mode 100644 index 000000000..35fe4e361 --- /dev/null +++ b/src/lib/measurements/consolidation-tz.ts @@ -0,0 +1,158 @@ +/** + * Leaf timezone day-math + per-sample-row shape shared by the + * consolidation drains. + * + * This module owns the primitives that both `consolidation-base.ts` (the + * shared `runConsolidation` driver) and `drain-per-sample-cumulative.ts` + * (the cumulative drain) need. It deliberately imports nothing from + * either of those — it is a dependency-free leaf so the two can both + * reach the helpers without forming an import cycle. + * + * History: `dayKeyForUserTz`, `canonicalDailyTimestamp`, `localStartOfDay`, + * `localDayWindow`, the grace constant, and the `PerSampleRow` shape used + * to live in `drain-per-sample-cumulative.ts`. `consolidation-base.ts` + * imported them from there while `drain` imported the runner back — a + * value-level cycle. Bundled for production (Turbopack merges tightly + * cyclic modules into one body), the eager re-export + * `const DRAIN_CUMULATIVE_CUTOFF_HOURS = CONSOLIDATION_GRACE_CUTOFF_HOURS` + * was hoisted ahead of the constant it read, throwing a + * `ReferenceError: Cannot access '…' before initialization` at worker + * boot. Hoisting the shared leaf into this module removes the cycle at + * its root; `drain-per-sample-cumulative.ts` re-exports these names so + * every existing import site keeps working unchanged. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Canonical grace window shared by the cumulative + mean drains. Rows + * whose `measuredAt` is newer than `now() - CONSOLIDATION_GRACE_CUTOFF_HOURS` + * stay raw so today's still-in-flight watch syncs surface in the live + * "today" view. 36 hours covers the previous calendar day plus a + * trailing sync window for watches that weren't worn at midnight. + */ +export const CONSOLIDATION_GRACE_CUTOFF_HOURS = 36; + +/** + * Per-sample row shape the drains scan and bucket. Exposed for unit + * testing the bucketing semantics without booting Prisma. + */ +export interface PerSampleRow { + id: string; + type: MeasurementType; + value: number; + measuredAt: Date; + externalId: string | null; + /** + * Optional — only the mean-consolidation pass selects `unit` so it can + * read the canonical unit straight off the day's rows rather than + * issuing a separate query. The cumulative drain leaves it unselected. + */ + unit?: string; +} + +/** + * Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given + * timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice + * the mood-entries path locked in v1.4.25 W7b so iOS-side and + * server-side day-keys round-trip byte-identically. + */ +export function dayKeyForUserTz(date: Date, tz: string): string { + return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date); +} + +/** + * Read the IANA-zone offset (in minutes east of UTC) at a given + * instant. Returns 0 for UTC and any zone the shortOffset formatter + * can't resolve (defensive — Node 22's full-icu build covers every + * zone we care about). + */ +function tzOffsetMinutesAt(instant: Date, tz: string): number { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour12: false, + hour: "2-digit", + minute: "2-digit", + timeZoneName: "shortOffset", + }); + const parts = fmt.formatToParts(instant); + const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; + const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); + if (!match) return 0; + const sign = match[1] === "+" ? 1 : -1; + const hours = Number.parseInt(match[2], 10); + const minutes = match[3] ? Number.parseInt(match[3], 10) : 0; + return sign * (hours * 60 + minutes); +} + +/** + * Compute the canonical timestamp for a calendar-day key in the user's + * timezone. Returns the JS-Date instant at the user's local 12:00 noon + * — matches the Withings activity sync convention (one daily row per + * type, anchored to midday so the row sorts cleanly between same-day + * spot samples). The string returned by `toISOString()` is UTC. + */ +export function canonicalDailyTimestamp(dateKey: string, tz: string): Date { + // Compute the UTC offset for noon-local of the given day. We don't + // have a lightweight TZ-math library on the server, so the trick is: + // build "12:00 UTC of the day", read what wall-clock that shows in + // the target zone, then shift by the resulting offset. + const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); + const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); + // utcNoon represents 12:00 UTC. The user's local clock reads + // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, + // subtract the offset. + return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000); +} + +/** + * v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00 + * for a calendar-day key. Robust on DST transitions because the offset + * is read at the UTC-midnight instant of the day, and EU/US DST + * transitions happen at local 02:00 / 03:00 — so the offset at UTC + * midnight is unambiguous on every day of the year. + * + * Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on + * a 23-h spring-forward or 25-h fall-back day; the previous shape + * (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour + * of samples on two days per year. + * + * Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so + * the returned window covers the true local-day span — 23 h on + * spring-forward, 24 h on a regular day, 25 h on fall-back. + */ +export function localStartOfDay(dateKey: string, tz: string): Date { + // Anchor at 00:00 UTC of the day; the offset read at that instant + // is the same offset the local clock uses at midnight (DST + // transitions in EU/US happen at 02:00 / 03:00 local, not at the + // 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45, + // Pacific/Chatham UTC+12:45) the minute component is preserved. + const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`); + const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz); + // Local 00:00 at this date = UTC midnight - offset. + return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000); +} + +/** + * v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a + * calendar-day key in the user's IANA timezone. The window honours + * DST so the drill-down branch returns the correct 23 / 24 / 25-hour + * span for transition days. + * + * Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING + * calendar day — `< dayEnd` is the canonical half-open bound used by + * the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate. + */ +export function localDayWindow( + dateKey: string, + tz: string, +): { dayStart: Date; dayEnd: Date } { + const dayStart = localStartOfDay(dateKey, tz); + // Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon + // sidesteps every DST edge case for the calendar increment). Then + // re-extract the ISO date slice — guaranteed to be the next-day key. + const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`); + nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1); + const nextDateKey = nextUtcNoon.toISOString().slice(0, 10); + const dayEnd = localStartOfDay(nextDateKey, tz); + return { dayStart, dayEnd }; +} diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index da823046f..9b8202e31 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -40,11 +40,32 @@ import { hkIdentifierForType, } from "./apple-health-mapping"; import { - CONSOLIDATION_GRACE_CUTOFF_HOURS, bucketRowsByDay, runConsolidation, type DayWriteOutcome, } from "./consolidation-base"; +import { + CONSOLIDATION_GRACE_CUTOFF_HOURS, + canonicalDailyTimestamp, + dayKeyForUserTz, + localDayWindow, + localStartOfDay, + type PerSampleRow, +} from "./consolidation-tz"; + +// Re-export the shared timezone day-math primitives + `PerSampleRow` +// shape so every established `drain-per-sample-cumulative` import site +// (the measurements route, the Apple-Health importer, the unit tests) +// keeps resolving them from here. The definitions moved to the +// dependency-free leaf `consolidation-tz.ts` to break the init-order +// cycle with `consolidation-base.ts`. +export { + canonicalDailyTimestamp, + dayKeyForUserTz, + localDayWindow, + localStartOfDay, +}; +export type { PerSampleRow }; /** Prefix marking an already-collapsed daily-stats row. */ const DAILY_STATS_PREFIX = "stats:"; @@ -118,135 +139,6 @@ export interface DrainOptions { cutoffHours?: number; } -/** - * Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given - * timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice - * the mood-entries path locked in v1.4.25 W7b so iOS-side and - * server-side day-keys round-trip byte-identically. - */ -export function dayKeyForUserTz(date: Date, tz: string): string { - return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date); -} - -/** - * Read the IANA-zone offset (in minutes east of UTC) at a given - * instant. Returns 0 for UTC and any zone the shortOffset formatter - * can't resolve (defensive — Node 22's full-icu build covers every - * zone we care about). - */ -function tzOffsetMinutesAt(instant: Date, tz: string): number { - const fmt = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - hour12: false, - hour: "2-digit", - minute: "2-digit", - timeZoneName: "shortOffset", - }); - const parts = fmt.formatToParts(instant); - const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT"; - const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/); - if (!match) return 0; - const sign = match[1] === "+" ? 1 : -1; - const hours = Number.parseInt(match[2], 10); - const minutes = match[3] ? Number.parseInt(match[3], 10) : 0; - return sign * (hours * 60 + minutes); -} - -/** - * Compute the canonical timestamp for a calendar-day key in the user's - * timezone. Returns the JS-Date instant at the user's local 12:00 noon - * — matches the Withings activity sync convention (one daily row per - * type, anchored to midday so the row sorts cleanly between same-day - * spot samples). The string returned by `toISOString()` is UTC. - */ -export function canonicalDailyTimestamp( - dateKey: string, - tz: string, -): Date { - // Compute the UTC offset for noon-local of the given day. We don't - // have a lightweight TZ-math library on the server, so the trick is: - // build "12:00 UTC of the day", read what wall-clock that shows in - // the target zone, then shift by the resulting offset. - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); - const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); - // utcNoon represents 12:00 UTC. The user's local clock reads - // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, - // subtract the offset. - return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000); -} - -/** - * v1.4.37 W10 — Compute the JS-Date instant at the user's local 00:00 - * for a calendar-day key. Robust on DST transitions because the offset - * is read at the UTC-midnight instant of the day, and EU/US DST - * transitions happen at local 02:00 / 03:00 — so the offset at UTC - * midnight is unambiguous on every day of the year. - * - * Used by the W7c drill-down branch to resolve [dayStart, dayEnd) on - * a 23-h spring-forward or 25-h fall-back day; the previous shape - * (`canonicalDailyTimestamp ± 12h`) silently leaked or hid an hour - * of samples on two days per year. - * - * Pair with `localStartOfDay(nextDayKey, tz)` for the right bound so - * the returned window covers the true local-day span — 23 h on - * spring-forward, 24 h on a regular day, 25 h on fall-back. - */ -export function localStartOfDay(dateKey: string, tz: string): Date { - // Anchor at 00:00 UTC of the day; the offset read at that instant - // is the same offset the local clock uses at midnight (DST - // transitions in EU/US happen at 02:00 / 03:00 local, not at the - // 00:00 boundary). For sub-half-hour zones (Asia/Kathmandu UTC+5:45, - // Pacific/Chatham UTC+12:45) the minute component is preserved. - const utcMidnight = new Date(`${dateKey}T00:00:00.000Z`); - const offsetMinutes = tzOffsetMinutesAt(utcMidnight, tz); - // Local 00:00 at this date = UTC midnight - offset. - return new Date(utcMidnight.getTime() - offsetMinutes * 60 * 1000); -} - -/** - * v1.4.37 W10 — Resolve the [dayStart, dayEnd) UTC window for a - * calendar-day key in the user's IANA timezone. The window honours - * DST so the drill-down branch returns the correct 23 / 24 / 25-hour - * span for transition days. - * - * Returns a tuple where `dayEnd` is the local 00:00 of the FOLLOWING - * calendar day — `< dayEnd` is the canonical half-open bound used by - * the route's `measuredAt: { gte: dayStart, lt: dayEnd }` predicate. - */ -export function localDayWindow( - dateKey: string, - tz: string, -): { dayStart: Date; dayEnd: Date } { - const dayStart = localStartOfDay(dateKey, tz); - // Add ONE day to dateKey using UTC arithmetic on a noon anchor (noon - // sidesteps every DST edge case for the calendar increment). Then - // re-extract the ISO date slice — guaranteed to be the next-day key. - const nextUtcNoon = new Date(`${dateKey}T12:00:00.000Z`); - nextUtcNoon.setUTCDate(nextUtcNoon.getUTCDate() + 1); - const nextDateKey = nextUtcNoon.toISOString().slice(0, 10); - const dayEnd = localStartOfDay(nextDateKey, tz); - return { dayStart, dayEnd }; -} - -/** - * Group an array of per-sample rows into per-day buckets keyed by the - * user's timezone. Exposed for unit testing the bucketing semantics - * without booting Prisma. - */ -export interface PerSampleRow { - id: string; - type: MeasurementType; - value: number; - measuredAt: Date; - externalId: string | null; - /** - * Optional — only the mean-consolidation pass selects `unit` so it can - * read the canonical unit straight off the day's rows rather than - * issuing a separate query. The cumulative drain leaves it unselected. - */ - unit?: string; -} - export interface BucketedRows { /** Map keyed by `YYYY-MM-DD` (user TZ). */ byDay: Map; From 56e9816def0fab64e7d1c5d79614c99ad8bdf8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 01:07:36 +0200 Subject: [PATCH 49/49] test(medications): pin the card clock so window-status and day labels are deterministic --- .../medication-card-next-due.test.tsx | 33 ++++++++++--------- .../medication-card-symmetry.test.tsx | 16 ++++++++- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/components/medications/__tests__/medication-card-next-due.test.tsx b/src/components/medications/__tests__/medication-card-next-due.test.tsx index bb4fd48f5..d37d5ae2b 100644 --- a/src/components/medications/__tests__/medication-card-next-due.test.tsx +++ b/src/components/medications/__tests__/medication-card-next-due.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -52,6 +52,20 @@ const pastWindow = { dose: null, }; +// Pin the clock to a fixed mid-day instant so window-status ("take now" +// vs "next intake") and relative day labels are deterministic regardless +// of the CI host clock + timezone. The 01:00–02:00 windows below read as +// inactive at noon in every timezone; an unpinned run that happened to +// fall inside that window (e.g. 01:01 CEST) flipped the card to the +// take-now pill and broke these assertions. +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + describe(" — next-due reads from server nextDueAt", () => { it("renders the server nextDueAt date for a rolling medication, not a today/tomorrow walker value", () => { // Rolling cadence: server anchors the next dose 35 days out. The @@ -152,17 +166,9 @@ describe(" — next-due reads from server nextDueAt", () => { it("renders the day label for a single-time daysOfWeek medication due tomorrow", () => { // A daily / weekly medication whose server next-due is tomorrow // still renders the relative "tomorrow" label — proving the ordinary - // single-time path keeps working. - // - // Pin the clock to a midday UTC instant and express `due` as a fixed - // UTC instant ~20h later: it lands on the next calendar day in every - // timezone the card might label in (UTC and the Berlin default alike), - // so the relative label is deterministic regardless of the CI host - // clock / timezone (the late-UTC run made an unpinned "tomorrow" read - // as "today" in the Berlin frame). - vi.useFakeTimers({ toFake: ["Date"] }); - try { - vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); + // single-time path keeps working. `due` is a fixed UTC instant ~20h + // after the pinned clock, so it lands on the next calendar day in both + // UTC and the Berlin default — the relative label is deterministic. const now = new Date(); const due = new Date(now); due.setUTCDate(due.getUTCDate() + 1); @@ -208,8 +214,5 @@ describe(" — next-due reads from server nextDueAt", () => { expect(html).toContain("Next intake:"); // Relative day label from the server instant (capitalised "Tomorrow"). expect(html).toContain("Tomorrow,"); - } finally { - vi.useRealTimers(); - } }); }); diff --git a/src/components/medications/__tests__/medication-card-symmetry.test.tsx b/src/components/medications/__tests__/medication-card-symmetry.test.tsx index 8085c0cb7..d6d2627fe 100644 --- a/src/components/medications/__tests__/medication-card-symmetry.test.tsx +++ b/src/components/medications/__tests__/medication-card-symmetry.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -127,6 +127,20 @@ const mounjaro: Glp1Medication = { ], }; +// Pin the clock to a fixed mid-day instant so window-status is +// deterministic: the all-day window (00:00–23:59) reads active (the +// take-now pill) and the 01:00–02:00 window reads inactive (the upcoming +// "next intake" line that carries the purple dose accent), in every +// timezone. An unpinned run that fell inside 01:00–02:00 (e.g. 01:01 CEST) +// flipped the accent test's card to the take-now pill. +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-02T12:00:00Z")); +}); +afterEach(() => { + vi.useRealTimers(); +}); + describe("medication card symmetry — Ramipril vs Mounjaro", () => { it("both cards collapse the header actions into a single kebab + navigable header link", () => { const client = makeClient();