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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 10:03:08 +02:00
1 parent 1d1570733e
commit 37c6b0e144
8 files changed
+154 -49

No files matched your search

+3 -5
View File
@@ -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<string, Date>();
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) {
+27 -2
View File
@@ -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<number, Array<{ id: string; createdAt: Date }>>();
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
@@ -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
+3 -5
View File
@@ -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<string, Date>();
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({
+3 -5
View File
@@ -33,6 +33,7 @@ import {
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
export type MedicationsListResult = Array<Record<string, unknown>>;
@@ -164,11 +165,8 @@ export async function buildMedicationsList(
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
}
const eraStartByMedId = new Map<string, Date>();
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) => [
@@ -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"]);
});
});
@@ -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<string, Date> {
const out = new Map<string, Date>();
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();
}
@@ -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,