mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'wave/v125-corr' into release/v1.25.0
This commit is contained in:
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
@@ -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([]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user