mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }[];
|
||||
|
||||
Reference in new issue
Block a user