diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index 969e84758..a2daeada1 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -485,6 +485,7 @@ interface FakeRow { deletedAt: Date | null; source: string; syncVersion: number; + attributionSource?: "AUTO" | "USER_PIN"; } function fakeClient(meds: ReturnType[], rows: FakeRow[]) { @@ -892,3 +893,99 @@ describe("runIntakeAutoTakePass — rolling cadence keeps moving", () => { expect(autoRecordedSlots(rows)).toEqual([display!.at.toISOString()]); }); }); + +// ── a take bound to another slot never claims this one ─────────────── + +describe("runIntakeAutoTakePass — nearby takes of another slot", () => { + const twiceDaily = () => + medication({ + autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"), + schedules: [schedule({ timesOfDay: ["08:00", "20:00"] })], + }); + // 2026-06-10 (CEST): 08:00 = 06:00Z, 20:00 = 18:00Z; 19:00 = 17:00Z. + const MORNING = new Date("2026-06-10T06:00:00Z"); + const EVENING = "2026-06-10T18:00:00.000Z"; + const autoSlots = (rows: FakeRow[]) => + rows.filter((r) => r.autoRecorded).map((r) => r.scheduledFor.toISOString()); + + it("the morning dose pinned and taken at 19:00 leaves the evening dose due", async () => { + const rows: FakeRow[] = [ + row({ + id: "pin", + scheduledFor: MORNING, + takenAt: new Date("2026-06-10T17:00:00Z"), + attributionSource: "USER_PIN", + source: "WEB", + }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([EVENING]); + }); + + it("the same without a pin: a take on the morning slot is the morning's", async () => { + const rows: FakeRow[] = [ + row({ + id: "late", + scheduledFor: MORNING, + takenAt: new Date("2026-06-10T17:00:00Z"), + source: "WEB", + }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([EVENING]); + }); + + it("an ad-hoc take at 19:00 still claims the evening dose", async () => { + const at19 = new Date("2026-06-10T17:00:00Z"); + const rows: FakeRow[] = [ + // The morning dose, taken on time. + row({ + id: "am", + scheduledFor: MORNING, + takenAt: MORNING, + source: "WEB", + }), + row({ id: "adhoc", scheduledFor: at19, takenAt: at19, source: "WEB" }), + ]; + await runIntakeAutoTakePass(fakeClient([twiceDaily()], rows), { + nowMs: Date.parse("2026-06-10T18:20:00Z"), + }); + expect(autoSlots(rows)).toEqual([]); + }); + + it("decideSlot: a pinned row elsewhere is not nearby, an ad-hoc one is", () => { + const band = { + earlyStart: new Date("2026-06-10T15:00:00Z"), + overdueEnd: new Date("2026-06-10T21:00:00Z"), + }; + const evening = new Date(EVENING); + const taken = new Date("2026-06-10T17:00:00Z"); + const base = { + takenAt: taken, + skipped: false, + autoMissed: false, + deletedAt: null, + }; + expect( + decideSlot( + evening, + [ + { + ...base, + id: "p", + scheduledFor: new Date("2026-06-09T12:00:00Z"), + attributionSource: "USER_PIN", + }, + ], + band, + ), + ).toEqual({ kind: "create" }); + expect( + decideSlot(evening, [{ ...base, id: "a", scheduledFor: taken }], band), + ).toEqual({ kind: "leave", reason: "taken_nearby" }); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index e65338966..6df52c701 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -387,6 +387,7 @@ async function autoTakeMedication( skipped: true, autoMissed: true, deletedAt: true, + attributionSource: true, }, }); @@ -435,6 +436,7 @@ async function autoTakeMedication( lastIntakeAt: anchor, }); + const expectedSlots = new Set(bands.keys()); let recordedRolling = false; for (const slot of slots) { handled.add(slot.at.getTime()); @@ -445,6 +447,9 @@ async function autoTakeMedication( slot.at, rows, isRollingSlot ? null : (bands.get(slot.at.getTime()) ?? null), + // Every expected slot around the window: a take sitting on one of + // them is that slot's dose, never a nearby take of this one. + expectedSlots, ); const written = await applyDecision(prisma, { decision, diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index a9113cd48..e02e87a3e 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -78,6 +78,8 @@ export interface SlotRow { skipped: boolean; autoMissed: boolean; deletedAt: Date | null; + /** `USER_PIN` when the person bound the row to its `scheduledFor`. */ + attributionSource?: "AUTO" | "USER_PIN"; } export type SlotDecision = @@ -96,11 +98,18 @@ export type SlotDecision = * `band` is the slot's attribution band when the cadence has one (a take * anywhere inside it belongs to this slot); `null` for a rolling slot, whose * next occurrence is by definition later than every taken dose. + * + * Only a take that belongs to no other slot claims this one from inside its + * band. A take whose `scheduledFor` is another expected slot (`otherSlots`), + * or that the person pinned to an instant other than this slot, is that + * slot's dose however close its `takenAt` lies: the morning dose taken in + * the evening is still the morning dose, and the evening one stays due. */ export function decideSlot( slotAt: Date, rows: readonly SlotRow[], band: Pick | null, + otherSlots: ReadonlySet = new Set(), ): SlotDecision { const t = slotAt.getTime(); const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t); @@ -121,7 +130,8 @@ export function decideSlot( !r.skipped && r.takenAt !== null && r.takenAt.getTime() >= from && - r.takenAt.getTime() <= to, + r.takenAt.getTime() <= to && + !belongsElsewhere(r, t, otherSlots), ); if (takenNearby) return { kind: "leave", reason: "taken_nearby" }; } @@ -131,6 +141,17 @@ export function decideSlot( return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" }; } +/** True when a row is attributed to a slot other than the one at `slotMs`. */ +function belongsElsewhere( + row: SlotRow, + slotMs: number, + otherSlots: ReadonlySet, +): boolean { + const at = row.scheduledFor.getTime(); + if (at === slotMs) return false; + return row.attributionSource === "USER_PIN" || otherSlots.has(at); +} + export interface AutoTakeRollingMedication extends WorkerMedicationRow { schedules: WorkerScheduleRow[]; pauseEras: { pausedAt: Date; resumedAt: Date | null }[];