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" }, });