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