From 6320c54eddc514cf57aaf6209c6c061e55d21f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:27:20 +0200 Subject: [PATCH] feat(insights): look for level shifts in thirteen vitals over 180 days The health-status read scanned five vitals over 90 days for a dated, sustained level shift. It now scans every vital with a stable daily level (adding RMSSD, diastolic pressure, pulse, body fat, respiratory rate, oxygen saturation, walking heart rate and VO2 max) over 180 days, reads one value per day per metric, leaves a metric unread when its module is off, and reports how many days stand on each side of the break. The Coach keeps its five-metric, 90-day scan unchanged. --- docs/api/openapi.yaml | 10 ++ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- src/app/api/insights/health-status/route.ts | 11 +- .../insights/health-status-card.tsx | 6 + .../__tests__/changepoint-extended.test.ts | 105 ++++++++++++++++ src/lib/insights/derived/changepoint.ts | 118 ++++++++++++++++-- src/lib/insights/health-status.ts | 11 +- src/lib/openapi/routes/insights-signals.ts | 10 ++ .../integration/changepoint-extended.test.ts | 76 +++++++++++ 16 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/changepoint-extended.test.ts create mode 100644 tests/integration/changepoint-extended.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45452791c..bb3a494c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -66698,6 +66698,16 @@ components: enum: - up - down + beforeDays: + description: v1.44 — days with a value before the break. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + afterDays: + description: v1.44 — days with a value from the break on. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 required: - metric - breakDate diff --git a/messages/de.json b/messages/de.json index 1862f7fce..8e3f68622 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.", "rangeContext": "Aktuell {value}, sonst {low}–{high}.", "rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.", - "shiftContext": "Etwa {before} davor, {after} seitdem." + "shiftContext": "Etwa {before} davor, {after} seitdem.", + "shiftDays": "Grundlage: {before} Tage davor und {after} seither." }, "breathingScreening": { "sectionTitle": "Atmung im Schlaf", diff --git a/messages/en.json b/messages/en.json index 31c814e13..2b8e8848a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} has shifted down from your recent normal.", "rangeContext": "Now {value}, usually {low}–{high}.", "rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.", - "shiftContext": "Around {before} before, {after} since." + "shiftContext": "Around {before} before, {after} since.", + "shiftDays": "Based on {before} days before and {after} since." }, "breathingScreening": { "sectionTitle": "Breathing during sleep", diff --git a/messages/es.json b/messages/es.json index b8f865a17..b605c6b55 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} ha bajado respecto a tu normal reciente.", "rangeContext": "Ahora {value}, normalmente {low}–{high}.", "rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.", - "shiftContext": "Cerca de {before} antes, {after} desde entonces." + "shiftContext": "Cerca de {before} antes, {after} desde entonces.", + "shiftDays": "Con {before} días antes y {after} desde entonces." }, "breathingScreening": { "sectionTitle": "Respiración durante el sueño", diff --git a/messages/fr.json b/messages/fr.json index 52a6d0729..1a4ed1d79 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} a diminué par rapport à votre normale récente.", "rangeContext": "Actuellement {value}, habituellement {low}–{high}.", "rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.", - "shiftContext": "Environ {before} avant, {after} depuis." + "shiftContext": "Environ {before} avant, {after} depuis.", + "shiftDays": "Sur {before} jours avant et {after} depuis." }, "breathingScreening": { "sectionTitle": "Respiration pendant le sommeil", diff --git a/messages/it.json b/messages/it.json index 69ab3a6be..3ade4fd02 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.", "rangeContext": "Ora {value}, di solito {low}–{high}.", "rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.", - "shiftContext": "Circa {before} prima, {after} da allora." + "shiftContext": "Circa {before} prima, {after} da allora.", + "shiftDays": "Su {before} giorni prima e {after} da allora." }, "breathingScreening": { "sectionTitle": "Respirazione durante il sonno", diff --git a/messages/ko.json b/messages/ko.json index ffaa699a6..8009dce6e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital}: 최근 평소 수준보다 내려갔어요.", "rangeContext": "지금 {value}, 평소 {low}–{high}.", "rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.", - "shiftContext": "이전엔 약 {before}, 이후엔 {after}." + "shiftContext": "이전엔 약 {before}, 이후엔 {after}.", + "shiftDays": "이전 {before}일과 이후 {after}일 기준입니다." }, "breathingScreening": { "sectionTitle": "수면 중 호흡", diff --git a/messages/nl.json b/messages/nl.json index c66ceb99d..ec5cd2dd0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} is gedaald ten opzichte van wat recent normaal was voor jou.", "rangeContext": "Nu {value}, meestal {low}–{high}.", "rangeContextTimeOfDay": "Nu {value}, op dit moment van de dag meestal {low}–{high}.", - "shiftContext": "Eerst rond {before}, sindsdien {after}." + "shiftContext": "Eerst rond {before}, sindsdien {after}.", + "shiftDays": "Op basis van {before} dagen ervoor en {after} sindsdien." }, "breathingScreening": { "sectionTitle": "Ademhaling tijdens de slaap", diff --git a/messages/pl.json b/messages/pl.json index a7e53374e..190ae53cf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4748,7 +4748,8 @@ "shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.", "rangeContext": "Teraz {value}, zwykle {low}–{high}.", "rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.", - "shiftContext": "Około {before} wcześniej, {after} od tego czasu." + "shiftContext": "Około {before} wcześniej, {after} od tego czasu.", + "shiftDays": "Na podstawie {before} dni wcześniej i {after} od tego czasu." }, "breathingScreening": { "sectionTitle": "Oddychanie podczas snu", diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts index 2b0bcd0be..601c472ee 100644 --- a/src/app/api/insights/health-status/route.ts +++ b/src/app/api/insights/health-status/route.ts @@ -23,7 +23,8 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; -import { buildChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { summariseHealthStatus } from "@/lib/insights/health-status"; export const dynamic = "force-dynamic"; @@ -45,7 +46,13 @@ export const GET = apiHandler(async () => { tz: user.timezone, now, }); - const shifts = await buildChangepointSignals(user.id, now); + // v1.44 — the wider scan: every vital with a stable daily level over 180 + // days, a metric whose module is off left unread. + const shifts = await buildExtendedChangepointSignals( + user.id, + now, + await resolveModuleMap(user.id), + ); const vitals = coincident.status === "ok" ? coincident.value.vitals : []; const summary = summariseHealthStatus(vitals, shifts); diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx index 87487f8f1..b598c23be 100644 --- a/src/components/insights/health-status-card.tsx +++ b/src/components/insights/health-status-card.tsx @@ -155,6 +155,12 @@ export function HealthStatusCard({ before: metricValue(s.metric, s.beforeMean), after: metricValue(s.metric, s.afterMean), })} + {s.beforeDays !== undefined && s.afterDays !== undefined + ? ` ${t("insights.healthStatus.shiftDays", { + before: s.beforeDays, + after: s.afterDays, + })}` + : null}

diff --git a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts new file mode 100644 index 000000000..edfcfe85b --- /dev/null +++ b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readDayMeanSeries = vi.fn(); +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: (...args: unknown[]) => readDayMeanSeries(...args), +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn(async () => new Map()), +})); + +import { + buildChangepointSignals, + buildExtendedChangepointSignals, + CHANGEPOINT_WINDOW_DAYS, + EXTENDED_CHANGEPOINT_METRICS, + EXTENDED_CHANGEPOINT_WINDOW_DAYS, +} from "../changepoint"; +import { shiftDateKey } from "@/lib/tz/format"; + +const NOW = new Date("2026-10-21T08:00:00.000Z"); + +/** 20 days at `a`, then 20 at `b`, a day apart from 2026-06-01. */ +function step(a: number, b: number) { + return Array.from({ length: 40 }, (_, i) => ({ + day: shiftDateKey("2026-06-01", i), + mean: i < 20 ? a : b, + })); +} + +beforeEach(() => { + readDayMeanSeries.mockReset(); +}); + +describe("buildExtendedChangepointSignals", () => { + it("scans every extended metric over 180 days and reports segment sizes", async () => { + readDayMeanSeries.mockImplementation(async (_u: string, metric: string) => + metric === "RESPIRATORY_RATE" + ? { points: step(14, 17), source: "DAY" } + : { points: [], source: "DAY" }, + ); + const shifts = await buildExtendedChangepointSignals("u1", NOW, {}); + const read = readDayMeanSeries.mock.calls.map((c) => c[1]); + expect(read).toEqual([...EXTENDED_CHANGEPOINT_METRICS]); + for (const call of readDayMeanSeries.mock.calls) { + expect(call[2]).toBe(EXTENDED_CHANGEPOINT_WINDOW_DAYS); + } + expect(shifts).toEqual([ + expect.objectContaining({ + metric: "RESPIRATORY_RATE", + breakDate: "2026-06-21", + beforeMean: 14, + afterMean: 17, + direction: "up", + beforeDays: 20, + afterDays: 20, + }), + ]); + }); + + it("leaves a metric unread when its module is off", async () => { + readDayMeanSeries.mockResolvedValue({ points: [], source: "DAY" }); + await buildExtendedChangepointSignals("u1", NOW, { + recovery: false, + glucose: false, + }); + const read = readDayMeanSeries.mock.calls.map((c) => c[1]); + expect(read).not.toContain("HRV_RMSSD"); + expect(read).not.toContain("RESTING_HEART_RATE"); + expect(read).not.toContain("BLOOD_GLUCOSE"); + expect(read).toContain("WEIGHT"); + }); + + it("caps at five, strongest first", async () => { + let k = 0; + readDayMeanSeries.mockImplementation(async () => { + k += 1; + return { points: step(10, 10 + k), source: "DAY" }; + }); + const shifts = await buildExtendedChangepointSignals("u1", NOW, {}); + expect(shifts).toHaveLength(5); + const magnitudes = shifts.map((s) => s.magnitude); + expect([...magnitudes].sort((a, b) => b - a)).toEqual(magnitudes); + }); +}); + +describe("buildChangepointSignals (the Coach's scan)", () => { + it("keeps five metrics, 90 days and its shape", async () => { + readDayMeanSeries.mockResolvedValue({ + points: step(56, 63), + source: "DAY", + }); + const shifts = await buildChangepointSignals("u1", NOW); + expect(readDayMeanSeries).toHaveBeenCalledTimes(5); + expect(readDayMeanSeries.mock.calls[0][2]).toBe(CHANGEPOINT_WINDOW_DAYS); + expect(shifts).toHaveLength(2); + expect(Object.keys(shifts[0]).sort()).toEqual([ + "afterMean", + "beforeMean", + "breakDate", + "direction", + "magnitude", + "metric", + ]); + }); +}); diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index 47fc448f7..d7505c8c0 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -28,6 +28,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; +import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; +import type { SurfaceModuleMap } from "@/lib/modules/surface"; const MAD_TO_SIGMA = 1.4826; @@ -184,24 +186,60 @@ const MAX_CHANGEPOINTS = 2; const round1 = (n: number): number => Math.round(n * 10) / 10; /** - * Read the DAY-mean series for a small set of stable metrics and surface any - * validated level shift. Best-effort + fault-isolated per metric: a read - * failure on one metric never sinks the others. Returns the strongest - * changepoints (by magnitude), capped, or an empty array when nothing fires. + * v1.44 — the metrics the health-status surface scans: the five above plus + * every other vital with a stable daily level and enough readings to carry a + * 180-day window. Activity volumes, audio and sleep stay out: the first two are + * day-to-day behaviour rather than a level, and a sleep "day mean" over stage + * rows is not a night. Wrist temperature stays out for the same privacy reason + * it is not a correlation channel. */ -export async function buildChangepointSignals( - userId: string, - now: Date = new Date(), -): Promise { - const coverage = await probeRollupCoverage(userId); - const found: ChangepointSignal[] = []; +export const EXTENDED_CHANGEPOINT_METRICS: readonly MeasurementType[] = [ + ...CHANGEPOINT_METRICS, + "HRV_RMSSD", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "WALKING_HEART_RATE_AVERAGE", + "VO2_MAX", +]; - for (const metric of CHANGEPOINT_METRICS) { +/** Window of the extended scan (days). */ +export const EXTENDED_CHANGEPOINT_WINDOW_DAYS = 180; +/** At most this many shifts on the health-status surface. */ +const MAX_EXTENDED_CHANGEPOINTS = 5; + +/** A changepoint with the day counts of the two segments it compares. */ +export interface ExtendedChangepointSignal extends ChangepointSignal { + /** Days with a value before the break. */ + beforeDays: number; + /** Days with a value from the break on. */ + afterDays: number; +} + +/** + * Scan a metric set for one validated shift each, fault-isolated per metric, + * strongest first, capped. One per-day read per metric (DAY rollups, or the + * per-day SQL fold for a window past the DAY tier), never raw readings in + * process, so the cost is a row per day per metric whatever the density. + */ +async function scanChangepoints( + userId: string, + now: Date, + metrics: readonly MeasurementType[], + windowDays: number, + max: number, +): Promise { + const coverage = await probeRollupCoverage(userId); + const found: ExtendedChangepointSignal[] = []; + + for (const metric of metrics) { try { const { points } = await readDayMeanSeries( userId, metric, - CHANGEPOINT_WINDOW_DAYS, + windowDays, now, coverage, ); @@ -217,6 +255,8 @@ export async function buildChangepointSignals( afterMean: round1(shift.afterMean), direction: shift.direction, magnitude: round1(shift.magnitude), + beforeDays: shift.breakIndex + 1, + afterDays: points.length - shift.breakIndex - 1, }); } catch { // Fault-isolated — skip this metric, keep scanning. @@ -224,5 +264,57 @@ export async function buildChangepointSignals( } found.sort((a, b) => b.magnitude - a.magnitude); - return found.slice(0, MAX_CHANGEPOINTS); + return found.slice(0, max); +} + +/** + * v1.44 — the health-status scan: {@link EXTENDED_CHANGEPOINT_METRICS} over + * {@link EXTENDED_CHANGEPOINT_WINDOW_DAYS} days, a metric whose module is off + * left unread. The Coach keeps {@link buildChangepointSignals}: what reaches a + * model is not widened by this. + */ +export async function buildExtendedChangepointSignals( + userId: string, + now: Date, + modules: SurfaceModuleMap, +): Promise { + const metrics = EXTENDED_CHANGEPOINT_METRICS.filter((metric) => { + const owner = moduleForMeasurementType(metric); + return owner === null || modules[owner] !== false; + }); + return scanChangepoints( + userId, + now, + metrics, + EXTENDED_CHANGEPOINT_WINDOW_DAYS, + MAX_EXTENDED_CHANGEPOINTS, + ); +} + +/** + * Read the DAY-mean series for a small set of stable metrics and surface any + * validated level shift. Best-effort + fault-isolated per metric: a read + * failure on one metric never sinks the others. Returns the strongest + * changepoints (by magnitude), capped, or an empty array when nothing fires. + */ +export async function buildChangepointSignals( + userId: string, + now: Date = new Date(), +): Promise { + const found = await scanChangepoints( + userId, + now, + CHANGEPOINT_METRICS, + CHANGEPOINT_WINDOW_DAYS, + MAX_CHANGEPOINTS, + ); + // The Coach snapshot's shape is unchanged: no segment day counts. + return found.map((signal) => ({ + metric: signal.metric, + breakDate: signal.breakDate, + beforeMean: signal.beforeMean, + afterMean: signal.afterMean, + direction: signal.direction, + magnitude: signal.magnitude, + })); } diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts index 6cf8e718d..9279de218 100644 --- a/src/lib/insights/health-status.ts +++ b/src/lib/insights/health-status.ts @@ -46,6 +46,10 @@ export interface HealthStatusShift { beforeMean: number; afterMean: number; direction: "up" | "down"; + /** v1.44 — days with a value before the break, when the scan reports it. */ + beforeDays?: number; + /** v1.44 — days with a value from the break on. */ + afterDays?: number; } export interface HealthStatusSummary { @@ -62,7 +66,9 @@ export interface HealthStatusSummary { */ export function summariseHealthStatus( vitals: readonly VitalDeviation[], - shifts: readonly ChangepointSignal[], + shifts: ReadonlyArray< + ChangepointSignal & { beforeDays?: number; afterDays?: number } + >, ): HealthStatusSummary { const deviations: HealthStatusDeviation[] = vitals // Present tense, so present readings only. "Your pulse is above your usual @@ -88,6 +94,9 @@ export function summariseHealthStatus( beforeMean: s.beforeMean, afterMean: s.afterMean, direction: s.direction, + ...(s.beforeDays !== undefined && s.afterDays !== undefined + ? { beforeDays: s.beforeDays, afterDays: s.afterDays } + : {}), })); return { diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts index b08885b3d..de79c40bb 100644 --- a/src/lib/openapi/routes/insights-signals.ts +++ b/src/lib/openapi/routes/insights-signals.ts @@ -44,6 +44,16 @@ const healthStatusResponse = z beforeMean: z.number(), afterMean: z.number(), direction: z.enum(["up", "down"]), + beforeDays: z + .number() + .int() + .optional() + .describe("v1.44 — days with a value before the break."), + afterDays: z + .number() + .int() + .optional() + .describe("v1.44 — days with a value from the break on."), }), ), generatedAt: z.string().describe("ISO-8601 instant the read was computed."), diff --git a/tests/integration/changepoint-extended.test.ts b/tests/integration/changepoint-extended.test.ts new file mode 100644 index 000000000..d369dc6b6 --- /dev/null +++ b/tests/integration/changepoint-extended.test.ts @@ -0,0 +1,76 @@ +/** + * v1.44 — the health-status changepoint scan over 180 days against a real + * Postgres. Past the DAY tier's 90-day reach the read folds per day in SQL + * (`readDayAggregates`); this proves that path finds a step, reports its + * segment sizes, and leaves a switched-off module's metric unread. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; + +function at(daysAgo: number): Date { + const d = new Date(Date.now() - daysAgo * DAY_MS); + d.setUTCHours(12, 0, 0, 0); + return d; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("extended changepoint scan — real per-day fold", () => { + it("finds a step in a metric past the five, over 120 days", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "cp-extended", + email: "cp-extended@example.test", + role: "USER", + timezone: "UTC", + }, + }); + const rows = []; + // 120 days ago to 61 days ago at 14, then 60 days at 17, two readings a day. + for (let d = 120; d >= 1; d -= 1) { + const value = d > 60 ? 14 : 17; + rows.push( + { value, measuredAt: at(d) }, + { value, measuredAt: new Date(at(d).getTime() + 3_600_000) }, + ); + rows.push({ + value: d > 60 ? 140 : 150, + measuredAt: at(d), + type: "BLOOD_GLUCOSE" as const, + unit: "mg/dL", + }); + } + await prisma.measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: "RESPIRATORY_RATE" as const, + unit: "count/min", + source: "APPLE_HEALTH" as const, + ...r, + })), + }); + + const shifts = await buildExtendedChangepointSignals(user.id, new Date(), { + glucose: false, + }); + expect(shifts.map((s) => s.metric)).toEqual(["RESPIRATORY_RATE"]); + expect(shifts[0]).toMatchObject({ + beforeMean: 14, + afterMean: 17, + direction: "up", + beforeDays: 60, + afterDays: 60, + }); + }); +});