fix(medications): a take bound to another slot never claims this one

The auto-take pass left a slot alone when any take fell inside its band,
so the morning dose pinned and taken at 19:00 also hid the 20:00 dose.
Only a take attributed to no other expected slot counts as nearby now.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:10:01 +02:00
1 parent dc86c42bef
commit fe782493a1
3 files changed
+124 -1

No files matched your search

@@ -485,6 +485,7 @@ interface FakeRow {
deletedAt: Date | null;
source: string;
syncVersion: number;
attributionSource?: "AUTO" | "USER_PIN";
}
function fakeClient(meds: ReturnType<typeof medication>[], 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" });
});
});
+5
View File
@@ -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,
+22 -1
View File
@@ -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<SlotBand, "earlyStart" | "overdueEnd"> | null,
otherSlots: ReadonlySet<number> = 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<number>,
): 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 }[];