Merge branch 'wave/v125-corr' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:39:56 +02:00
commit 517b5d452f
48 files changed
+1767 -206

No files matched your search

@@ -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;
+48 -10
View File
@@ -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)
@@ -128,6 +128,11 @@ beforeEach(() => {
dateOfBirth: new Date("1985-01-01"),
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
+52 -2
View File
@@ -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<string, { sum: number; count: number }>();
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(
+2
View File
@@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) {
symptomsPresent: parsed.data.measurements.some(
(m) => m.symptomsPresent === true,
),
timezone: user.timezone ?? undefined,
}).catch(() => {});
// v1.5.0 — refresh the persistent rollup table for every distinct
@@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) {
},
],
symptomsPresent,
timezone: user.timezone ?? undefined,
}).catch(() => {});
// v1.5.0 — refresh the persistent rollup row for the affected
@@ -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".
+23
View File
@@ -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
@@ -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" },
});
+2
View File
@@ -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 } },
},
});
@@ -68,6 +68,43 @@ describe("estimateDailyDoseCount", () => {
).toBeCloseTo(1 / 30);
});
it("treats a once-weekly RRULE as one dose per 7 days", () => {
// FREQ=WEEKLY;BYDAY=MO is the modern once-weekly injection encoding;
// it must NOT fall through to the daysPerWeek=7 legacy branch.
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO" }),
]),
).toBeCloseTo(1 / 7);
});
it("halves a bi-weekly RRULE via INTERVAL=2 (one dose per 14 days)", () => {
expect(
estimateDailyDoseCount([
schedule({
timesOfDay: ["08:00"],
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO",
}),
]),
).toBeCloseTo(1 / 14);
});
it("counts each BYDAY pick on a weekly RRULE (Mon+Thu → 2 per 7 days)", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO,TH" }),
]),
).toBeCloseTo(2 / 7);
});
it("defaults a weekly RRULE with no BYDAY to one pick per week", () => {
expect(
estimateDailyDoseCount([
schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY" }),
]),
).toBeCloseTo(1 / 7);
});
it("sums across multiple schedules", () => {
expect(
estimateDailyDoseCount([
@@ -46,6 +46,25 @@ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number {
perDay += times / 365;
continue;
}
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
// create path stores the cadence on the rrule and leaves daysOfWeek
// empty). Honour the BYDAY pick count and the week INTERVAL: a
// once-weekly injection is one dose per 7 days, a bi-weekly one per
// 14. Without this branch a weekly rrule fell through to the legacy
// daysOfWeek fallback below with daysPerWeek=7, over-estimating the
// rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early.
if (/FREQ=WEEKLY/.test(rrule)) {
const byday = /BYDAY=([^;]+)/.exec(rrule);
const bydayCount =
byday && byday[1].length > 0 ? byday[1].split(",").length : 1;
const intervalMatch = /INTERVAL=(\d+)/.exec(rrule);
const interval =
intervalMatch && Number(intervalMatch[1]) >= 1
? Number(intervalMatch[1])
: 1;
perDay += (times * bydayCount) / (7 * interval);
continue;
}
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7;
const weeks = intervalWeeks >= 1 ? intervalWeeks : 1;
+2
View File
@@ -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 } },
@@ -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);
});
});
@@ -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<string, boolean>([
["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<string, boolean>([
["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<Array<{ id: string; measuredAt: Date; value: number }>> => {
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);
});
});
});
@@ -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();
});
});
+56 -1
View File
@@ -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;
}
/**
+57 -36
View File
@@ -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<string, number>;
let dailyPulseMean: Map<string, number>;
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<string, number[]>();
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<string, number>();
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<ChunkedRow[]> {
const out: ChunkedRow[] = [];
+47
View File
@@ -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 };
}
+28 -20
View File
@@ -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),
+6
View File
@@ -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,
@@ -0,0 +1,290 @@
/**
* v1.25 — on-write sustained fever / low-SpO2 escalation (finding H-CS1).
*
* The retrospective correlation detector classifies a 3-day fever or sustained
* low-SpO2 run as "seek care", but that escalation only fired when the user
* opened the episode's correlation tab. These tests pin the on-write seam:
* a sustained run escalates on INGEST through the SAME illness red-flag push,
* with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector
* uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm.
*
* Calendar-day bucketing runs against TZ=UTC (the gate's contract).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */
const ledger: Array<{ userId: string; reason: string }> = [];
const measurementFindMany = vi.fn();
const dispatchMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...a: unknown[]) => measurementFindMany(...a),
},
pushAttempt: {
findFirst: ({ where }: { where: { userId: string; reason: string } }) => {
const hit = ledger.find(
(r) => r.userId === where.userId && r.reason === where.reason,
);
return Promise.resolve(hit ? { id: "prior" } : null);
},
create: ({ data }: { data: { userId: string; reason: string } }) => {
ledger.push({ userId: data.userId, reason: data.reason });
return Promise.resolve({});
},
},
},
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }),
}));
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
}));
import { runSafetyFloorCheck } from "../safety-floor-check";
/** A measured-at on a given UTC calendar day, mid-morning. */
function utcDay(day: string): Date {
return new Date(`${day}T08:00:00Z`);
}
beforeEach(() => {
ledger.length = 0;
measurementFindMany.mockReset();
dispatchMock.mockReset().mockResolvedValue(undefined);
});
afterEach(() => vi.clearAllMocks());
describe("runSafetyFloorCheck — sustained fever", () => {
it("escalates once on a 3-day calendar-consecutive fever run", async () => {
// °C ≥ 38.5 on three consecutive UTC days.
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.1, measuredAt: utcDay("2026-01-02") },
{ value: 38.8, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const opts = dispatchMock.mock.calls[0][0];
expect(opts.eventType).toBe("SYSTEM_ALERT");
expect(opts.urgent).toBe(true);
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
expect(opts.messageKey).toBe("illness.correlation.redFlagFever");
});
it("does not re-fire on a 4th reading inside the dedupe window", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.1, measuredAt: utcDay("2026-01-02") },
{ value: 38.8, measuredAt: utcDay("2026-01-01") },
]);
// First ingest — fires.
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
// A later same-day reading — run still holds, but the ledger anchor dedupes.
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.2,
measuredAt: new Date("2026-01-03T20:00:00Z"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
});
it("does NOT escalate on a 2-day fever run (below RED_FLAG_RUN_DAYS)", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-02") },
{ value: 38.9, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-02"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("does NOT escalate on three NON-consecutive febrile days", async () => {
measurementFindMany.mockResolvedValue([
{ value: 39.0, measuredAt: utcDay("2026-01-05") },
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.0, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-05"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("does NOT escalate when the run sits below the fever floor", async () => {
// 38.0 °C < 38.5 °C floor — elevated but not the escalation band.
measurementFindMany.mockResolvedValue([
{ value: 38.0, measuredAt: utcDay("2026-01-03") },
{ value: 38.0, measuredAt: utcDay("2026-01-02") },
{ value: 38.0, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 38.0,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
});
describe("runSafetyFloorCheck — sustained low SpO2", () => {
it("escalates once on a 3-day calendar-consecutive low-SpO2 run", async () => {
// ≤ 92% on three consecutive UTC days (per-day MIN is the worst).
measurementFindMany.mockResolvedValue([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 91, measuredAt: utcDay("2026-01-02") },
{ value: 92, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
const opts = dispatchMock.mock.calls[0][0];
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2");
});
it("does NOT escalate when a single day recovers above the floor", async () => {
measurementFindMany.mockResolvedValue([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 96, measuredAt: utcDay("2026-01-02") }, // breaks the run
{ value: 90, measuredAt: utcDay("2026-01-01") },
]);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).not.toHaveBeenCalled();
});
it("a fever escalation never suppresses a concurrent SpO2 one (distinct dedupe keys)", async () => {
// Two independent runs landing on the same write: both should fire once.
measurementFindMany.mockImplementation(
({ where }: { where: { type: string } }) => {
if (where.type === "BODY_TEMPERATURE") {
return Promise.resolve([
{ value: 39.0, measuredAt: utcDay("2026-01-03") },
{ value: 39.0, measuredAt: utcDay("2026-01-02") },
{ value: 39.0, measuredAt: utcDay("2026-01-01") },
]);
}
return Promise.resolve([
{ value: 89, measuredAt: utcDay("2026-01-03") },
{ value: 90, measuredAt: utcDay("2026-01-02") },
{ value: 91, measuredAt: utcDay("2026-01-01") },
]);
},
);
await runSafetyFloorCheck({
userId: "u1",
written: [
{
type: "BODY_TEMPERATURE",
value: 39.0,
measuredAt: utcDay("2026-01-03"),
},
{
type: "OXYGEN_SATURATION",
value: 89,
measuredAt: utcDay("2026-01-03"),
},
],
timezone: "UTC",
});
expect(dispatchMock).toHaveBeenCalledTimes(2);
const keys = dispatchMock.mock.calls.map((c) => c[0].messageKey).sort();
expect(keys).toEqual([
"illness.correlation.redFlagFever",
"illness.correlation.redFlagSpo2",
]);
});
});
+8 -2
View File
@@ -408,8 +408,14 @@ export interface IllnessRedFlag {
const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT;
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
const FEVER_RED_FLAG = FEVER_RED_FLAG_C;
/** Consecutive days an adverse clinical threshold must hold to escalate. */
const RED_FLAG_RUN_DAYS = 3;
/**
* Consecutive days an adverse clinical threshold must hold to escalate.
* Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a
* sustained fever / low-SpO2 run on ingest using the SAME run length as this
* retrospective detector — the two paths can never disagree on what "sustained"
* means.
*/
export const RED_FLAG_RUN_DAYS = 3;
/* ── pure helpers ─────────────────────────────────────────────────────── */
+230
View File
@@ -15,9 +15,11 @@
* The caller (the measurement POST route) passes the just-written rows plus
* the transient `symptomsPresent` flag; this helper does the bounded reads.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
import {
evaluateBloodPressure,
evaluateGlucose,
@@ -26,6 +28,13 @@ import {
type GlucoseSample,
} from "@/lib/illness/safety-floors";
import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify";
import {
RED_FLAG_RUN_DAYS,
dayDiff,
type IllnessRedFlag,
} from "@/lib/illness/correlation";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
import { userDayKey } from "@/lib/tz/format";
/** A row the write hook just persisted (the subset the check needs). */
export interface WrittenReading {
@@ -46,14 +55,30 @@ export async function runSafetyFloorCheck(input: {
userId: string;
written: WrittenReading[];
symptomsPresent?: boolean;
/**
* The user's IANA timezone, used to bucket the sustained fever / SpO2 run by
* CALENDAR day. Defaults to UTC when absent — a missing timezone must never
* silence a safety escalation, only shift the day boundary.
*/
timezone?: string;
}): Promise<void> {
const { userId, written } = input;
const symptomCoupled = input.symptomsPresent === true;
const tz =
input.timezone && input.timezone.length > 0 ? input.timezone : "UTC";
try {
await Promise.all([
checkBloodPressure(userId, written, symptomCoupled),
checkGlucose(userId, written, symptomCoupled),
// Sustained multi-day runs against ABSOLUTE clinical floors. Unlike the
// confirm-gated BP / glucose checks above (single breach + a confirming
// re-test inside an hour), a fever / low-SpO2 escalation only fires when
// the floor held for RED_FLAG_RUN_DAYS calendar-consecutive days — the
// SAME run length and floors the retrospective correlation detector uses,
// so the on-write seam and the correlation surface can never disagree.
checkSustainedFever(userId, written, tz),
checkSustainedLowSpo2(userId, written, tz),
]);
} catch (err) {
getEvent()?.addWarning(
@@ -171,3 +196,208 @@ async function checkGlucose(
const decision = evaluateGlucose({ candidate, recent, symptomCoupled });
await notifySafetyFloor({ userId, decision });
}
/* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */
/**
* Lookback span (calendar days, inclusive of the candidate day) the sustained
* run scan reads. RED_FLAG_RUN_DAYS days are needed to form a run that ends on
* the candidate day; one extra day absorbs UTC-vs-local boundary skew so a run
* that ends "today" in the user's timezone is never clipped. We only react to
* RECENT runs by design — an old run is the correlation surface's job, not the
* write hook's.
*/
const SUSTAINED_LOOKBACK_DAYS = RED_FLAG_RUN_DAYS + 1;
/**
* Sustained fever: BODY_TEMPERATURE ≥ FEVER_RED_FLAG_C (°C) for
* RED_FLAG_RUN_DAYS calendar-consecutive days. Mirrors the correlation
* detector's fever path — per-day MAX is the worst (an evening spike must not
* be masked by a daily mean) and the floor + run length are the shared
* constants. Fires the SAME illness red-flag escalation the retrospective path
* fires, so the notification class matches exactly.
*/
async function checkSustainedFever(
userId: string,
written: WrittenReading[],
tz: string,
): Promise<void> {
const fresh = written.find((r) => r.type === "BODY_TEMPERATURE");
if (!fresh) return;
const days = await loadDailyWorst(
userId,
"BODY_TEMPERATURE",
fresh.measuredAt,
tz,
"max",
);
const flag = sustainedRunFlag(
days,
(v) => v >= FEVER_RED_FLAG_C,
"sustained_fever",
"BODY_TEMPERATURE",
"max",
);
if (!flag) return;
await notifyIllnessRedFlag({
userId,
episodeId: SUSTAINED_DEDUPE_KEY.sustained_fever,
redFlags: [flag],
});
}
/**
* Sustained low SpO2: OXYGEN_SATURATION ≤ SPO2_RED_FLAG_PCT for
* RED_FLAG_RUN_DAYS calendar-consecutive days. SpO2's worst is the per-day MIN,
* matching the correlation detector. Fires the SAME illness red-flag escalation.
*/
async function checkSustainedLowSpo2(
userId: string,
written: WrittenReading[],
tz: string,
): Promise<void> {
const fresh = written.find((r) => r.type === "OXYGEN_SATURATION");
if (!fresh) return;
const days = await loadDailyWorst(
userId,
"OXYGEN_SATURATION",
fresh.measuredAt,
tz,
"min",
);
const flag = sustainedRunFlag(
days,
(v) => v <= SPO2_RED_FLAG_PCT,
"sustained_low_spo2",
"OXYGEN_SATURATION",
"min",
);
if (!flag) return;
await notifyIllnessRedFlag({
userId,
episodeId: SUSTAINED_DEDUPE_KEY.sustained_low_spo2,
redFlags: [flag],
});
}
/**
* Synthetic, stable per-reason dedupe anchors. The on-write sustained checks
* may run with no active illness episode, so they cannot key the red-flag
* dedupe ledger on a real episode id. These per-reason keys ride the SAME
* `pushAttempt` ledger + 24h window `notifyIllnessRedFlag` already enforces, so
* a stubbornly febrile user logging a 4th reading inside the day re-runs the
* detector but never re-fires the alarm. Distinct keys per reason so a fever
* escalation never suppresses a concurrent SpO2 one.
*/
const SUSTAINED_DEDUPE_KEY: Record<IllnessRedFlag["reason"], string> = {
sustained_fever: "onwrite:sustained_fever",
sustained_low_spo2: "onwrite:sustained_low_spo2",
};
/** A per-day worst value, keyed by the user-timezone calendar day. */
interface DailyWorst {
day: string;
worst: number;
}
/**
* Read the recent same-type readings in the lookback window and collapse them
* to one worst value per user-timezone calendar day (MAX for fever, MIN for
* SpO2). Sorted chronologically so the run scan sees calendar order. Bounded
* read (window + row cap); the candidate's own freshly-written rows are
* INCLUDED so a run that completes on this very write escalates.
*/
async function loadDailyWorst(
userId: string,
type: MeasurementType,
candidateAt: Date,
tz: string,
worst: "min" | "max",
): Promise<DailyWorst[]> {
const windowStart = new Date(
candidateAt.getTime() - SUSTAINED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: windowStart, lte: candidateAt },
},
orderBy: { measuredAt: "desc" },
take: 500,
select: { value: true, measuredAt: true },
});
const byDay = new Map<string, number>();
for (const r of rows) {
const day = userDayKey(r.measuredAt, tz);
const prev = byDay.get(day);
if (prev === undefined) {
byDay.set(day, r.value);
} else {
byDay.set(
day,
worst === "min" ? Math.min(prev, r.value) : Math.max(prev, r.value),
);
}
}
return [...byDay.entries()]
.map(([day, w]) => ({ day, worst: w }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
/**
* Longest CALENDAR-consecutive run matching `predicate`; returns an
* {@link IllnessRedFlag} when the run reaches RED_FLAG_RUN_DAYS, else null.
* Mirrors the correlation engine's `runFlag`: present-days-only points (never
* zero-filled), so a sparse febrile series (days 01/10/15) must NOT count as
* one long run — a point only extends the run when it is the calendar day
* immediately after the previous matching one (`dayDiff === 1`).
*/
function sustainedRunFlag(
sorted: DailyWorst[],
predicate: (v: number) => boolean,
reason: IllnessRedFlag["reason"],
type: MeasurementType,
worst: "min" | "max",
): IllnessRedFlag | null {
let bestRun = 0;
let bestWorst: number | null = null;
let run = 0;
let runWorst: number | null = null;
let prevDay: string | null = null;
for (const p of sorted) {
if (predicate(p.worst)) {
const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1;
if (adjacent) {
run++;
runWorst =
runWorst === null
? p.worst
: worst === "min"
? Math.min(runWorst, p.worst)
: Math.max(runWorst, p.worst);
} else {
run = 1;
runWorst = p.worst;
}
if (run > bestRun) {
bestRun = run;
bestWorst = runWorst;
}
prevDay = p.day;
} else {
run = 0;
runWorst = null;
prevDay = null;
}
}
if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) {
return { type, reason, worstValue: bestWorst, days: bestRun };
}
return null;
}
@@ -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");
+34 -18
View File
@@ -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<PairedPoint & { dayKey: string }> {
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<string, number>();
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,
+6 -2
View File
@@ -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)
+42 -29
View File
@@ -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<number, { sum: number; n: number }>();
const monthly = new Map<number, { sum: number; n: number }>();
@@ -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) {
@@ -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" },
});
+2
View File
@@ -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 } },
},
});
+23 -7
View File
@@ -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 })),
@@ -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;
+7 -4
View File
@@ -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
+25 -9
View File
@@ -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<PairedPoint & { dayKey: string }> {
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<number, { sum: number; count: number }>();
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,
});
}
+11 -3
View File
@@ -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);
}
+24 -11
View File
@@ -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
+2 -2
View File
@@ -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 })),
+26 -13
View File
@@ -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<PairedPoint & { dayKey: string }> {
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,
@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { userDayKey } from "@/lib/tz/format";
/**
* Wearable daily-record anchoring (H-TZ1 / H-TZ2). Day-grained (date-only)
* daily records from Oura / Polar / Fitbit must anchor at NOON, not UTC
* midnight: a UTC-midnight anchor (`${day}T00:00:00.000Z`) double-shifts the
* calendar day for west-of-UTC users on read, because the read path
* re-buckets via `userDayKey(measuredAt, userTz)`. A noon anchor sits a full
* 12 h inside the day, so it round-trips back to the same calendar day for
* every IANA zone within ±12 h of UTC.
*
* These tests pin that invariant directly:
* userDayKey(canonicalDailyTimestamp(day, tz), tz) === day
*/
describe("canonicalDailyTimestamp — day round-trip across TZ offsets", () => {
const days = [
"2026-06-01",
"2026-01-01",
"2026-12-31",
"2025-03-09", // US spring-forward
"2025-11-02", // US fall-back
];
const zones = [
"UTC",
"America/Los_Angeles", // negative offset (UTC-7/-8)
"America/New_York", // negative offset (UTC-4/-5)
"Asia/Tokyo", // positive offset (UTC+9, no DST)
"Pacific/Kiritimati", // extreme positive (UTC+14)
"Etc/GMT+12", // extreme negative (UTC-12)
"Asia/Kathmandu", // sub-hour offset (UTC+5:45)
];
for (const tz of zones) {
for (const day of days) {
it(`round-trips ${day} in ${tz}`, () => {
const anchor = canonicalDailyTimestamp(day, tz);
expect(userDayKey(anchor, tz)).toBe(day);
});
}
}
it("anchors at the user's local noon when a tz is given", () => {
// 2026-06-01 noon in America/Los_Angeles (PDT, UTC-7) === 19:00 UTC.
const anchor = canonicalDailyTimestamp("2026-06-01", "America/Los_Angeles");
expect(anchor.toISOString()).toBe("2026-06-01T19:00:00.000Z");
});
});
describe("canonicalDailyTimestamp — noon-UTC fallback (no tz)", () => {
it("anchors at 12:00 UTC when tz is omitted", () => {
const anchor = canonicalDailyTimestamp("2026-06-01");
expect(anchor.toISOString()).toBe("2026-06-01T12:00:00.000Z");
});
it("the noon-UTC fallback round-trips for every zone within ±12 h", () => {
// This is the shape the wearable mappers (Oura/Polar/Fitbit) take, since
// they do not have the user's timezone in scope at map time.
const day = "2026-06-01";
const anchor = canonicalDailyTimestamp(day); // 12:00 UTC
for (const tz of [
"UTC",
"America/Los_Angeles",
"America/New_York",
"Asia/Tokyo",
"Australia/Sydney",
"Etc/GMT+11",
"Etc/GMT-11",
]) {
expect(userDayKey(anchor, tz)).toBe(day);
}
});
});
@@ -1,5 +1,14 @@
import { describe, expect, it, vi } from "vitest";
// The SUM drain now recomputes the affected DAY rollup bucket after each
// per-day fold (so a drained day's stale double-counted sum self-heals).
// Mock it: these are pure-logic tests over a Prisma mock with no DB, and
// the real recompute issues raw SQL against the singleton client.
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
}));
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
bucketRowsByUserDay,
canonicalDailyTimestamp,
@@ -444,6 +453,26 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () =
const createArg = create.mock.calls[0]?.[0] as { data: { value: number } };
expect(createArg.data.value).toBe(500);
});
it("recomputes the DAY rollup bucket for the drained day so the stale sum self-heals", async () => {
vi.mocked(recomputeBucketsForMeasurement).mockClear();
const { prisma } = buildFoldMock(null);
await drainPerSampleCumulative(prisma, { log: () => {} });
// Without this the rollup tier keeps serving the pre-drain
// double-counted sumValue forever (it reads only deleted_at IS NULL).
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1);
const [userId, type, canonicalTs] = vi.mocked(
recomputeBucketsForMeasurement,
).mock.calls[0]!;
expect(userId).toBe("user-1");
expect(type).toBe("ACTIVITY_STEPS");
// Canonical local-noon of the Berlin day 2026-05-16 (CEST, UTC+2).
expect((canonicalTs as Date).toISOString()).toBe(
"2026-05-16T10:00:00.000Z",
);
});
});
// Root cause of the v1.18.10 bloat: a single day whose mint collided with the
@@ -39,6 +39,7 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { dailyStatsExternalId } from "./apple-health-mapping";
import {
@@ -259,6 +260,16 @@ export async function consolidateLegacySteps(
});
removed = del.count;
});
// Re-aggregate the affected (user, type, day) rollup bucket after the
// soft-delete commits. The legacy granular rows that just dropped out
// of the live read were being summed alongside any existing daily-stats
// total, so the DAY rollup bucket carried a double-counted total. The
// rollup tier reads only `deleted_at IS NULL`; without this recompute
// the stale bucket persists forever. Mirrors the mean drain's
// post-write recompute exactly.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
return { kind: "written", sourceRowsRemoved: removed };
} catch (err) {
// A pre-existing row can collide on the second unique index
+21 -7
View File
@@ -88,18 +88,32 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number {
}
/**
* Compute the canonical timestamp for a calendar-day key in the user's
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
* — matches the Withings activity sync convention (one daily row per
* type, anchored to midday so the row sorts cleanly between same-day
* spot samples). The string returned by `toISOString()` is UTC.
* Compute the canonical timestamp for a calendar-day key. With a `tz`,
* returns the JS-Date instant at the user's local 12:00 noon; without
* one, returns 12:00 UTC of the day. Either anchor sits a full 12 h
* inside its calendar day, so it round-trips back to the same day
* through `userDayKey()` for every zone within ±12 h — which is the
* whole point of anchoring date-only daily records at noon rather than
* midnight (a UTC-midnight anchor double-shifts the day for west-of-UTC
* users on read). Matches the Withings activity sync convention (one
* daily row per type, anchored to midday so the row sorts cleanly
* between same-day spot samples). The string returned by
* `toISOString()` is UTC.
*
* The optional-`tz` shape is what the wearable daily mappers (Oura,
* Polar, Fitbit) anchor on — they do not have the user's timezone in
* scope at map time, so they take the noon-UTC fallback, which is
* correct for every zone within ±12 h.
*/
export function canonicalDailyTimestamp(dateKey: string, tz: string): Date {
export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date {
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
// No timezone in scope: 12:00 UTC is a safe day-stable anchor for
// every zone within ±12 h.
if (!tz) return utcNoon;
// Compute the UTC offset for noon-local of the given day. We don't
// have a lightweight TZ-math library on the server, so the trick is:
// build "12:00 UTC of the day", read what wall-clock that shows in
// the target zone, then shift by the resulting offset.
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz);
// utcNoon represents 12:00 UTC. The user's local clock reads
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
@@ -34,6 +34,7 @@
import { Prisma } from "@/generated/prisma/client";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import {
CUMULATIVE_HK_TYPES,
@@ -355,6 +356,15 @@ export async function drainPerSampleCumulative(
if (!isUniqueConstraintViolation(err)) throw err;
removed = await foldOnce();
}
// Re-aggregate the affected (user, type, day) rollup bucket after the
// fold commits — the per-sample rows are now gone and the canonical
// daily-sum row is the only live reading. The rollup tier reads only
// `deleted_at IS NULL`, so without this the day's DAY bucket keeps the
// stale pre-drain (double-counted) sumValue forever and never
// self-heals. Mirrors the mean drain's post-write recompute exactly.
await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp);
return { kind: "written", sourceRowsRemoved: removed };
},
recordBucket: ({
@@ -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"];
}
/**
+4 -2
View File
@@ -473,10 +473,12 @@ describe("mapResilience", () => {
}
});
it("anchors the row at the day's UTC midnight", () => {
it("anchors the date-only row at noon UTC so it round-trips the day", () => {
// Noon, not midnight: a UTC-midnight anchor double-shifts the calendar
// day for west-of-UTC users when the read path re-buckets via userDayKey.
const mapped = mapResilience({ day: "2026-06-10", level: "adequate" });
expect(mapped[0]?.measuredAt.toISOString()).toBe(
"2026-06-10T00:00:00.000Z",
"2026-06-10T12:00:00.000Z",
);
});
});
+11 -4
View File
@@ -14,6 +14,7 @@
* `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`.
*/
import { getEvent } from "@/lib/logging/context";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { safeFetch } from "@/lib/safe-fetch";
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
@@ -455,10 +456,16 @@ export interface MappedMeasurement {
}
function dayAnchor(day: string, bedtimeEnd?: string): Date | null {
// Prefer the precise wake instant when present; else anchor the day at UTC
// midnight. The recovery resolver reads the local day key off this stamp.
const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`;
const d = new Date(candidate);
// Prefer the precise wake instant when present; else anchor the date-only
// day at noon (canonicalDailyTimestamp), NOT UTC midnight — a midnight
// anchor double-shifts the calendar day for west-of-UTC users when the
// recovery resolver re-buckets via userDayKey(). Noon sits a full 12 h
// inside the day, so it round-trips to the same day for every zone ±12 h.
if (bedtimeEnd) {
const d = new Date(bedtimeEnd);
return Number.isNaN(d.getTime()) ? null : d;
}
const d = canonicalDailyTimestamp(day);
return Number.isNaN(d.getTime()) ? null : d;
}
+4 -2
View File
@@ -312,14 +312,16 @@ describe("mapSleep", () => {
);
});
it("falls back to a midnight-UTC anchor when the window is missing", () => {
it("falls back to a noon-UTC anchor when the window is missing", () => {
const mapped = mapSleep({
date: "2026-06-10",
light_sleep: 3600,
sleep_score: 80,
});
const core = mapped.find((m) => m.sleepStage === "CORE")!;
expect(core.measuredAt.toISOString()).toBe("2026-06-10T00:00:00.000Z");
// Noon, not midnight: keeps the date-only row on the same calendar day for
// every zone when the read path re-buckets via userDayKey.
expect(core.measuredAt.toISOString()).toBe("2026-06-10T12:00:00.000Z");
expect(core.reconstructed).toBeUndefined();
// No IN_BED envelope without a window.
expect(mapped.find((m) => m.sleepStage === "IN_BED")).toBeUndefined();
+8 -7
View File
@@ -21,6 +21,7 @@
* treated as success.
*/
import { getEvent } from "@/lib/logging/context";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
@@ -387,13 +388,13 @@ function rechargeStatusToScore(status: number): number {
* Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE`
* (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the
* nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`.
* `measuredAt` is the record's date at local midnight UTC anchor (the wake
* `measuredAt` is the record's date anchored at noon (the wake
* morning the night belongs to).
*/
export function mapNightlyRecharge(
r: PolarNightlyRecharge,
): MappedMeasurement[] {
const measuredAt = new Date(`${r.date}T00:00:00.000Z`);
const measuredAt = canonicalDailyTimestamp(r.date);
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
@@ -537,10 +538,10 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
return out;
}
// Fallback: no usable window. Anchor at midnight UTC of the wake date and emit
// Fallback: no usable window. Anchor at noon of the wake date and emit
// untimed stage rows (the pre-v1.17.1 shape) so a record missing the window
// fields still contributes a night total.
const measuredAt = new Date(`${s.date}T00:00:00.000Z`);
const measuredAt = canonicalDailyTimestamp(s.date);
if (Number.isNaN(measuredAt.getTime())) return [];
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec === "number" && sec >= 0) {
@@ -572,7 +573,7 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
* total `calories`, which includes BMR — matching the Fitbit active-only
* convention). */
export function mapActivity(a: PolarActivity): MappedMeasurement[] {
const measuredAt = new Date(`${a.date}T00:00:00.000Z`);
const measuredAt = canonicalDailyTimestamp(a.date);
if (Number.isNaN(measuredAt.getTime())) return [];
const out: MappedMeasurement[] = [];
const steps = a["active-steps"];
@@ -615,7 +616,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] {
* (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so
* guard on `number` only. */
export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] {
const measuredAt = new Date(`${c.date}T00:00:00.000Z`);
const measuredAt = canonicalDailyTimestamp(c.date);
if (Number.isNaN(measuredAt.getTime())) return [];
if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return [];
return [
@@ -632,7 +633,7 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] {
/** Map one Polar SpO2 test result: `blood_oxygen_percentage` →
* `OXYGEN_SATURATION` (percent, 0..100). */
export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
const measuredAt = new Date(`${r.date}T00:00:00.000Z`);
const measuredAt = canonicalDailyTimestamp(r.date);
if (Number.isNaN(measuredAt.getTime())) return [];
const pct = r.blood_oxygen_percentage;
if (typeof pct !== "number" || pct <= 0 || pct > 100) return [];
+2
View File
@@ -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" },
});