From 30248efaf1081e159e7729ea3fba3c32ff9d851a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:12:23 +0200 Subject: [PATCH 1/6] fix(workouts): count a session synced twice once in strain, briefing and day view The training impulse summed every stored workout row of the day, and the briefing's workout tally and the day view's workout section listed every row, while the workout list collapses a session recorded by the watch and synced again through a second service. All three now run the same cross-source picker. The strain engine keeps the heart-rate series of a dropped twin when the picked row has none, so the collapse never turns a scored session into an unscored one. --- .../workout-canonical-identity-guard.test.ts | 6 + src/lib/day/records.ts | 35 ++++-- src/lib/insights/__tests__/features.test.ts | 24 ++++ .../insights/__tests__/strain-score.test.ts | 96 +++++++++++++++- src/lib/insights/feature-blocks.ts | 38 +++++-- src/lib/insights/strain-score.ts | 104 +++++++++++++++--- .../pick-canonical-workout-rows.ts | 9 ++ 7 files changed, 277 insertions(+), 35 deletions(-) diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..dfbc5cfbe 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -52,6 +52,12 @@ const IDENTITY_OWNER: Record = { // about to insert, so a duplicate inside one batch is dropped by the rule // the list later applies. "app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts", + // The day-total training impulse, the briefing's workout tally and the day + // view's workout section: each counted a session synced by two services + // twice while the list showed it once. + "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", + "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", + "lib/day/records.ts": "lib/day/records.ts", }; /** diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index b3581cac9..4d1cd2bf4 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -42,6 +42,7 @@ import { effectiveMoodTz } from "@/lib/mood/date-key"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -904,14 +905,32 @@ async function assessmentsPart(frame: DayFrame): Promise { /* ─── workouts, cycle ─────────────────────────────────────────────────────── */ async function workoutsPart(frame: DayFrame): Promise { - const rows = await prisma.workout.findMany({ - where: { - userId: frame.userId, - startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { id: true, sportType: true, startedAt: true, durationSec: true }, - orderBy: { startedAt: "asc" }, - }); + const [stored, account] = await Promise.all([ + prisma.workout.findMany({ + where: { + userId: frame.userId, + startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, + }, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + }), + prisma.user.findUnique({ + where: { id: frame.userId }, + select: { sourcePriorityJson: true }, + }), + ]); + // One session synced by two services is one workout on the day, as in the + // workout list it links to. + const rows = pickCanonicalWorkoutRows( + stored, + account?.sourcePriorityJson ?? null, + ); return { running: [], events: rows.map((row) => diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 82480c191..f38e096f0 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -557,6 +557,30 @@ describe("extractFeatures — v1.22 workouts block", () => { expect(f.workouts?.last7.totalDistanceKm).toBe(5); expect(f.workouts?.latest?.sportType).toBe("running"); }); + + it("counts a session synced by two services once", async () => { + const startedAt = new Date(Date.now() - 1 * DAY_MS); + prismaMock.workout.findMany.mockResolvedValue([ + { + source: "APPLE_HEALTH", + sportType: "running", + startedAt, + durationSec: 1800, + totalDistanceM: 5000, + }, + { + source: "STRAVA", + sportType: "running", + startedAt: new Date(startedAt.getTime() + 30_000), + durationSec: 1795, + totalDistanceM: 5010, + }, + ]); + const f = await extractFeatures("user-1", false); + expect(f.workouts?.last7.count).toBe(1); + expect(f.workouts?.last7.totalDurationMin).toBe(30); + expect(f.workouts?.last7.totalDistanceKm).toBe(5); + }); }); describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => { diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 6ac225af0..fcf7173fb 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -32,6 +32,7 @@ import { percentile, resolvePersonalReference, persistStrainScore, + canonicalWorkoutTrimps, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +153,14 @@ describe("saturateToScore", () => { function makePrisma(opts: { energy?: number[]; - workouts?: Array<{ id: string; samples: { samples: unknown } | null }>; + workouts?: Array<{ + id: string; + samples: { samples: unknown } | null; + source?: string; + sportType?: string; + startedAt?: Date; + durationSec?: number; + }>; rhr?: number | null; gender?: string; dateOfBirth?: Date | null; @@ -172,7 +180,17 @@ function makePrisma(opts: { const measurementFindFirst = vi .fn() .mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr }); - const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []); + // Distinct sessions by default: each fixture workout starts an hour after + // the previous one, so the cross-source picker keeps every row. + const workoutFindMany = vi.fn().mockResolvedValue( + (opts.workouts ?? []).map((w, i) => ({ + source: "APPLE_HEALTH", + sportType: "RUNNING", + startedAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), + durationSec: 1800, + ...w, + })), + ); const upsert = vi.fn().mockResolvedValue({}); const findUnique = vi.fn().mockResolvedValue({ dateOfBirth: @@ -519,3 +537,77 @@ describe("persistStrainScore — personal anchor + cache", () => { expect(cacheArg.create.refPersonal).toBeLessThan(80); }); }); + +describe("canonicalWorkoutTrimps — one impulse per session", () => { + const start = new Date("2026-06-01T07:00:00.000Z"); + const row = ( + id: string, + source: string, + trimp: number, + offsetMin = 0, + sportType = "RUNNING", + ) => ({ + id, + source: source as "APPLE_HEALTH", + sportType, + startedAt: new Date(start.getTime() + offsetMin * 60_000), + durationSec: 1800, + trimp, + }); + + it("counts a session synced by two services once", () => { + const rows = [row("a", "APPLE_HEALTH", 40), row("s", "STRAVA", 38, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([40]); + }); + + it("takes the dropped twin's impulse when the picked row has no series", () => { + // Apple ranks first on the default ladder; its copy carries no HR here. + const rows = [row("a", "APPLE_HEALTH", 0), row("w", "WHOOP", 55, 2)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([55]); + }); + + it("keeps two distinct sessions apart", () => { + const rows = [ + row("a", "APPLE_HEALTH", 40), + row("b", "APPLE_HEALTH", 20, 120), + row("c", "APPLE_HEALTH", 15, 1, "CYCLING"), + ]; + expect( + canonicalWorkoutTrimps(rows, null, (r) => r.trimp).sort((x, y) => x - y), + ).toEqual([15, 20, 40]); + }); + + it("is empty when nothing carries an impulse", () => { + expect( + canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), + ).toEqual([]); + }); +}); + +describe("persistStrainScore — duplicate sessions", () => { + it("scores a session stored twice the same as the session stored once", async () => { + const series = { + samples: [ + { t: "2026-06-01T08:00:00.000Z", hr: 150 }, + { t: "2026-06-01T08:30:00.000Z", hr: 155 }, + { t: "2026-06-01T09:00:00.000Z", hr: 150 }, + ], + }; + const startedAt = new Date("2026-06-01T08:00:00.000Z"); + const once = makePrisma({ + rhr: 50, + workouts: [{ id: "w1", samples: series, startedAt }], + }); + const twice = makePrisma({ + rhr: 50, + workouts: [ + { id: "w1", samples: series, startedAt }, + { id: "w2", samples: series, startedAt, source: "STRAVA" }, + ], + }); + const a = await persistStrainScore(once.prisma, "user-1", NOW); + const b = await persistStrainScore(twice.prisma, "user-1", NOW); + expect(a.score).toBeGreaterThan(0); + expect(b.score).toBe(a.score); + }); +}); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 8e5af6078..5e3916870 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -28,6 +28,7 @@ import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -535,17 +536,32 @@ export async function readWorkoutsBlock( now: number, ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: { startedAt: "desc" }, - take: 2000, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }); + const [stored, account] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: 2000, + select: { + source: true, + sportType: true, + startedAt: true, + durationSec: true, + totalDistanceM: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); + // One session synced by two services (or re-sent by HealthKit) is one + // workout: the same collapse the workout list applies, so the count and the + // minutes here match what the person sees there. The picker returns its rows + // oldest first; the block reads the newest at index 0. + const rows = pickCanonicalWorkoutRows( + stored, + account?.sourcePriorityJson ?? null, + ).reverse(); if (rows.length === 0) return undefined; const tally = (windowDays: number) => { diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 3cfee08f0..52e02f51a 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -72,6 +72,11 @@ import { upsertScoreRow, } from "@/lib/insights/score-row"; import { shiftDateKey } from "@/lib/tz/format"; +import { + pickCanonicalWorkoutRows, + workoutDuplicateSlotKey, + type WorkoutPickerRow, +} from "@/lib/measurements/pick-canonical-workout-rows"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,12 +418,28 @@ export async function computeStrainScore( }); 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, - }); + // The day's workouts + their HR series, with the session identity the + // cross-source picker needs: one run recorded by the watch and synced again + // through a second service is one training impulse, not two. + const [workouts, account] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + samples: { select: { samples: true } }, + }, + take: 100, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -426,15 +447,24 @@ export async function computeStrainScore( let dayTrimp = 0; let workoutsWithSeries = 0; if (canTrimp) { - for (const w of workouts) { + const trimpOf = (w: (typeof workouts)[number]): number => { 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 (!Array.isArray(raw)) return 0; + return banisterTrimp( + raw as unknown as HrSample[], + hrRest!, + hrMax!, + profile.sex, + ); + }; + const sessions = canonicalWorkoutTrimps( + workouts, + account?.sourcePriorityJson ?? null, + trimpOf, + ); + for (const trimp of sessions) { + dayTrimp += trimp; + workoutsWithSeries += 1; } } @@ -499,6 +529,52 @@ export async function computeStrainScore( }; } +/** + * One training impulse per workout SESSION, not per stored row. + * + * The same session can be stored more than once: the watch writes it to Apple + * Health and a second service (Strava, WHOOP, a ring) syncs it again, or + * HealthKit re-sends it under a new identifier. The workout list collapses + * those through `pickCanonicalWorkoutRows`; summing every row here counted the + * session twice in the day's TRIMP while the list showed it once. + * + * The picker chooses the row by source order, which says nothing about heart + * rate: the winning row can be one without a series while its dropped twin + * carries one. So the impulse of a slot is the sum over its picked rows, and + * only when that is zero the largest impulse among the slot's dropped rows. + * Returns the positive impulses, one per session. Pure; exported for tests. + */ +export function canonicalWorkoutTrimps< + T extends WorkoutPickerRow & { id: string }, +>( + rows: readonly T[], + priorityJson: unknown, + trimpOf: (row: T) => number, +): number[] { + // By id: the picker may hand back a copy of a row (it fills a missing heart + // rate from a WHOOP twin). + const picked = new Set( + pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), + ); + const slots = new Map(); + for (const row of rows) { + const key = workoutDuplicateSlotKey(row); + const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const trimp = trimpOf(row); + if (trimp > 0) { + if (picked.has(row.id)) slot.picked += trimp; + else slot.dropped = Math.max(slot.dropped, trimp); + } + slots.set(key, slot); + } + const out: number[] = []; + for (const slot of slots.values()) { + const trimp = slot.picked > 0 ? slot.picked : slot.dropped; + if (trimp > 0) out.push(trimp); + } + return out; +} + /** * Build the Strain profile (age + sex) and resting HR from the user row + * the latest resting-HR reading. `prisma` is the (worker) client. diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 50fda8fdf..d426cb1c0 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -127,6 +127,15 @@ function deduplicateWinningRows(rows: T[]): T[] { */ const BUCKET_SLOT_MS = 5 * 60 * 1000; +/** + * The cross-source duplicate slot a workout falls in. Exported for the one + * reader that has to look past the picked row: the strain engine, whose winner + * may carry no heart-rate series while its dropped twin does. + */ +export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string { + return bucketKey(row); +} + function bucketKey(row: WorkoutPickerRow): string { const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); return `${row.userId ?? ""}:${slot}:${row.sportType}`; From faa3ef867b563fae728ae7e89f08590c1b42c0c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:39:15 +0200 Subject: [PATCH 2/6] feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate The discovery matrix gains the families people log but could not test: water, caffeine and up to four varying micronutrients from the nutrient log, alcohol days from the mood tag, workout minutes (one per session) and the day's training impulse, exercise, stand and mindful minutes, State of Mind as an outcome, and the overnight room climate against that night's sleep only. Every channel runs through the same seasonal adjustment, effective-n p-value, Benjamini-Hochberg control and effect-size floor, is read only while its module is on, and drops out below the paired-day floor. State of Mind, mindful minutes and alcohol days are statistics for the person: the Coach, the period narrative and the assessment cards drop every pair that touches them, after the shared scan so the remaining pairs keep the statistics the correlations page shows. A guard walks the import closure of the analysis modules and refuses any path to a provider. --- messages/de.json | 28 +- messages/en.json | 28 +- messages/es.json | 28 +- messages/fr.json | 28 +- messages/it.json | 28 +- messages/ko.json | 28 +- messages/nl.json | 28 +- messages/pl.json | 28 +- .../analysis-depth-no-ai-guard.test.ts | 101 +++++++ src/__tests__/discovery-matrix-guard.test.ts | 4 + .../pulse-day-mean-reader-guard.test.ts | 4 + .../workout-canonical-identity-guard.test.ts | 4 + .../correlations/__tests__/route.test.ts | 4 + src/app/api/insights/correlations/route.ts | 5 + .../mood/mood-discovered-relations.tsx | 23 +- .../coach/__tests__/correlations-read.test.ts | 5 + src/lib/ai/coach/tools/correlations-read.ts | 40 ++- .../correlation-extended-channels.test.ts | 284 ++++++++++++++++++ .../discovery-matrix-modules.test.ts | 98 ++++++ .../metric-correlation-context.test.ts | 5 + .../insights/correlation-channel-series.ts | 184 +++++++++++- src/lib/insights/correlation-discovery.ts | 135 ++++++++- .../insights/correlation-series-builders.ts | 242 +++++++++++++++ .../__tests__/coach-read-locale.test.ts | 5 + src/lib/insights/discovery-matrix.ts | 73 +++++ .../insights/metric-correlation-context.ts | 4 + .../__tests__/period-narrative-db.test.ts | 5 + .../insights/narrative/period-narrative.ts | 6 +- .../__tests__/surface-module-registry.test.ts | 20 ++ src/lib/modules/surface.ts | 21 ++ .../correlation-extended-channels.test.ts | 244 +++++++++++++++ 31 files changed, 1706 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/analysis-depth-no-ai-guard.test.ts create mode 100644 src/lib/insights/__tests__/correlation-extended-channels.test.ts create mode 100644 tests/integration/correlation-extended-channels.test.ts diff --git a/messages/de.json b/messages/de.json index a72dcb006..dd2a6c8e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag", "ENV_PM25": "der Feinstaub (PM2,5)", "ENV_OZONE_8H": "das Ozon", - "ENV_POLLEN_MAX": "der Pollenflug" + "ENV_POLLEN_MAX": "der Pollenflug", + "HRV_RMSSD": "die Herzratenvariabilität (RMSSD)", + "HYDRATION": "die Trinkmenge", + "CAFFEINE": "die Koffeinmenge", + "ALCOHOL": "der Alkoholkonsum", + "WORKOUT_MINUTES": "die Workout-Dauer", + "TRAINING_LOAD": "die Trainingslast", + "EXERCISE_MINUTES": "die Trainingsminuten der Aktivitätsringe", + "STAND_MINUTES": "die Stehminuten", + "MINDFUL_MINUTES": "die Achtsamkeitsminuten", + "STATE_OF_MIND_VALENCE": "die erfasste Gemütslage", + "INDOOR_CO2": "der CO2-Wert im Raum", + "INDOOR_TEMPERATURE": "die Raumtemperatur", + "INDOOR_HUMIDITY": "die Luftfeuchtigkeit im Raum" }, "daily": { "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", @@ -2716,6 +2729,12 @@ "high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache." + }, + "channelNutrient": "die Zufuhr von {nutrient}", + "dailyOvernight": { + "high": "Wenn {behaviour} über Nacht höher liegt, fällt {outcome} in derselben Nacht in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "moderate": "Wenn {behaviour} über Nacht höher liegt, scheint {outcome} in derselben Nacht nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "faint": "Wenn {behaviour} über Nacht höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} in derselben Nacht {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache." } }, "emptyTitle": "Noch nicht genug Daten", @@ -4255,7 +4274,12 @@ "outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.", "stat": "{n} gepaarte Tage, r = {r}, q = {q}", "statLabel": "Wie das berechnet wurde", - "explainerLabel": "Wie das berechnet wurde" + "explainerLabel": "Wie das berechnet wurde", + "channel": { + "ALCOHOL": "Alkoholkonsum", + "WORKOUT_MINUTES": "Workout-Dauer", + "TRAINING_LOAD": "Trainingslast" + } }, "inTargetTitle": "Im Zielbereich", "dimensions": { diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..d9a637e04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "intraday pressure swing", "ENV_PM25": "fine particles (PM2.5)", "ENV_OZONE_8H": "ozone", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "heart-rate variability (RMSSD)", + "HYDRATION": "water intake", + "CAFFEINE": "caffeine intake", + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load", + "EXERCISE_MINUTES": "exercise minutes", + "STAND_MINUTES": "standing minutes", + "MINDFUL_MINUTES": "mindful minutes", + "STATE_OF_MIND_VALENCE": "logged state of mind", + "INDOOR_CO2": "indoor CO2", + "INDOOR_TEMPERATURE": "room temperature", + "INDOOR_HUMIDITY": "room humidity" }, "daily": { "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.", @@ -2716,6 +2729,12 @@ "high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.", "moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.", "faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause." + }, + "channelNutrient": "{nutrient} intake", + "dailyOvernight": { + "high": "Higher {behaviour} overnight tends to go with {direction} {outcome} that night in your data. It is a pattern to watch, not a cause.", + "moderate": "Higher {behaviour} overnight looks like it goes with {direction} {outcome} that night in your data, on the evidence so far. It is a pattern to watch, not a cause.", + "faint": "Higher {behaviour} overnight shows at most a faint hint of {direction} {outcome} that night in your data. It is too small to lean on and never a cause." } }, "noAnalysisYet": "No assessment yet.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.", "stat": "{n} paired days, r = {r}, q = {q}", "statLabel": "How this was computed", - "explainerLabel": "How this was computed" + "explainerLabel": "How this was computed", + "channel": { + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load" + } }, "inTargetTitle": "In your range", "dimensions": { diff --git a/messages/es.json b/messages/es.json index 3239be811..cf161392a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variación de presión del día", "ENV_PM25": "las partículas finas (PM2,5)", "ENV_OZONE_8H": "el ozono", - "ENV_POLLEN_MAX": "el polen" + "ENV_POLLEN_MAX": "el polen", + "HRV_RMSSD": "la variabilidad de la frecuencia cardiaca (RMSSD)", + "HYDRATION": "la ingesta de agua", + "CAFFEINE": "la ingesta de cafeína", + "ALCOHOL": "el consumo de alcohol", + "WORKOUT_MINUTES": "los minutos de entrenamiento", + "TRAINING_LOAD": "la carga de entrenamiento", + "EXERCISE_MINUTES": "los minutos de ejercicio", + "STAND_MINUTES": "los minutos de pie", + "MINDFUL_MINUTES": "los minutos de atención plena", + "STATE_OF_MIND_VALENCE": "el estado de ánimo registrado", + "INDOOR_CO2": "el CO2 interior", + "INDOOR_TEMPERATURE": "la temperatura interior", + "INDOOR_HUMIDITY": "la humedad interior" }, "daily": { "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.", @@ -2716,6 +2729,12 @@ "high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.", "moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", "faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa." + }, + "channelNutrient": "la ingesta de {nutrient}", + "dailyOvernight": { + "high": "Más {behaviour} durante la noche tiende a ir con {direction} {outcome} esa misma noche en tus datos: un patrón que conviene observar, no una causa.", + "moderate": "Más {behaviour} durante la noche parece ir con {direction} {outcome} esa misma noche en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", + "faint": "Más {behaviour} durante la noche muestra, como mucho, un leve indicio de {direction} {outcome} esa misma noche en tus datos: demasiado pequeño para fiarse, nunca una causa." } }, "noAnalysisYet": "Aún no hay valoración.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.", "stat": "{n} días emparejados, r = {r}, q = {q}", "statLabel": "Cómo se calculó esto", - "explainerLabel": "Cómo se calculó esto" + "explainerLabel": "Cómo se calculó esto", + "channel": { + "ALCOHOL": "consumo de alcohol", + "WORKOUT_MINUTES": "minutos de entrenamiento", + "TRAINING_LOAD": "carga de entrenamiento" + } }, "inTargetTitle": "En tu rango", "dimensions": { diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..1edb3089b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variation de pression sur la journée", "ENV_PM25": "les particules fines (PM2,5)", "ENV_OZONE_8H": "l’ozone", - "ENV_POLLEN_MAX": "le pollen" + "ENV_POLLEN_MAX": "le pollen", + "HRV_RMSSD": "la variabilité de la fréquence cardiaque (RMSSD)", + "HYDRATION": "l'apport en eau", + "CAFFEINE": "l'apport en caféine", + "ALCOHOL": "la consommation d'alcool", + "WORKOUT_MINUTES": "les minutes d'entraînement", + "TRAINING_LOAD": "la charge d'entraînement", + "EXERCISE_MINUTES": "les minutes d'exercice", + "STAND_MINUTES": "les minutes debout", + "MINDFUL_MINUTES": "les minutes de pleine conscience", + "STATE_OF_MIND_VALENCE": "l'état d'esprit noté", + "INDOOR_CO2": "le CO2 intérieur", + "INDOOR_TEMPERATURE": "la température intérieure", + "INDOOR_HUMIDITY": "l'humidité intérieure" }, "daily": { "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.", @@ -2716,6 +2729,12 @@ "high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.", "moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", "faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause." + }, + "channelNutrient": "l'apport en {nutrient}", + "dailyOvernight": { + "high": "Plus de {behaviour} pendant la nuit va souvent de pair avec {direction} {outcome} cette nuit-là dans tes données : un schéma à surveiller, pas une cause.", + "moderate": "Plus de {behaviour} pendant la nuit semble aller de pair avec {direction} {outcome} cette nuit-là dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", + "faint": "Plus de {behaviour} pendant la nuit ne montre, au mieux, qu’un faible indice de {direction} {outcome} cette nuit-là dans tes données : trop peu pour s’y fier, jamais une cause." } }, "noAnalysisYet": "Pas encore d’évaluation.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.", "stat": "{n} jours appariés, r = {r}, q = {q}", "statLabel": "Comment ceci est calculé", - "explainerLabel": "Comment ceci est calculé" + "explainerLabel": "Comment ceci est calculé", + "channel": { + "ALCOHOL": "consommation d'alcool", + "WORKOUT_MINUTES": "minutes d'entraînement", + "TRAINING_LOAD": "charge d'entraînement" + } }, "inTargetTitle": "Dans votre plage", "dimensions": { diff --git a/messages/it.json b/messages/it.json index 85e890708..52ef2d137 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata", "ENV_PM25": "il particolato fine (PM2,5)", "ENV_OZONE_8H": "l’ozono", - "ENV_POLLEN_MAX": "i pollini" + "ENV_POLLEN_MAX": "i pollini", + "HRV_RMSSD": "la variabilità della frequenza cardiaca (RMSSD)", + "HYDRATION": "l'assunzione di acqua", + "CAFFEINE": "l'assunzione di caffeina", + "ALCOHOL": "il consumo di alcol", + "WORKOUT_MINUTES": "i minuti di allenamento", + "TRAINING_LOAD": "il carico di allenamento", + "EXERCISE_MINUTES": "i minuti di esercizio", + "STAND_MINUTES": "i minuti in piedi", + "MINDFUL_MINUTES": "i minuti di consapevolezza", + "STATE_OF_MIND_VALENCE": "lo stato d'animo registrato", + "INDOOR_CO2": "la CO2 interna", + "INDOOR_TEMPERATURE": "la temperatura interna", + "INDOOR_HUMIDITY": "l'umidità interna" }, "daily": { "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.", @@ -2716,6 +2729,12 @@ "high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.", "moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", "faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa." + }, + "channelNutrient": "l'assunzione di {nutrient}", + "dailyOvernight": { + "high": "Più {behaviour} durante la notte tende ad accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati: uno schema da osservare, non una causa.", + "moderate": "Più {behaviour} durante la notte sembra accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", + "faint": "Più {behaviour} durante la notte mostra al massimo un debole indizio di {direction} {outcome} quella stessa notte nei tuoi dati: troppo poco per contarci, mai una causa." } }, "noAnalysisYet": "Ancora nessuna valutazione.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.", "stat": "{n} giorni appaiati, r = {r}, q = {q}", "statLabel": "Come è stato calcolato", - "explainerLabel": "Come è stato calcolato" + "explainerLabel": "Come è stato calcolato", + "channel": { + "ALCOHOL": "consumo di alcol", + "WORKOUT_MINUTES": "minuti di allenamento", + "TRAINING_LOAD": "carico di allenamento" + } }, "inTargetTitle": "Nel tuo intervallo", "dimensions": { diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..26c14f484 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "하루 중 기압 변화", "ENV_PM25": "초미세먼지(PM2.5)", "ENV_OZONE_8H": "오존", - "ENV_POLLEN_MAX": "꽃가루" + "ENV_POLLEN_MAX": "꽃가루", + "HRV_RMSSD": "심박 변이도(RMSSD)", + "HYDRATION": "수분 섭취량", + "CAFFEINE": "카페인 섭취량", + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하", + "EXERCISE_MINUTES": "운동하기 시간", + "STAND_MINUTES": "서 있은 시간", + "MINDFUL_MINUTES": "마음챙김 시간", + "STATE_OF_MIND_VALENCE": "기록한 마음 상태", + "INDOOR_CO2": "실내 CO2", + "INDOOR_TEMPERATURE": "실내 온도", + "INDOOR_HUMIDITY": "실내 습도" }, "daily": { "high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.", @@ -2716,6 +2729,12 @@ "high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." + }, + "channelNutrient": "{nutrient} 섭취량", + "dailyOvernight": { + "high": "밤사이 {behaviour}이(가) 높을수록 내 데이터에서는 그날 밤 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "moderate": "지금까지의 데이터로 보면 밤사이 {behaviour}이(가) 높을수록 그날 밤 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "faint": "밤사이 {behaviour}이(가) 높을 때 내 데이터에서 그날 밤 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." } }, "noAnalysisYet": "아직 평가가 없어요.", @@ -4255,7 +4274,12 @@ "outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.", "stat": "짝지은 {n}일, r = {r}, q = {q}", "statLabel": "계산 방법", - "explainerLabel": "계산 방법" + "explainerLabel": "계산 방법", + "channel": { + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하" + } }, "inTargetTitle": "내 범위 안", "dimensions": { diff --git a/messages/nl.json b/messages/nl.json index c44560507..8fd2353e6 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "luchtdrukschommeling gedurende de dag", "ENV_PM25": "fijnstof (PM2.5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "hartslagvariabiliteit (RMSSD)", + "HYDRATION": "waterinname", + "CAFFEINE": "cafeïne-inname", + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting", + "EXERCISE_MINUTES": "beweegminuten", + "STAND_MINUTES": "staminuten", + "MINDFUL_MINUTES": "mindfulnessminuten", + "STATE_OF_MIND_VALENCE": "vastgelegde gemoedstoestand", + "INDOOR_CO2": "CO2 binnen", + "INDOOR_TEMPERATURE": "binnentemperatuur", + "INDOOR_HUMIDITY": "luchtvochtigheid binnen" }, "daily": { "high": "Bij hogere waarden voor {behaviour} ligt {outcome} de volgende dag in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", @@ -2716,6 +2729,12 @@ "high": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor ligt {outcome} in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor. Het is een patroon om in de gaten te houden, geen oorzaak.", "faint": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." + }, + "channelNutrient": "inname van {nutrient}", + "dailyOvernight": { + "high": "Bij hogere waarden voor {behaviour} 's nachts ligt {outcome} die nacht in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", + "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} die nacht in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} 's nachts. Het is een patroon om in de gaten te houden, geen oorzaak.", + "faint": "Bij hogere waarden voor {behaviour} 's nachts is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} die nacht {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." } }, "noAnalysisYet": "Nog geen beoordeling.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Een hogere stemming gaat in je gegevens vaak samen met lagere waarden voor {factor} de volgende dag.", "stat": "{n} gekoppelde dagen, r = {r}, q = {q}", "statLabel": "Hoe dit is berekend", - "explainerLabel": "Hoe dit is berekend" + "explainerLabel": "Hoe dit is berekend", + "channel": { + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting" + } }, "inTargetTitle": "Binnen je bereik", "dimensions": { diff --git a/messages/pl.json b/messages/pl.json index c1812715f..304d35ac3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia", "ENV_PM25": "pył drobny (PM2,5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pyłki" + "ENV_POLLEN_MAX": "pyłki", + "HRV_RMSSD": "zmienność rytmu serca (RMSSD)", + "HYDRATION": "spożycie wody", + "CAFFEINE": "spożycie kofeiny", + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe", + "EXERCISE_MINUTES": "minuty ćwiczeń", + "STAND_MINUTES": "minuty na stojąco", + "MINDFUL_MINUTES": "minuty uważności", + "STATE_OF_MIND_VALENCE": "zapisany stan ducha", + "INDOOR_CO2": "CO2 w pomieszczeniu", + "INDOOR_TEMPERATURE": "temperatura w pomieszczeniu", + "INDOOR_HUMIDITY": "wilgotność w pomieszczeniu" }, "daily": { "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.", @@ -2716,6 +2729,12 @@ "high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." + }, + "channelNutrient": "spożycie: {nutrient}", + "dailyOvernight": { + "high": "Więcej {behaviour} w nocy idzie w twoich danych zwykle w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Więcej {behaviour} w nocy wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "faint": "Więcej {behaviour} w nocy pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} tej samej nocy. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." } }, "noAnalysisYet": "Brak oceny.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.", "stat": "{n} sparowanych dni, r = {r}, q = {q}", "statLabel": "Jak to obliczono", - "explainerLabel": "Jak to obliczono" + "explainerLabel": "Jak to obliczono", + "channel": { + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe" + } }, "inTargetTitle": "W Twoim zakresie", "dimensions": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts new file mode 100644 index 000000000..0eb5df59f --- /dev/null +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -0,0 +1,101 @@ +/** + * The statistics behind the correlation, trend and changepoint surfaces are + * computed without a model. + * + * These modules answer "what does the record show": correlation channels, + * lab slopes and projected band exits, screener change against the + * clinically meaningful threshold, training load, level shifts. None of that + * may depend on a provider being configured, and none of it may become a way + * to reach one. A capability gate cannot protect a computation that quietly + * imports the provider chain, so this guard walks each module's import + * closure (value imports, `@/` and relative, through re-exports) and fails on + * anything under `lib/ai/` except the byte cipher the symptom decrypt uses, + * and on any provider call in the module's own text. + * + * Limits, stated: a dynamic `import()` or a `require` is not followed, and a + * type-only import is skipped by design (it carries no code). The list below + * is the set this guard covers; a new analysis module joins it by being added. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +/** The analysis modules held to "no model anywhere below". */ +const ANALYSIS_MODULES = [ + "lib/insights/correlation-discovery.ts", + "lib/insights/correlation-series-builders.ts", + "lib/insights/correlation-channel-series.ts", + "lib/insights/discovery-matrix.ts", + "lib/insights/derived/changepoint.ts", +] as const; + +/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ +const AI_TREE_ALLOWED = new Set(["lib/ai/coach/bytes-codec.ts"]); + +/** A provider call, as `ai-egress-capability-guard` defines it. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +const VALUE_IMPORT = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/g; + +function resolveImport(fromFile: string, spec: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function closure(entry: string): string[] { + const seen = new Set(); + const stack = [join(SRC, entry)]; + while (stack.length > 0) { + const file = stack.pop() as string; + if (seen.has(file)) continue; + seen.add(file); + // The generated Prisma client is never read (several MB, no model in it). + if (file.includes(`${SRC}/generated/`)) continue; + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(VALUE_IMPORT)) { + const next = resolveImport(file, match[1]); + if (next) stack.push(next); + } + } + return [...seen].map((f) => relative(SRC, f)); +} + +describe("the analysis modules compute without a model", () => { + it.each(ANALYSIS_MODULES)("%s reaches no provider", (entry) => { + const reached = closure(entry); + // A walk that found nothing would pass any assertion below. + expect(reached.length).toBeGreaterThan(1); + const ai = reached.filter( + (f) => f.startsWith("lib/ai/") && !AI_TREE_ALLOWED.has(f), + ); + expect(ai).toEqual([]); + }); + + it.each(ANALYSIS_MODULES)("%s makes no provider call", (entry) => { + expect(readFileSync(join(SRC, entry), "utf8")).not.toMatch(EGRESS_CALL); + }); + + it("would notice a provider import", () => { + // The walk follows the provider module itself into the chain. + expect(closure("lib/ai/provider.ts").length).toBeGreaterThan(1); + expect( + closure("lib/ai/provider.ts").some( + (f) => f === "lib/ai/provider-chain.ts", + ), + ).toBe(true); + }); +}); diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index 128b5e8ac..36e7d48d7 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", "fetchMeasurementWindowSeries", + "fetchNutrientSeries", + "fetchAlcoholSeries", + "fetchTrainingSeries", + "fetchIndoorClimateSeries", ] as const; /** diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index a94277515..bdd7656ac 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -104,6 +104,10 @@ const SQL_READERS: Record = { "lib/measurements/daily-series-read.ts": HELPER, "lib/rollups/measurement-rollups.ts": HELPER, "lib/rollups/tiered-context.ts": HELPER, + "lib/insights/correlation-channel-series.ts": { + exempt: + "the one SQL mean is the room-climate hour mean, over INDOOR_CO2 / INDOOR_TEMPERATURE / INDOOR_HUMIDITY only; pulse reaches the correlation channels through `dayValueRows` and the rollup compose", + }, }; /** Files that name pulse and hold an in-memory mean or a mean-making fold. */ diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index dfbc5cfbe..56c8c4c6e 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -58,6 +58,10 @@ const IDENTITY_OWNER: Record = { "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", "lib/day/records.ts": "lib/day/records.ts", + // The workout-minutes correlation channel: one session per day per sport, + // as the list shows it. + "lib/insights/correlation-channel-series.ts": + "lib/insights/correlation-channel-series.ts", }; /** diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 0dad495bb..37759be1d 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -3,6 +3,10 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, auditLog: { create: vi.fn() }, appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, user: { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 7d46a41bb..245c33421 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -245,6 +245,11 @@ async function buildCorrelationsResponse( custom_metric_channels: diagnostics.customMetricChannels, symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, + // v1.44 — reach of the extended families. + nutrient_days: diagnostics.nutrientDays, + alcohol_days: diagnostics.alcoholDays, + training_days: diagnostics.trainingDays, + indoor_nights: diagnostics.indoorNights, }, }); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 6f72d7148..1902f881d 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record = { // v1.43 — the Dawarich movement channels. MOVEMENT_AWAY: "environment.movement.awayFromHome", MOVEMENT_DISTANCE: "environment.movement.distance", + // v1.44 — the extended channel families. + HRV_RMSSD: "measurements.typeHrvRmssd", + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + HYDRATION: "nutrients.names.water", + CAFFEINE: "nutrients.names.caffeine", + ALCOHOL: "insights.mood.discovery.channel.ALCOHOL", + WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES", + TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD", }; +/** A micronutrient channel (`NUTRIENT:`) reads under its catalog name. */ +const NUTRIENT_PREFIX = "NUTRIENT:"; + +function channelLabelKey(channelKey: string): string { + if (channelKey.startsWith(NUTRIENT_PREFIX)) { + return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`; + } + return CHANNEL_LABEL_KEY[channelKey] ?? channelKey; +} + /** Keep only the discovered pairs that involve the mood channel. */ export function moodPairsOf( discovered: DiscoveredCorrelation[], @@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({ const dynamicLabel = moodIsOutcome ? pair.behaviourLabel : pair.outcomeLabel; - const factorLabel = - dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey)); if ( patternId && dismissal.isDismissed(patternId, pair.dismissed === true) diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 23e8bd7cd..25ece9e00 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/modules/gate", () => ({ })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, user: { findUnique: (a: unknown) => userFindUnique(a) }, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index b9579386b..2d9fc8f04 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -50,6 +50,10 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, discoverEmergingCorrelations, + withoutModelFencedPairs, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, discoverLabOutcomeCorrelations, EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, @@ -158,6 +162,8 @@ function tzDayKey(at: Date, tz: string): string { const CHANNEL_LABELS: Record = { [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", + [HYDRATION_CHANNEL_KEY]: "water intake", + [CAFFEINE_CHANNEL_KEY]: "caffeine intake", ...Object.fromEntries( ENVIRONMENT_FIELDS.map((field) => [field.key, field.narrationLabel]), ), @@ -172,6 +178,9 @@ const CHANNEL_LABELS: Record = { */ function humanise(key: string, label?: string): string { if (label && label.trim().length > 0) return label.trim().toLowerCase(); + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + return `${key.slice(NUTRIENT_CHANNEL_PREFIX.length).replace(/_/g, " ")} intake`; + } return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } @@ -281,7 +290,12 @@ export async function readCoachCorrelations( }); const discovery = discoverCorrelations(series, { locale }); - const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ + // The Coach is a model: pairs on a fenced channel never reach it. The + // scan above is the shared one, so the remaining pairs' statistics are the + // ones the correlations page shows. + const drivers: CoachCorrelationDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: humanise(d.behaviour, d.behaviourLabel), outcome: humanise(d.outcome, d.outcomeLabel), direction: d.r >= 0 ? "higher" : "lower", @@ -303,18 +317,18 @@ export async function readCoachCorrelations( recentFromDayKey, locale, }); - const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( - (d) => ({ - behaviour: humanise(d.behaviour, d.behaviourLabel), - outcome: humanise(d.outcome, d.outcomeLabel), - direction: d.r >= 0 ? "higher" : "lower", - lagDays: d.lagDays, - n: d.n, - r: Math.round(d.r * 100) / 100, - note: d.interpretation, - provisional: true, - }), - ); + const emerging: CoachEmergingDriver[] = withoutModelFencedPairs( + emergingResult.emerging, + ).map((d) => ({ + behaviour: humanise(d.behaviour, d.behaviourLabel), + outcome: humanise(d.outcome, d.outcomeLabel), + direction: d.r >= 0 ? "higher" : "lower", + lagDays: d.lagDays, + n: d.n, + r: Math.round(d.r * 100) / 100, + note: d.interpretation, + provisional: true, + })); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). const labResult = discoverLabOutcomeCorrelations(labDraws, series, { diff --git a/src/lib/insights/__tests__/correlation-extended-channels.test.ts b/src/lib/insights/__tests__/correlation-extended-channels.test.ts new file mode 100644 index 000000000..0b0accfa7 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-extended-channels.test.ts @@ -0,0 +1,284 @@ +/** + * v1.44 — the extended correlation channel families: the pure series shapers + * (intake, alcohol days, workout minutes, training load, overnight room + * climate) and the engine's per-channel `targets` / `phrasing`, plus the fence + * that keeps the sensitive channels away from every model-bound surface. + */ +import { describe, expect, it } from "vitest"; + +import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + discoverCorrelations, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TARGETS, + metricFamily, + MODEL_FENCED_CHANNELS, + withoutModelFencedPairs, + type DailySeriesPoint, + type NamedSeries, +} from "../correlation-discovery"; +import { + buildAlcoholDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, + MAX_MICRONUTRIENT_CHANNELS, + MIN_ALCOHOL_DAYS, + MIN_OVERNIGHT_HOURS, +} from "../correlation-series-builders"; +import { shiftDateKey } from "@/lib/tz/format"; + +const DAY0 = "2026-01-01"; +const dayN = (n: number) => shiftDateKey(DAY0, n); + +function daily(values: number[], from = 0): DailySeriesPoint[] { + return values.map((value, i) => ({ day: dayN(from + i), value })); +} + +describe("buildNutrientDailySeries", () => { + it("sums sources per day and maps water and caffeine to their channels", () => { + const out = buildNutrientDailySeries( + [ + { day: dayN(0), nutrient: "water", amount: 1200 }, + { day: dayN(0), nutrient: "water", amount: 300 }, + { day: dayN(1), nutrient: "caffeine", amount: 95 }, + ], + { minDays: 20 }, + ); + expect(out.map((s) => s.key)).toEqual([ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ]); + expect(out[0].points).toEqual([{ day: dayN(0), value: 1500 }]); + expect(out[1].points).toEqual([{ day: dayN(1), value: 95 }]); + }); + + it("admits the most-logged varying micronutrients, capped, never a constant", () => { + const rows = []; + // Six varying micronutrients with 30..25 days, one constant with 40. + const codes = [ + "zinc", + "iron", + "magnesium", + "vitamin_d", + "selenium", + "iodine", + ]; + codes.forEach((code, c) => { + for (let d = 0; d < 30 - c; d += 1) { + rows.push({ day: dayN(d), nutrient: code, amount: 10 + (d % 3) }); + } + }); + for (let d = 0; d < 40; d += 1) { + rows.push({ day: dayN(d), nutrient: "vitamin_c", amount: 500 }); + } + // One varying code below the day floor. + for (let d = 0; d < 10; d += 1) { + rows.push({ day: dayN(d), nutrient: "copper", amount: d }); + } + const keys = buildNutrientDailySeries(rows, { minDays: 20 }) + .map((s) => s.key) + .slice(2); + expect(keys).toHaveLength(MAX_MICRONUTRIENT_CHANNELS); + expect(keys).toEqual([ + "NUTRIENT:zinc", + "NUTRIENT:iron", + "NUTRIENT:magnesium", + "NUTRIENT:vitamin_d", + ]); + }); +}); + +describe("buildAlcoholDailySeries", () => { + const at = (n: number, hour = 20) => + new Date(`${dayN(n)}T${String(hour).padStart(2, "0")}:00:00.000Z`); + + it("marks a day 1 when any entry is tagged, 0 when entries are untagged", () => { + const entries = []; + for (let d = 0; d < 12; d += 1) { + entries.push({ at: at(d, 9), tagged: false }); + entries.push({ at: at(d, 21), tagged: d % 2 === 0 }); + } + const out = buildAlcoholDailySeries(entries, "UTC"); + expect(out.key).toBe(ALCOHOL_CHANNEL_KEY); + expect(out.points).toHaveLength(12); + expect(out.points.filter((p) => p.value === 1)).toHaveLength(6); + expect(out.points[0]).toEqual({ day: dayN(0), value: 1 }); + expect(out.points[1]).toEqual({ day: dayN(1), value: 0 }); + }); + + it("is empty below the tagged-day floor or with nothing to compare against", () => { + const few = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: d < MIN_ALCOHOL_DAYS - 1, + })); + expect(buildAlcoholDailySeries(few, "UTC").points).toEqual([]); + const all = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: true, + })); + expect(buildAlcoholDailySeries(all, "UTC").points).toEqual([]); + }); +}); + +describe("buildWorkoutMinutesSeries / buildTrainingLoadSeries", () => { + it("sums a day's sessions and zero-fills only between the first and last", () => { + const out = buildWorkoutMinutesSeries( + [ + { startedAt: new Date(`${dayN(0)}T07:00:00Z`), durationSec: 1800 }, + { startedAt: new Date(`${dayN(0)}T18:00:00Z`), durationSec: 900 }, + { startedAt: new Date(`${dayN(3)}T07:00:00Z`), durationSec: 3600 }, + ], + "UTC", + ); + expect(out.points).toEqual([ + { day: dayN(0), value: 45 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 0 }, + { day: dayN(3), value: 60 }, + ]); + }); + + it("keys a session on the person's day, not UTC's", () => { + const out = buildWorkoutMinutesSeries( + [{ startedAt: new Date(`${dayN(0)}T23:30:00Z`), durationSec: 600 }], + "Europe/Berlin", + ); + expect(out.points).toEqual([{ day: dayN(1), value: 10 }]); + }); + + it("fills a missing strain day with zero impulse", () => { + const out = buildTrainingLoadSeries([ + { day: dayN(0), dayTrimp: 80 }, + { day: dayN(2), dayTrimp: 40 }, + ]); + expect(out.points).toEqual([ + { day: dayN(0), value: 80 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 40 }, + ]); + }); +}); + +describe("buildOvernightClimateSeries", () => { + it("keys 21:00–07:00 on the waking day, averages hours, drops thin nights", () => { + const rows = []; + // Night into day 1: 21–23 on day 0 (600 ppm), 0–4 on day 1 (900 ppm). + for (const h of [21, 22, 23]) { + rows.push({ type: "INDOOR_CO2", day: dayN(0), hour: h, mean: 600 }); + } + for (const h of [0, 1, 2, 3, 4]) { + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: h, mean: 900 }); + } + // Daytime hours never count. + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: 12, mean: 5000 }); + // A thin night into day 2. + for (let h = 0; h < MIN_OVERNIGHT_HOURS - 1; h += 1) { + rows.push({ type: "INDOOR_CO2", day: dayN(2), hour: h, mean: 700 }); + } + const [co2, temp] = buildOvernightClimateSeries( + rows, + ["INDOOR_CO2", "INDOOR_TEMPERATURE"], + INDOOR_CLIMATE_TARGETS, + ); + // (3·600 + 5·900) / 8 = 787.5 + expect(co2.points).toEqual([{ day: dayN(1), value: 787.5 }]); + expect(co2.lagDays).toBe(0); + expect(co2.phrasing).toBe("overnight"); + expect(co2.targets).toEqual(["SLEEP_DURATION"]); + expect(temp.points).toEqual([]); + }); +}); + +describe("discoverCorrelations — targets and phrasing", () => { + // 60 days of a behaviour alternating in blocks, an outcome that is an exact + // linear function of the previous day's behaviour: r = −1 after any + // detrending, n = 59 lag-joined days. + const x = Array.from({ length: 60 }, (_, d) => (d % 4 < 2 ? 100 : 300)); + const sleepNextDay = [420, ...x.slice(0, 59).map((v) => 480 - 0.5 * v)]; + + it("finds caffeine → next-day sleep with the exact n and r", () => { + const series: NamedSeries[] = [ + { key: CAFFEINE_CHANNEL_KEY, role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe(CAFFEINE_CHANNEL_KEY); + expect(pair.n).toBe(59); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("caffeine intake"); + }); + + it("tests a targeted channel against its targets only", () => { + const series: NamedSeries[] = [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + phrasing: "overnight", + targets: INDOOR_CLIMATE_TARGETS, + points: daily(x), + }, + { + key: "SLEEP_DURATION", + role: "outcome", + points: daily(x.map((v) => 480 - 0.5 * v)), + }, + { key: "MOOD", role: "outcome", points: daily(x.map((v) => v / 100)) }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: daily(x.map((v) => 50 + v / 50)), + }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.outcome).toBe("SLEEP_DURATION"); + expect(pair.lagDays).toBe(0); + expect(pair.n).toBe(60); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("overnight"); + expect(pair.interpretation).toContain("that night"); + }); + + it("names a micronutrient by its catalog name in the reader's language", () => { + const series: NamedSeries[] = [ + { key: "NUTRIENT:magnesium", role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const [pair] = discoverCorrelations(series, { locale: "de" }).discovered; + expect(pair.interpretation).toContain("die Zufuhr von Magnesium"); + }); +}); + +describe("families and the model fence", () => { + it("puts State of Mind in mood's family and the new families in their own", () => { + expect(metricFamily("STATE_OF_MIND_VALENCE")).toBe("MOOD"); + expect(metricFamily("INDOOR_CO2")).toBe("INDOOR"); + expect(metricFamily("TRAINING_LOAD")).toBe("TRAINING"); + expect(metricFamily("WORKOUT_MINUTES")).toBe("TRAINING"); + expect(metricFamily("NUTRIENT:zinc")).toBe("NUTRIENTS"); + expect(metricFamily(CAFFEINE_CHANNEL_KEY)).toBe("NUTRIENTS"); + }); + + it("drops every pair that touches a fenced channel", () => { + const pairs = [ + { behaviour: ALCOHOL_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + { behaviour: "MINDFUL_MINUTES", outcome: "MOOD" }, + { behaviour: "ACTIVITY_STEPS", outcome: "STATE_OF_MIND_VALENCE" }, + { behaviour: CAFFEINE_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + ]; + expect(withoutModelFencedPairs(pairs)).toEqual([pairs[3]]); + expect([...MODEL_FENCED_CHANNELS].sort()).toEqual([ + "ALCOHOL", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + ]); + }); +}); diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 8f53be251..daba264d1 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -48,6 +48,29 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({ fetchSymptomEventSeries: vi.fn(async () => [ { key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts }, ]), + fetchNutrientSeries: vi.fn(async () => [ + { key: "HYDRATION", role: "behaviour", points: pts }, + { key: "CAFFEINE", role: "behaviour", points: pts }, + { key: "NUTRIENT:zinc", role: "behaviour", points: pts }, + ]), + fetchAlcoholSeries: vi.fn(async () => ({ + key: "ALCOHOL", + role: "behaviour", + points: pts, + })), + fetchTrainingSeries: vi.fn(async () => [ + { key: "WORKOUT_MINUTES", role: "behaviour", points: pts }, + { key: "TRAINING_LOAD", role: "behaviour", points: pts }, + ]), + fetchIndoorClimateSeries: vi.fn(async () => [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + targets: ["SLEEP_DURATION"], + points: pts, + }, + ]), })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(), @@ -58,8 +81,12 @@ import { maskSeriesByModules, } from "@/lib/insights/discovery-matrix"; import { + fetchAlcoholSeries, fetchEnvironmentSeries, + fetchIndoorClimateSeries, fetchMovementSeries, + fetchNutrientSeries, + fetchTrainingSeries, } from "@/lib/insights/correlation-channel-series"; const OPTS = { @@ -177,6 +204,77 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { }); }); +describe("assembleDiscoveryMatrix — the extended families (v1.44)", () => { + const EXTENDED = [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]; + + it("folds every extended family in after the earlier ones", async () => { + const { series, byMetric } = await assembleDiscoveryMatrix("u1", OPTS); + const keys = series.map((s) => s.key); + for (const key of EXTENDED) expect(keys, key).toContain(key); + // Earlier families keep their place: the first extended key comes after + // the last person-defined symptom. + expect(keys.indexOf("EXERCISE_MINUTES")).toBeGreaterThan( + keys.indexOf("SYMPTOM:def1"), + ); + expect(series.find((s) => s.key === "STATE_OF_MIND_VALENCE")?.role).toBe( + "outcome", + ); + // The extended types feed the scan only, never the narrative's metrics. + for (const key of ["EXERCISE_MINUTES", "STATE_OF_MIND_VALENCE"]) { + expect(byMetric.has(key), key).toBe(false); + } + }); + + it("reads and keeps each family only while its module is on", async () => { + const reads = [ + fetchNutrientSeries, + fetchAlcoholSeries, + fetchTrainingSeries, + fetchIndoorClimateSeries, + ].map((fn) => vi.mocked(fn)); + for (const read of reads) read.mockClear(); + const { series } = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { + nutrients: false, + mood: false, + workouts: false, + environment: false, + }, + }); + for (const read of reads) expect(read).not.toHaveBeenCalled(); + const keys = series.map((s) => s.key); + for (const gone of [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]) { + expect(keys, gone).not.toContain(gone); + } + // Exercise and stand minutes are ambient movement and carry no owner. + expect(keys).toContain("EXERCISE_MINUTES"); + expect(keys).toContain("STAND_MINUTES"); + }); +}); + describe("maskSeriesByModules", () => { it("keeps the fold order of what remains", () => { const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({ diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 0afd64e98..03273cfa8 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: vi.fn() }, measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 47ea68dbb..cce0f970f 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -30,6 +30,8 @@ import { } from "@/lib/analytics/compliance"; import { FACTOR_CHANNEL_PREFIX, + INDOOR_CLIMATE_TARGETS, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, @@ -46,7 +48,12 @@ import { } from "@/lib/environment/fields"; import { isAirQualityActive } from "@/lib/environment/open-meteo-air-quality"; import { + buildAlcoholDailySeries, buildComplianceDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, buildSymptomEventDailySeries, buildSymptomSeverityDailySeries, type SymptomDayLogRow, @@ -62,7 +69,14 @@ import { pickMainNightAndNaps, type SleepStageRow, } from "@/lib/analytics/sleep-night"; -import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + isNearUtc, + isValidTimezone, + shiftDateKey, + userDayKey, +} from "@/lib/tz/format"; +import { MIN_PAIRED_N } from "@/lib/insights/correlations"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { @@ -1155,3 +1169,171 @@ export async function fetchLabDraws( } return draws; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** + * Upper bound on nutrient rows read for one scan: 180 days × 28 codes × two + * sources is about ten thousand; the cap falls on the oldest rows. + */ +const NUTRIENT_READ_CAP = 12_000; + +/** + * HYDRATION, CAFFEINE and up to four micronutrients from the nutrient log. + * The caller skips this read when the opt-in `nutrients` module is off. + */ +export async function fetchNutrientSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.nutrientIntakeDay.findMany({ + where: { userId, day: { gte: userDayKey(since, tz) } }, + orderBy: { day: "desc" }, + take: NUTRIENT_READ_CAP, + select: { day: true, nutrient: true, amount: true }, + }); + return buildNutrientDailySeries(rows, { minDays: MIN_PAIRED_N }); +} + +/** The mood read the alcohol channel shares its cap with. */ +const ALCOHOL_TAG_KEY = "alcohol"; + +/** + * The ALCOHOL channel from the mood log's `alcohol` tag: a structured tag + * link, or the key in the legacy flat `tags` list. Only the timestamp and the + * tag's presence are read; no note, no score. + */ +export async function fetchAlcoholSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, + select: { + moodLoggedAt: true, + tags: true, + tagLinks: { + where: { moodTag: { key: ALCOHOL_TAG_KEY } }, + select: { moodTagId: true }, + take: 1, + }, + }, + }); + return buildAlcoholDailySeries( + rows.map((row) => ({ + at: row.moodLoggedAt, + tagged: row.tagLinks.length > 0 || flatTagsInclude(row.tags), + })), + tz, + ); +} + +/** Whether a legacy flat `tags` JSON array names the alcohol tag. */ +function flatTagsInclude(raw: string | null): boolean { + if (!raw) return false; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) && parsed.includes(ALCOHOL_TAG_KEY); + } catch { + return false; + } +} + +/** Upper bound on workout rows read for one scan. */ +const WORKOUT_READ_CAP = 3000; + +/** + * WORKOUT_MINUTES (sessions collapsed across sources by the workout list's + * picker) and TRAINING_LOAD (the strain engine's stored day TRIMP). The caller + * skips this read when the `workouts` module is off. + */ +export async function fetchTrainingSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const [workouts, account, trimp] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: WORKOUT_READ_CAP, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + prisma.strainTrimpCache.findMany({ + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day (scoreDayKey) + where: { userId, day: { gte: since.toISOString().slice(0, 10) } }, + orderBy: { day: "asc" }, + take: 1000, + select: { day: true, dayTrimp: true }, + }), + ]); + const sessions = pickCanonicalWorkoutRows( + workouts, + account?.sourcePriorityJson ?? null, + ); + return [ + buildWorkoutMinutesSeries(sessions, tz), + buildTrainingLoadSeries(trimp), + ]; +} + +/** + * The room-climate channels, one per type, as overnight means keyed on the + * waking day. Postgres folds the readings to one row per local hour (a sensor + * reporting every minute is ~260k rows over the window; the hours are at most + * 180 × 24 per type), so the process never holds the raw readings. The caller + * skips this read when the `environment` module is off. + */ +export async function fetchIndoorClimateSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const zone = isValidTimezone(tz) ? tz : "UTC"; + const rows = await prisma.$queryRaw< + Array<{ type: string; day: string; hour: number; mean: number }> + >` + WITH src AS ( + SELECT + m."type"::text AS type, + (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${zone} AS local_at, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" IN ('INDOOR_CO2'::measurement_type, 'INDOOR_TEMPERATURE'::measurement_type, 'INDOOR_HUMIDITY'::measurement_type) + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${since} + ) + SELECT + type, + to_char(local_at, 'YYYY-MM-DD') AS day, + EXTRACT(HOUR FROM local_at)::int AS hour, + AVG(value)::double precision AS mean + FROM src + GROUP BY type, day, hour + `; + return buildOvernightClimateSeries( + rows.map((r) => ({ + type: r.type, + day: r.day, + hour: Number(r.hour), + mean: Number(r.mean), + })), + INDOOR_CLIMATE_TYPES, + INDOOR_CLIMATE_TARGETS, + ); +} diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index c08f20f70..11e833f89 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -138,6 +138,20 @@ export function metricFamily(key: string): string { if (key.startsWith("ENV_")) return "ENVIRONMENT"; if (key.startsWith("MOVEMENT_")) return "MOVEMENT"; if (key.startsWith("CUSTOM_METRIC:")) return key; + // v1.44 — State of Mind is a mood self-report from another app: pairing it + // with the logged mood would correlate one feeling with itself. + if (key === "STATE_OF_MIND_VALENCE") return "MOOD"; + // The room-climate readings are one room's air; the training channels one + // training record; the intake channels one supplement log. + if (key.startsWith("INDOOR_")) return "INDOOR"; + if (key === WORKOUT_MINUTES_CHANNEL_KEY || key === TRAINING_LOAD_CHANNEL_KEY) + return "TRAINING"; + if ( + key === HYDRATION_CHANNEL_KEY || + key === CAFFEINE_CHANNEL_KEY || + key.startsWith(NUTRIENT_CHANNEL_PREFIX) + ) + return "NUTRIENTS"; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -196,6 +210,19 @@ export interface NamedSeries { * there means the averaged 0–1 window, tested as one hypothesis. */ lagDays?: number; + /** + * Behaviour channels only: the outcomes this channel is tested against. + * Absent means every outcome. A channel with one plausible question (the + * bedroom's air and the night's sleep) names it, so the pairs nobody asked + * about do not widen the false-discovery family for every other pair. + */ + targets?: readonly string[]; + /** + * Behaviour channels only: how the sentence names the exposure window when + * it is not the default next day. `overnight` is the night's mean, keyed on + * the day the person woke up (`lagDays: 0`). + */ + phrasing?: "overnight"; } /** One discovered, FDR-surviving correlation pair. */ @@ -325,6 +352,7 @@ interface RawPair { lagDays: number; behaviourLabel?: string; outcomeLabel?: string; + phrasing?: "overnight"; } /** @@ -374,6 +402,7 @@ function interpret( t: Translate, behaviourLabel?: string, outcomeLabel?: string, + phrasing?: "overnight", ): string { const params = { behaviour: behaviourLabel ?? humanise(behaviour, t), @@ -389,7 +418,13 @@ function interpret( tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate"; // Lag 0 is the averaged same-day window of the exposure channels ("on the // day and the day before"); every other pair reads as next-day. - const template = lagDays === 0 ? "dailySameDay" : "daily"; + // The overnight room climate reads as "overnight … that night". + const template = + phrasing === "overnight" + ? "dailyOvernight" + : lagDays === 0 + ? "dailySameDay" + : "daily"; return t(`insights.correlation.${template}.${tierKey}`, params); } @@ -417,6 +452,12 @@ function humanise(key: string, t: Translate): string { .toLowerCase(); return t("insights.correlation.channelFactor", { factor }); } + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + const code = key.slice(NUTRIENT_CHANNEL_PREFIX.length); + const nutrient = + translateOrNull(t, `nutrients.names.${code}`) ?? code.replace(/_/g, " "); + return t("insights.correlation.channelNutrient", { nutrient }); + } const labelKey = `insights.correlation.channel.${key}`; // An unmapped channel falls back to a readable prettified form, never a // raw key. @@ -463,6 +504,95 @@ export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY"; export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY"; export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE"; +// ─── v1.44 — the extended channel families ───────────────────────────────── +// +// Folded by the assembler beside the declared matrix rather than into +// `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES`: those two lists also feed the +// per-metric status fingerprint (`status-cache.ts`), and a channel added there +// would make every written assessment card go stale on a reading of a type no +// card is about. Every family below is a behaviour except State of Mind, and +// each degrades to absent below the n ≥ 20 floor like every other channel. + +/** Daily water intake (mL), from the nutrient log. Owned by `nutrients`. */ +export const HYDRATION_CHANNEL_KEY = "HYDRATION"; +/** Daily caffeine intake (mg), from the nutrient log. Owned by `nutrients`. */ +export const CAFFEINE_CHANNEL_KEY = "CAFFEINE"; +/** One micronutrient's daily intake: `NUTRIENT:`. */ +export const NUTRIENT_CHANNEL_PREFIX = "NUTRIENT:"; +/** + * Days with alcohol, from the `alcohol` tag on mood entries: 1 on a day with a + * tagged entry, 0 on a day with entries and no tag, nothing on a day without + * an entry. Owned by `mood`. AUDIT-C is not a channel: it is a questionnaire + * answered every few months about the past year, and has no day to pair. + */ +export const ALCOHOL_CHANNEL_KEY = "ALCOHOL"; +/** Minutes of workout per day, one per session. Owned by `workouts`. */ +export const WORKOUT_MINUTES_CHANNEL_KEY = "WORKOUT_MINUTES"; +/** The day's heart-rate training impulse (TRIMP). Owned by `workouts`. */ +export const TRAINING_LOAD_CHANNEL_KEY = "TRAINING_LOAD"; + +/** Apple Health day totals read as behaviours (minutes per day). */ +export const EXTENDED_BEHAVIOUR_TYPES = [ + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", +] as const; + +/** Apple Health readings read as outcomes. */ +export const EXTENDED_OUTCOME_TYPES = ["STATE_OF_MIND_VALENCE"] as const; + +/** + * Room-climate readings, paired with the night's sleep only. The value is the + * mean of the readings between 21:00 and 07:00 local time, keyed on the day + * the person woke up, so it pairs with that night's sleep at lag 0. + */ +export const INDOOR_CLIMATE_TYPES = [ + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", +] as const; +export const INDOOR_CLIMATE_TARGETS: readonly string[] = ["SLEEP_DURATION"]; + +/** Every fixed channel key the extended families can write. */ +export const EXTENDED_CHANNEL_KEYS: readonly string[] = [ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ALCOHOL_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ...INDOOR_CLIMATE_TYPES, +]; + +/** + * Channels whose pairs are statistics for the person only and never reach a + * model: the Coach tool, the period narrative and the assessment cards drop + * every pair that touches one. State of Mind and mindfulness sit in the + * report's sensitive group and are kept out of the Coach snapshot and MCP; + * alcohol reaches a model only through the health profile's own opt-in. + * + * The scan itself is the same on every surface, so the statistics of the + * remaining pairs do not depend on who is reading; the fence is applied to + * the result. + */ +export const MODEL_FENCED_CHANNELS: ReadonlySet = new Set([ + "STATE_OF_MIND_VALENCE", + "MINDFUL_MINUTES", + ALCOHOL_CHANNEL_KEY, +]); + +/** Drop the pairs that touch a {@link MODEL_FENCED_CHANNELS} channel. */ +export function withoutModelFencedPairs< + T extends { behaviour: string; outcome: string }, +>(pairs: readonly T[]): T[] { + return pairs.filter( + (p) => + !MODEL_FENCED_CHANNELS.has(p.behaviour) && + !MODEL_FENCED_CHANNELS.has(p.outcome), + ); +} + /** * v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum * values: each is backed by a different model (MOOD → MoodEntry, @@ -543,6 +673,7 @@ export function discoverCorrelations( // auto-correlation tautology, not a cross-domain insight; excluding it // before testing stops it crowding out genuine cross-metric links. if (metricFamily(b.key) === metricFamily(o.key)) continue; + if (b.targets && !b.targets.includes(o.key)) continue; const pairLag = b.lagDays ?? lagDays; const pairs = lagJoinPairs(b.points, o.points, pairLag); if (pairs.length < minPairs) continue; @@ -558,6 +689,7 @@ export function discoverCorrelations( lagDays: pairLag, behaviourLabel: b.label, outcomeLabel: o.label, + ...(b.phrasing ? { phrasing: b.phrasing } : {}), }); } } @@ -612,6 +744,7 @@ export function discoverCorrelations( translate, t.behaviourLabel, t.outcomeLabel, + t.phrasing, ), lagDays: t.lagDays, })) diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index a34095560..dc3381884 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -38,8 +38,14 @@ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { shiftDateKey } from "@/lib/tz/format"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, MEDICATION_COMPLIANCE_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, SYMPTOM_SEVERITY_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -244,3 +250,239 @@ export function buildSymptomEventDailySeries(args: { points, }; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** One stored nutrient day total (one row per day, nutrient and source). */ +export interface NutrientDayRow { + day: string; + nutrient: string; + amount: number; +} + +/** Ceiling on `NUTRIENT:` channels in one scan. */ +export const MAX_MICRONUTRIENT_CHANNELS = 4; + +/** + * The intake channels from the nutrient log: water as HYDRATION, caffeine as + * CAFFEINE, and at most {@link MAX_MICRONUTRIENT_CHANNELS} micronutrients as + * `NUTRIENT:`. + * + * A day's amount is the sum across its sources (a manual water entry and the + * Apple Health total are two rows, and reads sum them). A day without a row is + * no point: the log does not say whether nothing was taken or nothing was + * recorded. The micronutrients admitted are those with at least `minDays` + * logged days and any day-to-day variance, the most-logged first (code as the + * tie-break): a daily supplement at a fixed dose is a constant, and a + * constant cannot correlate with anything. Pure. + */ +export function buildNutrientDailySeries( + rows: readonly NutrientDayRow[], + opts: { minDays: number }, +): NamedSeries[] { + const byNutrient = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.amount)) continue; + const days = byNutrient.get(row.nutrient) ?? new Map(); + days.set(row.day, (days.get(row.day) ?? 0) + row.amount); + byNutrient.set(row.nutrient, days); + } + const pointsOf = (code: string): DailySeriesPoint[] => + [...(byNutrient.get(code) ?? new Map()).entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + + const out: NamedSeries[] = [ + { + key: HYDRATION_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("water"), + }, + { + key: CAFFEINE_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("caffeine"), + }, + ]; + const candidates = [...byNutrient.keys()] + .filter((code) => code !== "water" && code !== "caffeine") + .map((code) => ({ code, points: pointsOf(code) })) + .filter( + ({ points }) => + points.length >= opts.minDays && + points.some((p) => p.value !== points[0].value), + ) + .sort( + (a, b) => b.points.length - a.points.length || (a.code < b.code ? -1 : 1), + ) + .slice(0, MAX_MICRONUTRIENT_CHANNELS); + for (const { code, points } of candidates) { + out.push({ + key: `${NUTRIENT_CHANNEL_PREFIX}${code}`, + role: "behaviour", + points, + }); + } + return out; +} + +/** One mood entry as the alcohol channel reads it. */ +export interface AlcoholTagEntry { + at: Date; + tagged: boolean; +} + +/** Fewest tagged days before the alcohol channel is offered at all. */ +export const MIN_ALCOHOL_DAYS = 5; + +/** + * The ALCOHOL behaviour series: per day with at least one mood entry, 1 when + * any entry that day carries the `alcohol` tag and 0 otherwise. A day without + * an entry is no point. Below {@link MIN_ALCOHOL_DAYS} tagged days (or with no + * untagged day to compare against) the series is empty: a handful of ones is + * what a spurious point-biserial r is made of. Pure. + */ +export function buildAlcoholDailySeries( + entries: readonly AlcoholTagEntry[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const entry of entries) { + const day = dayKey(entry.at, tz); + byDay.set(day, Math.max(byDay.get(day) ?? 0, entry.tagged ? 1 : 0)); + } + const points = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + const tagged = points.filter((p) => p.value === 1).length; + const usable = tagged >= MIN_ALCOHOL_DAYS && tagged < points.length; + return { + key: ALCOHOL_CHANNEL_KEY, + role: "behaviour", + points: usable ? points : [], + }; +} + +/** One canonical workout session as the training channels read it. */ +export interface WorkoutSessionRow { + startedAt: Date; + durationSec: number; +} + +/** + * Fill the days between the first and the last key with 0. Inside that span + * the person was recording training, so a day without a session is a rest + * day; outside it nothing says whether they were recording at all. Pure. + */ +function zeroFillSpan(byDay: ReadonlyMap): DailySeriesPoint[] { + const keys = [...byDay.keys()].sort(); + const points: DailySeriesPoint[] = []; + if (keys.length === 0) return points; + const last = keys[keys.length - 1]; + for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) { + points.push({ day, value: byDay.get(day) ?? 0 }); + } + return points; +} + +/** + * WORKOUT_MINUTES: the day's summed session minutes (user zone, by start), + * over sessions the caller has already collapsed across sources, zero-filled + * between the first and the last session. Pure. + */ +export function buildWorkoutMinutesSeries( + sessions: readonly WorkoutSessionRow[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const s of sessions) { + if (!Number.isFinite(s.durationSec) || s.durationSec <= 0) continue; + const day = dayKey(s.startedAt, tz); + byDay.set(day, (byDay.get(day) ?? 0) + s.durationSec / 60); + } + return { + key: WORKOUT_MINUTES_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** + * TRAINING_LOAD: the stored day-total TRIMP, zero-filled between the first and + * the last stored day. The nightly strain run writes a row only on a scored + * day, so a missing day inside the span carried no training impulse. The day + * keys are the strain engine's UTC days (the same tolerance the DAY rollups + * carry). Pure. + */ +export function buildTrainingLoadSeries( + rows: ReadonlyArray<{ day: string; dayTrimp: number }>, +): NamedSeries { + const byDay = new Map(); + for (const row of rows) { + if (!Number.isFinite(row.dayTrimp) || row.dayTrimp < 0) continue; + byDay.set(row.day, row.dayTrimp); + } + return { + key: TRAINING_LOAD_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** One local hour's mean of one room-climate type. */ +export interface ClimateHourRow { + type: string; + /** Local calendar day of the hour, YYYY-MM-DD. */ + day: string; + /** Local hour, 0–23. */ + hour: number; + mean: number; +} + +/** The overnight window, local time: from this hour … */ +export const OVERNIGHT_FROM_HOUR = 21; +/** … until (exclusive) this hour of the next morning. */ +export const OVERNIGHT_TO_HOUR = 7; +/** Fewest hours inside the window before a night has a value. */ +export const MIN_OVERNIGHT_HOURS = 4; + +/** + * One series per room-climate type: the night's mean of its hourly means, the + * night running 21:00 to 07:00 local time and keyed on the morning's day, the + * key the sleep series uses. Averaging hours rather than readings keeps a + * sensor that reports every minute from outweighing one that reports every + * quarter hour. A night with fewer than {@link MIN_OVERNIGHT_HOURS} hours has + * no point. Each series pairs with the night's sleep only, at lag 0. Pure. + */ +export function buildOvernightClimateSeries( + rows: readonly ClimateHourRow[], + types: readonly string[], + targets: readonly string[], +): NamedSeries[] { + const nights = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.mean)) continue; + let night: string; + if (row.hour >= OVERNIGHT_FROM_HOUR) night = shiftDateKey(row.day, 1); + else if (row.hour < OVERNIGHT_TO_HOUR) night = row.day; + else continue; + const byNight = + nights.get(row.type) ?? new Map(); + const acc = byNight.get(night) ?? { sum: 0, n: 0 }; + acc.sum += row.mean; + acc.n += 1; + byNight.set(night, acc); + nights.set(row.type, byNight); + } + return types.map((type) => ({ + key: type, + role: "behaviour" as const, + lagDays: 0, + phrasing: "overnight" as const, + targets, + points: [...(nights.get(type) ?? new Map()).entries()] + .filter(([, acc]) => acc.n >= MIN_OVERNIGHT_HOURS) + .map(([day, acc]) => ({ day, value: acc.sum / acc.n })) + .sort((a, b) => (a.day < b.day ? -1 : 1)), + })); +} diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index cfbc2e8eb..78a594444 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -35,6 +35,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a), findFirst: (a: unknown) => measurementFindFirst(a), diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index dd7ee058a..9cddb927f 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -36,18 +36,28 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { + ALCOHOL_CHANNEL_KEY, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, discoveryMeasurementTypes, + EXTENDED_BEHAVIOUR_TYPES, + EXTENDED_OUTCOME_TYPES, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { buildMeasurementDailySeries, + fetchAlcoholSeries, fetchComplianceSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, fetchCustomMetricBehaviourSeries, fetchSymptomEventSeries, fetchEnvironmentSeries, @@ -162,6 +172,14 @@ export interface DiscoveryMatrixDiagnostics { moodFactorChannels: number; /** `SYMPTOM:*` channels read (before the module mask). */ symptomEventChannels: number; + /** Stored daily points summed across the intake channels (v1.44). */ + nutrientDays: number; + /** Days in the ALCOHOL series (0 below its tagged-day floor). */ + alcoholDays: number; + /** Daily points summed across the two training channels. */ + trainingDays: number; + /** Nights summed across the room-climate channels. */ + indoorNights: number; } export interface DiscoveryMatrix { @@ -260,6 +278,11 @@ async function readMeasurementDaily( }; } +/** Daily points summed across a family's series. */ +function daysIn(family: readonly NamedSeries[]): number { + return family.reduce((sum, s) => sum + s.points.length, 0); +} + /** * Build the full discovery matrix for a user over a window. * @@ -292,8 +315,18 @@ export async function assembleDiscoveryMatrix( ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), ...(opts.extraMeasurementTypes ?? []), + // v1.44 — the Apple Health day totals and State of Mind read through the + // same measurement path; they are left out of `byMetric` below. + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, ]), ) as MeasurementType[]; + const extendedTypes = new Set([ + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ]); + const visible = (channelKey: string): boolean => + isSurfaceVisible(correlationChannelSurfaceId(channelKey), opts.modules); const [ measurements, @@ -304,6 +337,10 @@ export async function assembleDiscoveryMatrix( customMetricSeries, symptomEventSeries, movementSeries, + nutrientSeries, + alcoholSeries, + trainingSeries, + indoorSeries, ] = await Promise.all([ readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), readMood(userId, tz, since, opts.includeMoodFactors === true), @@ -329,6 +366,20 @@ export async function assembleDiscoveryMatrix( ) ? fetchMovementSeries(userId, tz, since) : Promise.resolve([] as NamedSeries[]), + // v1.44 — each extended family is read only while its module is on; the + // mask below would drop it anyway. + visible(HYDRATION_CHANNEL_KEY) + ? fetchNutrientSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(ALCOHOL_CHANNEL_KEY) + ? fetchAlcoholSeries(userId, tz, since) + : Promise.resolve(null), + visible(WORKOUT_MINUTES_CHANNEL_KEY) + ? fetchTrainingSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(INDOOR_CLIMATE_TYPES[0]) + ? fetchIndoorClimateSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), ]); const { factorSeries } = mood; @@ -366,6 +417,21 @@ export async function assembleDiscoveryMatrix( // channel and its own family, capped at eight; the illness module owns them // through the `SYMPTOM:` prefix rule in the surface map. series.push(...symptomEventSeries); + // v1.44 — the extended families, folded after every earlier family so the + // ranking of the pairs that existed before is unchanged on a tie. The + // intake, alcohol and training channels pair D → D+1; the room climate is + // the overnight mean keyed on the waking day and pairs with that night's + // sleep only. Exercise, stand and mindful minutes are behaviours; State of + // Mind is an outcome in the mood family. + for (const key of EXTENDED_BEHAVIOUR_TYPES) { + series.push({ key, role: "behaviour", points: points(key) }); + } + for (const key of EXTENDED_OUTCOME_TYPES) { + series.push({ key, role: "outcome", points: points(key) }); + } + series.push(...nutrientSeries); + if (alcoholSeries) series.push(alcoholSeries); + series.push(...trainingSeries, ...indoorSeries); // A factor is plausibly both a lag source ("rated work today → next-day // sleep") and a lag target ("steps today → next-day rated energy"), so it // enters as both roles. The engine skips self-pairs — every factor shares @@ -376,6 +442,9 @@ export async function assembleDiscoveryMatrix( } const byMetric = new Map(measurements.byType); + // The extended types feed the scan only. `byMetric` is what the narrative + // cites deltas from, and none of them is a metric it describes. + for (const key of extendedTypes) byMetric.delete(key); if (mood.moodDaily.length > 0) byMetric.set("MOOD", mood.moodDaily); for (const [key, factorPoints] of factorSeries) { byMetric.set(key, factorPoints); @@ -408,6 +477,10 @@ export async function assembleDiscoveryMatrix( ), moodFactorChannels: factorSeries.size, symptomEventChannels: symptomEventSeries.length, + nutrientDays: daysIn(nutrientSeries), + alcoholDays: alcoholSeries?.points.length ?? 0, + trainingDays: daysIn(trainingSeries), + indoorNights: daysIn(indoorSeries), }, }; } diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index e9cfb3bd0..9a9ea9354 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -32,6 +32,7 @@ import { discoverCorrelations, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + withoutModelFencedPairs, } from "@/lib/insights/correlation-discovery"; import { resolveModuleMap } from "@/lib/modules/gate"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; @@ -133,6 +134,9 @@ export async function getRelevantCorrelationsForMetric( // written in its place rather than after it; the docstring, the name // and the `channelKeyForType` guard above all kept saying otherwise. .filter((d) => d.behaviour === channel || d.outcome === channel) + // The card's prompt is model input: a pair on a fenced channel stays + // on the correlations page. + .filter((d) => withoutModelFencedPairs([d]).length === 1) .filter((d) => { const decision = decisionForEvidence(decisions, { factorKey: d.behaviour, diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index d208195f0..92125c7c2 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -38,6 +38,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: (a: unknown) => userFindUnique(a) }, measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index d51cdec1b..3a9311a5e 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -48,6 +48,7 @@ import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + withoutModelFencedPairs, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -422,7 +423,10 @@ export function assemblePeriodNarrativeContext( // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── const discovery = discoverCorrelations(discoverySeries, { locale }); - const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ + // The narrative is model input: pairs on a fenced channel stay out of it. + const drivers: NarrativeDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: d.behaviour, outcome: d.outcome, r: d.r, diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 7bbf00579..efcd35cbc 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -60,6 +60,8 @@ import { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, DISCOVERY_OUTCOMES, + EXTENDED_CHANNEL_KEYS, + NUTRIENT_CHANNEL_PREFIX, FACTOR_CHANNEL_PREFIX, } from "@/lib/insights/correlation-discovery"; import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry"; @@ -197,6 +199,9 @@ describe("every surface id names a surface that exists", () => { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, "LAB_DRAWS", + ...EXTENDED_CHANNEL_KEYS, + // The fold target of every `NUTRIENT:` channel. + "NUTRIENT", ]); for (const id of localIds("correlation")) { expect(channels.has(id), id).toBe(true); @@ -209,6 +214,21 @@ describe("every surface id names a surface that exists", () => { expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe( "correlation:SYMPTOM_SEVERITY", ); + expect(correlationChannelSurfaceId(`${NUTRIENT_CHANNEL_PREFIX}zinc`)).toBe( + "correlation:NUTRIENT", + ); + }); + + it("every extended channel has an owner or is reviewed as unowned", () => { + const unowned = new Set(["EXERCISE_MINUTES", "STAND_MINUTES"]); + for (const key of EXTENDED_CHANNEL_KEYS) { + if (unowned.has(key)) { + expect(surfaceModule(`correlation:${key}`), key).toBeUndefined(); + } else { + expect(surfaceModule(`correlation:${key}`), key).toBeDefined(); + } + } + expect(surfaceModule("correlation:NUTRIENT")).toBe("nutrients"); }); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 8d0c9b029..b70d84516 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -228,6 +228,24 @@ const STATIC_SURFACE_MODULE = { // v1.43 — the Dawarich movement channels ride the environment module. "correlation:MOVEMENT_AWAY": "environment", "correlation:MOVEMENT_DISTANCE": "environment", + // v1.44 — the extended families. Intake rides the opt-in nutrient log (the + // `NUTRIENT:` micronutrient channels fold onto `correlation:NUTRIENT`), + // alcohol days are read from mood entries' tag, and the two training + // channels from the workout record. State of Mind and mindfulness are + // self-reports about feeling, so they follow mood. The room climate rides + // the environment module like the weather. Exercise and stand minutes are + // ambient movement like steps and carry no owner. + "correlation:HYDRATION": "nutrients", + "correlation:CAFFEINE": "nutrients", + "correlation:NUTRIENT": "nutrients", + "correlation:ALCOHOL": "mood", + "correlation:WORKOUT_MINUTES": "workouts", + "correlation:TRAINING_LOAD": "workouts", + "correlation:MINDFUL_MINUTES": "mood", + "correlation:STATE_OF_MIND_VALENCE": "mood", + "correlation:INDOOR_CO2": "environment", + "correlation:INDOOR_TEMPERATURE": "environment", + "correlation:INDOOR_HUMIDITY": "environment", // ── Day view sections (v1.42) ── // The readings carry no owner here: each value follows its own type's @@ -355,5 +373,8 @@ export function correlationChannelSurfaceId(channelKey: string): string { // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness // module, like the symptom-burden channel it sits beside. if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; + // One micronutrient's intake (`NUTRIENT:`, v1.44) rides the nutrient + // log like water and caffeine. + if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT"; return `correlation:${channelKey}`; } diff --git a/tests/integration/correlation-extended-channels.test.ts b/tests/integration/correlation-extended-channels.test.ts new file mode 100644 index 000000000..30c5c8bf0 --- /dev/null +++ b/tests/integration/correlation-extended-channels.test.ts @@ -0,0 +1,244 @@ +/** + * v1.44 — the extended correlation channel reads against a real Postgres. + * + * The unit battery (`correlation-extended-channels.test.ts`) pins the pure + * shapers; this suite proves the reads that feed them: the room-climate + * hour-mean SQL and its zone conversion, the nutrient day sum across sources, + * the alcohol tag read from the legacy flat list, and the training read with + * a session stored by two services. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + fetchAlcoholSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, +} from "@/lib/insights/correlation-channel-series"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; +const TZ = "Europe/Berlin"; + +let seq = 0; +async function seedUser(prisma: ReturnType) { + seq += 1; + return prisma.user.create({ + data: { + username: `corr-extended-${seq}`, + email: `corr-extended-${seq}@example.test`, + role: "USER", + timezone: TZ, + }, + }); +} + +/** A UTC day key `daysAgo` days back. */ +function dayKey(daysAgo: number): string { + return new Date(Date.now() - daysAgo * DAY_MS).toISOString().slice(0, 10); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("extended correlation channels — reads", () => { + it("folds the room climate to overnight means on the waking day, in the person's zone", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const wake = dayKey(5); + const evening = dayKey(6); + // Berlin is UTC+1 or +2; 20:30 UTC on the evening is at least 21:30 + // local, 02:00 UTC on the waking day at least 03:00 local, both inside + // the 21:00–07:00 window. Four hours each side, one reading per quarter + // hour at 600 ppm before midnight and 1000 ppm after. + const rows = []; + for (let h = 0; h < 2; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 600, + measuredAt: new Date( + `${evening}T2${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + for (let h = 1; h < 4; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 1000, + measuredAt: new Date( + `${wake}T0${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + // Midday on the waking day is outside the window. + rows.push({ value: 5000, measuredAt: new Date(`${wake}T11:00:00Z`) }); + await prisma.measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: "INDOOR_CO2" as const, + unit: "ppm", + source: "EXTERNAL" as const, + ...r, + })), + }); + + const series = await fetchIndoorClimateSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + const co2 = series.find((s) => s.key === "INDOOR_CO2"); + // Two evening hours at 600, three morning hours at 1000: (1200 + 3000) / 5. + expect(co2?.points).toEqual([{ day: wake, value: 840 }]); + expect(co2?.targets).toEqual(["SLEEP_DURATION"]); + }); + + it("sums a nutrient day across sources", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + await prisma.nutrientIntakeDay.createMany({ + data: [ + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 1500, + unit: "ml", + source: "APPLE_HEALTH", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 250, + unit: "ml", + source: "MANUAL", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "caffeine", + amount: 160, + unit: "mg", + source: "APPLE_HEALTH", + }, + ], + }); + const series = await fetchNutrientSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.find((s) => s.key === "HYDRATION")?.points).toEqual([ + { day: dayKey(3), value: 1750 }, + ]); + expect(series.find((s) => s.key === "CAFFEINE")?.points).toEqual([ + { day: dayKey(3), value: 160 }, + ]); + }); + + it("reads alcohol days from the flat tag list and skips deleted entries", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const data = []; + for (let d = 1; d <= 12; d += 1) { + data.push({ + userId: user.id, + date: dayKey(d), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(d)}T12:00:00Z`), + tags: d % 2 === 0 ? JSON.stringify(["alcohol"]) : null, + }); + } + data.push({ + userId: user.id, + date: dayKey(13), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(13)}T12:00:00Z`), + tags: JSON.stringify(["alcohol"]), + deletedAt: new Date(), + }); + await prisma.moodEntry.createMany({ data }); + const series = await fetchAlcoholSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.points).toHaveLength(12); + expect(series.points.filter((p) => p.value === 1)).toHaveLength(6); + }); + + it("counts a workout stored by two services once and reads the stored impulse", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const start = new Date(`${dayKey(4)}T10:00:00Z`); + await prisma.workout.createMany({ + data: [ + { + userId: user.id, + sportType: "RUNNING", + source: "APPLE_HEALTH", + startedAt: start, + endedAt: new Date(start.getTime() + 1800_000), + durationSec: 1800, + externalId: "a", + }, + { + userId: user.id, + sportType: "RUNNING", + source: "STRAVA", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 1830_000), + durationSec: 1795, + externalId: "s", + }, + ], + }); + await prisma.strainTrimpCache.createMany({ + data: [ + { + userId: user.id, + day: dayKey(4), + dayTrimp: 70, + refPersonal: 150, + anchor: "population", + trainingDays: 1, + }, + { + userId: user.id, + day: dayKey(2), + dayTrimp: 30, + refPersonal: 150, + anchor: "population", + trainingDays: 2, + }, + ], + }); + const [minutes, load] = await fetchTrainingSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(minutes.points).toEqual([{ day: dayKey(4), value: 30 }]); + expect(load.points).toEqual([ + { day: dayKey(4), value: 70 }, + { day: dayKey(3), value: 0 }, + { day: dayKey(2), value: 30 }, + ]); + }); +}); From 2ba6214a651efb15b80c3c8d29875062611b98e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:51:36 +0200 Subject: [PATCH 3/6] feat(labs): show each marker's slope and where the line would leave the range The biomarker detail read now carries a trend: the least-squares line through every numeric reading of the marker (all stored in its unit), with the slope's 95 % interval and p-value. It needs three readings over at least a year. The line is placed against the marker's band, and an exit date is given only when the interval excludes zero, the line is inside the band today and reaches a bound within three years. The detail page words it as a straight-line projection, not a forecast. --- docs/api/openapi.yaml | 173 +++++++++++++++- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/ko.json | 19 ++ messages/nl.json | 19 ++ messages/pl.json | 19 ++ .../analysis-depth-no-ai-guard.test.ts | 1 + .../biomarkers/[id]/__tests__/route.test.ts | 54 ++++- src/app/api/biomarkers/[id]/route.ts | 40 +++- .../labs/__tests__/lab-trend-card.test.tsx | 74 +++++++ src/components/labs/lab-biomarker-detail.tsx | 5 + src/components/labs/lab-trend-card.tsx | 81 ++++++++ src/components/labs/types.ts | 32 +++ src/lib/insights/correlations.ts | 21 +- src/lib/labs/__tests__/lab-trend.test.ts | 131 +++++++++++++ src/lib/labs/lab-trend.ts | 184 ++++++++++++++++++ src/lib/openapi/routes/biomarkers.ts | 56 +++++- 20 files changed, 993 insertions(+), 11 deletions(-) create mode 100644 src/components/labs/__tests__/lab-trend-card.test.tsx create mode 100644 src/components/labs/lab-trend-card.tsx create mode 100644 src/lib/labs/__tests__/lab-trend.test.ts create mode 100644 src/lib/labs/lab-trend.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..c9b3511d3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15869,8 +15869,9 @@ paths: tags: - Labs summary: Fetch a single biomarker - description: Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows - surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. + description: Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its + `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with + `meta.mergedInto`. parameters: - in: path name: id @@ -72267,7 +72268,7 @@ components: type: object properties: data: - $ref: "#/components/schemas/Biomarker" + $ref: "#/components/schemas/BiomarkerDetail" error: type: "null" meta: @@ -72280,6 +72281,172 @@ components: - data - error additionalProperties: false + BiomarkerDetail: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + lowerBound: + type: + - number + - "null" + upperBound: + type: + - number + - "null" + panel: + type: + - string + - "null" + analyteKey: + type: + - string + - "null" + hasContext: + type: boolean + context: + type: + - string + - "null" + hidden: + type: boolean + aliases: + description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on + the marker), sorted, without the marker's own name. Absent on older servers. + type: array + items: + type: string + createdAt: + type: string + updatedAt: + type: string + trend: + description: v1.44 — absent on older servers. + $ref: "#/components/schemas/BiomarkerTrend" + required: + - id + - name + - unit + - lowerBound + - upperBound + - panel + - analyteKey + - hasContext + - context + - hidden + - createdAt + - updatedAt + additionalProperties: false + BiomarkerTrend: + anyOf: + - type: object + properties: + status: + type: string + const: ok + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + firstAt: + type: string + description: YYYY-MM-DD of the first reading. + lastAt: + type: string + description: YYYY-MM-DD of the newest reading. + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + slopePerYear: + type: number + description: Least-squares change per year, in the marker's unit. + slopeCi95: + type: array + prefixItems: + - type: number + - type: number + items: false + minItems: 2 + maxItems: 2 + description: 95 % interval of the slope (Student t at n − 2 df). + pValue: + type: number + fittedNow: + type: number + description: The line's value on `asOf`. + asOf: + type: string + description: YYYY-MM-DD the line was evaluated on. + projection: + type: string + enum: + - exits + - beyond_horizon + - no_exit + - already_outside + - no_clear_trend + - no_band + exitDate: + description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." + type: + - string + - "null" + exitBound: + anyOf: + - type: string + enum: + - low + - high + - type: "null" + required: + - status + - n + - firstAt + - lastAt + - spanDays + - slopePerYear + - slopeCi95 + - pValue + - fittedNow + - asOf + - projection + - exitDate + - exitBound + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - too_few_values + - span_too_short + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - n + - spanDays + additionalProperties: false + description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's + unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the + line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the + line is inside the band today and reaches a bound within three years. Server-computed; clients render it as + given." UpdateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index dd2a6c8e4..fc61d1d46 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10115,6 +10115,25 @@ "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben", "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben", "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut." + }, + "trend": { + "title": "Verlauf", + "slope": { + "rising": "Steigt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "falling": "Sinkt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "flat": "Keine Veränderung über {n} Werte seit {since}." + }, + "projection": { + "exits": { + "high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.", + "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." + }, + "beyond_horizon": "In diesem Tempo bleibt die Gerade noch mindestens drei Jahre in deinem Referenzbereich.", + "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", + "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben." + }, + "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index d9a637e04..be2db6114 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Merged into {name}, {count} readings moved", "doneToastOther": "Merged into {name}, {count} readings moved", "error": "The biomarkers could not be merged. Try again." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rising by about {amount} {unit} a year across {n} readings since {since}.", + "falling": "Falling by about {amount} {unit} a year across {n} readings since {since}.", + "flat": "No change across {n} readings since {since}." + }, + "projection": { + "exits": { + "high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.", + "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." + }, + "beyond_horizon": "At this pace the line stays inside your reference range for at least three more years.", + "no_exit": "The line heads away from the edges of your reference range.", + "already_outside": "The line already sits outside your reference range.", + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn." + }, + "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index cf161392a..287b36a61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Combinado en {name}, {count} mediciones movidas", "doneToastOther": "Combinado en {name}, {count} mediciones movidas", "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo." + }, + "trend": { + "title": "Tendencia", + "slope": { + "rising": "Sube unos {amount} {unit} al año en {n} valores desde {since}.", + "falling": "Baja unos {amount} {unit} al año en {n} valores desde {since}.", + "flat": "Sin cambios en {n} valores desde {since}." + }, + "projection": { + "exits": { + "high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.", + "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." + }, + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia al menos tres años más.", + "no_exit": "La línea se aleja de los límites de tu rango de referencia.", + "already_outside": "La línea ya está fuera de tu rango de referencia.", + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada." + }, + "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 1edb3089b..d1c564694 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées", "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées", "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie." + }, + "trend": { + "title": "Tendance", + "slope": { + "rising": "Augmente d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "falling": "Diminue d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "flat": "Aucune variation sur {n} valeurs depuis le {since}." + }, + "projection": { + "exits": { + "high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.", + "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." + }, + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence pendant au moins trois ans encore.", + "no_exit": "La droite s'éloigne des limites de ta plage de référence.", + "already_outside": "La droite est déjà hors de ta plage de référence.", + "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée." + }, + "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 52ef2d137..dd0c39841 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Unito in {name}, {count} misurazioni spostate", "doneToastOther": "Unito in {name}, {count} misurazioni spostate", "error": "Non è stato possibile unire i biomarcatori. Riprova." + }, + "trend": { + "title": "Andamento", + "slope": { + "rising": "Sale di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "falling": "Scende di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "flat": "Nessuna variazione su {n} valori dal {since}." + }, + "projection": { + "exits": { + "high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.", + "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." + }, + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per almeno altri tre anni.", + "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", + "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla." + }, + "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 26c14f484..f8ac7397c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10115,6 +10115,25 @@ "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요." + }, + "trend": { + "title": "추세", + "slope": { + "rising": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 오르고 있습니다.", + "falling": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 내려가고 있습니다.", + "flat": "{since} 이후 {n}개 값에서 변화가 없습니다." + }, + "projection": { + "exits": { + "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", + "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." + }, + "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", + "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + }, + "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } }, "modules": { diff --git a/messages/nl.json b/messages/nl.json index 8fd2353e6..bc81ba2ae 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Samengevoegd in {name}, {count} metingen verplaatst", "doneToastOther": "Samengevoegd in {name}, {count} metingen verplaatst", "error": "De biomarkers konden niet worden samengevoegd. Probeer het opnieuw." + }, + "trend": { + "title": "Verloop", + "slope": { + "rising": "Stijgt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "falling": "Daalt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "flat": "Geen verandering over {n} waarden sinds {since}." + }, + "projection": { + "exits": { + "high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.", + "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." + }, + "beyond_horizon": "In dit tempo blijft de lijn nog minstens drie jaar binnen je referentiebereik.", + "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", + "already_outside": "De lijn ligt al buiten je referentiebereik.", + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken." + }, + "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index 304d35ac3..3939cf2eb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary", "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów", "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rośnie o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "falling": "Spada o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "flat": "Brak zmian na podstawie {n} wyników od {since}." + }, + "projection": { + "exits": { + "high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.", + "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." + }, + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym jeszcze co najmniej trzy lata.", + "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", + "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja." + }, + "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } }, "modules": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index 0eb5df59f..f5aa84195 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -29,6 +29,7 @@ const ANALYSIS_MODULES = [ "lib/insights/correlation-channel-series.ts", "lib/insights/discovery-matrix.ts", "lib/insights/derived/changepoint.ts", + "lib/labs/lab-trend.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts index 9cf66d9dd..46a840f1f 100644 --- a/src/app/api/biomarkers/[id]/__tests__/route.test.ts +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => { createMany: vi.fn(), }, biomarkerMerge: { findFirst: vi.fn(async () => null) }, - labResult: { deleteMany: vi.fn() }, + labResult: { deleteMany: vi.fn(), findMany: vi.fn(async () => []) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; // `$transaction` is set in `beforeEach`: the batch form for the delete, the @@ -50,7 +50,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT, DELETE } from "../route"; +import { GET, PUT, DELETE } from "../route"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -105,6 +105,56 @@ beforeEach(() => { vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never); }); +describe("GET /api/biomarkers/[id] — trend (v1.44)", () => { + const YEAR_MS = 365.25 * 86_400_000; + const getReq = () => + new NextRequest("http://localhost/api/biomarkers/bm-1", { method: "GET" }); + + it("carries the marker's slope over every stored reading", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + const t0 = Date.now() - 2 * YEAR_MS; + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { takenAt: new Date(t0), value: 60 }, + { takenAt: new Date(t0 + YEAR_MS), value: 70 }, + { takenAt: new Date(t0 + 2 * YEAR_MS), value: 80 }, + ] as never); + const res = await GET(getReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.name).toBe("LDL"); + expect(body.data.trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + projection: "exits", + exitBound: "high", + }); + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + biomarkerId: "bm-1", + deletedAt: null, + }), + }), + ); + }); + + it("answers insufficient below three readings", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + const body = await (await GET(getReq(), params("bm-1"))).json(); + expect(body.data.trend).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 0, + spanDays: 0, + }); + }); +}); + describe("DELETE /api/biomarkers/[id]", () => { it("drops the marker and its readings in one userId-narrowed transaction", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 0cc1601b9..e47454e51 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -26,6 +26,10 @@ import { mergedInto } from "@/lib/labs/biomarker-merge"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; +import { computeLabTrend } from "@/lib/labs/lab-trend"; + +/** Upper bound on readings the trend line reads. */ +const LAB_TREND_READ_CAP = 2000; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; @@ -58,14 +62,44 @@ export const GET = apiHandler( }); } - const aliases = await aliasNamesByMarker(prisma, user.id, [row]); + const [aliases, readings] = await Promise.all([ + aliasNamesByMarker(prisma, user.id, [row]), + // v1.44 — the marker's numeric readings for the trend line. Every + // reading is stored in the marker's unit, so the series needs no + // conversion; the cap is far above any real panel history. + prisma.labResult.findMany({ + where: { + userId: user.id, + biomarkerId: id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "asc" }, + take: LAB_TREND_READ_CAP, + select: { takenAt: true, value: true }, + }), + ]); + const trend = computeLabTrend( + readings.flatMap((r) => + r.value === null ? [] : [{ takenAt: r.takenAt, value: r.value }], + ), + { low: row.lowerBound, high: row.upperBound }, + new Date(), + ); annotate({ action: { name: "labs.biomarker.get" }, - meta: { biomarkerId: id }, + meta: { + biomarkerId: id, + trend: trend.status, + ...(trend.status === "ok" ? { projection: trend.projection } : {}), + }, }); - return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? [])); + return apiSuccess({ + ...serialiseBiomarker(row, aliases.get(row.id) ?? []), + trend, + }); }, ); diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx new file mode 100644 index 000000000..d5c3ae554 --- /dev/null +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -0,0 +1,74 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { LabTrendCard } from "../lab-trend-card"; +import type { BiomarkerTrendDto } from "../types"; + +primeLocales("de"); + +const OK: BiomarkerTrendDto = { + status: "ok", + n: 4, + firstAt: "2023-01-10", + lastAt: "2026-01-10", + spanDays: 1096, + slopePerYear: 12.5, + slopeCi95: [4.25, 20.75], + pValue: 0.02, + fittedNow: 130, + asOf: "2026-10-21", + projection: "exits", + exitDate: "2027-11-02", + exitBound: "high", +}; + +function render( + trend: BiomarkerTrendDto | undefined, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("words the server's slope and labels the exit as a projection", () => { + const html = render(OK); + expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings"); + expect(html).toContain('data-projection="exits"'); + expect(html).toContain("upper end of your reference range"); + expect(html).toContain("projection, not a forecast"); + expect(html).toContain("4.25 to 20.75 mg/dL"); + }); + + it("speaks German with du", () => { + const html = render(OK, "de"); + expect(html).toContain("deines Referenzbereichs"); + expect(html).toContain("Fortschreibung, keine Vorhersage"); + }); + + it("draws no projection sentence without a clear trend, and nothing when insufficient", () => { + const html = render({ + ...OK, + projection: "no_clear_trend", + exitDate: null, + exitBound: null, + }); + expect(html).toContain('data-projection="no_clear_trend"'); + expect(html).not.toContain("forecast"); + expect( + render({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 40, + }), + ).toBe(""); + expect(render(undefined)).toBe(""); + }); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index f83352055..67978895b 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import"; import { BiomarkerAliases } from "./biomarker-aliases"; import { BiomarkerForm } from "./biomarker-form"; import { LabForm } from "./lab-form"; +import { LabTrendCard } from "./lab-trend-card"; import type { BiomarkerDto, LabResultDto, @@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { /> + )} diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx new file mode 100644 index 000000000..94b81f164 --- /dev/null +++ b/src/components/labs/lab-trend-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerTrendDto } from "./types"; + +/** + * v1.44 — the marker's slope over all its readings, and where that straight + * line would leave the reference band. + * + * Server-authoritative: every number is the route's (`computeLabTrend`); this + * only words it. Renders nothing below three readings over a year. The exit + * sentence names itself a projection, never a forecast. + */ +export function LabTrendCard({ + trend, + unit, +}: { + trend: BiomarkerTrendDto | undefined; + unit: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!trend || trend.status !== "ok") return null; + + const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2; + const per = (v: number) => fmt.number(Math.abs(v), digits); + const direction = + trend.slopePerYear > 0 + ? "rising" + : trend.slopePerYear < 0 + ? "falling" + : "flat"; + const slopeLine = t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }); + const projectionLine = + trend.projection === "exits" && trend.exitDate + ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { + date: fmt.dateShortSmartCalendar(trend.exitDate), + }) + : trend.projection === "no_band" + ? null + : t(`labs.trend.projection.${trend.projection}`); + + return ( + + + + + +

+ {slopeLine} +

+ {projectionLine ? ( +

+ {projectionLine} +

+ ) : null} +

+ {t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit, + })} +

+
+
+ ); +} diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index d7a0e2816..f4c976a74 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -108,8 +108,40 @@ export interface BiomarkerDto { hidden: boolean; createdAt: string; updatedAt: string; + /** v1.44 — the detail read's trend line; absent on the list and older servers. */ + trend?: BiomarkerTrendDto; } +/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */ +export type BiomarkerTrendDto = + | { + status: "ok"; + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + slopePerYear: number; + slopeCi95: [number, number]; + pValue: number; + fittedNow: number; + asOf: string; + projection: + | "exits" + | "beyond_horizon" + | "no_exit" + | "already_outside" + | "no_clear_trend" + | "no_band"; + exitDate: string | null; + exitBound: "low" | "high" | null; + } + | { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; + }; + export interface BiomarkerListResponse { biomarkers: BiomarkerDto[]; } diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index 86ba3e7a9..892658646 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -388,7 +388,7 @@ function roundTo(value: number, digits: number): number { * gate is no longer load-bearing for correctness — only for sample * adequacy. */ -function twoSidedPFromT(absT: number, df: number): number { +export function twoSidedPFromT(absT: number, df: number): number { if (!Number.isFinite(absT)) return 0; if (df <= 0) return 1; if (absT === 0) return 1; @@ -396,6 +396,25 @@ function twoSidedPFromT(absT: number, df: number): number { return regularizedIncompleteBeta(x, df / 2, 0.5); } +/** + * The two-sided critical t at `alpha` with `df` degrees of freedom: the t + * whose two-sided p equals `alpha` (12.706 at df 1, 4.303 at df 2, 1.960 as + * df grows, for alpha 0.05). Bisection over {@link twoSidedPFromT}, which is + * monotone in t; 200 halvings of [0, 1e6] reach double precision. Null for a + * df below 1, where no interval exists. + */ +export function criticalTTwoSided(df: number, alpha = 0.05): number | null { + if (!(df >= 1) || !(alpha > 0 && alpha < 1)) return null; + let lo = 0; + let hi = 1e6; + for (let i = 0; i < 200; i += 1) { + const mid = (lo + hi) / 2; + if (twoSidedPFromT(mid, df) > alpha) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} + // ── Welch two-sample t-test (unequal variances) ──────────────────── export interface WelchOk { diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts new file mode 100644 index 000000000..b4aeb3dfa --- /dev/null +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { criticalTTwoSided } from "@/lib/insights/correlations"; +import { computeLabTrend } from "../lab-trend"; + +const YEAR_MS = 365.25 * 86_400_000; +const T0 = Date.UTC(2023, 0, 1); +const atYears = (y: number) => new Date(T0 + y * YEAR_MS); +const readings = (values: Array<[number, number]>) => + values.map(([y, value]) => ({ takenAt: atYears(y), value })); + +describe("criticalTTwoSided", () => { + it("matches the tabled Student t at alpha 0.05", () => { + // df 1: tan(0.95·π/2) exactly. + expect(criticalTTwoSided(1)).toBeCloseTo(Math.tan(0.475 * Math.PI), 6); + expect(criticalTTwoSided(2)).toBeCloseTo(4.302653, 5); + expect(criticalTTwoSided(10)).toBeCloseTo(2.228139, 5); + expect(criticalTTwoSided(1000)).toBeCloseTo(1.962339, 4); + expect(criticalTTwoSided(0)).toBeNull(); + }); +}); + +describe("computeLabTrend", () => { + it("needs three readings over at least a year", () => { + expect( + computeLabTrend( + readings([ + [0, 1], + [2, 2], + ]), + { low: 0, high: 5 }, + atYears(2), + ), + ).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 731, + }); + expect( + computeLabTrend( + readings([ + [0, 1], + [0.3, 2], + [0.6, 3], + ]), + { low: 0, high: 5 }, + atYears(1), + ), + ).toMatchObject({ status: "insufficient", reason: "span_too_short", n: 3 }); + }); + + it("projects the exit date of an exact line", () => { + // 100, 110, 120 a year apart: 10 per year, no residual, band top 140. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 110], + [2, 120], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + expect(trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + slopeCi95: [10, 10], + pValue: 0, + fittedNow: 120, + projection: "exits", + exitBound: "high", + // The line meets 140 four years after the first reading. + exitDate: new Date(T0 + 4 * YEAR_MS).toISOString().slice(0, 10), + }); + }); + + it("draws no projection when the slope's interval includes zero", () => { + // 100, 112, 118: slope 9, SSR 6, df 1, SE √3, t = 9/√3. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 112], + [2, 118], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + if (trend.status !== "ok") throw new Error("expected ok"); + const half = Math.tan(0.475 * Math.PI) * Math.sqrt(3); + expect(trend.slopePerYear).toBe(9); + expect(trend.slopeCi95[0]).toBeCloseTo(9 - half, 3); + expect(trend.slopeCi95[1]).toBeCloseTo(9 + half, 3); + // df 1 is the Cauchy: p = (2/π)·atan(√3 / 9). + expect(trend.pValue).toBeCloseTo( + (2 / Math.PI) * Math.atan(Math.sqrt(3) / 9), + 5, + ); + expect(trend.projection).toBe("no_clear_trend"); + expect(trend.exitDate).toBeNull(); + }); + + it("says when the line is already outside, has no band, or exits too late", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(2)), + ).toMatchObject({ projection: "already_outside" }); + expect( + computeLabTrend(line, { low: null, high: null }, atYears(2)), + ).toMatchObject({ projection: "no_band" }); + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2)), + ).toMatchObject({ projection: "beyond_horizon", exitDate: null }); + // Falling toward a band with no lower bound. + const falling = readings([ + [0, 120], + [1, 110], + [2, 100], + ]); + expect( + computeLabTrend(falling, { low: null, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "no_exit" }); + expect( + computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "exits", exitBound: "low" }); + }); +}); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts new file mode 100644 index 000000000..f577c1f3d --- /dev/null +++ b/src/lib/labs/lab-trend.ts @@ -0,0 +1,184 @@ +/** + * v1.44 — the slope of one lab marker over all its readings, and where that + * line would leave the reference band. + * + * A single panel says where a value is; the trend says where it is going. + * The statistic is an ordinary least-squares line through every numeric + * reading of the marker (stored in the marker's unit since v1.40, so the + * series is unit-normalised by construction), x in years. It is reported + * only with at least {@link LAB_TREND_MIN_VALUES} readings spanning at least + * {@link LAB_TREND_MIN_SPAN_DAYS} days: three draws in one month are a + * measurement-noise question, not a trend. + * + * The slope carries its 95 % confidence interval (Student t at n − 2 degrees + * of freedom). A projection is offered only when that interval excludes zero, + * the line is inside the band today and heads toward a bound it reaches + * within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line + * extrapolation, labelled as one wherever it is shown, never a forecast of + * the body and never a risk statement: lab values plateau, respond to + * treatment and carry assay noise a line does not know about. With three + * readings the interval is wide (t ≈ 12.7 at one degree of freedom), so the + * projection is rare exactly where the evidence is thin. + * + * Pure. The read lives in the biomarker route. + */ +import { criticalTTwoSided, twoSidedPFromT } from "@/lib/insights/correlations"; +import { linearFit } from "@/lib/stats/linear-fit"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +export const LAB_TREND_MIN_VALUES = 3; +export const LAB_TREND_MIN_SPAN_DAYS = 365; +export const LAB_PROJECTION_HORIZON_YEARS = 3; + +const DAY_MS = 86_400_000; +const YEAR_DAYS = 365.25; + +export type LabTrendProjection = + /** The line meets a bound within the horizon: `exitDate` is set. */ + | "exits" + /** The line heads to a bound but meets it after the horizon. */ + | "beyond_horizon" + /** The line heads away from the band edges, or to a side with no bound. */ + | "no_exit" + /** The line already sits outside the band today. */ + | "already_outside" + /** The slope's interval includes zero: no projection is drawn. */ + | "no_clear_trend" + /** The marker has no reference band to leave. */ + | "no_band"; + +export interface LabTrendOk { + status: "ok"; + /** Readings in the fit. */ + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + /** Change per year in the marker's unit. */ + slopePerYear: number; + /** 95 % interval of the slope per year. */ + slopeCi95: [number, number]; + /** Two-sided p of the slope against zero (t at n − 2 df). */ + pValue: number; + /** The line's value on `asOf`. */ + fittedNow: number; + asOf: string; + projection: LabTrendProjection; + /** Set only for `exits`: the day the line meets `exitBound`. */ + exitDate: string | null; + exitBound: "low" | "high" | null; +} + +export interface LabTrendInsufficient { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; +} + +export type LabTrend = LabTrendOk | LabTrendInsufficient; + +const round = (x: number, digits: number) => { + const f = 10 ** digits; + return Math.round(x * f) / f; +}; + +/** + * The calendar label of a draw instant. Lab readings carry the day of the + * draw as their meaning (`reading-day.ts`); the exit day is a point on the + * same axis, so both read through the same label. + */ +const dayOf = (ms: number) => dateOnlyKey(new Date(ms)); + +/** + * Fit the marker's readings and place the line against its band. + * `now` anchors "today" for the fitted value and the horizon. + */ +export function computeLabTrend( + readings: ReadonlyArray<{ takenAt: Date; value: number }>, + band: { low: number | null; high: number | null }, + now: Date, +): LabTrend { + const points = readings + .filter((r) => Number.isFinite(r.value)) + .map((r) => ({ t: r.takenAt.getTime(), v: r.value })) + .sort((a, b) => a.t - b.t); + const n = points.length; + const spanDays = + n > 0 ? Math.round((points[n - 1].t - points[0].t) / DAY_MS) : 0; + if (n < LAB_TREND_MIN_VALUES) { + return { status: "insufficient", reason: "too_few_values", n, spanDays }; + } + if (spanDays < LAB_TREND_MIN_SPAN_DAYS) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const t0 = points[0].t; + const xs = points.map((p) => (p.t - t0) / DAY_MS / YEAR_DAYS); + const ys = points.map((p) => p.v); + const fit = linearFit(xs, ys); + if (!fit) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const df = n - 2; + // Residual variance from the centred sums: SSR = Syy − Sxy² / Sxx. + const ssr = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + const se = df > 0 ? Math.sqrt(ssr / df / fit.sxx) : Infinity; + const tCrit = criticalTTwoSided(df) ?? Infinity; + const half = tCrit * se; + const lo = fit.slope - half; + const hi = fit.slope + half; + const pValue = + se === 0 + ? fit.slope === 0 + ? 1 + : 0 + : twoSidedPFromT(Math.abs(fit.slope / se), df); + + const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const fittedNow = fit.intercept + fit.slope * xNow; + + let projection: LabTrendProjection; + let exitDate: string | null = null; + let exitBound: "low" | "high" | null = null; + const hasBand = band.low != null || band.high != null; + if (!hasBand) projection = "no_band"; + else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; + else if ( + (band.low != null && fittedNow < band.low) || + (band.high != null && fittedNow > band.high) + ) { + projection = "already_outside"; + } else { + const bound = fit.slope > 0 ? band.high : band.low; + if (bound == null) projection = "no_exit"; + else { + const xCross = (bound - fit.intercept) / fit.slope; + const yearsAhead = xCross - xNow; + if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + projection = "beyond_horizon"; + } else { + projection = "exits"; + exitBound = fit.slope > 0 ? "high" : "low"; + exitDate = dayOf(t0 + xCross * YEAR_DAYS * DAY_MS); + } + } + } + + return { + status: "ok", + n, + firstAt: dayOf(points[0].t), + lastAt: dayOf(points[n - 1].t), + spanDays, + slopePerYear: round(fit.slope, 4), + slopeCi95: [round(lo, 4), round(hi, 4)], + pValue: round(pValue, 6), + fittedNow: round(fittedNow, 4), + asOf: dayOf(now.getTime()), + projection, + exitDate, + exitBound, + }; +} diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index b467b9ba3..8de2be86d 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -77,6 +77,58 @@ const biomarkerRow = z "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); +const labTrend = z + .union([ + z.object({ + status: z.literal("ok"), + n: z.number().int(), + firstAt: z.string().describe("YYYY-MM-DD of the first reading."), + lastAt: z.string().describe("YYYY-MM-DD of the newest reading."), + spanDays: z.number().int(), + slopePerYear: z + .number() + .describe("Least-squares change per year, in the marker's unit."), + slopeCi95: z + .tuple([z.number(), z.number()]) + .describe("95 % interval of the slope (Student t at n − 2 df)."), + pValue: z.number(), + fittedNow: z.number().describe("The line's value on `asOf`."), + asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."), + projection: z.enum([ + "exits", + "beyond_horizon", + "no_exit", + "already_outside", + "no_clear_trend", + "no_band", + ]), + exitDate: z + .string() + .nullable() + .describe( + "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast.", + ), + exitBound: z.enum(["low", "high"]).nullable(), + }), + z.object({ + status: z.literal("insufficient"), + reason: z.enum(["too_few_values", "span_too_short"]), + n: z.number().int(), + spanDays: z.number().int(), + }), + ]) + .meta({ + id: "BiomarkerTrend", + description: + "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.", + }); + +const biomarkerDetail = biomarkerRow + .extend({ + trend: labTrend.optional().describe("v1.44 — absent on older servers."), + }) + .meta({ id: "BiomarkerDetail" }); + const listResponse = z .object({ biomarkers: z.array(biomarkerRow) }) .meta({ id: "ListBiomarkersResponse" }); @@ -191,7 +243,7 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Fetch a single biomarker", description: - "Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", + "Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { ...recordRefusal(), @@ -199,7 +251,7 @@ export const biomarkerPaths: NonNullable = { description: "Biomarker detail.", content: { "application/json": { - schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"), + schema: dataEnvelope(biomarkerDetail, "GetBiomarkerResponse"), }, }, }, From 5c64192afc6f61dee60ef3bcdb04dad23cb2c964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:59:42 +0200 Subject: [PATCH 4/6] feat(mental-health): measure screener change against the meaningful-change threshold The assessments list now carries, per PHQ-9, GAD-7 and WHO-5, the latest total against the previous and the first administration, classified against the change studies treat as meaningful: 5 points on the PHQ-9, 4 on the GAD-7, 10 on the WHO-5 percentage scale. A smaller change reads as within the threshold rather than better or worse. The instrument detail states each change and whether it reaches that size, in neutral words. The read takes totals and dates only, never an answer. --- docs/api/openapi.yaml | 98 +++++++++++++ messages/de.json | 11 +- messages/en.json | 11 +- messages/es.json | 11 +- messages/fr.json | 11 +- messages/it.json | 11 +- messages/ko.json | 11 +- messages/nl.json | 11 +- messages/pl.json | 11 +- .../analysis-depth-no-ai-guard.test.ts | 1 + .../assessments/__tests__/route.test.ts | 46 ++++++ .../api/mental-health/assessments/route.ts | 28 +++- .../__tests__/assessment-trend-note.test.tsx | 65 +++++++++ .../mental-health/assessment-trend-note.tsx | 58 ++++++++ .../mental-health/instrument-detail.tsx | 8 +- .../mental-health/mental-wellbeing.tsx | 11 +- src/components/mental-health/types.ts | 15 ++ .../__tests__/assessment-trend.test.ts | 80 +++++++++++ src/lib/mental-health/assessment-trend.ts | 134 ++++++++++++++++++ src/lib/openapi/routes/mental-health.ts | 42 +++++- 20 files changed, 659 insertions(+), 15 deletions(-) create mode 100644 src/components/mental-health/__tests__/assessment-trend-note.test.tsx create mode 100644 src/components/mental-health/assessment-trend-note.tsx create mode 100644 src/lib/mental-health/__tests__/assessment-trend.test.ts create mode 100644 src/lib/mental-health/assessment-trend.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9b3511d3..1b7a1c041 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74922,6 +74922,11 @@ components: type: array items: $ref: "#/components/schemas/MentalHealthAssessment" + trends: + description: v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/MentalHealthAssessmentTrend" required: - assessments additionalProperties: false @@ -74971,6 +74976,99 @@ components: diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted. + MentalHealthAssessmentTrend: + type: object + properties: + instrument: + type: string + enum: + - PHQ9 + - GAD7 + - WHO5 + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + threshold: + type: number + description: "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale)." + higherIsBetter: + type: boolean + first: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + previous: + anyOf: + - type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + - type: "null" + latest: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + changeFromPrevious: + type: + - number + - "null" + changeFromFirst: + type: + - number + - "null" + sincePrevious: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + sinceFirst: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + required: + - instrument + - n + - threshold + - higherIsBetter + - first + - previous + - latest + - changeFromPrevious + - changeFromFirst + - sincePrevious + - sinceFirst + additionalProperties: false + description: v1.44 — the latest total against the previous and the first administration, classified against the + instrument's meaningful-change threshold in its own direction. A change below the threshold is + `within_threshold`. Server-computed over the whole history, not the page. CreateMentalHealthAssessmentEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index fc61d1d46..7fa10ef44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -11075,7 +11075,16 @@ "openDetail": "Details anzeigen", "historyLoadError": "Dein Testverlauf konnte nicht geladen werden", "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.", - "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht." + "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht.", + "trend": { + "sincePrevious": "Seit deinem vorigen Check-in am {date}: {change} Punkte.", + "sinceFirst": "Seit deinem ersten Check-in am {date}: {change} Punkte.", + "verdict": { + "improved": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die bessere Richtung.", + "worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.", + "within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen." + } + } }, "records": { "aiInclusion": { diff --git a/messages/en.json b/messages/en.json index be2db6114..3e8a2fd08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -11075,7 +11075,16 @@ "openDetail": "Show details", "historyLoadError": "Your assessment history could not be loaded", "historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.", - "privacyNote": "Your answers are stored encrypted and never reach the AI Coach." + "privacyNote": "Your answers are stored encrypted and never reach the AI Coach.", + "trend": { + "sincePrevious": "Since your previous check-in on {date}: {change} points.", + "sinceFirst": "Since your first check-in on {date}: {change} points.", + "verdict": { + "improved": "That reaches the {threshold}-point change studies treat as meaningful, in the better direction.", + "worsened": "That reaches the {threshold}-point change studies treat as meaningful, in the less good direction.", + "within_threshold": "That is smaller than the {threshold}-point change studies treat as meaningful." + } + } }, "records": { "aiInclusion": { diff --git a/messages/es.json b/messages/es.json index 287b36a61..74d46bd52 100644 --- a/messages/es.json +++ b/messages/es.json @@ -11075,7 +11075,16 @@ "openDetail": "Ver detalles", "historyLoadError": "No se pudo cargar tu historial de evaluaciones", "historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.", - "privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA." + "privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA.", + "trend": { + "sincePrevious": "Desde tu revisión anterior del {date}: {change} puntos.", + "sinceFirst": "Desde tu primera revisión del {date}: {change} puntos.", + "verdict": { + "improved": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección favorable.", + "worsened": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección menos favorable.", + "within_threshold": "Es menor que el cambio de {threshold} puntos que los estudios consideran relevante." + } + } }, "records": { "aiInclusion": { diff --git a/messages/fr.json b/messages/fr.json index d1c564694..b5fe83be3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -11075,7 +11075,16 @@ "openDetail": "Voir les détails", "historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé", "historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.", - "privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA." + "privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA.", + "trend": { + "sincePrevious": "Depuis ton bilan précédent du {date} : {change} points.", + "sinceFirst": "Depuis ton premier bilan du {date} : {change} points.", + "verdict": { + "improved": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le bon sens.", + "worsened": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le sens moins favorable.", + "within_threshold": "C'est moins que la variation de {threshold} points que les études jugent significative." + } + } }, "records": { "aiInclusion": { diff --git a/messages/it.json b/messages/it.json index dd0c39841..d4b271a51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -11075,7 +11075,16 @@ "openDetail": "Mostra dettagli", "historyLoadError": "Non è stato possibile caricare la cronologia dei test", "historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.", - "privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA." + "privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA.", + "trend": { + "sincePrevious": "Dal tuo check-in precedente del {date}: {change} punti.", + "sinceFirst": "Dal tuo primo check-in del {date}: {change} punti.", + "verdict": { + "improved": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione migliore.", + "worsened": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione meno buona.", + "within_threshold": "È meno della variazione di {threshold} punti che gli studi considerano rilevante." + } + } }, "records": { "aiInclusion": { diff --git a/messages/ko.json b/messages/ko.json index f8ac7397c..80faf9d15 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -11075,7 +11075,16 @@ "openDetail": "자세히 보기", "historyLoadError": "검사 기록을 불러오지 못했어요", "historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.", - "privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요." + "privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요.", + "trend": { + "sincePrevious": "{date} 이전 체크인 이후: {change}점.", + "sinceFirst": "{date} 첫 체크인 이후: {change}점.", + "verdict": { + "improved": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 좋아지는 방향입니다.", + "worsened": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 덜 좋은 방향입니다.", + "within_threshold": "연구에서 의미 있다고 보는 {threshold}점 변화보다 작습니다." + } + } }, "records": { "aiInclusion": { diff --git a/messages/nl.json b/messages/nl.json index bc81ba2ae..e4bf20265 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -11075,7 +11075,16 @@ "openDetail": "Details tonen", "historyLoadError": "Je testgeschiedenis kon niet worden geladen", "historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.", - "privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht." + "privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht.", + "trend": { + "sincePrevious": "Sinds je vorige check-in op {date}: {change} punten.", + "sinceFirst": "Sinds je eerste check-in op {date}: {change} punten.", + "verdict": { + "improved": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de betere richting.", + "worsened": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de minder goede richting.", + "within_threshold": "Dat is minder dan de verandering van {threshold} punten die studies als betekenisvol zien." + } + } }, "records": { "aiInclusion": { diff --git a/messages/pl.json b/messages/pl.json index 3939cf2eb..8913a25f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -11075,7 +11075,16 @@ "openDetail": "Pokaż szczegóły", "historyLoadError": "Nie udało się wczytać historii testów", "historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.", - "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI." + "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI.", + "trend": { + "sincePrevious": "Od poprzedniego sprawdzenia {date}: {change} pkt.", + "sinceFirst": "Od pierwszego sprawdzenia {date}: {change} pkt.", + "verdict": { + "improved": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w lepszym kierunku.", + "worsened": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w mniej korzystnym kierunku.", + "within_threshold": "To mniej niż zmiana o {threshold} pkt, którą badania uznają za istotną." + } + } }, "records": { "aiInclusion": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index f5aa84195..b3b9e4067 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -30,6 +30,7 @@ const ANALYSIS_MODULES = [ "lib/insights/discovery-matrix.ts", "lib/insights/derived/changepoint.ts", "lib/labs/lab-trend.ts", + "lib/mental-health/assessment-trend.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 715d25a99..3dfa8ded0 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -479,4 +479,50 @@ describe("GET /api/mental-health/assessments", () => { .calls[0][0] as { orderBy: unknown }; expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]); }); + + it("carries each screener's change against its meaningful-change threshold (v1.44)", async () => { + const at = (d: string) => new Date(`${d}T09:00:00.000Z`); + const history = [ + { + id: "p2", + instrument: "PHQ9", + totalScore: 8, + takenAt: at("2026-06-01"), + }, + { + id: "p1", + instrument: "PHQ9", + totalScore: 15, + takenAt: at("2026-02-01"), + }, + ]; + vi.mocked(prisma.mentalHealthAssessment.findMany) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce(history as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest( + new URL("http://localhost/api/mental-health/assessments?limit=1"), + ), + ); + const body = await res.json(); + expect(body.data.trends).toEqual([ + expect.objectContaining({ + instrument: "PHQ9", + n: 2, + threshold: 5, + changeFromPrevious: -7, + sincePrevious: "improved", + }), + ]); + // The trend read selects totals and dates only, never the answers. + const trendArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock + .calls[1][0] as { select: Record; where: unknown }; + expect(Object.keys(trendArgs.select).sort()).toEqual([ + "id", + "instrument", + "takenAt", + "totalScore", + ]); + expect(trendArgs.where).toMatchObject({ deletedAt: null }); + }); }); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 610bceab3..740ae8d0d 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -58,6 +58,16 @@ import type { MeasurementSource, } from "@/generated/prisma/client"; import { apiListSuccess } from "@/lib/api/list-cap"; +import { + computeAssessmentTrends, + TREND_INSTRUMENTS, +} from "@/lib/mental-health/assessment-trend"; + +/** + * Upper bound on administrations the trends read; years of weekly check-ins + * across three instruments stay far below it. The cap falls on the oldest. + */ +const TREND_READ_CAP = 1500; import { listPage } from "@/lib/http/list-query"; function shapeRow(row: { @@ -149,11 +159,25 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }), ); + // v1.44 — the change of each screener against its meaningful-change + // threshold, over the whole history rather than the page: totals and dates + // only, never an item answer. + const trendRows = await prisma.mentalHealthAssessment.findMany({ + where: { + userId: user.id, + deletedAt: null, + instrument: { in: TREND_INSTRUMENTS }, + }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take: TREND_READ_CAP, + select: { id: true, instrument: true, totalScore: true, takenAt: true }, + }); + const trends = computeAssessmentTrends(trendRows); annotate({ action: { name: "mental-health.list" }, - meta: { count: rows.length }, + meta: { count: rows.length, trends: trends.length }, }); - return apiListSuccess({ assessments: rows.map(shapeRow) }, cap); + return apiListSuccess({ assessments: rows.map(shapeRow), trends }, cap); }); // `withIdempotency` lets a double-tap / retry re-send the same diff --git a/src/components/mental-health/__tests__/assessment-trend-note.test.tsx b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx new file mode 100644 index 000000000..3c81d65ee --- /dev/null +++ b/src/components/mental-health/__tests__/assessment-trend-note.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { AssessmentTrendNote } from "../assessment-trend-note"; +import type { AssessmentTrendDto } from "../types"; + +primeLocales("de"); + +const TREND: AssessmentTrendDto = { + instrument: "PHQ9", + n: 3, + threshold: 5, + higherIsBetter: false, + first: { score: 16, takenAt: "2026-01-10T09:00:00.000Z" }, + previous: { score: 12, takenAt: "2026-03-15T09:00:00.000Z" }, + latest: { score: 9, takenAt: "2026-06-01T09:00:00.000Z" }, + changeFromPrevious: -3, + changeFromFirst: -7, + sincePrevious: "within_threshold", + sinceFirst: "improved", +}; + +const render = (trend: AssessmentTrendDto, locale: "en" | "de" = "en") => + renderToStaticMarkup( + + + , + ); + +describe("", () => { + it("states each change and whether it reaches the threshold", () => { + const html = render(TREND); + expect(html).toContain("-3 points"); + expect(html).toContain("smaller than the 5-point change"); + expect(html).toContain("-7 points"); + expect(html).toContain('data-verdict="improved"'); + }); + + it("shows a rise with its sign, in German with du", () => { + const html = render( + { ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" }, + "de", + ); + expect(html).toContain("Seit deinem vorigen Check-in"); + expect(html).toContain("+6 Punkte"); + expect(html).not.toContain("ersten"); + }); + + it("is silent with a single check-in", () => { + expect( + render({ + ...TREND, + n: 1, + previous: null, + changeFromPrevious: null, + changeFromFirst: null, + sincePrevious: null, + sinceFirst: null, + }), + ).toBe(""); + }); +}); diff --git a/src/components/mental-health/assessment-trend-note.tsx b/src/components/mental-health/assessment-trend-note.tsx new file mode 100644 index 000000000..f604ba1f1 --- /dev/null +++ b/src/components/mental-health/assessment-trend-note.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { AssessmentTrendDto } from "./types"; + +/** + * v1.44 — how the latest score compares with the previous check-in (and, + * from the third, with the first), against the change size studies treat as + * meaningful for this instrument. Every number and verdict is the server's; + * the wording stays neutral: a change, its size, and whether it reaches the + * threshold. No band words, no advice. + */ +export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) { + const { t } = useTranslations(); + const { date: formatDate, number } = useFormatters(); + if (trend.previous === null || trend.changeFromPrevious === null) return null; + + const signed = (delta: number) => + delta > 0 ? `+${number(delta, 0)}` : number(delta, 0); + const line = ( + key: "sincePrevious" | "sinceFirst", + at: string, + delta: number, + verdict: NonNullable, + ) => + `${t(`mentalHealth.trend.${key}`, { + date: formatDate(at), + change: signed(delta), + })} ${t(`mentalHealth.trend.verdict.${verdict}`, { + threshold: trend.threshold, + })}`; + + return ( +
+ {trend.sincePrevious ? ( +

+ {line( + "sincePrevious", + trend.previous.takenAt, + trend.changeFromPrevious, + trend.sincePrevious, + )} +

+ ) : null} + {trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? ( +

+ {line( + "sinceFirst", + trend.first.takenAt, + trend.changeFromFirst, + trend.sinceFirst, + )} +

+ ) : null} +
+ ); +} diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx index fb1b2d36b..4e798ee49 100644 --- a/src/components/mental-health/instrument-detail.tsx +++ b/src/components/mental-health/instrument-detail.tsx @@ -21,16 +21,20 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import { AssessmentHistory } from "./assessment-history"; -import type { AssessmentRow, InstrumentId } from "./types"; +import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types"; +import { AssessmentTrendNote } from "./assessment-trend-note"; export function InstrumentDetail({ instrument, rows, + trend, onStart, }: { instrument: InstrumentId; /** ALL assessments; the pinned history filters to this instrument. */ rows: AssessmentRow[]; + /** v1.44 — the server's change against the meaningful-change threshold. */ + trend?: AssessmentTrendDto; /** Start a check-in for this instrument (closes the host sheet). */ onStart: () => void; }) { @@ -75,6 +79,8 @@ export function InstrumentDetail({ )} + {trend ? : null} + {/* The Start action stays reachable here too — the detail is a calm reading surface, not a dead end. */} {canManageMind && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 3919ed1e6..a6341fb47 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card"; import { InstrumentDetail } from "./instrument-detail"; import type { AssessmentRow, + AssessmentTrendDto, CreateResponse, InstrumentId, Phase, @@ -81,9 +82,10 @@ export function MentalWellbeing() { } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => - apiGetList<{ assessments: AssessmentRow[] }>( - "/api/mental-health/assessments", - ), + apiGetList<{ + assessments: AssessmentRow[]; + trends?: AssessmentTrendDto[]; + }>("/api/mental-health/assessments"), }); const history = historyPage?.data; @@ -229,6 +231,9 @@ export function MentalWellbeing() { trend.instrument === detailInstrument, + )} onStart={() => begin(detailInstrument)} /> )} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 26d47f766..b5b679876 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -48,3 +48,18 @@ export interface CreateResponse { actionThreshold: number; crisis: CrisisSet | null; } + +/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */ +export interface AssessmentTrendDto { + instrument: "PHQ9" | "GAD7" | "WHO5"; + n: number; + threshold: number; + higherIsBetter: boolean; + first: { score: number; takenAt: string }; + previous: { score: number; takenAt: string } | null; + latest: { score: number; takenAt: string }; + changeFromPrevious: number | null; + changeFromFirst: number | null; + sincePrevious: "improved" | "worsened" | "within_threshold" | null; + sinceFirst: "improved" | "worsened" | "within_threshold" | null; +} diff --git a/src/lib/mental-health/__tests__/assessment-trend.test.ts b/src/lib/mental-health/__tests__/assessment-trend.test.ts new file mode 100644 index 000000000..5af9791c0 --- /dev/null +++ b/src/lib/mental-health/__tests__/assessment-trend.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + computeAssessmentTrends, + MEANINGFUL_CHANGE, + verdictForChange, +} from "../assessment-trend"; + +const row = ( + id: string, + instrument: string, + totalScore: number, + day: string, +) => ({ + id, + instrument, + totalScore, + takenAt: new Date(`${day}T09:00:00.000Z`), +}); + +describe("meaningful-change thresholds", () => { + it("are the published minimal important differences", () => { + expect(MEANINGFUL_CHANGE).toEqual({ PHQ9: 5, GAD7: 4, WHO5: 10 }); + }); + + it("classify in the instrument's direction, inclusive at the threshold", () => { + expect(verdictForChange(-5, 5, false)).toBe("improved"); + expect(verdictForChange(-4, 5, false)).toBe("within_threshold"); + expect(verdictForChange(5, 5, false)).toBe("worsened"); + expect(verdictForChange(12, 10, true)).toBe("improved"); + expect(verdictForChange(-10, 10, true)).toBe("worsened"); + }); +}); + +describe("computeAssessmentTrends", () => { + it("compares the latest score with the previous and the first", () => { + const trends = computeAssessmentTrends([ + row("c", "PHQ9", 9, "2026-06-01"), + row("a", "PHQ9", 16, "2026-01-10"), + row("b", "PHQ9", 12, "2026-03-15"), + row("g", "GAD7", 10, "2026-03-15"), + row("h", "GAD7", 13, "2026-06-01"), + row("w", "WHO5", 44, "2026-06-01"), + row("s", "SCI", 20, "2026-06-01"), + ]); + expect(trends.map((t) => t.instrument)).toEqual(["PHQ9", "GAD7", "WHO5"]); + const [phq, gad, who] = trends; + expect(phq).toMatchObject({ + n: 3, + threshold: 5, + changeFromPrevious: -3, + changeFromFirst: -7, + sincePrevious: "within_threshold", + sinceFirst: "improved", + first: { score: 16 }, + latest: { score: 9 }, + }); + expect(gad).toMatchObject({ + n: 2, + changeFromPrevious: 3, + sincePrevious: "within_threshold", + }); + expect(who).toMatchObject({ + n: 1, + previous: null, + changeFromPrevious: null, + sincePrevious: null, + sinceFirst: null, + }); + }); + + it("orders same-day administrations by id", () => { + const [phq] = computeAssessmentTrends([ + row("b", "PHQ9", 20, "2026-06-01"), + row("a", "PHQ9", 10, "2026-06-01"), + ]); + expect(phq.latest.score).toBe(20); + expect(phq.sincePrevious).toBe("worsened"); + }); +}); diff --git a/src/lib/mental-health/assessment-trend.ts b/src/lib/mental-health/assessment-trend.ts new file mode 100644 index 000000000..1922be139 --- /dev/null +++ b/src/lib/mental-health/assessment-trend.ts @@ -0,0 +1,134 @@ +/** + * v1.44 — screener change measured against the size studies treat as + * meaningful, instead of the last score on its own. + * + * A difference between two administrations is only worth naming when it is + * at least the instrument's minimal clinically important difference, which + * the primary literature puts at: + * + * - PHQ-9: 5 points (Löwe, Unützer, Callahan, Perkins & Kroenke 2004, + * Med Care 42(12):1194–1201). + * - GAD-7: 4 points (Toussaint, Hüsing, Gumz et al. 2020, J Affect Disord + * 265:395–401). + * - WHO-5: 10 points on the 0–100 percentage scale (Topp, Østergaard, + * Søndergaard & Bech 2015, Psychother Psychosom 84(3):167–176). + * + * A change below the threshold reads as "within the threshold", not as + * better or worse; a change at or beyond it reads in the instrument's own + * direction (lower is better on PHQ-9 / GAD-7, higher on WHO-5). This is a + * description of the person's own scores, never a diagnosis and never a + * treatment response; the SCI has no published threshold of this kind and + * gets no trend. Pure; the read lives in the assessments route. + */ +import { + INSTRUMENTS, + type InstrumentId, +} from "@/lib/mental-health/instruments"; + +export const MEANINGFUL_CHANGE: Readonly< + Partial> +> = { + PHQ9: 5, + GAD7: 4, + WHO5: 10, +}; + +export const TREND_INSTRUMENTS = Object.keys( + MEANINGFUL_CHANGE, +) as InstrumentId[]; + +export type ChangeVerdict = "improved" | "worsened" | "within_threshold"; + +export interface ScorePoint { + score: number; + takenAt: string; +} + +export interface AssessmentTrend { + instrument: InstrumentId; + /** Administrations of this instrument on record. */ + n: number; + /** The meaningful-change threshold, in the instrument's reported points. */ + threshold: number; + higherIsBetter: boolean; + first: ScorePoint; + previous: ScorePoint | null; + latest: ScorePoint; + /** latest − previous; null with one administration. */ + changeFromPrevious: number | null; + /** latest − first; null with one administration. */ + changeFromFirst: number | null; + sincePrevious: ChangeVerdict | null; + sinceFirst: ChangeVerdict | null; +} + +/** Classify a signed change against the threshold in the instrument's direction. */ +export function verdictForChange( + delta: number, + threshold: number, + higherIsBetter: boolean, +): ChangeVerdict { + if (Math.abs(delta) < threshold) return "within_threshold"; + return delta > 0 === higherIsBetter ? "improved" : "worsened"; +} + +/** + * One trend per instrument that has a threshold and at least one + * administration, in the instrument order. Rows may arrive in any order; + * they are ordered by `takenAt`, then `id`. + */ +export function computeAssessmentTrends( + rows: ReadonlyArray<{ + id: string; + instrument: string; + totalScore: number; + takenAt: Date; + }>, +): AssessmentTrend[] { + const out: AssessmentTrend[] = []; + for (const instrument of TREND_INSTRUMENTS) { + const threshold = MEANINGFUL_CHANGE[instrument] as number; + const series = rows + .filter( + (r) => r.instrument === instrument && Number.isFinite(r.totalScore), + ) + .sort( + (a, b) => + a.takenAt.getTime() - b.takenAt.getTime() || (a.id < b.id ? -1 : 1), + ); + if (series.length === 0) continue; + const { higherIsBetter } = INSTRUMENTS[instrument]; + const point = (r: (typeof series)[number]): ScorePoint => ({ + score: r.totalScore, + takenAt: r.takenAt.toISOString(), + }); + const latest = series[series.length - 1]; + const previous = series.length > 1 ? series[series.length - 2] : null; + const first = series[0]; + const changeFromPrevious = previous + ? latest.totalScore - previous.totalScore + : null; + const changeFromFirst = + series.length > 1 ? latest.totalScore - first.totalScore : null; + out.push({ + instrument, + n: series.length, + threshold, + higherIsBetter, + first: point(first), + previous: previous ? point(previous) : null, + latest: point(latest), + changeFromPrevious, + changeFromFirst, + sincePrevious: + changeFromPrevious === null + ? null + : verdictForChange(changeFromPrevious, threshold, higherIsBetter), + sinceFirst: + changeFromFirst === null + ? null + : verdictForChange(changeFromFirst, threshold, higherIsBetter), + }); + } + return out; +} diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index b7b81e7d8..8a15fd8e7 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -100,8 +100,48 @@ const createResponse = z }) .meta({ id: "CreateMentalHealthAssessmentResponse" }); +const scorePoint = z.object({ + score: z.number(), + takenAt: z.string(), +}); +const changeVerdict = z + .enum(["improved", "worsened", "within_threshold"]) + .nullable(); + +const assessmentTrend = z + .object({ + instrument: z.enum(["PHQ9", "GAD7", "WHO5"]), + n: z.number().int(), + threshold: z + .number() + .describe( + "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale).", + ), + higherIsBetter: z.boolean(), + first: scorePoint, + previous: scorePoint.nullable(), + latest: scorePoint, + changeFromPrevious: z.number().nullable(), + changeFromFirst: z.number().nullable(), + sincePrevious: changeVerdict, + sinceFirst: changeVerdict, + }) + .meta({ + id: "MentalHealthAssessmentTrend", + description: + "v1.44 — the latest total against the previous and the first administration, classified against the instrument's meaningful-change threshold in its own direction. A change below the threshold is `within_threshold`. Server-computed over the whole history, not the page.", + }); + const listResponse = z - .object({ assessments: z.array(assessmentRow) }) + .object({ + assessments: z.array(assessmentRow), + trends: z + .array(assessmentTrend) + .optional() + .describe( + "v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.", + ), + }) .meta({ id: "ListMentalHealthAssessmentsResponse" }); const detailResponse = z From 9be720146c0b0ed77f5342f26f406381e4ff163c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:13:28 +0200 Subject: [PATCH 5/6] feat(workouts): show fitness, fatigue and form from the stored training impulse The first page of the workout list now carries the fitness-fatigue model over the day-total heart-rate impulse the nightly strain run already stores: fitness as the 42-day and fatigue as the 7-day exponentially weighted average, form as their difference. A day without a stored impulse counts as rest. It needs 28 days and three training days, flags the first six weeks as warming up, and reads in the person's own units only. The workouts page shows the three figures above the list. --- docs/api/openapi.yaml | 92 +++++++++++ messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/ko.json | 8 + messages/nl.json | 8 + messages/pl.json | 8 + .../analysis-depth-no-ai-guard.test.ts | 1 + .../__tests__/canonical-dedup.test.ts | 44 +++++ src/app/insights/workouts/page-client.tsx | 3 + .../__tests__/training-load-card.test.tsx | 59 +++++++ .../workouts/training-load-card.tsx | 60 +++++++ src/hooks/use-workouts.ts | 7 + .../insights/__tests__/training-load.test.ts | 110 +++++++++++++ src/lib/insights/training-load.ts | 152 ++++++++++++++++++ src/lib/openapi/routes/workouts.ts | 46 ++++++ src/lib/workouts/list-read.ts | 14 +- 19 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 src/components/workouts/__tests__/training-load-card.test.tsx create mode 100644 src/components/workouts/training-load-card.tsx create mode 100644 src/lib/insights/__tests__/training-load.test.ts create mode 100644 src/lib/insights/training-load.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b7a1c041..45452791c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -48526,6 +48526,10 @@ components: - offset - droppedDuplicates additionalProperties: false + trainingLoad: + description: v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and + older servers. + $ref: "#/components/schemas/WorkoutTrainingLoad" required: - workouts - meta @@ -48602,6 +48606,94 @@ components: - hasRoute - hasHrSeries additionalProperties: false + WorkoutTrainingLoad: + anyOf: + - type: object + properties: + status: + type: string + const: ok + asOf: + type: string + description: The last scored UTC day the figures describe. + ctl: + type: number + description: "Fitness: 42-day exponentially weighted TRIMP." + atl: + type: number + description: "Fatigue: 7-day exponentially weighted TRIMP." + tsb: + type: number + description: "Form: fitness minus fatigue at `asOf`." + daysCovered: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trainingDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + warmingUp: + type: boolean + description: "True below 42 covered days: both averages start at zero." + series: + type: array + items: + type: object + properties: + day: + type: string + ctl: + type: number + atl: + type: number + tsb: + type: number + required: + - day + - ctl + - atl + - tsb + additionalProperties: false + description: The trailing 90 days, oldest first; `tsb` is form going into the day. + required: + - status + - asOf + - ctl + - atl + - tsb + - daysCovered + - trainingDays + - warmingUp + - series + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - no_impulse_history + - too_little_history + daysCovered: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trainingDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - daysCovered + - trainingDays + additionalProperties: false + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day + without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with + the person's own past only." GetWorkoutResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 7fa10ef44..1862f7fce 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3833,6 +3833,14 @@ "editTitle": "Training bearbeiten", "editAction": "Bearbeiten", "updated": "Training aktualisiert" + }, + "load": { + "title": "Trainingslast", + "fitness": "Fitness", + "fatigue": "Ermüdung", + "form": "Form", + "explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.", + "warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist." } }, "bloodGlucose": { diff --git a/messages/en.json b/messages/en.json index 3e8a2fd08..31c814e13 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3833,6 +3833,14 @@ "editTitle": "Edit workout", "editAction": "Edit", "updated": "Workout updated" + }, + "load": { + "title": "Training load", + "fitness": "Fitness", + "fatigue": "Fatigue", + "form": "Form", + "explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.", + "warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is." } }, "bloodGlucose": { diff --git a/messages/es.json b/messages/es.json index 74d46bd52..b8f865a17 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3833,6 +3833,14 @@ "editTitle": "Editar entrenamiento", "editAction": "Editar", "updated": "Entrenamiento actualizado" + }, + "load": { + "title": "Carga de entrenamiento", + "fitness": "Forma de base", + "fatigue": "Fatiga", + "form": "Estado", + "explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.", + "warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es." } }, "bloodGlucose": { diff --git a/messages/fr.json b/messages/fr.json index b5fe83be3..52a6d0729 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3833,6 +3833,14 @@ "editTitle": "Modifier l’entraînement", "editAction": "Modifier", "updated": "Entraînement mis à jour" + }, + "load": { + "title": "Charge d'entraînement", + "fitness": "Condition", + "fatigue": "Fatigue", + "form": "Fraîcheur", + "explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.", + "warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est." } }, "bloodGlucose": { diff --git a/messages/it.json b/messages/it.json index d4b271a51..69ab3a6be 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3833,6 +3833,14 @@ "editTitle": "Modifica allenamento", "editAction": "Modifica", "updated": "Allenamento aggiornato" + }, + "load": { + "title": "Carico di allenamento", + "fitness": "Forma di base", + "fatigue": "Affaticamento", + "form": "Freschezza", + "explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.", + "warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia." } }, "bloodGlucose": { diff --git a/messages/ko.json b/messages/ko.json index 80faf9d15..ffaa699a6 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3833,6 +3833,14 @@ "editTitle": "운동 편집", "editAction": "편집", "updated": "운동을 업데이트했습니다" + }, + "load": { + "title": "훈련 부하", + "fitness": "체력", + "fatigue": "피로", + "form": "컨디션", + "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", + "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다." } }, "bloodGlucose": { diff --git a/messages/nl.json b/messages/nl.json index e4bf20265..c66ceb99d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3833,6 +3833,14 @@ "editTitle": "Workout bewerken", "editAction": "Bewerken", "updated": "Workout bijgewerkt" + }, + "load": { + "title": "Trainingsbelasting", + "fitness": "Fitheid", + "fatigue": "Vermoeidheid", + "form": "Vorm", + "explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.", + "warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is." } }, "bloodGlucose": { diff --git a/messages/pl.json b/messages/pl.json index 8913a25f1..a7e53374e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3833,6 +3833,14 @@ "editTitle": "Edytuj trening", "editAction": "Edytuj", "updated": "Trening zaktualizowany" + }, + "load": { + "title": "Obciążenie treningowe", + "fitness": "Wydolność", + "fatigue": "Zmęczenie", + "form": "Forma", + "explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.", + "warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest." } }, "bloodGlucose": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index b3b9e4067..11ed68149 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -31,6 +31,7 @@ const ANALYSIS_MODULES = [ "lib/insights/derived/changepoint.ts", "lib/labs/lab-trend.ts", "lib/mental-health/assessment-trend.ts", + "lib/insights/training-load.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index b5f0f181f..82906eb8e 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -26,6 +26,8 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), }, + // v1.44 — the first page carries the training load. + strainTrimpCache: { findMany: vi.fn(async () => []) }, }, })); @@ -423,3 +425,45 @@ describe("GET /api/workouts — canonical dedup", () => { ); }); }); + +describe("GET /api/workouts — training load (v1.44)", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetAllCachesForTests(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + }); + + it("carries fitness, fatigue and form on the first page only", async () => { + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + const rows = Array.from({ length: 50 }, (_, i) => ({ + day: new Date( + Date.parse(`${yesterday}T00:00:00Z`) - (49 - i) * 86_400_000, + ) + .toISOString() + .slice(0, 10), + dayTrimp: 80, + })); + vi.mocked(prisma.strainTrimpCache.findMany).mockResolvedValue( + rows as never, + ); + const first = await (await GET(makeRequest())).json(); + expect(first.data.trainingLoad).toMatchObject({ + status: "ok", + asOf: yesterday, + daysCovered: 50, + trainingDays: 50, + warmingUp: false, + }); + const later = await (await GET(makeRequest("?offset=100"))).json(); + expect(later.data.trainingLoad).toBeUndefined(); + }); +}); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index c307d65af..b978cabee 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -15,6 +15,7 @@ import { WorkoutList } from "@/components/insights/workout-list"; import { Skeleton } from "@/components/ui/skeleton"; import { Button } from "@/components/ui/button"; import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet"; +import { TrainingLoadCard } from "@/components/workouts/training-load-card"; import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { canLogWorkout } from "@/lib/workouts/manual-entry"; @@ -61,6 +62,7 @@ export default function InsightsWorkoutsPageClient() { total, isLoading, isEmpty, + trainingLoad, isError, error, isFetchNextPageError, @@ -171,6 +173,7 @@ export default function InsightsWorkoutsPageClient() { })} ) : null} + {workouts.length > 0 ? ( <>

+ renderToStaticMarkup( + + + , + ); + +describe("", () => { + it("shows the server's fitness, fatigue and form", () => { + const html = render(OK); + expect(html).toContain("Training load"); + expect(html).toMatch(/training-load-fitness[^]*?>5462 { + const html = render({ ...OK, tsb: 4.4, warmingUp: false }, "de"); + expect(html).toContain("+"); + expect(html).toContain("Trainingslast"); + expect(html).not.toContain("bisher"); + }); + + it("renders nothing while insufficient", () => { + expect( + render({ + status: "insufficient", + reason: "too_little_history", + daysCovered: 12, + trainingDays: 2, + }), + ).toBe(""); + expect(render(undefined)).toBe(""); + }); +}); diff --git a/src/components/workouts/training-load-card.tsx b/src/components/workouts/training-load-card.tsx new file mode 100644 index 000000000..530fec929 --- /dev/null +++ b/src/components/workouts/training-load-card.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { TrainingLoad } from "@/lib/insights/training-load"; + +/** + * v1.44 — fitness, fatigue and form from the stored training impulse, the + * server's figures as given. Renders nothing until the model has 28 days and + * three training days to stand on. + */ +export function TrainingLoadCard({ + trainingLoad, +}: { + trainingLoad: TrainingLoad | undefined; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!trainingLoad || trainingLoad.status !== "ok") return null; + + const stats = [ + { key: "fitness", value: trainingLoad.ctl }, + { key: "fatigue", value: trainingLoad.atl }, + { key: "form", value: trainingLoad.tsb }, + ] as const; + + return ( + + + + + +

+ {stats.map((stat) => ( +
+
+ {t(`insights.workouts.load.${stat.key}`)} +
+
+ {stat.key === "form" && stat.value > 0 ? "+" : ""} + {fmt.number(stat.value, 0)} +
+
+ ))} +
+

{t("insights.workouts.load.explainer")}

+ {trainingLoad.warmingUp ? ( +

+ {t("insights.workouts.load.warmingUp", { + days: trainingLoad.daysCovered, + })} +

+ ) : null} + + + ); +} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 9b08342d2..3485551dd 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -1,5 +1,6 @@ "use client"; +import type { TrainingLoad } from "@/lib/insights/training-load"; import { useMemo } from "react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; @@ -55,6 +56,8 @@ export interface WorkoutListMeta { export interface WorkoutListPayload { workouts: WorkoutListEntry[]; meta: WorkoutListMeta; + /** v1.44 — first page only; see `GET /api/workouts`. */ + trainingLoad?: TrainingLoad; } export interface UseWorkoutsOptions { @@ -179,6 +182,8 @@ export interface UseInfiniteWorkoutsResult { total: number; isLoading: boolean; isEmpty: boolean; + /** v1.44 — fitness, fatigue and form, from the first page. */ + trainingLoad: TrainingLoad | undefined; error: Error | null; /** True when the first page failed and there is no history to preserve. */ isError: boolean; @@ -218,6 +223,7 @@ export function useInfiniteWorkouts( [pages], ); const total = query.data?.pages[0]?.meta.total ?? 0; + const trainingLoad = query.data?.pages[0]?.trainingLoad; const isEmpty = Boolean(isAuthenticated) && query.data !== undefined && @@ -228,6 +234,7 @@ export function useInfiniteWorkouts( total, isLoading: query.isLoading, isEmpty, + trainingLoad, error: query.error as Error | null, isError: query.isError && query.data === undefined, isFetchNextPageError: query.isFetchNextPageError, diff --git a/src/lib/insights/__tests__/training-load.test.ts b/src/lib/insights/__tests__/training-load.test.ts new file mode 100644 index 000000000..2e1e33ae2 --- /dev/null +++ b/src/lib/insights/__tests__/training-load.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { shiftDateKey } from "@/lib/tz/format"; +import { computeTrainingLoad } from "../training-load"; + +const D0 = "2026-01-01"; +const day = (n: number) => shiftDateKey(D0, n); + +describe("computeTrainingLoad", () => { + it("matches the closed form for a constant daily impulse", () => { + // 100 every day for 60 days: CTL = 100·(1 − (41/42)^60), + // ATL = 100·(1 − (6/7)^60). + const rows = Array.from({ length: 60 }, (_, i) => ({ + day: day(i), + dayTrimp: 100, + })); + const load = computeTrainingLoad(rows, day(59)); + if (load.status !== "ok") throw new Error("expected ok"); + const ctl = 100 * (1 - (41 / 42) ** 60); + const atl = 100 * (1 - (6 / 7) ** 60); + expect(load.ctl).toBe(Math.round(ctl * 10) / 10); + expect(load.atl).toBe(Math.round(atl * 10) / 10); + expect(load.tsb).toBe(Math.round((ctl - atl) * 10) / 10); + expect(load.daysCovered).toBe(60); + expect(load.trainingDays).toBe(60); + expect(load.warmingUp).toBe(false); + expect(load.series).toHaveLength(60); + // Form on day 0 is taken going into the day: both averages are still 0. + expect(load.series[0]).toEqual({ + day: day(0), + ctl: 2.4, + atl: 14.3, + tsb: 0, + }); + }); + + it("counts a day without a stored row as a rest day", () => { + // Train on day 0 only, then 29 empty days up to day 29. + const load = computeTrainingLoad( + [ + { day: day(0), dayTrimp: 84 }, + { day: day(1), dayTrimp: 42 }, + { day: day(2), dayTrimp: 42 }, + ], + day(29), + ); + if (load.status !== "ok") throw new Error("expected ok"); + let ctl = 0; + let atl = 0; + for (let i = 0; i < 30; i += 1) { + const l = i === 0 ? 84 : i < 3 ? 42 : 0; + ctl += (l - ctl) / 42; + atl += (l - atl) / 7; + } + expect(load.ctl).toBe(Math.round(ctl * 10) / 10); + expect(load.atl).toBe(Math.round(atl * 10) / 10); + expect(load.daysCovered).toBe(30); + expect(load.warmingUp).toBe(true); + }); + + it("answers insufficient without impulse or with too little history", () => { + expect(computeTrainingLoad([], day(10))).toMatchObject({ + status: "insufficient", + reason: "no_impulse_history", + }); + expect( + computeTrainingLoad([{ day: day(0), dayTrimp: 0 }], day(40)), + ).toMatchObject({ status: "insufficient", reason: "no_impulse_history" }); + expect( + computeTrainingLoad( + Array.from({ length: 10 }, (_, i) => ({ day: day(i), dayTrimp: 50 })), + day(9), + ), + ).toMatchObject({ + status: "insufficient", + reason: "too_little_history", + daysCovered: 10, + }); + }); + + it("ignores rows after the day it describes", () => { + const rows = Array.from({ length: 40 }, (_, i) => ({ + day: day(i), + dayTrimp: 60, + })); + const a = computeTrainingLoad(rows, day(29)); + const b = computeTrainingLoad(rows.slice(0, 30), day(29)); + expect(a).toEqual(b); + }); +}); + +describe("readTrainingLoad", () => { + it("describes the day the strain run scored", async () => { + const { vi } = await import("vitest"); + vi.resetModules(); + const findMany = vi.fn(async () => []); + vi.doMock("@/lib/db", () => ({ + prisma: { strainTrimpCache: { findMany } }, + })); + const { readTrainingLoad } = await import("../training-load"); + const { scoreDayKey } = await import("@/lib/insights/score-row"); + const now = new Date("2026-10-21T01:30:00.000Z"); + await readTrainingLoad("u1", now); + const args = (findMany.mock.calls[0] as unknown[])[0] as { + where: { day: { lte: string } }; + }; + expect(args.where.day.lte).toBe(scoreDayKey(now)); + vi.doUnmock("@/lib/db"); + }); +}); diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts new file mode 100644 index 000000000..b96eaa865 --- /dev/null +++ b/src/lib/insights/training-load.ts @@ -0,0 +1,152 @@ +/** + * v1.44 — training load as fitness, fatigue and form, from the stored daily + * training impulse. + * + * The impulse is the Banister TRIMP the nightly strain run already writes to + * `strain_trimp_cache` (one row per scored UTC day; a day without a row + * carried no impulse). Over it run the two exponentially weighted averages + * of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the + * time-constant form popularised as chronic and acute training load): + * + * fitness CTL_d = CTL_{d−1} + (L_d − CTL_{d−1}) / 42 + * fatigue ATL_d = ATL_{d−1} + (L_d − ATL_{d−1}) / 7 + * form TSB_d = CTL_{d−1} − ATL_{d−1} (going into day d) + * + * Both averages start at zero on the first stored day, so the figures are + * flagged `warmingUp` until {@link CTL_DAYS} days are covered. The values are + * in TRIMP units: comparable with the person's own past, not with anyone + * else's and not with another app's training-stress score. Descriptive only: + * no performance prediction and no advice. Pure apart from the one read. + */ +import { prisma } from "@/lib/db"; +import { shiftDateKey } from "@/lib/tz/format"; + +export const CTL_DAYS = 42; +export const ATL_DAYS = 7; +/** Days of history before the figures are offered at all. */ +export const TRAINING_LOAD_MIN_DAYS = 28; +/** Training days (impulse > 0) before the figures are offered at all. */ +export const TRAINING_LOAD_MIN_TRAINING_DAYS = 3; +/** Days of history read; the seed's pull is gone long before. */ +export const TRAINING_LOAD_READ_DAYS = 365; +/** Trailing days of the series returned for a chart. */ +export const TRAINING_LOAD_SERIES_DAYS = 90; + +export interface TrainingLoadPoint { + day: string; + ctl: number; + atl: number; + tsb: number; +} + +export type TrainingLoad = + | { + status: "ok"; + /** The last scored day the figures describe (UTC day key). */ + asOf: string; + ctl: number; + atl: number; + /** Form going into the day after `asOf`: CTL − ATL at `asOf`. */ + tsb: number; + daysCovered: number; + trainingDays: number; + warmingUp: boolean; + series: TrainingLoadPoint[]; + } + | { + status: "insufficient"; + reason: "no_impulse_history" | "too_little_history"; + daysCovered: number; + trainingDays: number; + }; + +const round1 = (x: number) => Math.round(x * 10) / 10; + +/** + * Run the model from the first stored day through `asOf` (inclusive), a + * missing day counting as zero impulse. Rows after `asOf` are ignored. + */ +export function computeTrainingLoad( + rows: ReadonlyArray<{ day: string; dayTrimp: number }>, + asOf: string, +): TrainingLoad { + const byDay = new Map(); + for (const row of rows) { + if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue; + byDay.set(row.day, Math.max(0, row.dayTrimp)); + } + const days = [...byDay.keys()].sort(); + const trainingDays = [...byDay.values()].filter((v) => v > 0).length; + if (days.length === 0 || trainingDays === 0) { + return { + status: "insufficient", + reason: "no_impulse_history", + daysCovered: 0, + trainingDays, + }; + } + + const series: TrainingLoadPoint[] = []; + let ctl = 0; + let atl = 0; + let daysCovered = 0; + for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) { + const tsb = ctl - atl; + const load = byDay.get(day) ?? 0; + ctl += (load - ctl) / CTL_DAYS; + atl += (load - atl) / ATL_DAYS; + daysCovered += 1; + series.push({ day, ctl, atl, tsb }); + } + + if ( + daysCovered < TRAINING_LOAD_MIN_DAYS || + trainingDays < TRAINING_LOAD_MIN_TRAINING_DAYS + ) { + return { + status: "insufficient", + reason: "too_little_history", + daysCovered, + trainingDays, + }; + } + + return { + status: "ok", + asOf, + ctl: round1(ctl), + atl: round1(atl), + tsb: round1(ctl - atl), + daysCovered, + trainingDays, + warmingUp: daysCovered < CTL_DAYS, + series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({ + day: p.day, + ctl: round1(p.ctl), + atl: round1(p.atl), + tsb: round1(p.tsb), + })), + }; +} + +/** Read the stored impulse and run the model up to the last scored day. */ +export async function readTrainingLoad( + userId: string, + now: Date = new Date(), +): Promise { + // The strain run scores the previous UTC day (`scoreDayKey`). Restated + // here rather than imported: `score-row` sits on the score writers' import + // graph, and this read stays off it. + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day + const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); + const rows = await prisma.strainTrimpCache.findMany({ + where: { + userId, + day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf }, + }, + orderBy: { day: "asc" }, + take: TRAINING_LOAD_READ_DAYS + 1, + select: { day: true, dayTrimp: true }, + }); + return computeTrainingLoad(rows, asOf); +} diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 41579014a..217766ab0 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -110,6 +110,47 @@ const workoutListEntry = z }) .meta({ id: "WorkoutListEntry" }); +const trainingLoadPoint = z.object({ + day: z.string(), + ctl: z.number(), + atl: z.number(), + tsb: z.number(), +}); + +const trainingLoad = z + .union([ + z.object({ + status: z.literal("ok"), + asOf: z + .string() + .describe("The last scored UTC day the figures describe."), + ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."), + atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."), + tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."), + daysCovered: z.number().int(), + trainingDays: z.number().int(), + warmingUp: z + .boolean() + .describe("True below 42 covered days: both averages start at zero."), + series: z + .array(trainingLoadPoint) + .describe( + "The trailing 90 days, oldest first; `tsb` is form going into the day.", + ), + }), + z.object({ + status: z.literal("insufficient"), + reason: z.enum(["no_impulse_history", "too_little_history"]), + daysCovered: z.number().int(), + trainingDays: z.number().int(), + }), + ]) + .meta({ + id: "WorkoutTrainingLoad", + description: + "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.", + }); + const workoutListResponse = z .object({ workouts: z.array(workoutListEntry), @@ -119,6 +160,11 @@ const workoutListResponse = z offset: z.number().int().nonnegative(), droppedDuplicates: z.number().int().nonnegative(), }), + trainingLoad: trainingLoad + .optional() + .describe( + "v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.", + ), }) .meta({ id: "WorkoutListResponse" }); diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index 52aa9e32d..c61e11d9c 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -22,6 +22,10 @@ import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-work import { cached, caches } from "@/lib/cache/server-cache"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; import { isoDateTime } from "@/lib/validations/datetime"; +import { + readTrainingLoad, + type TrainingLoad, +} from "@/lib/insights/training-load"; function emptyQueryValueToUndefined(value: unknown): unknown { if (value == null) return undefined; @@ -113,6 +117,12 @@ export interface WorkoutsResult { offset: number; droppedDuplicates: number; }; + /** + * v1.44 — fitness, fatigue and form from the stored training impulse. + * Account-wide (no filter applies) and on the first page only, so a client + * paging through history does not recompute it per page. + */ + trainingLoad?: TrainingLoad; } export async function buildWorkoutsProjection( @@ -257,5 +267,7 @@ export async function readWorkoutsListCached( annotate, ); - return sliceWorkoutsProjection(projection, params.limit, params.offset); + const page = sliceWorkoutsProjection(projection, params.limit, params.offset); + if (params.offset !== 0) return page; + return { ...page, trainingLoad: await readTrainingLoad(userId) }; } 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 6/6] 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, + }); + }); +});