From 37c6b0e14465181b29f483be1bacfadb62981384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 10:03:08 +0200 Subject: [PATCH] fix(medications): never mint a slot from before the live schedule era The projector floored today's slots only on an empty archived era, the reminder worker and the next-due reads on the newest revision boundary, and the auto-miss pass on the creation instant alone. Switching tracking back on and then moving a dose time the same afternoon therefore put the morning dose on the card, and the next day's pass stamped it a miss; a same-day times edit on any medication did the same. One helper now names the live era start (the newest active revision's boundary) and every path floors on it: the projector, the worker, the doses card, the medication list, the MCP schedule tool and the auto-miss pass, which leaves a placeholder minted after the switch for a slot before it. --- src/lib/dashboard/meds-today.ts | 8 +-- src/lib/jobs/intake-auto-skip.ts | 29 ++++++++- .../reminder/medication-reminder-check.ts | 13 ++-- src/lib/mcp/tools.ts | 8 +-- src/lib/medications/list-read.ts | 8 +-- .../scheduling/__tests__/live-era.test.ts | 36 ++++++++++ src/lib/medications/scheduling/live-era.ts | 65 +++++++++++++++++++ .../scheduling/project-today-intakes.ts | 36 ++++------ 8 files changed, 154 insertions(+), 49 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/live-era.test.ts create mode 100644 src/lib/medications/scheduling/live-era.ts diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 4a15cc486..7cefb726d 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -47,6 +47,7 @@ import { TRACKED_INTAKE_EVENT_WHERE, TRACKED_INTAKE_WHERE, } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; export interface MedsTodayDueCandidate { medicationId: string; @@ -198,11 +199,8 @@ export async function buildMedsTodayBlock( if (list) list.push(mark); else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const dueCandidates: MedsTodayDueCandidate[] = []; for (const m of medications) { diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index 18bec6cee..fbbad4e86 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -67,6 +67,10 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + LIVE_ERA_REVISION_ARGS, + liveEraStart, +} from "@/lib/medications/scheduling/live-era"; export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip"; @@ -249,16 +253,24 @@ export async function runIntakeAutoSkipPass( schedules: { select: { rrule: true, rollingIntervalDays: true, doseWindows: true }, }, + scheduleRevisions: LIVE_ERA_REVISION_ARGS, }, }); // Group medications by their derived delay so one `updateMany` covers // each distinct cutoff instead of one query per medication. - const medsByDelay = new Map>(); + const medsByDelay = new Map< + number, + Array<{ id: string; createdAt: Date; eraStart: Date | null }> + >(); for (const medication of medications) { const delayMs = medicationAutoMissDelayMs(medication.schedules); const group = medsByDelay.get(delayMs) ?? []; - group.push({ id: medication.id, createdAt: medication.createdAt }); + group.push({ + id: medication.id, + createdAt: medication.createdAt, + eraStart: liveEraStart(medication.scheduleRevisions), + }); medsByDelay.set(delayMs, group); } @@ -272,9 +284,22 @@ export async function runIntakeAutoSkipPass( scheduledFor: { lt: new Date(nowMs - delayMs) }, // Per medication, only slots from its creation on: a placeholder // for a slot before it was never an expected dose (see above). + // + // And only slots the schedule in force at the time expected: a + // placeholder anchored before the live era start but minted after it + // came from the NEW schedule projected back over the part of the + // day that belonged to the previous era (or to none, while intake + // tracking was off). Earlier placeholders minted before the switch + // were real expectations of the old era and still resolve. OR: group.map((m) => ({ medicationId: m.id, scheduledFor: { gte: m.createdAt }, + ...(m.eraStart && { + NOT: { + scheduledFor: { lt: m.eraStart }, + createdAt: { gte: m.eraStart }, + }, + }), })), }, // `syncVersion` bumps so delta-sync clients pick up the terminal diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 40ae77f48..fd46f6a15 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -40,6 +40,10 @@ import { import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + LIVE_ERA_REVISION_ARGS, + liveEraStart as liveEraStartOf, +} from "@/lib/medications/scheduling/live-era"; export interface ReminderCheckPayload { triggeredAt: string; @@ -174,12 +178,7 @@ export async function handleReminderCheck( include: { schedules: true, phaseConfig: true, - scheduleRevisions: { - where: { supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - take: 1, - select: { validUntil: true }, - }, + scheduleRevisions: LIVE_ERA_REVISION_ARGS, user: { select: { id: true, @@ -249,7 +248,7 @@ export async function handleReminderCheck( // skips and USER_PIN decisions are mutations, whose Prisma @updatedAt // timestamp records the decision. Ordinary takes use their takenAt. const liveEraStart = - med.scheduleRevisions?.[0]?.validUntil.getTime() ?? null; + liveEraStartOf(med.scheduleRevisions)?.getTime() ?? null; const liveEraEvents = liveEraStart === null ? todayEvents diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 6a01484b6..bc5c80366 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -73,6 +73,7 @@ import { listTargetsBySource } from "@/lib/links"; import { encounterKindEnum } from "@/lib/validations/encounters"; import type { McpAuthContext } from "./auth"; import { dueSchedules } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; /** * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every @@ -1532,11 +1533,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [ if (list) list.push(mark); else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const rows = medications.map((m) => { const display = computeDisplayDue({ diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 65023b81d..0556502e1 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -33,6 +33,7 @@ import { isRecordOnly, scheduleWireFields, } from "@/lib/medications/intake-tracking"; +import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; export type MedicationsListResult = Array>; @@ -164,11 +165,8 @@ export async function buildMedicationsList( else resolvedSlotsByMedId.set(e.medicationId, [mark]); } - const eraStartByMedId = new Map(); - for (const f of eraFloors) { - if (f._max.validUntil) - eraStartByMedId.set(f.medicationId, f._max.validUntil); - } + // The live era start per medication (see `live-era.ts`). + const eraStartByMedId = liveEraStartsByMedication(eraFloors); const lastTakenAtByMedicationId = Object.fromEntries( latestIntakes.map((entry) => [ diff --git a/src/lib/medications/scheduling/__tests__/live-era.test.ts b/src/lib/medications/scheduling/__tests__/live-era.test.ts new file mode 100644 index 000000000..0362674dc --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/live-era.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + isBeforeLiveEra, + liveEraStart, + liveEraStartsByMedication, +} from "@/lib/medications/scheduling/live-era"; + +const d = (iso: string) => new Date(iso); + +describe("live era start", () => { + it("is null without a revision", () => { + expect(liveEraStart(undefined)).toBeNull(); + expect(liveEraStart([])).toBeNull(); + expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), null)).toBe(false); + }); + + it("is the newest revision boundary, and floors earlier slots", () => { + const start = liveEraStart([ + { validUntil: d("2026-06-01T00:00:00Z") }, + { validUntil: d("2026-06-10T12:30:00Z") }, + ]); + expect(start?.toISOString()).toBe("2026-06-10T12:30:00.000Z"); + expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), start)).toBe(true); + expect(isBeforeLiveEra(d("2026-06-10T12:30:00Z"), start)).toBe(false); + expect(isBeforeLiveEra(d("2026-06-11T06:00:00Z"), start)).toBe(false); + }); + + it("maps a groupBy of active revisions", () => { + const map = liveEraStartsByMedication([ + { medicationId: "a", _max: { validUntil: d("2026-06-10T12:30:00Z") } }, + { medicationId: "b", _max: { validUntil: null } }, + ]); + expect([...map.keys()]).toEqual(["a"]); + }); +}); diff --git a/src/lib/medications/scheduling/live-era.ts b/src/lib/medications/scheduling/live-era.ts new file mode 100644 index 000000000..4c98b446d --- /dev/null +++ b/src/lib/medications/scheduling/live-era.ts @@ -0,0 +1,65 @@ +/** + * The start of a medication's LIVE schedule era: the instant from which its + * current `MedicationSchedule` rows are in force. + * + * A schedule replace, or switching intake tracking off or back on, archives + * the previous era as a `MedicationScheduleRevision` ending "now". The newest + * active revision's `validUntil` is therefore where the live rows began. A + * slot the live rows would produce BEFORE that instant was never expected: + * the day's earlier doses belonged to the previous era (or to no era at all, + * while tracking was off). Minting it would put a dose on the card that the + * person could not have taken on this schedule, and the auto-miss pass would + * later stamp it a miss. + * + * Every path that mints or displays a slot from the live rows floors on this + * one instant: the today projector, the reminder worker, the next-due read + * (doses card, medication list, MCP) and the auto-miss pass. `null` means the + * medication has never had a schedule revision: the live rows have been in + * force since it was created, and the callers' own creation handling applies. + */ + +/** + * Prisma relation args selecting the newest ACTIVE revision's boundary. + * Superseded revisions are audit records whose `validUntil` may sit past a + * later correction, so only active rows count. + */ +export const LIVE_ERA_REVISION_ARGS = { + where: { supersededByRevisionId: null }, + orderBy: { validUntil: "desc" }, + take: 1, + select: { validUntil: true }, +} as const; + +/** The live era start from revisions read with {@link LIVE_ERA_REVISION_ARGS}. */ +export function liveEraStart( + revisions: readonly { validUntil: Date }[] | null | undefined, +): Date | null { + if (!revisions || revisions.length === 0) return null; + let newest = revisions[0].validUntil; + for (const r of revisions) { + if (r.validUntil.getTime() > newest.getTime()) newest = r.validUntil; + } + return newest; +} + +/** + * Live era starts per medication from a `medicationScheduleRevision.groupBy` + * over active revisions with `_max: { validUntil: true }`. + */ +export function liveEraStartsByMedication( + rows: readonly { + medicationId: string; + _max: { validUntil: Date | null }; + }[], +): Map { + const out = new Map(); + for (const row of rows) { + if (row._max.validUntil) out.set(row.medicationId, row._max.validUntil); + } + return out; +} + +/** True when a slot anchored at `slot` predates the live era. */ +export function isBeforeLiveEra(slot: Date, eraStart: Date | null): boolean { + return eraStart !== null && slot.getTime() < eraStart.getTime(); +} diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 976f7f107..940162cc9 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -37,6 +37,11 @@ import { import { localHmAsUtc } from "@/lib/tz/local-day"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { + isBeforeLiveEra, + LIVE_ERA_REVISION_ARGS, + liveEraStart, +} from "@/lib/medications/scheduling/live-era"; export interface ProjectTodayIntakesResult { projected: number; @@ -61,18 +66,10 @@ export async function projectTodayIntakesAndRecompute(input: { endsOn: true, oneShot: true, createdAt: true, - // The newest active schedule revision. When it archived an EMPTY - // payload, the stretch up to its `validUntil` expected no dose at all - // (intake tracking was off, or the medication was as-needed), so the - // live schedule starts there and no slot anchored before it is - // projected. Turning tracking back on at 15:00 therefore does not put - // the morning dose on the doses card. - scheduleRevisions: { - where: { supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, - take: 1, - select: { validUntil: true, payload: true }, - }, + // The live era start: no slot anchored before it is projected. Turning + // tracking back on at 15:00, or moving a dose time at 14:30, therefore + // never puts a dose from earlier that day on the card. + scheduleRevisions: LIVE_ERA_REVISION_ARGS, schedules: { select: { id: true, @@ -112,13 +109,7 @@ export async function projectTodayIntakesAndRecompute(input: { for (const med of activeMedications) { if (med.schedules.length === 0) continue; - const latestRevision = med.scheduleRevisions?.[0]; - const expectedNothingUntil = - latestRevision && - Array.isArray(latestRevision.payload) && - latestRevision.payload.length === 0 - ? latestRevision.validUntil.getTime() - : null; + const eraStart = liveEraStart(med.scheduleRevisions); // Rolling cadence anchors off the last logged intake. One findFirst // per medication, scoped to `takenAt IS NOT NULL` — byte-identical to @@ -172,12 +163,7 @@ export async function projectTodayIntakesAndRecompute(input: { const [h, m] = slotTime.split(":").map(Number); if (!Number.isFinite(h) || !Number.isFinite(m)) continue; const scheduledFor = localHmAsUtc(now, userTz, h, m); - if ( - expectedNothingUntil !== null && - scheduledFor.getTime() < expectedNothingUntil - ) { - continue; - } + if (isBeforeLiveEra(scheduledFor, eraStart)) continue; projected.push({ medicationId: schedule.medicationId, scheduledFor,