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