From c371310fc0d4fdd9cf208b797f1b143ee9c465d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:27:46 +0200 Subject: [PATCH 1/5] fix(integrations): anchor Oura and Polar daily records at local noon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date-only daily scalars from Oura and Polar were stored at UTC midnight (`${day}T00:00:00.000Z`). On read these rows re-bucket through `userDayKey(measuredAt, userTz)`, so for any west-of-UTC user the day shifted back one: a June 1 SpO2 or readiness row filed under May 31 and fell out of alignment with the HR/sleep rows that carry true instants. Extend the existing `canonicalDailyTimestamp` helper to accept an optional timezone and anchor a date-only day at local noon (noon UTC when no zone is supplied) — a full twelve hours inside the calendar day, so it round-trips through `userDayKey` to the same day for every zone within plus/minus twelve hours. Route every Oura day-keyed mapper (via the shared `dayAnchor` fallback) and all five Polar daily mappers through it; real wake/measurement instants stay untouched. Fitbit already anchored at noon and is now consistent; Whoop and the Withings cursor anchors were verified not to be measurement anchors. --- .../canonical-daily-timestamp.test.ts | 76 +++++++++++++++++++ src/lib/measurements/consolidation-tz.ts | 28 +++++-- src/lib/oura/__tests__/client.test.ts | 6 +- src/lib/oura/client.ts | 15 +++- src/lib/polar/__tests__/client.test.ts | 6 +- src/lib/polar/client.ts | 15 ++-- 6 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts diff --git a/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts new file mode 100644 index 000000000..5ba803cf7 --- /dev/null +++ b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; + +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * Wearable daily-record anchoring (H-TZ1 / H-TZ2). Day-grained (date-only) + * daily records from Oura / Polar / Fitbit must anchor at NOON, not UTC + * midnight: a UTC-midnight anchor (`${day}T00:00:00.000Z`) double-shifts the + * calendar day for west-of-UTC users on read, because the read path + * re-buckets via `userDayKey(measuredAt, userTz)`. A noon anchor sits a full + * 12 h inside the day, so it round-trips back to the same calendar day for + * every IANA zone within ±12 h of UTC. + * + * These tests pin that invariant directly: + * userDayKey(canonicalDailyTimestamp(day, tz), tz) === day + */ +describe("canonicalDailyTimestamp — day round-trip across TZ offsets", () => { + const days = [ + "2026-06-01", + "2026-01-01", + "2026-12-31", + "2025-03-09", // US spring-forward + "2025-11-02", // US fall-back + ]; + + const zones = [ + "UTC", + "America/Los_Angeles", // negative offset (UTC-7/-8) + "America/New_York", // negative offset (UTC-4/-5) + "Asia/Tokyo", // positive offset (UTC+9, no DST) + "Pacific/Kiritimati", // extreme positive (UTC+14) + "Etc/GMT+12", // extreme negative (UTC-12) + "Asia/Kathmandu", // sub-hour offset (UTC+5:45) + ]; + + for (const tz of zones) { + for (const day of days) { + it(`round-trips ${day} in ${tz}`, () => { + const anchor = canonicalDailyTimestamp(day, tz); + expect(userDayKey(anchor, tz)).toBe(day); + }); + } + } + + it("anchors at the user's local noon when a tz is given", () => { + // 2026-06-01 noon in America/Los_Angeles (PDT, UTC-7) === 19:00 UTC. + const anchor = canonicalDailyTimestamp("2026-06-01", "America/Los_Angeles"); + expect(anchor.toISOString()).toBe("2026-06-01T19:00:00.000Z"); + }); +}); + +describe("canonicalDailyTimestamp — noon-UTC fallback (no tz)", () => { + it("anchors at 12:00 UTC when tz is omitted", () => { + const anchor = canonicalDailyTimestamp("2026-06-01"); + expect(anchor.toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + + it("the noon-UTC fallback round-trips for every zone within ±12 h", () => { + // This is the shape the wearable mappers (Oura/Polar/Fitbit) take, since + // they do not have the user's timezone in scope at map time. + const day = "2026-06-01"; + const anchor = canonicalDailyTimestamp(day); // 12:00 UTC + for (const tz of [ + "UTC", + "America/Los_Angeles", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + "Etc/GMT+11", + "Etc/GMT-11", + ]) { + expect(userDayKey(anchor, tz)).toBe(day); + } + }); +}); diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index 1f58be04c..41eb8e578 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -88,18 +88,32 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number { } /** - * Compute the canonical timestamp for a calendar-day key in the user's - * timezone. Returns the JS-Date instant at the user's local 12:00 noon - * — matches the Withings activity sync convention (one daily row per - * type, anchored to midday so the row sorts cleanly between same-day - * spot samples). The string returned by `toISOString()` is UTC. + * Compute the canonical timestamp for a calendar-day key. With a `tz`, + * returns the JS-Date instant at the user's local 12:00 noon; without + * one, returns 12:00 UTC of the day. Either anchor sits a full 12 h + * inside its calendar day, so it round-trips back to the same day + * through `userDayKey()` for every zone within ±12 h — which is the + * whole point of anchoring date-only daily records at noon rather than + * midnight (a UTC-midnight anchor double-shifts the day for west-of-UTC + * users on read). Matches the Withings activity sync convention (one + * daily row per type, anchored to midday so the row sorts cleanly + * between same-day spot samples). The string returned by + * `toISOString()` is UTC. + * + * The optional-`tz` shape is what the wearable daily mappers (Oura, + * Polar, Fitbit) anchor on — they do not have the user's timezone in + * scope at map time, so they take the noon-UTC fallback, which is + * correct for every zone within ±12 h. */ -export function canonicalDailyTimestamp(dateKey: string, tz: string): Date { +export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { + const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); + // No timezone in scope: 12:00 UTC is a safe day-stable anchor for + // every zone within ±12 h. + if (!tz) return utcNoon; // Compute the UTC offset for noon-local of the given day. We don't // have a lightweight TZ-math library on the server, so the trick is: // build "12:00 UTC of the day", read what wall-clock that shows in // the target zone, then shift by the resulting offset. - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); // utcNoon represents 12:00 UTC. The user's local clock reads // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 53e42414f..661ede829 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -473,10 +473,12 @@ describe("mapResilience", () => { } }); - it("anchors the row at the day's UTC midnight", () => { + it("anchors the date-only row at noon UTC so it round-trips the day", () => { + // Noon, not midnight: a UTC-midnight anchor double-shifts the calendar + // day for west-of-UTC users when the read path re-buckets via userDayKey. const mapped = mapResilience({ day: "2026-06-10", level: "adequate" }); expect(mapped[0]?.measuredAt.toISOString()).toBe( - "2026-06-10T00:00:00.000Z", + "2026-06-10T12:00:00.000Z", ); }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 08c045cf0..e492c51bd 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -14,6 +14,7 @@ * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; @@ -455,10 +456,16 @@ export interface MappedMeasurement { } function dayAnchor(day: string, bedtimeEnd?: string): Date | null { - // Prefer the precise wake instant when present; else anchor the day at UTC - // midnight. The recovery resolver reads the local day key off this stamp. - const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`; - const d = new Date(candidate); + // Prefer the precise wake instant when present; else anchor the date-only + // day at noon (canonicalDailyTimestamp), NOT UTC midnight — a midnight + // anchor double-shifts the calendar day for west-of-UTC users when the + // recovery resolver re-buckets via userDayKey(). Noon sits a full 12 h + // inside the day, so it round-trips to the same day for every zone ±12 h. + if (bedtimeEnd) { + const d = new Date(bedtimeEnd); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = canonicalDailyTimestamp(day); return Number.isNaN(d.getTime()) ? null : d; } diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 35ddd1fe7..9798679d9 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -312,14 +312,16 @@ describe("mapSleep", () => { ); }); - it("falls back to a midnight-UTC anchor when the window is missing", () => { + it("falls back to a noon-UTC anchor when the window is missing", () => { const mapped = mapSleep({ date: "2026-06-10", light_sleep: 3600, sleep_score: 80, }); const core = mapped.find((m) => m.sleepStage === "CORE")!; - expect(core.measuredAt.toISOString()).toBe("2026-06-10T00:00:00.000Z"); + // Noon, not midnight: keeps the date-only row on the same calendar day for + // every zone when the read path re-buckets via userDayKey. + expect(core.measuredAt.toISOString()).toBe("2026-06-10T12:00:00.000Z"); expect(core.reconstructed).toBeUndefined(); // No IN_BED envelope without a window. expect(mapped.find((m) => m.sleepStage === "IN_BED")).toBeUndefined(); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 16065f34d..3b20ab991 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -21,6 +21,7 @@ * treated as success. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; @@ -387,13 +388,13 @@ function rechargeStatusToScore(status: number): number { * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date at local midnight UTC anchor (the wake + * `measuredAt` is the record's date anchored at noon (the wake * morning the night belongs to). */ export function mapNightlyRecharge( r: PolarNightlyRecharge, ): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; @@ -537,10 +538,10 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { return out; } - // Fallback: no usable window. Anchor at midnight UTC of the wake date and emit + // Fallback: no usable window. Anchor at noon of the wake date and emit // untimed stage rows (the pre-v1.17.1 shape) so a record missing the window // fields still contributes a night total. - const measuredAt = new Date(`${s.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(s.date); if (Number.isNaN(measuredAt.getTime())) return []; for (const [sec, stage, fieldTag] of stages) { if (typeof sec === "number" && sec >= 0) { @@ -572,7 +573,7 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { * total `calories`, which includes BMR — matching the Fitbit active-only * convention). */ export function mapActivity(a: PolarActivity): MappedMeasurement[] { - const measuredAt = new Date(`${a.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(a.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; const steps = a["active-steps"]; @@ -615,7 +616,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so * guard on `number` only. */ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { - const measuredAt = new Date(`${c.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(c.date); if (Number.isNaN(measuredAt.getTime())) return []; if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; return [ @@ -632,7 +633,7 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { /** Map one Polar SpO2 test result: `blood_oxygen_percentage` → * `OXYGEN_SATURATION` (percent, 0..100). */ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const pct = r.blood_oxygen_percentage; if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; From d85ac3e555d27276aedc156cb61f19a6cba56a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:27:55 +0200 Subject: [PATCH 2/5] fix(measurements): recompute rollups after cumulative consolidation drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two cumulative drains soft-deleted per-sample rows and wrote a daily total but never recomputed the affected DAY rollup bucket. The rollup tier reads only `deleted_at IS NULL`, so the stored `sumValue` kept the pre-drain double-counted total indefinitely — dashboard and every rolled-up week/month/year step or energy figure stayed wrong and never self-healed, because the backfill only re-flags days with no DAY row and the daily-series read-swap only re-folds on a day-count change. Call `recomputeBucketsForMeasurement` after each per-day write, mirroring the daily-mean drain that already does this. Added per-drain (not hoisted) so the mean drain, which recomputes inside its own writer, is not double-recomputed. --- .../drain-per-sample-cumulative.test.ts | 29 +++++++++++++++++++ .../measurements/consolidate-legacy-steps.ts | 11 +++++++ .../drain-per-sample-cumulative.ts | 10 +++++++ 3 files changed, 50 insertions(+) diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index d391105e5..260a9c5b3 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +// The SUM drain now recomputes the affected DAY rollup bucket after each +// per-day fold (so a drained day's stale double-counted sum self-heals). +// Mock it: these are pure-logic tests over a Prisma mock with no DB, and +// the real recompute issues raw SQL against the singleton client. +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + import { bucketRowsByUserDay, canonicalDailyTimestamp, @@ -444,6 +453,26 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () = const createArg = create.mock.calls[0]?.[0] as { data: { value: number } }; expect(createArg.data.value).toBe(500); }); + + it("recomputes the DAY rollup bucket for the drained day so the stale sum self-heals", async () => { + vi.mocked(recomputeBucketsForMeasurement).mockClear(); + const { prisma } = buildFoldMock(null); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + // Without this the rollup tier keeps serving the pre-drain + // double-counted sumValue forever (it reads only deleted_at IS NULL). + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); + const [userId, type, canonicalTs] = vi.mocked( + recomputeBucketsForMeasurement, + ).mock.calls[0]!; + expect(userId).toBe("user-1"); + expect(type).toBe("ACTIVITY_STEPS"); + // Canonical local-noon of the Berlin day 2026-05-16 (CEST, UTC+2). + expect((canonicalTs as Date).toISOString()).toBe( + "2026-05-16T10:00:00.000Z", + ); + }); }); // Root cause of the v1.18.10 bloat: a single day whose mint collided with the diff --git a/src/lib/measurements/consolidate-legacy-steps.ts b/src/lib/measurements/consolidate-legacy-steps.ts index 4c3f8dc81..5ddfe1519 100644 --- a/src/lib/measurements/consolidate-legacy-steps.ts +++ b/src/lib/measurements/consolidate-legacy-steps.ts @@ -39,6 +39,7 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { dailyStatsExternalId } from "./apple-health-mapping"; import { @@ -259,6 +260,16 @@ export async function consolidateLegacySteps( }); removed = del.count; }); + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // soft-delete commits. The legacy granular rows that just dropped out + // of the live read were being summed alongside any existing daily-stats + // total, so the DAY rollup bucket carried a double-counted total. The + // rollup tier reads only `deleted_at IS NULL`; without this recompute + // the stale bucket persists forever. Mirrors the mean drain's + // post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; } catch (err) { // A pre-existing row can collide on the second unique index diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9bd92ca3b..319b42933 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -34,6 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { CUMULATIVE_HK_TYPES, @@ -355,6 +356,15 @@ export async function drainPerSampleCumulative( if (!isUniqueConstraintViolation(err)) throw err; removed = await foldOnce(); } + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // fold commits — the per-sample rows are now gone and the canonical + // daily-sum row is the only live reading. The rollup tier reads only + // `deleted_at IS NULL`, so without this the day's DAY bucket keeps the + // stale pre-drain (double-counted) sumValue forever and never + // self-heals. Mirrors the mean drain's post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; }, recordBucket: ({ From 3405bb6fd39b4d958cb89519b979e7c0e532f29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:02 +0200 Subject: [PATCH 3/5] fix(medications): size weekly-cadence supply runway correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `estimateDailyDoseCount` only special-cased FREQ=MONTHLY and FREQ=YEARLY. A modern FREQ=WEEKLY rrule (the encoding the create path stores, with an empty daysOfWeek) fell through to the legacy branch at seven days per week, so a once-weekly injection counted as one dose per day. The detail card's "lasts about N days" and the daily low-stock job — which share this math — fired reorder alerts roughly seven times too early (fourteen for bi-weekly). Parse FREQ=WEEKLY honouring INTERVAL and BYDAY count: perDay += (times * bydayCount) / (7 * interval). --- .../__tests__/supply-runway.test.ts | 37 +++++++++++++++++++ .../medications/detail/supply-runway.ts | 19 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index d1c11ad56..77ea502d7 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -68,6 +68,43 @@ describe("estimateDailyDoseCount", () => { ).toBeCloseTo(1 / 30); }); + it("treats a once-weekly RRULE as one dose per 7 days", () => { + // FREQ=WEEKLY;BYDAY=MO is the modern once-weekly injection encoding; + // it must NOT fall through to the daysPerWeek=7 legacy branch. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO" }), + ]), + ).toBeCloseTo(1 / 7); + }); + + it("halves a bi-weekly RRULE via INTERVAL=2 (one dose per 14 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ + timesOfDay: ["08:00"], + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + }), + ]), + ).toBeCloseTo(1 / 14); + }); + + it("counts each BYDAY pick on a weekly RRULE (Mon+Thu → 2 per 7 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO,TH" }), + ]), + ).toBeCloseTo(2 / 7); + }); + + it("defaults a weekly RRULE with no BYDAY to one pick per week", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY" }), + ]), + ).toBeCloseTo(1 / 7); + }); + it("sums across multiple schedules", () => { expect( estimateDailyDoseCount([ diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index b4972e946..bbc36a58f 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -46,6 +46,25 @@ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { perDay += times / 365; continue; } + // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the + // create path stores the cadence on the rrule and leaves daysOfWeek + // empty). Honour the BYDAY pick count and the week INTERVAL: a + // once-weekly injection is one dose per 7 days, a bi-weekly one per + // 14. Without this branch a weekly rrule fell through to the legacy + // daysOfWeek fallback below with daysPerWeek=7, over-estimating the + // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. + if (/FREQ=WEEKLY/.test(rrule)) { + const byday = /BYDAY=([^;]+)/.exec(rrule); + const bydayCount = + byday && byday[1].length > 0 ? byday[1].split(",").length : 1; + const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); + const interval = + intervalMatch && Number(intervalMatch[1]) >= 1 + ? Number(intervalMatch[1]) + : 1; + perDay += (times * bydayCount) / (7 * interval); + continue; + } const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; From 9e6a58b1866d73ec699a26d3f880d651673124a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:11 +0200 Subject: [PATCH 4/5] fix(illness): escalate sustained fever and low SpO2 on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sustained fever (three calendar-consecutive days at or above the fever red-flag floor) or sustained low SpO2 was already classified "seek care", but the only escalation path ran inside a GET on the episode correlation route — no push fired unless the user opened that tab, and no cron drove it. The proactive on-write safety check covered only blood pressure and glucose; the two signals with the strongest safety case had the weakest delivery. Add sustained-run temperature and SpO2 checks to the on-write `runSafetyFloorCheck`, wired from both measurement-ingest seams. The run length and clinical floors are shared with the retrospective detector (no duplicated thresholds), day bucketing honours the user's timezone, and escalation reuses the existing red-flag notification with its push-attempt dedup so a febrile user logging repeatedly is alerted at most once per reason per day. --- src/app/api/measurements/route.ts | 2 + .../__tests__/safety-floor-sustained.test.ts | 290 ++++++++++++++++++ src/lib/illness/correlation.ts | 10 +- src/lib/illness/safety-floor-check.ts | 230 ++++++++++++++ 4 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 src/lib/illness/__tests__/safety-floor-sustained.test.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 989c5fba4..50ec6ebd8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) { symptomsPresent: parsed.data.measurements.some( (m) => m.symptomsPresent === true, ), + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup table for every distinct @@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) { }, ], symptomsPresent, + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup row for the affected diff --git a/src/lib/illness/__tests__/safety-floor-sustained.test.ts b/src/lib/illness/__tests__/safety-floor-sustained.test.ts new file mode 100644 index 000000000..bc874e075 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floor-sustained.test.ts @@ -0,0 +1,290 @@ +/** + * v1.25 — on-write sustained fever / low-SpO2 escalation (finding H-CS1). + * + * The retrospective correlation detector classifies a 3-day fever or sustained + * low-SpO2 run as "seek care", but that escalation only fired when the user + * opened the episode's correlation tab. These tests pin the on-write seam: + * a sustained run escalates on INGEST through the SAME illness red-flag push, + * with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector + * uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm. + * + * Calendar-day bucketing runs against TZ=UTC (the gate's contract). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */ +const ledger: Array<{ userId: string; reason: string }> = []; +const measurementFindMany = vi.fn(); +const dispatchMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + pushAttempt: { + findFirst: ({ where }: { where: { userId: string; reason: string } }) => { + const hit = ledger.find( + (r) => r.userId === where.userId && r.reason === where.reason, + ); + return Promise.resolve(hit ? { id: "prior" } : null); + }, + create: ({ data }: { data: { userId: string; reason: string } }) => { + ledger.push({ userId: data.userId, reason: data.reason }); + return Promise.resolve({}); + }, + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }), +})); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), +})); + +import { runSafetyFloorCheck } from "../safety-floor-check"; + +/** A measured-at on a given UTC calendar day, mid-morning. */ +function utcDay(day: string): Date { + return new Date(`${day}T08:00:00Z`); +} + +beforeEach(() => { + ledger.length = 0; + measurementFindMany.mockReset(); + dispatchMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("runSafetyFloorCheck — sustained fever", () => { + it("escalates once on a 3-day calendar-consecutive fever run", async () => { + // °C ≥ 38.5 on three consecutive UTC days. + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.eventType).toBe("SYSTEM_ALERT"); + expect(opts.urgent).toBe(true); + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagFever"); + }); + + it("does not re-fire on a 4th reading inside the dedupe window", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + // First ingest — fires. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + // A later same-day reading — run still holds, but the ledger anchor dedupes. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.2, + measuredAt: new Date("2026-01-03T20:00:00Z"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT escalate on a 2-day fever run (below RED_FLAG_RUN_DAYS)", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.9, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-02"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate on three NON-consecutive febrile days", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-05") }, + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-05"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate when the run sits below the fever floor", async () => { + // 38.0 °C < 38.5 °C floor — elevated but not the escalation band. + measurementFindMany.mockResolvedValue([ + { value: 38.0, measuredAt: utcDay("2026-01-03") }, + { value: 38.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 38.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +describe("runSafetyFloorCheck — sustained low SpO2", () => { + it("escalates once on a 3-day calendar-consecutive low-SpO2 run", async () => { + // ≤ 92% on three consecutive UTC days (per-day MIN is the worst). + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 91, measuredAt: utcDay("2026-01-02") }, + { value: 92, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2"); + }); + + it("does NOT escalate when a single day recovers above the floor", async () => { + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 96, measuredAt: utcDay("2026-01-02") }, // breaks the run + { value: 90, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("a fever escalation never suppresses a concurrent SpO2 one (distinct dedupe keys)", async () => { + // Two independent runs landing on the same write: both should fire once. + measurementFindMany.mockImplementation( + ({ where }: { where: { type: string } }) => { + if (where.type === "BODY_TEMPERATURE") { + return Promise.resolve([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + } + return Promise.resolve([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 90, measuredAt: utcDay("2026-01-02") }, + { value: 91, measuredAt: utcDay("2026-01-01") }, + ]); + }, + ); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + const keys = dispatchMock.mock.calls.map((c) => c[0].messageKey).sort(); + expect(keys).toEqual([ + "illness.correlation.redFlagFever", + "illness.correlation.redFlagSpo2", + ]); + }); +}); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 4cb561d16..e9edbe04c 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -408,8 +408,14 @@ export interface IllnessRedFlag { const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT; /** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ const FEVER_RED_FLAG = FEVER_RED_FLAG_C; -/** Consecutive days an adverse clinical threshold must hold to escalate. */ -const RED_FLAG_RUN_DAYS = 3; +/** + * Consecutive days an adverse clinical threshold must hold to escalate. + * Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a + * sustained fever / low-SpO2 run on ingest using the SAME run length as this + * retrospective detector — the two paths can never disagree on what "sustained" + * means. + */ +export const RED_FLAG_RUN_DAYS = 3; /* ── pure helpers ─────────────────────────────────────────────────────── */ diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts index 1b25e1520..908c0cd4f 100644 --- a/src/lib/illness/safety-floor-check.ts +++ b/src/lib/illness/safety-floor-check.ts @@ -15,9 +15,11 @@ * The caller (the measurement POST route) passes the just-written rows plus * the transient `symptomsPresent` flag; this helper does the bounded reads. */ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; import { evaluateBloodPressure, evaluateGlucose, @@ -26,6 +28,13 @@ import { type GlucoseSample, } from "@/lib/illness/safety-floors"; import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify"; +import { + RED_FLAG_RUN_DAYS, + dayDiff, + type IllnessRedFlag, +} from "@/lib/illness/correlation"; +import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify"; +import { userDayKey } from "@/lib/tz/format"; /** A row the write hook just persisted (the subset the check needs). */ export interface WrittenReading { @@ -46,14 +55,30 @@ export async function runSafetyFloorCheck(input: { userId: string; written: WrittenReading[]; symptomsPresent?: boolean; + /** + * The user's IANA timezone, used to bucket the sustained fever / SpO2 run by + * CALENDAR day. Defaults to UTC when absent — a missing timezone must never + * silence a safety escalation, only shift the day boundary. + */ + timezone?: string; }): Promise { const { userId, written } = input; const symptomCoupled = input.symptomsPresent === true; + const tz = + input.timezone && input.timezone.length > 0 ? input.timezone : "UTC"; try { await Promise.all([ checkBloodPressure(userId, written, symptomCoupled), checkGlucose(userId, written, symptomCoupled), + // Sustained multi-day runs against ABSOLUTE clinical floors. Unlike the + // confirm-gated BP / glucose checks above (single breach + a confirming + // re-test inside an hour), a fever / low-SpO2 escalation only fires when + // the floor held for RED_FLAG_RUN_DAYS calendar-consecutive days — the + // SAME run length and floors the retrospective correlation detector uses, + // so the on-write seam and the correlation surface can never disagree. + checkSustainedFever(userId, written, tz), + checkSustainedLowSpo2(userId, written, tz), ]); } catch (err) { getEvent()?.addWarning( @@ -171,3 +196,208 @@ async function checkGlucose( const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); await notifySafetyFloor({ userId, decision }); } + +/* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */ + +/** + * Lookback span (calendar days, inclusive of the candidate day) the sustained + * run scan reads. RED_FLAG_RUN_DAYS days are needed to form a run that ends on + * the candidate day; one extra day absorbs UTC-vs-local boundary skew so a run + * that ends "today" in the user's timezone is never clipped. We only react to + * RECENT runs by design — an old run is the correlation surface's job, not the + * write hook's. + */ +const SUSTAINED_LOOKBACK_DAYS = RED_FLAG_RUN_DAYS + 1; + +/** + * Sustained fever: BODY_TEMPERATURE ≥ FEVER_RED_FLAG_C (°C) for + * RED_FLAG_RUN_DAYS calendar-consecutive days. Mirrors the correlation + * detector's fever path — per-day MAX is the worst (an evening spike must not + * be masked by a daily mean) and the floor + run length are the shared + * constants. Fires the SAME illness red-flag escalation the retrospective path + * fires, so the notification class matches exactly. + */ +async function checkSustainedFever( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "BODY_TEMPERATURE"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "BODY_TEMPERATURE", + fresh.measuredAt, + tz, + "max", + ); + const flag = sustainedRunFlag( + days, + (v) => v >= FEVER_RED_FLAG_C, + "sustained_fever", + "BODY_TEMPERATURE", + "max", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_fever, + redFlags: [flag], + }); +} + +/** + * Sustained low SpO2: OXYGEN_SATURATION ≤ SPO2_RED_FLAG_PCT for + * RED_FLAG_RUN_DAYS calendar-consecutive days. SpO2's worst is the per-day MIN, + * matching the correlation detector. Fires the SAME illness red-flag escalation. + */ +async function checkSustainedLowSpo2( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "OXYGEN_SATURATION"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "OXYGEN_SATURATION", + fresh.measuredAt, + tz, + "min", + ); + const flag = sustainedRunFlag( + days, + (v) => v <= SPO2_RED_FLAG_PCT, + "sustained_low_spo2", + "OXYGEN_SATURATION", + "min", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_low_spo2, + redFlags: [flag], + }); +} + +/** + * Synthetic, stable per-reason dedupe anchors. The on-write sustained checks + * may run with no active illness episode, so they cannot key the red-flag + * dedupe ledger on a real episode id. These per-reason keys ride the SAME + * `pushAttempt` ledger + 24h window `notifyIllnessRedFlag` already enforces, so + * a stubbornly febrile user logging a 4th reading inside the day re-runs the + * detector but never re-fires the alarm. Distinct keys per reason so a fever + * escalation never suppresses a concurrent SpO2 one. + */ +const SUSTAINED_DEDUPE_KEY: Record = { + sustained_fever: "onwrite:sustained_fever", + sustained_low_spo2: "onwrite:sustained_low_spo2", +}; + +/** A per-day worst value, keyed by the user-timezone calendar day. */ +interface DailyWorst { + day: string; + worst: number; +} + +/** + * Read the recent same-type readings in the lookback window and collapse them + * to one worst value per user-timezone calendar day (MAX for fever, MIN for + * SpO2). Sorted chronologically so the run scan sees calendar order. Bounded + * read (window + row cap); the candidate's own freshly-written rows are + * INCLUDED so a run that completes on this very write escalates. + */ +async function loadDailyWorst( + userId: string, + type: MeasurementType, + candidateAt: Date, + tz: string, + worst: "min" | "max", +): Promise { + const windowStart = new Date( + candidateAt.getTime() - SUSTAINED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: windowStart, lte: candidateAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 500, + select: { value: true, measuredAt: true }, + }); + + const byDay = new Map(); + for (const r of rows) { + const day = userDayKey(r.measuredAt, tz); + const prev = byDay.get(day); + if (prev === undefined) { + byDay.set(day, r.value); + } else { + byDay.set( + day, + worst === "min" ? Math.min(prev, r.value) : Math.max(prev, r.value), + ); + } + } + + return [...byDay.entries()] + .map(([day, w]) => ({ day, worst: w })) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} + +/** + * Longest CALENDAR-consecutive run matching `predicate`; returns an + * {@link IllnessRedFlag} when the run reaches RED_FLAG_RUN_DAYS, else null. + * Mirrors the correlation engine's `runFlag`: present-days-only points (never + * zero-filled), so a sparse febrile series (days 01/10/15) must NOT count as + * one long run — a point only extends the run when it is the calendar day + * immediately after the previous matching one (`dayDiff === 1`). + */ +function sustainedRunFlag( + sorted: DailyWorst[], + predicate: (v: number) => boolean, + reason: IllnessRedFlag["reason"], + type: MeasurementType, + worst: "min" | "max", +): IllnessRedFlag | null { + let bestRun = 0; + let bestWorst: number | null = null; + let run = 0; + let runWorst: number | null = null; + let prevDay: string | null = null; + for (const p of sorted) { + if (predicate(p.worst)) { + const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1; + if (adjacent) { + run++; + runWorst = + runWorst === null + ? p.worst + : worst === "min" + ? Math.min(runWorst, p.worst) + : Math.max(runWorst, p.worst); + } else { + run = 1; + runWorst = p.worst; + } + if (run > bestRun) { + bestRun = run; + bestWorst = runWorst; + } + prevDay = p.day; + } else { + run = 0; + runWorst = null; + prevDay = null; + } + } + if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { + return { type, reason, worstValue: bestWorst, days: bestRun }; + } + return null; +} From a827fe2ca752a8887da0e2775b132fd7a76b4fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:37 +0200 Subject: [PATCH 5/5] fix(insights): correct medication compliance and per-user insight bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cluster of related correctness gaps in the compliance denominator and the insight surfaces that read it: - Paused medications. A doctor-ordered pause flipped `active` off and stamped `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable and every paused day became a missed dose after resume — collapsing adherence across the Health Score, the BP gate, the Coach snapshot and the report. Persist pause intervals as durable eras (new `MedicationPauseEra`, migration 0211), close the open era on resume, thread the eras through the compliance context and drop slots inside a paused interval from the denominator. Every compliance call site now fetches and passes the eras. - Health Score eras. The Health-Score fast path was the only compliance caller that skipped the shared schedule select and schedule revisions, so after a schedule edit it scored past days against the current schedule while every other surface used the historical era. Switch it to the shared select and thread revisions (and pause eras) like the canonical path. - Insight bucketing timezone. The per-metric status series bucketed every user in Europe/Berlin, so a near-midnight reading landed in the wrong day and the relative-day phrasing was off for non-Berlin self-hosters. Thread the resolved user timezone and key off `userDayKey`. - Correlation significance. User-facing correlations surfaced from five paired points with strength read straight off the coefficient. Route them through the rigorous engine (at least twenty pairs, Student-t p below 0.05) so small-N coincidences no longer render as confident findings. - Resting-pulse input. The mood-versus-"resting pulse" correlation fed the raw PULSE day-mean, which folds in workout HR, under a "Resting pulse" label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a daily low-percentile proxy) so the label matches the input. --- .../0211_v125_med_pause_era/migration.sql | 31 +++ prisma/schema.prisma | 58 +++- .../comprehensive/__tests__/route.test.ts | 5 + src/app/api/insights/comprehensive/route.ts | 54 +++- .../api/medications/[id]/compliance/route.ts | 2 + src/app/api/medications/[id]/route.ts | 23 ++ src/app/api/medications/compliance/route.ts | 2 + src/app/api/medications/intake/route.ts | 2 + src/lib/ai/coach/snapshot.ts | 2 + .../__tests__/compliance-pause-era.test.ts | 250 ++++++++++++++++++ .../__tests__/correlations-fast-path.test.ts | 85 +++++- .../analytics/__tests__/correlations.test.ts | 46 +++- src/lib/analytics/compliance.ts | 57 +++- src/lib/analytics/correlations-fast-path.ts | 93 ++++--- src/lib/analytics/correlations.ts | 47 ++++ src/lib/analytics/health-score-fast-path.ts | 48 ++-- src/lib/doctor-report-data.ts | 6 + .../insights/__tests__/bucket-series.test.ts | 63 +++++ src/lib/insights/blood-pressure-status.ts | 52 ++-- src/lib/insights/bmi-status.ts | 8 +- src/lib/insights/bucket-series.ts | 71 +++-- .../insights/correlation-channel-series.ts | 2 + src/lib/insights/features.ts | 2 + src/lib/insights/general-status.ts | 30 ++- .../insights/medication-compliance-status.ts | 7 +- src/lib/insights/metric-status.ts | 11 +- src/lib/insights/mood-aggregates.ts | 34 ++- src/lib/insights/mood-narratives.ts | 14 +- src/lib/insights/mood-status.ts | 35 ++- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/weight-status.ts | 39 ++- src/lib/medications/compliance-payload.ts | 4 + src/lib/targets/build-response.ts | 2 + 33 files changed, 1007 insertions(+), 182 deletions(-) create mode 100644 prisma/migrations/0211_v125_med_pause_era/migration.sql create mode 100644 src/lib/analytics/__tests__/compliance-pause-era.test.ts diff --git a/prisma/migrations/0211_v125_med_pause_era/migration.sql b/prisma/migrations/0211_v125_med_pause_era/migration.sql new file mode 100644 index 000000000..11a18e6f1 --- /dev/null +++ b/prisma/migrations/0211_v125_med_pause_era/migration.sql @@ -0,0 +1,31 @@ +-- v1.25 — durable medication pause intervals (H-MED1). +-- +-- The `medications.paused_at` column is a single live marker: resume clears +-- it, so the paused window is irrecoverable once the medication resumes. +-- After a resume the compliance denominator spanned created_at..now and every +-- paused day's expected slot collapsed to "missed", deflating adherence. +-- +-- `medication_pause_eras` records each pause as its own durable row: a pause +-- stamps an open era (`resumed_at` NULL), resume closes the latest open era +-- (`resumed_at` = now). The compliance engine drops expected dose slots whose +-- anchor falls inside any `[paused_at, resumed_at ?? now)` interval so a +-- resumed medication never counts the paused days as missed. The `paused_at` +-- marker column on `medications` is left intact — current readers depend on it. +CREATE TABLE "medication_pause_eras" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "paused_at" TIMESTAMPTZ(6) NOT NULL, + "resumed_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_pause_eras_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "medication_pause_eras_medication_id_idx" ON "medication_pause_eras"("medication_id"); + +CREATE INDEX "medication_pause_eras_user_id_idx" ON "medication_pause_eras"("user_id"); + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..26e8c1281 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -583,6 +583,7 @@ model User { measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] + medicationPauseEras MedicationPauseEra[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -1947,6 +1948,14 @@ model Medication { /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. scheduleRevisions MedicationScheduleRevision[] + /// v1.25 — durable pause intervals. A pause stamps an open era + /// (`resumedAt = null`); resume closes the latest open era. The + /// compliance engine drops expected slots whose anchor falls inside a + /// pause interval so a resumed medication never counts the paused days + /// as missed. The `pausedAt` column on this model stays the live + /// single-pause marker the reminder/today surfaces read; the era table + /// is the additive durable record the rate paths need. + pauseEras MedicationPauseEra[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2097,6 +2106,35 @@ model MedicationScheduleRevision { @@map("medication_schedule_revisions") } +/// v1.25 — durable medication pause intervals. The `Medication.pausedAt` +/// column is a single live marker that resume clears, so the paused window +/// is irrecoverable once the medication resumes. This table records each +/// pause as its own row: a pause stamps an open era (`resumedAt = null`), +/// resume closes the latest open era (`resumedAt = now`). The compliance +/// engine reads the eras and drops expected dose slots whose anchor falls +/// inside any `[pausedAt, resumedAt ?? now)` interval so a resumed +/// medication never counts the paused days as missed. +model MedicationPauseEra { + id String @id @default(cuid()) + medicationId String @map("medication_id") + userId String @map("user_id") + /// Inclusive start of the pause interval (the instant the medication + /// was paused). + pausedAt DateTime @map("paused_at") @db.Timestamptz(6) + /// Exclusive end of the pause interval (the instant the medication was + /// resumed). NULL = still paused; readers treat an open era as running + /// to `now`. + resumedAt DateTime? @map("resumed_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([medicationId]) + @@index([userId]) + @@map("medication_pause_eras") +} + enum IntakeSource { WEB API @@ -2553,21 +2591,21 @@ model TelegramPromptContext { // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { - id String @id @default(cuid()) - userId String @map("user_id") - name String - tokenHash String @unique @map("token_hash") // SHA-256 of the token - permissions String[] @default(["medication:ingest"]) - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - revoked Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). - mcpConnectionId String? @map("mcp_connection_id") + mcpConnectionId String? @map("mcp_connection_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 97ea186cd..221b47d5f 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -128,6 +128,11 @@ beforeEach(() => { dateOfBirth: new Date("1985-01-01"), }); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + // The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows (and the + // raw-PULSE proxy only when none exist) straight from the measurement table. + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); ( prisma.moodEntryRollup.findMany as ReturnType ).mockResolvedValue([]); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index d59471136..5f5eb76ad 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -21,6 +21,7 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -263,8 +264,55 @@ export async function buildComprehensiveResponse(user: AuthedUser) { weight: p.b, })); - const pulseDaily = aggregate.dailyByType.PULSE ?? []; - const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily); + // v1.2.5 (M-CS2) — this correlation is labelled "Resting pulse (bpm)"; + // score it against the RESTING series, not the raw PULSE day-mean. The + // aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout + // HR for Apple-Health users and inflates the "resting" figure. Prefer the + // clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the + // low-percentile daily proxy) when the user has no resting rows — the + // proxy needs the per-day distribution, which the day-mean discards. + // Day keys follow this route's UTC-bucket convention (the per-user-tz + // bucketing is the documented v1.5 follow-up) so the resting series pairs + // against the same UTC day keys as the mood series. + const restingPulseRows = await prisma.measurement.findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }); + const rawPulseRowsForResting = + restingPulseRows.length === 0 + ? await prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }) + : []; + const utcDayKey = (d: Date) => d.toISOString().slice(0, 10); + const { series: restingPulseSeries } = resolveRestingPulseSeries({ + restingSamples: restingPulseRows, + pulseSamples: rawPulseRowsForResting, + dayKeyOf: utcDayKey, + }); + const restingByDay = new Map(); + for (const s of restingPulseSeries) { + const key = utcDayKey(s.measuredAt); + const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += s.value; + cur.count += 1; + restingByDay.set(key, cur); + } + const restingPulseDaily = Array.from(restingByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, @@ -283,6 +331,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); const categoryMap = await getMedicationCategories( diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index e6394b5e2..0a10216d2 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -71,6 +71,8 @@ export const GET = apiHandler( schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); // Privacy gate — same sealed 404 for "absent" and "not yours". diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c7fe4ecb3..ca2ec4e7a 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -679,6 +679,29 @@ export const PUT = apiHandler( } if (!medication) throw lastUpdateErr; + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await prisma.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await prisma.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + } + // Apply the v1.5.5 primary-schedule grace bridge after the // medication update so a failure here doesn't roll the medication // edit back. Re-read the row so the response shape carries the diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 6ee1eebfa..6c6263e02 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -50,6 +50,8 @@ export const GET = apiHandler(async () => { schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0a348d87b..d50e5766c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -256,6 +256,8 @@ async function buildScheduleAnchoredComplianceBuckets( schedules: true, // v1.16.3 — archived schedule eras for era-aware expected counts. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 583a1b189..420aafeb7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1160,6 +1160,8 @@ async function buildCoachSnapshotImpl( supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, intakeEvents: { // Tombstoned intake rows must never reach the Coach snapshot. where: { deletedAt: null, scheduledFor: { gte: cutoff } }, diff --git a/src/lib/analytics/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/__tests__/compliance-pause-era.test.ts new file mode 100644 index 000000000..a1a81f30c --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-pause-era.test.ts @@ -0,0 +1,250 @@ +/** + * v1.25 correctness fixes — two compliance-denominator regressions. + * + * H-MED1: a paused medication's gap must NOT count as missed once the + * medication resumes. The pause interval lives in `MedicationPauseEra` + * (threaded onto `ComplianceMedicationContext.pauseEras`); the ledger drops + * expected slots whose anchor falls inside `[pausedAt, resumedAt ?? now)` so + * the denominator never inflates with the paused days. + * + * M-MED3: the compliance rate must honour archived schedule eras + * (`scheduleRevisions`). A past day scores against the schedule that was live + * THEN, not the current one. This pins that threading `scheduleRevisions` + * through `buildComplianceMedicationContext` changes the rate — the exact + * behaviour the Health-Score fast-path was missing (it never threaded eras). + * + * All instants are UTC; run under `TZ=UTC` so the day grid is deterministic. + */ +import { describe, it, expect } from "vitest"; + +import { + buildComplianceMedicationContext, + tallyComplianceFromLedger, + type ComplianceSchedule, + type MedicationPauseEraLike, +} from "../compliance"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; + +const TZ = "UTC"; + +/** A taken intake for the 08:00 slot of `day` (08:15 → on-time). */ +function taken08(day: Date) { + const scheduledFor = new Date(day); + scheduledFor.setUTCHours(8, 0, 0, 0); + const takenAt = new Date(scheduledFor.getTime() + 15 * 60_000); + return { scheduledFor, takenAt, skipped: false }; +} + +function dayUTC(y: number, m: number, d: number): Date { + return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0)); +} + +describe("H-MED1 — paused days drop out of the compliance denominator", () => { + // Daily 08:00 medication. Window June 1 – June 28; the user takes every + // day EXCEPT June 10–19 (the pause window) where nothing is logged. + const createdAt = dayUTC(2026, 6, 1); + const endsOn = dayUTC(2026, 6, 28); + const from = dayUTC(2026, 6, 1); + const to = dayUTC(2026, 6, 29); + const now = dayUTC(2026, 6, 29); + + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + timesOfDay: ["08:00"], + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ]; + + // Taken June 1–9 and June 20–28; nothing logged June 10–19 (paused). + const events = [ + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => taken08(dayUTC(2026, 6, d))), + ...[20, 21, 22, 23, 24, 25, 26, 27, 28].map((d) => + taken08(dayUTC(2026, 6, d)), + ), + ]; + + const pauseEras: MedicationPauseEraLike[] = [ + { pausedAt: dayUTC(2026, 6, 10), resumedAt: dayUTC(2026, 6, 20) }, + ]; + + const medBase = { + startsOn: createdAt, + endsOn, + oneShot: false, + createdAt, + }; + + it("WITHOUT pauseEras the paused days collapse to missed", () => { + const ctx = buildComplianceMedicationContext(medBase, null, TZ); + const tally = tallyComplianceFromLedger( + events, + schedules, + ctx, + from, + to, + now, + ); + // June 10–19 = 10 expected slots with no intake → 10 misses. + expect(tally.missed).toBe(10); + expect(tally.taken).toBe(18); + expect(tally.denominator).toBe(28); + }); + + it("WITH pauseEras the paused days are excluded → no missed", () => { + const ctx = buildComplianceMedicationContext( + { ...medBase, pauseEras }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + events, + schedules, + ctx, + from, + to, + now, + ); + // The 10 paused slots are dropped from the ledger entirely. + expect(tally.missed).toBe(0); + expect(tally.taken).toBe(18); + expect(tally.denominator).toBe(18); + expect(tally.rate).toBe(100); + }); + + it("the resumed day (resumedAt boundary) still counts", () => { + // No intake on June 20 (the resume day) → it must count as missed: the + // exclusion is half-open `[pausedAt, resumedAt)`, so June 20 is live. + const eventsMissResumeDay = [ + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => taken08(dayUTC(2026, 6, d))), + ...[21, 22, 23, 24, 25, 26, 27, 28].map((d) => + taken08(dayUTC(2026, 6, d)), + ), + ]; + const ctx = buildComplianceMedicationContext( + { ...medBase, pauseEras }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + eventsMissResumeDay, + schedules, + ctx, + from, + to, + now, + ); + // June 20 is past the pause window and was not taken → exactly 1 miss. + expect(tally.missed).toBe(1); + }); +}); + +describe("M-MED3 — the compliance rate honours archived schedule eras", () => { + // The user ran a TWICE-daily schedule (08:00 + 20:00) for the first half of + // the window, then edited it down to ONCE daily (08:00). Throughout, only + // the 08:00 dose is ever taken. With the era threaded, the old-era days + // expect two doses (one missed each); without it, every day is scored + // against the current one-dose schedule and the rate is a misleading 100 %. + const createdAt = dayUTC(2026, 6, 1); + const replaceAt = dayUTC(2026, 6, 15); // the edit instant + const endsOn = dayUTC(2026, 6, 28); + const from = dayUTC(2026, 6, 1); + const to = dayUTC(2026, 6, 29); + const now = dayUTC(2026, 6, 29); + + // Current (live) schedule: one dose at 08:00. + const liveSchedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + timesOfDay: ["08:00"], + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ]; + + // Archived era: the superseded twice-daily schedule (08:00 + 20:00). + const oldEraRevision: ScheduleRevisionLike = { + id: "rev-twice-daily", + validFrom: createdAt, + validUntil: replaceAt, + payload: [ + { + timesOfDay: ["08:00", "20:00"], + windowStart: "08:00", + windowEnd: "20:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }, + ], + }; + + // Only the 08:00 dose is taken, every day June 1–28. The 20:00 dose that + // the old era expected on June 1–14 is never taken. + const events = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 22, 23, 24, 25, 26, 27, 28, + ].map((d) => taken08(dayUTC(2026, 6, d))); + + const medBase = { + startsOn: createdAt, + endsOn, + oneShot: false, + createdAt, + }; + + it("WITHOUT scheduleRevisions every day scores against the current one-dose schedule → 100 %", () => { + const ctx = buildComplianceMedicationContext(medBase, null, TZ); + const tally = tallyComplianceFromLedger( + events, + liveSchedules, + ctx, + from, + to, + now, + ); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("WITH scheduleRevisions the old-era days expect the second dose → rate drops", () => { + const ctx = buildComplianceMedicationContext( + { ...medBase, scheduleRevisions: [oldEraRevision] }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + events, + liveSchedules, + ctx, + from, + to, + now, + ); + // June 1–14 expected the 20:00 dose too; it was never taken → 14 misses. + expect(tally.missed).toBe(14); + expect(tally.rate).toBeLessThan(100); + // The era path strictly raises the denominator vs the no-era path. + expect(tally.denominator).toBeGreaterThan(28); + }); +}); diff --git a/src/lib/analytics/__tests__/correlations-fast-path.test.ts b/src/lib/analytics/__tests__/correlations-fast-path.test.ts index 3ef558d3b..5e447d2aa 100644 --- a/src/lib/analytics/__tests__/correlations-fast-path.test.ts +++ b/src/lib/analytics/__tests__/correlations-fast-path.test.ts @@ -77,8 +77,8 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(CORRELATION_WINDOW_DAYS).toBe(28); }); - describe("rollup-fast-path — SYS / PULSE / WEIGHT covered", () => { - it("reads per-day means from measurement_rollups, never from measurements", async () => { + describe("rollup-fast-path — SYS / WEIGHT covered", () => { + it("reads SYS + WEIGHT per-day means from measurement_rollups (PULSE no longer)", async () => { const coverage = new Map([ ["BLOOD_PRESSURE_SYS", true], ["PULSE", true], @@ -88,7 +88,6 @@ describe("computeCorrelationHypothesesFastPath", () => { PROBE.mockResolvedValue(coverage); ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // SYS buckets - .mockResolvedValueOnce([]) // PULSE buckets .mockResolvedValueOnce([]); // WEIGHT buckets const result = await computeCorrelationHypothesesFastPath({ @@ -101,9 +100,14 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(result.path).toBe("rollup"); expect(result.windowDays).toBe(28); expect(result.degraded).toBe(false); - // No raw measurement reads when on rollup-fast-path. - expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); + // v1.2.5 (M-CS2) — SYS + WEIGHT ride the rollup tier; PULSE no + // longer does. The mood × resting-pulse hypothesis instead reads the + // RESTING_HEART_RATE rows (and, only when absent, RAW PULSE for the + // low-percentile proxy) from `measurements`, so the rollup count + // drops to 2 and the resting reads land on `measurements`. + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + // RESTING read returns [] (default mock) → RAW PULSE proxy read too. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); // Mood + intake reads always live. expect(MOOD_FIND_MANY).toHaveBeenCalledTimes(1); expect(INTAKE_FIND_MANY).toHaveBeenCalledTimes(1); @@ -145,9 +149,7 @@ describe("computeCorrelationHypothesesFastPath", () => { FULLY_COVERED.mockReturnValue(false); PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // SYS rows - .mockResolvedValueOnce([]) // PULSE rows - .mockResolvedValueOnce([]); // WEIGHT rows + MEASUREMENT_FIND_MANY.mockResolvedValue([]); // SYS / WEIGHT / RESTING / PULSE const result = await computeCorrelationHypothesesFastPath({ userId: "user-partial", @@ -157,7 +159,9 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("live"); - expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3); + // SYS + WEIGHT (live series) + RESTING + RAW-PULSE proxy (resting + // series, since RESTING returns [] under the default mock) = 4 reads. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(4); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); @@ -219,8 +223,9 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("rollup"); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); - expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + // Resting-pulse reads (RESTING + RAW-PULSE proxy) land on measurements. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); const calls = ANNOTATE.mock.calls.map((c) => c[0]); const corrCall = calls.find((c) => c?.meta?.correlations !== undefined); @@ -248,7 +253,7 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("live"); - expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3); + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(4); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); const calls = ANNOTATE.mock.calls.map((c) => c[0]); @@ -280,4 +285,58 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); }); + + describe("mood × resting pulse uses the RESTING series, not the raw PULSE mean (M-CS2)", () => { + it("scores the low-percentile resting proxy, so workout HR can't inflate the pair", async () => { + // One day with a handful of resting-low readings buried under a dense + // workout burst. The OLD code paired mood against the day's raw PULSE + // MEAN (~120 bpm — workout-inflated). The fix routes through + // `resolveRestingPulseSeries`, whose proxy is the day's 20th-percentile + // (the resting floor), so the paired value sits in the ~50s, not ~120. + const day = new Date("2026-05-16T10:00:00.000Z"); // Berlin 12:00 → 05-16 + const pulseValues = [55, 57, 59, 150, 151, 152, 153, 154, 155, 156]; + const rawMean = + pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length; // ~124.2 + + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", false], // force the live path + ]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + MEASUREMENT_FIND_MANY.mockImplementation( + async (args: { + where?: { type?: string }; + }): Promise> => { + const type = args?.where?.type; + if (type === "PULSE") { + // No RESTING_HEART_RATE rows exist, so the proxy reads RAW PULSE. + return pulseValues.map((value, i) => ({ + id: `pulse-${i}`, + measuredAt: day, + value, + })); + } + // SYS / WEIGHT / RESTING_HEART_RATE → empty. + return []; + }, + ); + MOOD_FIND_MANY.mockResolvedValue([{ score: 4, moodLoggedAt: day }]); + + const result = await computeCorrelationHypothesesFastPath({ + userId: "user-resting", + userTz: "Europe/Berlin", + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + // n = 1 day → below the n>=20 surfacing gate, but the points preview + // still carries the paired resting value the runner computed. + expect(result.moodPulse.points).toHaveLength(1); + const y = result.moodPulse.points[0].y; + // The paired pulse is the resting floor (~50s), NOT the raw day-mean. + expect(y).toBeLessThan(70); + expect(y).toBeLessThan(rawMean - 40); + }); + }); }); diff --git a/src/lib/analytics/__tests__/correlations.test.ts b/src/lib/analytics/__tests__/correlations.test.ts index fdd7b0940..1e91db9f4 100644 --- a/src/lib/analytics/__tests__/correlations.test.ts +++ b/src/lib/analytics/__tests__/correlations.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import type { DataPoint } from "../trends"; -import { pairByTimestamp, pearsonCorrelation } from "../correlations"; +import { + pairByTimestamp, + pearsonCorrelation, + significantPearsonCorrelation, +} from "../correlations"; +import type { PairedPoint } from "../correlations"; function makePoints(values: number[], startDaysAgo = 30): DataPoint[] { const now = Date.now(); @@ -102,3 +107,42 @@ describe("pearsonCorrelation", () => { expect(result.strength).toBe("keine"); }); }); + +describe("significantPearsonCorrelation (M-CS3 — n>=20 AND p<0.05)", () => { + const mk = (a: number, b: number): PairedPoint => ({ + a, + b, + date: new Date(), + }); + + it("suppresses a 5-pair r≈0.7 dataset (below the n>=20 floor)", () => { + // Five points with a strong-looking slope — exactly the small-n fluke the + // plain `pearsonCorrelation` would surface as a "stark" correlation. + const pairs = [mk(1, 1), mk(2, 2), mk(3, 2), mk(4, 4), mk(5, 3)]; + // Sanity-check the legacy path WOULD have surfaced it (non-null, n=5). + const legacy = pearsonCorrelation(pairs); + expect(legacy).not.toBeNull(); + expect(legacy!.n).toBe(5); + // The gated path refuses it — too few pairs. + expect(significantPearsonCorrelation(pairs)).toBeNull(); + }); + + it("surfaces a >=20-pair significant dataset", () => { + // 20 points on a near-perfect line → high r, vanishingly small p. + const pairs = Array.from({ length: 20 }, (_, i) => + mk(i, i * 2 + (i % 2 === 0 ? 0.3 : -0.3)), + ); + const result = significantPearsonCorrelation(pairs); + expect(result).not.toBeNull(); + expect(result!.n).toBe(20); + expect(result!.strength).toBe("stark"); + expect(result!.r).toBeGreaterThan(0.9); + }); + + it("suppresses a 20-pair NON-significant (noisy) dataset", () => { + // 20 points with no real relationship → p well above 0.05. + const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3]; + const pairs = ys.map((y, i) => mk(i, y)); + expect(significantPearsonCorrelation(pairs)).toBeNull(); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index d7d4be0a6..3dd7962ee 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -436,6 +436,24 @@ export interface ComplianceMedicationContext { * callers without revisions keep the live-only expansion. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When + * present, expected dose slots whose anchor falls inside any + * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a + * resumed medication never counts the paused days as missed. Optional: + * callers without eras keep counting every expected slot. + */ + pauseEras?: MedicationPauseEraLike[]; +} + +/** + * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. + * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) + * drops in. An open era (`resumedAt === null`) runs to `now`. + */ +export interface MedicationPauseEraLike { + pausedAt: Date; + resumedAt: Date | null; } /** @@ -452,6 +470,8 @@ export function buildComplianceMedicationContext( createdAt: Date; /** v1.16.3 — thread the archived eras when the bundle carries them. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ + pauseEras?: MedicationPauseEraLike[]; }, lastIntakeAt: Date | null, timeZone: string, @@ -464,6 +484,7 @@ export function buildComplianceMedicationContext( lastIntakeAt, timeZone, ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), + ...(med.pauseEras && { pauseEras: med.pauseEras }), }; } @@ -1348,7 +1369,41 @@ export function buildComplianceLedgerRows( pinned: e.attributionSource === "USER_PIN", })); - return reconstructDoseHistory(bands, intakes, now); + const rows = reconstructDoseHistory(bands, intakes, now); + + // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a + // pause interval. While a medication is paused no dose is expected, so a + // slot minted across the paused window must never count as "missed" (the + // denominator-inflating status). Only `slot` rows are dropped, and only + // those whose status feeds the tally (taken / missed) — skip / ad-hoc / + // upcoming rows are already excluded from the denominator, so dropping + // them here would be a redundant double-exclusion. An open era + // (`resumedAt === null`) runs to `now`. + const pauseEras = ctx.pauseEras; + if (pauseEras && pauseEras.length > 0) { + const isInPause = (at: Date): boolean => { + const t = at.getTime(); + for (const era of pauseEras) { + const start = era.pausedAt.getTime(); + const end = (era.resumedAt ?? now).getTime(); + if (t >= start && t < end) return true; + } + return false; + }; + return rows.filter((row) => { + if (row.kind !== "slot") return true; + if ( + row.status !== "taken_on_time" && + row.status !== "taken_late" && + row.status !== "missed" + ) { + return true; + } + return !isInPause(row.at); + }); + } + + return rows; } /** diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index b09a89bc5..79370b404 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -23,14 +23,17 @@ * The meta-dict annotate carries `window_days: 28` as a sentinel * so the UI / docs can quote the truthful window. * - * 2. **Probe-gated SYS / PULSE / WEIGHT** — when DAY-bucket coverage - * exists for all three types the runner hydrates the per-day-mean - * maps from `measurement_rollups`. Each rollup row already + * 2. **Probe-gated SYS / WEIGHT** — when DAY-bucket coverage exists + * for both types the runner hydrates the per-day-mean / per-day + * weight maps from `measurement_rollups`. Each rollup row already * carries the day's `mean` value computed by Postgres' `AVG`, so * the pairing logic is byte-for-byte identical to the live path's - * "average the day's raw values" step. + * "average the day's raw values" step. PULSE is intentionally NOT + * hydrated from the rollup tier — the mood × resting-pulse + * hypothesis scores the RESTING series (RESTING_HEART_RATE rows, + * else a low-percentile RAW-PULSE proxy), never the day-mean. * - * 3. **Mood + medication intake** — no rollup equivalent today; both + * 3. **Mood + medication intake + resting pulse** — no rollup equivalent today; both * reads stay live regardless of coverage. The mood read is small * (per-day entries, bounded by the 28-day window) and the * medication intake read scales with active medications × 28 days @@ -57,6 +60,7 @@ import { correlateWeightWeekday, type CorrelationResult, } from "@/lib/insights/correlations"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -141,19 +145,24 @@ export async function computeCorrelationHypothesesFastPath( // local-day buckets and this guard can come back down. const userNearUtc = isNearUtc(userTz, now); - // v1.4.38.8 — gate only on the three types this helper actually - // reads (SYS / PULSE / WEIGHT). The prior `isFullyCovered &&` AND - // poisoned the correlations fast-path with any unrelated brand-new - // type the user had logged, even when SYS + PULSE + WEIGHT were - // fully covered. Per-type coverage is the correct gate. + // v1.4.38.8 — gate only on the types this helper reads from the rollup + // tier (SYS / WEIGHT). The prior `isFullyCovered &&` AND poisoned the + // correlations fast-path with any unrelated brand-new type the user had + // logged, even when those were fully covered. Per-type coverage is the + // correct gate. + // + // v1.2.5 (M-CS2) — PULSE is no longer hydrated from the rollup tier: + // the mood × "resting pulse" hypothesis now scores the RESTING series + // (RESTING_HEART_RATE rows, else a low-percentile PULSE proxy from RAW + // samples), not the day-mean PULSE the rollup carries. A day-mean folds + // in workout HR and inflates the "resting" figure for Apple-Health + // users, so the rollup pulse-mean is structurally the wrong input here. const measurementsOnRollups = userNearUtc && coverage.get("BLOOD_PRESSURE_SYS") === true && - coverage.get("PULSE") === true && coverage.get("WEIGHT") === true; let dailySysMean: Map; - let dailyPulseMean: Map; let weightRows: ChunkedRow[]; if (measurementsOnRollups) { @@ -173,7 +182,7 @@ export async function computeCorrelationHypothesesFastPath( // v1.11.2 — load the source-priority blob once and thread it into every // reader so the per-type fan-out doesn't re-query the user 3×. const priority = await loadUserSourcePriority(userId); - const [sysBuckets, pulseBuckets, weightBuckets] = await Promise.all([ + const [sysBuckets, weightBuckets] = await Promise.all([ readRollupBuckets( userId, "BLOOD_PRESSURE_SYS", @@ -182,17 +191,12 @@ export async function computeCorrelationHypothesesFastPath( now, priority, ), - readRollupBuckets(userId, "PULSE", "DAY", since, now, priority), readRollupBuckets(userId, "WEIGHT", "DAY", since, now, priority), ]); dailySysMean = new Map(); for (const b of sysBuckets) { dailySysMean.set(userDayKey(b.bucketStart, userTz), b.mean); } - dailyPulseMean = new Map(); - for (const b of pulseBuckets) { - dailyPulseMean.set(userDayKey(b.bucketStart, userTz), b.mean); - } // The weekday hypothesis needs the per-event weight series for // ANOVA — the runner expects one row per measurement so the group // counts reflect reality. Hydrate from buckets by emitting one @@ -207,9 +211,8 @@ export async function computeCorrelationHypothesesFastPath( })); } else { // Live fallback — chunked reads against the measurements table. - const [sysRows, pulseRows, weightRowsLive] = await Promise.all([ + const [sysRows, weightRowsLive] = await Promise.all([ fetchSeriesChunked(userId, "BLOOD_PRESSURE_SYS", since), - fetchSeriesChunked(userId, "PULSE", since), fetchSeriesChunked(userId, "WEIGHT", since), ]); dailySysMean = new Map(); @@ -224,21 +227,39 @@ export async function computeCorrelationHypothesesFastPath( const sum = list.reduce((s, v) => s + v, 0); dailySysMean.set(key, sum / list.length); } - dailyPulseMean = new Map(); - const pulseBuckets = new Map(); - for (const r of pulseRows) { - const key = userDayKey(r.measuredAt, userTz); - const list = pulseBuckets.get(key) ?? []; - list.push(r.value); - pulseBuckets.set(key, list); - } - for (const [key, list] of pulseBuckets.entries()) { - const sum = list.reduce((s, v) => s + v, 0); - dailyPulseMean.set(key, sum / list.length); - } weightRows = weightRowsLive; } + // v1.2.5 (M-CS2) — resolve the RESTING-pulse series the mood × pulse + // hypothesis scores against. Prefer the clean RESTING_HEART_RATE rows; + // only when the user has none do we pull RAW PULSE samples for the + // low-percentile daily proxy (the proxy needs the per-day distribution, + // not the day-mean — a day-mean folds in workout HR). Both reads are + // raw because the proxy floor is a percentile, not an AVG. Keying the + // proxy on `userDayKey(d, userTz)` aligns its buckets with the mood / + // intake day keys used below. + const restingSamples = await fetchSeriesChunked( + userId, + "RESTING_HEART_RATE", + since, + ); + const pulseSamplesForResting = + restingSamples.length === 0 + ? await fetchSeriesChunked(userId, "PULSE", since) + : []; + const { series: restingPulseSeries } = resolveRestingPulseSeries({ + restingSamples, + pulseSamples: pulseSamplesForResting, + dayKeyOf: (d) => userDayKey(d, userTz), + }); + // One resting figure per user-local day. RESTING_HEART_RATE can carry + // more than one row/day (rare); last-write-wins is fine for a single + // daily resting figure, and the proxy already emits one row per day. + const dailyRestingPulse = new Map(); + for (const s of restingPulseSeries) { + dailyRestingPulse.set(userDayKey(s.measuredAt, userTz), s.value); + } + // Mood + intake — always live. Both reads are small (28-day window, // narrow projections) and neither has a rollup equivalent today. const [moodRows, intakeRows] = await Promise.all([ @@ -298,13 +319,13 @@ export async function computeCorrelationHypothesesFastPath( restingPulse: number; }> = []; for (const [key, moodScores] of dailyMood.entries()) { - const meanPulse = dailyPulseMean.get(key); - if (meanPulse === undefined) continue; + const restingPulse = dailyRestingPulse.get(key); + if (restingPulse === undefined) continue; const meanMood = moodScores.reduce((s, v) => s + v, 0) / moodScores.length; moodPulsePairs.push({ date: dateFromDayKey(key), mood: meanMood, - restingPulse: meanPulse, + restingPulse, }); } const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }); @@ -349,7 +370,7 @@ export async function computeCorrelationHypothesesFastPath( async function fetchSeriesChunked( userId: string, - type: "BLOOD_PRESSURE_SYS" | "PULSE" | "WEIGHT", + type: "BLOOD_PRESSURE_SYS" | "PULSE" | "WEIGHT" | "RESTING_HEART_RATE", since: Date, ): Promise { const out: ChunkedRow[] = []; diff --git a/src/lib/analytics/correlations.ts b/src/lib/analytics/correlations.ts index 1becbeaec..10270f1f5 100644 --- a/src/lib/analytics/correlations.ts +++ b/src/lib/analytics/correlations.ts @@ -4,6 +4,7 @@ */ import type { DataPoint } from "./trends"; +import { pearson, MAX_P_VALUE } from "@/lib/insights/correlations"; export interface PairedPoint { a: number; @@ -104,3 +105,49 @@ export function pearsonCorrelation( return { r, strength, n }; } + +/** + * v1.2.5 (M-CS3) — significance-gated Pearson for USER-FACING surfaces. + * + * The plain `pearsonCorrelation` above assigns a strength band from |r| + * alone with no significance test and a default floor of only 5 pairs — + * so a 5-point r ≈ 0.7 fluke surfaces as a "stark" correlation. Every + * surfaced insight-card / status-snapshot correlation must instead clear + * the same rigorous bar the `/insights` correlation engine enforces: + * + * - n >= 20 paired points (the engine's `MIN_PAIRED_N`), AND + * - a two-sided Student-t p-value < 0.05 (`MAX_P_VALUE`). + * + * Below either bar this returns `null` so the caller suppresses the + * correlation exactly as it already does for the < minPairs case — no + * call-site shape change. The returned `{ r, strength, n }` keeps the + * legacy shape (German strength band) so the snapshot byte-shape is + * unchanged on the happy path. + * + * This routes through the engine's `pearson` (exact regularised- + * incomplete-beta p-value) rather than re-deriving the t-test, so the + * surface decision shares ONE definition with the dedicated cards. + */ +export function significantPearsonCorrelation( + pairs: PairedPoint[], +): CorrelationResult | null { + const result = pearson({ + xs: pairs.map((p) => p.a), + ys: pairs.map((p) => p.b), + }); + // `pearson` defaults `minPairs` to MIN_PAIRED_N (20); below it (or with + // zero variance) it reports `insufficient` and we suppress the card. + if (result.status !== "ok") return null; + // Significance gate — a non-significant coefficient never surfaces. + if (result.pValue >= MAX_P_VALUE) return null; + + const r = Math.round(result.r * 1000) / 1000; + const absR = Math.abs(r); + let strength: CorrelationResult["strength"]; + if (absR >= 0.7) strength = "stark"; + else if (absR >= 0.4) strength = "moderat"; + else if (absR >= 0.2) strength = "schwach"; + else strength = "keine"; + + return { r, strength, n: result.n }; +} diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 4659d0169..a74b3f929 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -50,6 +50,7 @@ import { buildComplianceMedicationContext, calculateCompliance, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "./compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { @@ -345,29 +346,26 @@ export async function computeUserHealthScoreFastPath( startsOn: true, endsOn: true, oneShot: true, - schedules: { + // v1.25 M-MED3 — route through the SHARED compliance select like every + // other caller, so the Health-Score pillar honours the same per-dose + // on-time windows + cadence fields the dashboard / detail surfaces use. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.25 M-MED3 — archived schedule eras so a past day scores against + // the schedule that was live THEN, not the current one (every other + // compliance caller already threads these). The builder copies them + // through into the context. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, select: { - windowStart: true, - windowEnd: true, - // v1.5.0 — cadence-aware compliance reads daysOfWeek so a - // weekly med (Mondays only) doesn't get a 30-day denominator - // that depresses the score by ~85 percentage points. Closes #214. - daysOfWeek: true, - // v1.7.0 SB-SCHED-2 — widen the select so the engine reads - // RRULE / rolling / PRN / cyclic, not just the legacy - // daysOfWeek string. - rrule: true, - rollingIntervalDays: true, - timesOfDay: true, - reminderGraceMinutes: true, - scheduleType: true, - cyclicOnWeeks: true, - cyclicOffWeeks: true, - // v1.15.18 — per-dose window so the score's compliance honours the - // same on-time band the detail % + history use. - doseWindows: true, + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }), ]); @@ -428,6 +426,16 @@ export async function computeUserHealthScoreFastPath( takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null, skipped: e.skipped, })); + // v1.25 M-MED3 — `med` (and so its scheduleRevisions + pauseEras) is + // passed UNshifted while the intake events shift +7d (the pre-existing + // approximation that lets the pinned `now` anchor still capture the prior + // logical 30 days). The revisions / pause eras are absolute historical + // records; no canonical previous-window caller shifts them, so they stay + // as-is here for consistency with the rest of the engine. Trade-off: in + // the trailing-30-day DELTA the era / pause segmentation can be up to 7 + // days out of phase with the shifted slots. The primary (current-window) + // score — `medicationCompliance30` above — is exact; only the secondary + // trend delta carries this best-effort approximation. const medicationContext = buildComplianceMedicationContext( med, lastNonSkippedTakenAt(shifted), diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index bc2fc0702..345f974e8 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -47,6 +47,7 @@ import { tallyComplianceFromLedger, type ComplianceSchedule, type IntakeEvent, + type MedicationPauseEraLike, } from "@/lib/analytics/compliance"; import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; @@ -639,6 +640,8 @@ export interface DoctorReportComplianceMedication { createdAt: Date; schedules: ComplianceSchedule[]; scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ + pauseEras?: MedicationPauseEraLike[]; } /** A window-bounded intake row keyed to its medication. */ @@ -797,6 +800,8 @@ export async function collectDoctorReportData( supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, // v1.4.25 W4d — eager-load dose history + recent intake site // for any active medication. Generic meds carry empty arrays // so the legacy data path is byte-identical. @@ -965,6 +970,7 @@ export async function collectDoctorReportData( createdAt: m.createdAt, schedules: m.schedules, scheduleRevisions: m.scheduleRevisions, + pauseEras: m.pauseEras, })), intakeEvents.map((e) => ({ medicationId: e.medicationId, diff --git a/src/lib/insights/__tests__/bucket-series.test.ts b/src/lib/insights/__tests__/bucket-series.test.ts index cea7be1f9..2ddde27d9 100644 --- a/src/lib/insights/__tests__/bucket-series.test.ts +++ b/src/lib/insights/__tests__/bucket-series.test.ts @@ -137,6 +137,69 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => { }); }); +describe("bucketSeries() honours the per-user timezone (M-TZ3)", () => { + // A reading at 23:30 in America/Los_Angeles (PDT, UTC-7) on 2026-05-09 is + // already 08:30 on 2026-05-10 in Europe/Berlin (CEST, UTC+2). The same + // raw instant therefore lands on a different calendar day — and a + // different `dayOffset` — depending on which timezone the buckets key in. + const reading = new Date("2026-05-10T06:30:00.000Z"); // LA 05-09 23:30 + const now = new Date("2026-05-10T20:00:00.000Z"); // LA 05-10 13:00 + + it("buckets a near-midnight reading on the user's LOCAL day for LA", () => { + const { daily } = bucketSeries([{ measuredAt: reading, value: 42 }], { + now, + tz: "America/Los_Angeles", + }); + // LA: now is 05-10, reading is 05-09 → one local day ago. + expect(daily).toHaveLength(1); + expect(daily[0].dayOffset).toBe(1); + }); + + it("gives a DIFFERENT dayOffset for the same reading under Berlin", () => { + const { daily } = bucketSeries([{ measuredAt: reading, value: 42 }], { + now, + tz: "Europe/Berlin", + }); + // Berlin: both now and reading fall on 05-10 → same local day (offset 0). + expect(daily).toHaveLength(1); + expect(daily[0].dayOffset).toBe(0); + }); + + it("leaves a Berlin user's bucketing identical to the default", () => { + const records = pointsAtOffsets(now, [0, 1, 5], 100).concat([ + { measuredAt: reading, value: 42 }, + ]); + const def = bucketSeries(records, { now }); + const berlin = bucketSeries(records, { now, tz: "Europe/Berlin" }); + expect(berlin).toEqual(def); + }); + + it("dayOffsetToBerlinDayKey resolves the offset in the passed timezone", () => { + // `now` is 05-09 in LA but 05-10 in Berlin. + expect(dayOffsetToBerlinDayKey(reading, 0, "America/Los_Angeles")).toBe( + "2026-05-09", + ); + // Default (no tz) and explicit Berlin agree. + expect(dayOffsetToBerlinDayKey(reading, 0)).toBe("2026-05-10"); + expect(dayOffsetToBerlinDayKey(reading, 0, "Europe/Berlin")).toBe( + "2026-05-10", + ); + }); + + it("applyPayloadBudget threads the timezone through to the buckets", () => { + const la = applyPayloadBudget([{ measuredAt: reading, value: 42 }], { + now, + tz: "America/Los_Angeles", + }); + const berlin = applyPayloadBudget([{ measuredAt: reading, value: 42 }], { + now, + tz: "Europe/Berlin", + }); + expect(la.daily[0].dayOffset).toBe(1); + expect(berlin.daily[0].dayOffset).toBe(0); + }); +}); + describe("applyPayloadBudget()", () => { const now = new Date("2026-05-09T12:00:00Z"); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index c79383912..991eba764 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -16,7 +16,7 @@ import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlat import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { - pearsonCorrelation, + significantPearsonCorrelation, type PairedPoint, } from "@/lib/analytics/correlations"; import { @@ -25,7 +25,6 @@ import { lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; import { getMedicationCategories } from "@/lib/medication-category"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -63,7 +62,12 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + resolveUserTimezone, + toBerlinDayKey, + userDayKey, +} from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Cap on the embedded correlation / paired-daily arrays. The Pearson @@ -83,6 +87,7 @@ function pairDailyBuckets( seriesA: DailyBucket[], seriesB: DailyBucket[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -90,7 +95,7 @@ function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); // UTC midnight of the Berlin day — formatting this Date with // `toBerlinDayKey()` is guaranteed DST-safe because the y-m-d // fields below are the Berlin calendar day fields by construction. @@ -243,6 +248,12 @@ export async function prepareBloodPressureStatusForUser( const now = new Date(); + // v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — resolve the user timezone once, + // up front, so BOTH the BP-status compliance gate AND every day-bucketing + // pass below (applyPayloadBudget, pairDailyBuckets, the continuity series) + // key on the user's own calendar day, not Berlin's. + const userTz = await resolveUserTimezone(userId); + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") @@ -250,7 +261,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( @@ -260,7 +271,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const diaSeries = applyPayloadBudget( @@ -270,7 +281,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const bpTargets = getBpTargets(user?.dateOfBirth ?? null); @@ -278,6 +289,7 @@ export async function prepareBloodPressureStatusForUser( sysSeries.daily, diaSeries.daily, now, + userTz, ).map((entry) => ({ day: entry.dayKey, sys: entry.a, @@ -305,8 +317,11 @@ export async function prepareBloodPressureStatusForUser( weightSeries.daily, sysSeries.daily, now, + userTz, + ); + const weightVsSystolicCorrelation = significantPearsonCorrelation( + weightVsSystolicPairs, ); - const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); const activeMedications = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never feed the BP-status @@ -318,6 +333,8 @@ export async function prepareBloodPressureStatusForUser( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); @@ -357,10 +374,6 @@ export async function prepareBloodPressureStatusForUser( }, }); - // v1.7.0 SB-SCHED-2 — resolve the user timezone once so the BP-status - // compliance gate routes its denominator through the canonical engine. - const userTz = await resolveUserTimezone(userId); - const medicationCompliance = bpMedications.map((medication) => { const eventsForMedication = bpMedicationEvents .filter((event) => event.medicationId === medication.id) @@ -406,13 +419,15 @@ export async function prepareBloodPressureStatusForUser( const takenByDay = new Map(); for (const event of bpMedicationEvents) { if (event.skipped || !event.takenAt) continue; - const dayKey = toBerlinDayKey(event.scheduledFor); + // Key on the user's own calendar day so the continuity tally aligns + // with the user-tz day keys the systolic series is bucketed under. + const dayKey = userDayKey(event.scheduledFor, userTz); takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); } const continuityVsSystolicSeries = sysSeries.daily.map((point) => { // DST-safe: dayOffsetToBerlinDayKey computes calendar days, not 24h ticks. - const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset, userTz); const taken = takenByDay.get(dayKey) ?? 0; const continuityPct = expectedBpIntakesPerDay > 0 @@ -443,7 +458,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })), - { now }, + { now, tz: userTz }, ); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -456,7 +471,7 @@ export async function prepareBloodPressureStatusForUser( const gradedFromDaily = (buckets: DailyBucket[]) => buildGradedSeriesFromPoints( buckets.map((b) => ({ - measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset, userTz)), value: b.value, })), now, @@ -483,7 +498,7 @@ export async function prepareBloodPressureStatusForUser( }; }) .filter((entry): entry is PairedPoint => entry !== null); - const continuityVsSystolicCorrelation = pearsonCorrelation( + const continuityVsSystolicCorrelation = significantPearsonCorrelation( continuityVsSystolicPairs, ); @@ -600,8 +615,9 @@ export async function prepareBloodPressureStatusForUser( moodSeries.daily, sysSeries.daily, now, + userTz, ); - return pearsonCorrelation(moodVsSysPairs); + return significantPearsonCorrelation(moodVsSysPairs); })(), } : null, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 269b87193..ddbb0c5e2 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -43,7 +43,7 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, toBerlinDayKey } from "@/lib/tz/resolver"; /** * Public entry — unchanged signature. Prepares the card (cache-read, @@ -160,6 +160,7 @@ export async function prepareBmiStatusForUser( where: { id: userId }, select: { heightCm: true, + timezone: true, }, }); @@ -231,6 +232,9 @@ export async function prepareBmiStatusForUser( .then((rows) => rows.reverse()); const now = new Date(); + // v1.2.5 (M-TZ3) — bucket day windows on the user's own calendar so a + // near-midnight reading lands on the user's day for non-Berlin self-hosters. + const userTz = user.timezone ?? DEFAULT_TIMEZONE; const heightFactor = (user.heightCm / 100) ** 2; const bmiPoints = measurements.map((measurement) => ({ @@ -239,7 +243,7 @@ export async function prepareBmiStatusForUser( })); // `applyPayloadBudget` daily buckets drive the latest/previous focus; // the compact graded series is what reaches the prompt. - const weightSeries = applyPayloadBudget(bmiPoints, { now }); + const weightSeries = applyPayloadBudget(bmiPoints, { now, tz: userTz }); // BMI has no rollup tier of its own — it is weight ÷ height², a linear // transform by the per-user height factor. Read the WEIGHT tier (recent // / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR) diff --git a/src/lib/insights/bucket-series.ts b/src/lib/insights/bucket-series.ts index 29744f89b..0b100524b 100644 --- a/src/lib/insights/bucket-series.ts +++ b/src/lib/insights/bucket-series.ts @@ -16,12 +16,12 @@ * does not produce 360 zero-rows of noise. */ -import { toBerlinYmd as toBerlinYmdStrings } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; const MS_PER_DAY = 24 * 60 * 60 * 1000; export interface DailyBucket { - /** 0 = today (Berlin), 359 = 359 days ago. */ + /** 0 = today (user-local), 359 = 359 days ago. */ dayOffset: number; value: number; n: number; @@ -51,26 +51,27 @@ export interface BucketOptions { monthlyMonths?: number; /** Reference "now". Default new Date(). */ now?: Date; + /** + * IANA timezone the day buckets are keyed in. Defaults to + * `DEFAULT_TIMEZONE` (Europe/Berlin) so legacy / test callers keep their + * behaviour, but every per-user surface MUST pass the resolved + * `User.timezone` so a near-midnight reading lands on the user's own + * calendar day (and the "today / N-days-ago" framing is offset-correct + * for non-Berlin self-hosters). Pre-v1.4.40 these helpers hard-pinned + * Berlin; this threads the user TZ the display surfaces already use. + */ + tz?: string; } -interface BerlinYmd { - year: number; - month: number; - day: number; -} - -function toBerlinYmd(date: Date): BerlinYmd { - const parts = toBerlinYmdStrings(date); - return { - year: Number(parts.year), - month: Number(parts.month), - day: Number(parts.day), - }; -} - -/** UTC midnight of the Berlin calendar day containing `date`. */ -function utcMidnightOfBerlinDay(date: Date): number { - const { year, month, day } = toBerlinYmd(date); +/** + * UTC midnight of the user-local calendar day containing `date`. + * + * Anchors on the `YYYY-MM-DD` day key in `tz` (DST-immune via Intl) and + * re-projects it to a UTC midnight so all downstream calendar arithmetic + * runs in a space where every day is exactly 86_400_000 ms wide. + */ +function utcMidnightOfUserDay(date: Date, tz: string): number { + const [year, month, day] = userDayKey(date, tz).split("-").map(Number); return Date.UTC(year, month - 1, day); } @@ -91,11 +92,18 @@ function utcMidnightOfBerlinDay(date: Date): number { * Exported so the cross-metric `pairDailyBuckets` helpers in * `blood-pressure-status.ts`, `weight-status.ts`, `mood-status.ts`, * etc. all use the same source of truth. + * + * `tz` defaults to Berlin only so legacy callers keep working; per-user + * surfaces pass the resolved `User.timezone`. */ -export function dayOffsetToBerlinDayKey(now: Date, dayOffset: number): string { - const todayMidnight = utcMidnightOfBerlinDay(now); - // Subtraction in UTC-anchored Berlin-day space: every day is 24h wide - // because both endpoints are UTC midnights of consecutive Berlin +export function dayOffsetToBerlinDayKey( + now: Date, + dayOffset: number, + tz: string = DEFAULT_TIMEZONE, +): string { + const todayMidnight = utcMidnightOfUserDay(now, tz); + // Subtraction in UTC-anchored user-day space: every day is 24h wide + // because both endpoints are UTC midnights of consecutive user-local // calendar days, regardless of how long the wall-clock day actually was. const targetUtc = new Date(todayMidnight - dayOffset * MS_PER_DAY); // Read the Y-M-D off the UTC fields directly — we anchored at UTC @@ -111,11 +119,15 @@ function round(value: number): number { } /** - * Day offset between two Berlin-day timestamps, in whole days. + * Day offset between two user-local-day timestamps, in whole days. * Always non-negative when `today >= record`. */ -function dayOffsetBerlin(record: Date, todayMidnight: number): number { - const recordMidnight = utcMidnightOfBerlinDay(record); +function dayOffsetUser( + record: Date, + todayMidnight: number, + tz: string, +): number { + const recordMidnight = utcMidnightOfUserDay(record, tz); return Math.round((todayMidnight - recordMidnight) / MS_PER_DAY); } @@ -126,8 +138,9 @@ export function bucketSeries( const dailyDays = options.dailyDays ?? 360; const monthlyMonths = options.monthlyMonths ?? 24; const monthSize = 30; + const tz = options.tz ?? DEFAULT_TIMEZONE; - const todayMidnight = utcMidnightOfBerlinDay(options.now ?? new Date()); + const todayMidnight = utcMidnightOfUserDay(options.now ?? new Date(), tz); const daily = new Map(); const monthly = new Map(); @@ -139,7 +152,7 @@ export function bucketSeries( const monthlyMaxOffset = monthOffsetBase + monthlyMonths - 1; for (const record of records) { - const offset = dayOffsetBerlin(record.measuredAt, todayMidnight); + const offset = dayOffsetUser(record.measuredAt, todayMidnight, tz); if (offset < 0) continue; // future timestamp — ignore if (offset < dailyDays) { diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 2911716a4..98751db43 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -56,6 +56,8 @@ export async function fetchComplianceSeries( include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5d4c54872..6f4c09f32 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1623,6 +1623,8 @@ export async function extractFeatures( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 88a95f196..b63164015 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -42,7 +42,11 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + resolveUserTimezone, + toBerlinDayKey, + userDayKey, +} from "@/lib/tz/resolver"; // Derived from canonical enum so a new measurement type is auto-included // in the AI general-status fetch (V3 audit: enum drift cousins). @@ -185,6 +189,11 @@ export async function prepareGeneralStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar (a near-midnight reading + // lands on the user's day for non-Berlin self-hosters). + const userTz = await resolveUserTimezone(userId); + // `dailyByType` keeps the `applyPayloadBudget` daily buckets for the // downstream BP in-target pairing; the prompt embeds the compact // graded series instead of the full daily array. Skip types with no @@ -204,7 +213,7 @@ export async function prepareGeneralStatusForUser( })); if (records.length === 0) return []; - const series = applyPayloadBudget(records, { now }); + const series = applyPayloadBudget(records, { now, tz: userTz }); dailyByType.set(type, series); const graded = buildGradedSeriesFromPoints(records, now); @@ -246,7 +255,7 @@ export async function prepareGeneralStatusForUser( { total: number; taken: number; skipped: number } >(); for (const event of intakeEvents) { - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, userTz); const bucket = adherenceByDay.get(dayKey) ?? { total: 0, taken: 0, @@ -264,14 +273,21 @@ export async function prepareGeneralStatusForUser( // Adherence series — bucket the per-day rate so the model gets the // canonical 360+24 view. We collapse one rate per day first, then feed // it into applyPayloadBudget over a synthesised `{measuredAt, value}` - // record list. + // record list. The day keys are user-local (userDayKey above); anchor + // the synthesised instant at noon UTC so the downstream tz-aware + // re-bucket re-derives the same user-local calendar day across every + // realistic self-hoster offset rather than slipping a day at the + // midnight edge. const adherenceRecords = Array.from(adherenceByDay.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([day, value]) => ({ - measuredAt: new Date(`${day}T00:00:00.000Z`), + measuredAt: new Date(`${day}T12:00:00.000Z`), value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0, })); - const adherenceSeries = applyPayloadBudget(adherenceRecords, { now }); + const adherenceSeries = applyPayloadBudget(adherenceRecords, { + now, + tz: userTz, + }); const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now); // Fetch mood context (optional — for enrichment only). v1.4.28 @@ -289,7 +305,7 @@ export async function prepareGeneralStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodRecords, { now }); + const moodSeries = applyPayloadBudget(moodRecords, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodRecords, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 765178053..32f3bc70d 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -212,6 +212,8 @@ export async function prepareMedicationComplianceStatusForUser( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, }); @@ -304,7 +306,10 @@ export async function prepareMedicationComplianceStatusForUser( // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. - const dailyBudgeted = applyPayloadBudget(perDayRecords, { now }); + const dailyBudgeted = applyPayloadBudget(perDayRecords, { + now, + tz: userTz, + }); const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now); const latestDay = dailyBudgeted.daily[0] ?? null; diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 945ad3a6b..b7fcde718 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -236,11 +236,14 @@ export async function generateMetricStatus(args: { // to SLEEP_DURATION only so no other metric's status path changes. const isSleep = meta.measurementType === "SLEEP_DURATION"; + // v1.2.5 (M-TZ3) — resolve the user's timezone up front so the day + // bucketing below keys on the user's own calendar regardless of branch. + const userTz = await resolveUserTimezone(args.userId); + const points: Array<{ measuredAt: Date; value: number }> = []; let measurements: Array<{ measuredAt: Date }> = []; if (isSleep) { - const [tz, priorityJson, sleepRows] = await Promise.all([ - resolveUserTimezone(args.userId), + const [priorityJson, sleepRows] = await Promise.all([ loadUserSourcePriority(args.userId), prisma.measurement.findMany({ where: { @@ -263,7 +266,7 @@ export async function generateMetricStatus(args: { ]); const nights = reconstructSleepNights( sleepRows as SleepStageRow[], - tz, + userTz, priorityJson, ).filter((n) => n.asleepMinutes > 0); for (const n of nights) { @@ -288,7 +291,7 @@ export async function generateMetricStatus(args: { } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } - const series = applyPayloadBudget(points, { now }); + const series = applyPayloadBudget(points, { now, tz: userTz }); // SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that // path reads RAW per-stage rows (and the stage-summed `measurement_rollups` // tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 2f86a5a92..dc16fa790 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -26,13 +26,14 @@ import { } from "@/lib/insights/bucket-series"; import { round, summarizeSeries } from "@/lib/insights/status-shared"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { computeMoodNarratives, type MoodNarrative, } from "@/lib/insights/mood-narratives"; import { welchTTest } from "@/lib/insights/correlations"; import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery"; -import { toBerlinYmd } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinYmd } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; @@ -218,6 +219,7 @@ export function pairDailyBuckets( seriesA: DailyPoint[], seriesB: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -225,7 +227,7 @@ export function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); const [y, m, d] = dayKey.split("-").map(Number); return { a: entry.value, @@ -1512,10 +1514,11 @@ export interface WeekdayRow { export function computeWeekdayAverages( daily: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): WeekdayRow[] { const sums = new Map(); for (const bucket of daily) { - const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz); const d = new Date(dayKey + "T00:00:00Z"); const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0 const cur = sums.get(weekday) ?? { sum: 0, count: 0 }; @@ -1822,8 +1825,9 @@ export function computeMoodMetricCorrelation( moodDaily: DailyPoint[], metricDaily: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): MoodMetricCorrelation { - const pairs = pairDailyBuckets(moodDaily, metricDaily, now); + const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz); const result = pearsonCorrelation(pairs); const points = pairs.map((p) => ({ x: p.a, y: p.b })); return { result, points, n: pairs.length }; @@ -1933,15 +1937,22 @@ export function computeMoodAggregates(args: { * the default ladders. */ userPriorityJson?: unknown; + /** + * v1.2.5 (M-TZ3) — the user's IANA timezone. Threaded into every day + * bucketing pass so a near-midnight reading lands on the user's own + * calendar day. Defaults to Berlin for legacy / test callers. + */ + tz?: string; }): MoodAggregates { const { entries, measurements, now } = args; const userPriorityJson = args.userPriorityJson ?? null; + const tz = args.tz ?? DEFAULT_TIMEZONE; const moodPoints = entries.map((entry) => ({ measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz }); const moodDaily = moodSeries.daily; const moodSummary = summarizeSeries( @@ -1968,12 +1979,12 @@ export function computeMoodAggregates(args: { measurements .filter((m) => m.type === type) .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz }, ).daily; const windowDays = selectHeatmapWindow(totalSpanDays); - const weekday = computeWeekdayAverages(moodDaily, now); + const weekday = computeWeekdayAverages(moodDaily, now, tz); const timeOfDay = computeTimeOfDayAverages(entries); const stability = computeMoodStability(moodDaily); const tags = computeTagSummary(entries, now); @@ -1984,7 +1995,7 @@ export function computeMoodAggregates(args: { const correlations = Object.fromEntries( Object.entries(CORRELATION_METRICS).map(([key, type]) => [ key, - computeMoodMetricCorrelation(moodDaily, metricDaily(type), now), + computeMoodMetricCorrelation(moodDaily, metricDaily(type), now, tz), ]), ) as MoodAggregates["correlations"]; @@ -2042,6 +2053,7 @@ export function computeMoodAggregates(args: { inTargetPct, loggedDayKeys, now, + tz, }), correlations, }; @@ -2205,12 +2217,16 @@ export async function fetchMoodAggregates( }); } - const userPriorityJson = await loadUserSourcePriority(userId); + const [userPriorityJson, tz] = await Promise.all([ + loadUserSourcePriority(userId), + resolveUserTimezone(userId), + ]); return computeMoodAggregates({ entries, measurements, now, userPriorityJson, + tz, }); } diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 243d26e22..ef8107136 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -115,8 +115,12 @@ function mean(values: number[]): number | null { } /** Weekday (0 = Monday … 6 = Sunday) for a daily-bucket offset. */ -function weekdayOfOffset(now: Date, dayOffset: number): number { - const dayKey = dayOffsetToBerlinDayKey(now, dayOffset); +function weekdayOfOffset( + now: Date, + dayOffset: number, + tz: string = DEFAULT_TIMEZONE, +): number { + const dayKey = dayOffsetToBerlinDayKey(now, dayOffset, tz); const d = new Date(dayKey + "T00:00:00Z"); return (d.getUTCDay() + 6) % 7; } @@ -221,7 +225,11 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null { const weekend: number[] = []; const weekday: number[] = []; for (const bucket of input.daily) { - const wd = weekdayOfOffset(input.now, bucket.dayOffset); + const wd = weekdayOfOffset( + input.now, + bucket.dayOffset, + input.tz ?? DEFAULT_TIMEZONE, + ); if (wd >= 5) weekend.push(bucket.value); else weekday.push(bucket.value); } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index fee01ce2f..4a90e6c96 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -10,7 +10,7 @@ import { computeSteadyRun, pickVarietyLead, } from "@/lib/insights/assessment-context"; -import { pearsonCorrelation } from "@/lib/analytics/correlations"; +import { significantPearsonCorrelation } from "@/lib/analytics/correlations"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { type TagInfluenceRow, @@ -62,7 +62,7 @@ import { import { annotate } from "@/lib/logging/context"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver"; /** * Drop the ranking-only `pooledSd` before a tag-influence row enters the @@ -289,13 +289,17 @@ export async function prepareMoodStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar day. + const userTz = await resolveUserTimezone(userId); + const moodPoints = entries.map((entry) => ({ measuredAt: entry.moodLoggedAt, value: entry.score, })); // `applyPayloadBudget` daily buckets drive the in-target %, latest, // and correlations; the compact graded series reaches the prompt. - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -384,21 +388,21 @@ export async function prepareMoodStatusForUser( measurements .filter((m) => m.type === "WEIGHT") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( measurements .filter((m) => m.type === "BLOOD_PRESSURE_SYS") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); const pulseSeries = applyPayloadBudget( measurements .filter((m) => m.type === "PULSE") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); // Correlations between mood and other metrics — pair on dayOffset so @@ -407,22 +411,27 @@ export async function prepareMoodStatusForUser( moodSeries.daily, weightSeries.daily, now, + userTz, ); - const moodVsWeightCorrelation = pearsonCorrelation(moodVsWeightPairs); + const moodVsWeightCorrelation = + significantPearsonCorrelation(moodVsWeightPairs); const moodVsSysPairs = pairDailyBuckets( moodSeries.daily, sysSeries.daily, now, + userTz, ); - const moodVsSysCorrelation = pearsonCorrelation(moodVsSysPairs); + const moodVsSysCorrelation = significantPearsonCorrelation(moodVsSysPairs); const moodVsPulsePairs = pairDailyBuckets( moodSeries.daily, pulseSeries.daily, now, + userTz, ); - const moodVsPulseCorrelation = pearsonCorrelation(moodVsPulsePairs); + const moodVsPulseCorrelation = + significantPearsonCorrelation(moodVsPulsePairs); // Extract tag frequencies from recent entries — keep the v1.4.5 // ~90-day window so the model still gets a recency-weighted view of @@ -438,7 +447,7 @@ export async function prepareMoodStatusForUser( // board's metric factors are limited to those three channels (sleep / // steps are absent here by design — the model sees only what it fetched). const tagInfluence = computeTagInfluence(entries, now); - const emptyCorrelation = computeMoodMetricCorrelation([], [], now); + const emptyCorrelation = computeMoodMetricCorrelation([], [], now, userTz); const betterDays = computeBetterDays(tagInfluence, { sleep: emptyCorrelation, steps: emptyCorrelation, @@ -446,16 +455,19 @@ export async function prepareMoodStatusForUser( moodSeries.daily, pulseSeries.daily, now, + userTz, ), weight: computeMoodMetricCorrelation( moodSeries.daily, weightSeries.daily, now, + userTz, ), bloodPressureSystolic: computeMoodMetricCorrelation( moodSeries.daily, sysSeries.daily, now, + userTz, ), }); @@ -488,13 +500,14 @@ export async function prepareMoodStatusForUser( const narratives = computeMoodNarratives({ daily: moodSeries.daily, - weekday: computeWeekdayAverages(moodSeries.daily, now), + weekday: computeWeekdayAverages(moodSeries.daily, now, userTz), timeOfDay, tags: tagSummary, structuredTags: structuredTagSummary, inTargetPct: inTargetPctLast30DailyPoints, loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))), now, + tz: userTz, }); // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index a9d423bf0..b0862cdfd 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -227,7 +227,7 @@ export async function preparePulseStatusForUser( // `applyPayloadBudget` daily buckets still drive the derived stats // below (latest, in-target %, delta). They are NOT embedded in the // prompt — the compact graded series replaces the full daily array. - const pulseSeries = applyPayloadBudget(pulsePoints, { now }); + const pulseSeries = applyPayloadBudget(pulsePoints, { now, tz: userTz }); // Primary metric: recent / weekly fold from the bounded raw read, the // monthly / yearly tail comes from the MONTH / YEAR rollup tier (with // a full-history in-memory fallback on a cold-tier coverage miss). @@ -252,7 +252,7 @@ export async function preparePulseStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 795d522af..637abe119 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -4,7 +4,7 @@ import { getWeightUserPrompt, } from "@/lib/ai/prompts/weight"; import { - pearsonCorrelation, + significantPearsonCorrelation, type PairedPoint, } from "@/lib/analytics/correlations"; import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -54,7 +54,8 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Cap on the embedded correlation pair arrays. The Pearson coefficient @@ -74,6 +75,7 @@ function pairDailyBuckets( seriesA: DailyBucket[], seriesB: DailyBucket[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -81,7 +83,7 @@ function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); const [y, m, d] = dayKey.split("-").map(Number); return { a: entry.value, @@ -279,6 +281,10 @@ export async function prepareWeightStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar day. + const userTz = await resolveUserTimezone(userId); + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") @@ -286,7 +292,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( @@ -296,7 +302,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const diaSeries = applyPayloadBudget( @@ -306,7 +312,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); // Fetch mood context (optional — for enrichment only). v1.4.28 @@ -325,7 +331,7 @@ export async function prepareWeightStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })), - { now }, + { now, tz: userTz }, ); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -348,7 +354,7 @@ export async function prepareWeightStatusForUser( ]); const moodGraded = buildGradedSeriesFromPoints( moodSeries.daily.map((b) => ({ - measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset, userTz)), value: b.value, })), now, @@ -358,13 +364,17 @@ export async function prepareWeightStatusForUser( weightSeries.daily, sysSeries.daily, now, + userTz, + ); + const weightVsSystolicCorrelation = significantPearsonCorrelation( + weightVsSystolicPairs, ); - const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); const pairedSystolicDiastolic = pairDailyBuckets( sysSeries.daily, diaSeries.daily, now, + userTz, ).map((entry) => ({ day: entry.dayKey, sys: entry.a, @@ -376,7 +386,7 @@ export async function prepareWeightStatusForUser( // pairDailyBuckets — derive dayOffset from the offsets in sysSeries. const sysOffsetByDay = new Map( sysSeries.daily.map((bucket) => { - const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, userTz); return [dayKey, bucket.dayOffset]; }), ); @@ -392,14 +402,16 @@ export async function prepareWeightStatusForUser( weightSeries.daily, bpMeanDaily, now, + userTz, ); - const weightVsMeanBpCorrelation = pearsonCorrelation(weightVsMeanBpPairs); + const weightVsMeanBpCorrelation = + significantPearsonCorrelation(weightVsMeanBpPairs); // daily[0] = newest bucket (lowest dayOffset). const latestWeight = weightSeries.daily[0] ?? null; const previousWeight = weightSeries.daily[1] ?? null; const latestWeightDay = latestWeight - ? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset) + ? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset, userTz) : null; const sameDayBp = latestWeightDay ? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ?? @@ -513,8 +525,9 @@ export async function prepareWeightStatusForUser( moodSeries.daily, weightSeries.daily, now, + userTz, ); - return pearsonCorrelation(moodVsWeightPairs); + return significantPearsonCorrelation(moodVsWeightPairs); })(), } : null, diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 927eccdba..a71a94478 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -51,6 +51,10 @@ export interface ComplianceMedicationInput { scheduleRevisions?: Parameters< typeof buildComplianceMedicationContext >[0]["scheduleRevisions"]; + /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ + pauseEras?: Parameters< + typeof buildComplianceMedicationContext + >[0]["pauseEras"]; } /** diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6e2f44885..89b728e66 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -303,6 +303,8 @@ export async function buildTargetsResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, });