fix(medications): cap intake snap tolerance to the inter-dose gap so multi-dose orals don't collapse

A daily oral with multiple timesOfDay and a wide schedule window had its
dose-slot capture zones overlap, so a second same-day dose could snap onto
an already-actioned slot and be dropped by the no-downgrade guard — the
intake never landed as its own row. A twice-daily 08:00 / 20:00 schedule
with an 08:00-22:00 window yielded a +/-7h half-span tolerance that
exceeded half the 12h inter-slot gap, so the evening write collapsed onto
the morning slot. Weekly and single-dose schedules (one slot/day) were
immune.

snapToleranceMs now caps the tolerance at half the minimum gap between
adjacent sorted timesOfDay entries (the midnight wrap counted as one of
those gaps) whenever a schedule has more than one slot, keeping the two
nearest slots' capture zones disjoint. Single-slot schedules keep the
full half-window-span behaviour, and the >=1-minute drift floor is
preserved, so weekly injectables and once-daily orals are unchanged.

Adds unit coverage for the wide-window two-dose case (distinct slots, no
collapse) plus an explicit weekly single-dose regression, and an
integration test driving both same-day writes through the DB-backed slot
resolver and canonical-slot upsert to assert two distinct live rows.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 23:04:40 +02:00
1 parent d5eb887757
commit 793602f464
3 files changed
+269 -11

No files matched your search

@@ -87,6 +87,73 @@ describe("resolveCanonicalSlotInstant — multi-time", () => {
});
});
describe("resolveCanonicalSlotInstant — multi-time wide window", () => {
// The LIVE twice-daily-Ramipril regression: 08:00 / 20:00 doses with a
// WIDE schedule window (08:00–22:00 = 14h span → ±7h half-span). The
// pre-fix tolerance exceeded half the 12h inter-slot gap (±6h), so the
// 20:00 write's capture zone overlapped the 08:00 slot and a distinct
// evening dose collapsed onto the already-taken morning slot — leaving
// "nothing in Verlauf". The cap at half the inter-dose gap fixes it.
const med = makeMedication([
makeSchedule({
timesOfDay: ["08:00", "20:00"],
windowStart: "08:00",
windowEnd: "22:00",
}),
]);
it("snaps a 20:00 write to the 20:00 slot, NOT the wide-window 08:00 slot", () => {
const incoming = new Date("2026-06-15T18:00:00.000Z"); // 20:00 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const evening = localHmAsUtc(incoming, TZ, 20, 0);
const morning = localHmAsUtc(incoming, TZ, 8, 0);
expect(result?.toISOString()).toBe(evening.toISOString());
expect(result?.toISOString()).not.toBe(morning.toISOString());
});
it("snaps an 08:00 write to the 08:00 slot", () => {
const incoming = new Date("2026-06-15T06:00:00.000Z"); // 08:00 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const morning = localHmAsUtc(incoming, TZ, 8, 0);
expect(result?.toISOString()).toBe(morning.toISOString());
});
it("resolves the morning and evening writes to TWO distinct slot instants", () => {
const morningWrite = new Date("2026-06-15T06:05:00.000Z"); // 08:05 CEST
const eveningWrite = new Date("2026-06-15T18:03:00.000Z"); // 20:03 CEST
const morningSlot = resolveCanonicalSlotInstant({
medication: med,
userTz: TZ,
incoming: morningWrite,
});
const eveningSlot = resolveCanonicalSlotInstant({
medication: med,
userTz: TZ,
incoming: eveningWrite,
});
expect(morningSlot).not.toBeNull();
expect(eveningSlot).not.toBeNull();
// The two distinct doses must NOT collapse onto the same canonical slot.
expect(morningSlot?.toISOString()).not.toBe(eveningSlot?.toISOString());
expect(morningSlot?.toISOString()).toBe(
localHmAsUtc(morningWrite, TZ, 8, 0).toISOString(),
);
expect(eveningSlot?.toISOString()).toBe(
localHmAsUtc(eveningWrite, TZ, 20, 0).toISOString(),
);
});
it("snaps a write 90 minutes past the morning slot to the morning slot", () => {
// 09:30 local is 1.5h after 08:00, well inside the ±6h capture zone and
// far from 20:00 — must resolve to the morning slot, not the evening.
const incoming = new Date("2026-06-15T07:30:00.000Z"); // 09:30 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
expect(result?.toISOString()).toBe(
localHmAsUtc(incoming, TZ, 8, 0).toISOString(),
);
});
});
describe("resolveCanonicalSlotInstant — single-time", () => {
it("snaps to the one slot", () => {
const med = makeMedication([
@@ -99,6 +166,34 @@ describe("resolveCanonicalSlotInstant — single-time", () => {
});
});
describe("resolveCanonicalSlotInstant — weekly single-dose unchanged", () => {
// A once-weekly injectable (one slot/day) is immune to the overlap bug
// and must keep its full half-window-span tolerance: the inter-dose cap
// only kicks in for >1 slot/day. Use a wide window to prove the cap is
// NOT applied for a single slot — a 10:30 write 2.5h from the 08:00 slot
// still snaps (±7h half-span, no per-slot cap).
const med = makeMedication([
makeSchedule({
timesOfDay: ["08:00"],
windowStart: "08:00",
windowEnd: "22:00",
daysOfWeek: "MO",
rrule: "FREQ=WEEKLY;BYDAY=MO",
}),
]);
it("snaps a same-day write well outside an inter-dose gap (full half-span retained)", () => {
// 2026-06-15 is a Monday. 10:30 CEST is 2.5h past 08:00 — outside any
// ±6h inter-dose cap a multi-slot schedule would impose, inside the
// ±7h single-slot half-span.
const incoming = new Date("2026-06-15T08:30:00.000Z"); // 10:30 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
expect(result?.toISOString()).toBe(
localHmAsUtc(incoming, TZ, 8, 0).toISOString(),
);
});
});
describe("resolveCanonicalSlotInstant — exemptions", () => {
it("returns null for a PRN schedule (as-needed never snaps)", () => {
const med = makeMedication([
@@ -179,21 +179,71 @@ function canonicalSlotInstant(
}
/**
* Tolerance = ± half the schedule's window span (so two adjacent slots
* never overlap their capture zones), falling back to ±2h when the
* window is degenerate (`windowStart === windowEnd`) or unparseable.
* Snap tolerance for a schedule's dose slots.
*
* Base tolerance is ± half the schedule's window span, falling back to
* ±2h when the window is degenerate (`windowStart === windowEnd`) or
* unparseable.
*
* For a schedule with MORE THAN ONE time-of-day the window span is no
* longer a safe tolerance: a wide window (e.g. 08:00–22:00) spread across
* two slots (08:00 / 20:00) yields a ±7h half-span that exceeds half the
* 12h inter-slot gap, so the two slots' capture zones overlap and a
* distinct evening write can collapse onto the already-taken morning slot
* (the nearest-`deltaMs` pick still lands inside both zones). Cap the
* tolerance at half the MINIMUM gap between adjacent sorted slots (the
* midnight wrap counted as one of those gaps), so two distinct same-day
* doses can never share a capture zone. Single-slot schedules keep the
* half-span behaviour unchanged.
*/
function snapToleranceMs(schedule: CanonicalSchedule): number {
const startMin = hhmmToMinutes(schedule.windowStart);
const endMin = hhmmToMinutes(schedule.windowEnd);
if (startMin === null || endMin === null) return DEFAULT_TOLERANCE_MS;
let span = endMin - startMin;
if (span < 0) span += 24 * 60; // overnight window
if (span === 0) return DEFAULT_TOLERANCE_MS;
const halfSpanMs = (span / 2) * ONE_MINUTE_MS;
// Never go below the minute-drift floor: even a tight 30-minute window
// must absorb the iOS-vs-server sub-minute `scheduledFor` drift.
return Math.max(halfSpanMs, ONE_MINUTE_MS);
let halfSpanMs: number;
if (startMin === null || endMin === null) {
halfSpanMs = DEFAULT_TOLERANCE_MS;
} else {
let span = endMin - startMin;
if (span < 0) span += 24 * 60; // overnight window
halfSpanMs = span === 0 ? DEFAULT_TOLERANCE_MS : (span / 2) * ONE_MINUTE_MS;
}
let toleranceMs = halfSpanMs;
const slots = effectiveTimesOfDay(schedule)
.map(hhmmToMinutes)
.filter((m): m is number => m !== null)
.sort((a, b) => a - b);
if (slots.length > 1) {
// Smallest gap between adjacent slots, treating the day as circular so
// the wrap from the last slot back to the first (across midnight) is
// also bounded. Half that gap is the largest tolerance that keeps the
// two nearest slots' capture zones disjoint.
let minGapMin = Infinity;
for (let i = 1; i < slots.length; i++) {
minGapMin = Math.min(minGapMin, slots[i] - slots[i - 1]);
}
const wrapGap = slots[0] + 24 * 60 - slots[slots.length - 1];
minGapMin = Math.min(minGapMin, wrapGap);
if (Number.isFinite(minGapMin) && minGapMin > 0) {
toleranceMs = Math.min(toleranceMs, (minGapMin / 2) * ONE_MINUTE_MS);
}
}
// Never go below the minute-drift floor: even a tight window must absorb
// the iOS-vs-server sub-minute `scheduledFor` drift.
return Math.max(toleranceMs, ONE_MINUTE_MS);
}
/**
* Effective dose times for tolerance purposes — mirrors the recurrence
* engine's `effectiveTimesOfDay`: the explicit `timesOfDay` when set,
* else the single legacy `windowStart` slot.
*/
function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] {
return schedule.timesOfDay.length > 0
? schedule.timesOfDay
: [schedule.windowStart];
}
function hhmmToMinutes(hhmm: string): number | null {
+113
View File
@@ -25,6 +25,10 @@ import {
dedupeUserIntakeSlots,
enqueueBootTimeIntakeSlotDedup,
} from "@/lib/medications/intake-slot-dedup";
import {
applyCanonicalSlotWrite,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { localHmAsUtc } from "@/lib/timezone";
const TEST_USER_ID = "user-intake-slot-dedup";
@@ -306,6 +310,115 @@ describe("intake-slot-dedup — duplicate slot collapse", () => {
});
});
describe("intake write path — multi-dose wide window does not collapse", () => {
// The LIVE twice-daily-Ramipril regression, end-to-end through the
// DB-backed slot resolver + the canonical-slot upsert: two distinct
// same-day doses on an 08:00 / 20:00 schedule with a WIDE 08:00–22:00
// window must land as TWO separate live rows, not collapse onto one.
async function createWideWindowTwiceDailyMed(): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Ramipril",
dose: "5mg",
active: true,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "22:00", // wide window — the pre-fix overlap trigger
timesOfDay: ["08:00", "20:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
it("resolves morning and evening writes to two distinct canonical slots and lands two rows", async () => {
const prisma = getPrismaClient();
const medId = await createWideWindowTwiceDailyMed();
const morningWrite = new Date("2026-03-10T07:05:00.000Z"); // 08:05 CET
const eveningWrite = new Date("2026-03-10T19:03:00.000Z"); // 20:03 CET
const morningSlot = await resolveSlotInstantForWrite({
userId: TEST_USER_ID,
medicationId: medId,
userTz: "Europe/Berlin",
incoming: morningWrite,
client: prisma,
});
const eveningSlot = await resolveSlotInstantForWrite({
userId: TEST_USER_ID,
medicationId: medId,
userTz: "Europe/Berlin",
incoming: eveningWrite,
client: prisma,
});
expect(morningSlot).not.toBeNull();
expect(eveningSlot).not.toBeNull();
// The two doses must resolve to two distinct canonical slot instants.
expect(morningSlot?.toISOString()).not.toBe(eveningSlot?.toISOString());
expect(morningSlot?.toISOString()).toBe(
localHmAsUtc(DAY, "Europe/Berlin", 8, 0).toISOString(),
);
expect(eveningSlot?.toISOString()).toBe(
localHmAsUtc(DAY, "Europe/Berlin", 20, 0).toISOString(),
);
// Apply the morning taken write.
const morningResult = await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: morningSlot!,
takenAt: morningWrite,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
});
expect(morningResult.outcome).toBe("inserted");
// Apply the evening taken write — must NOT collapse onto the morning
// slot (the bug) and must land as its own row.
const eveningResult = await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: eveningSlot!,
takenAt: eveningWrite,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
});
expect(eveningResult.outcome).toBe("inserted");
expect(eveningResult.noDowngradeNoOp).toBe(false);
// Two distinct live rows, one per dose slot.
const live = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId, deletedAt: null },
orderBy: { scheduledFor: "asc" },
});
expect(live).toHaveLength(2);
expect(live[0]?.takenAt).not.toBeNull();
expect(live[1]?.takenAt).not.toBeNull();
expect(live[0]?.scheduledFor.toISOString()).toBe(
localHmAsUtc(DAY, "Europe/Berlin", 8, 0).toISOString(),
);
expect(live[1]?.scheduledFor.toISOString()).toBe(
localHmAsUtc(DAY, "Europe/Berlin", 20, 0).toISOString(),
);
});
});
describe("intake-slot-dedup — boot discovery scoping", () => {
it("returns 0 when no user holds a duplicate pair", async () => {
const prisma = getPrismaClient();