From 793602f464a0f5c96fe8c3e4dfce16921589f478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 23:04:06 +0200 Subject: [PATCH] fix(medications): cap intake snap tolerance to the inter-dose gap so multi-dose orals don't collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/resolve-slot-instant.test.ts | 95 +++++++++++++++ .../scheduling/resolve-slot-instant.ts | 72 +++++++++-- tests/integration/intake-slot-dedup.test.ts | 113 ++++++++++++++++++ 3 files changed, 269 insertions(+), 11 deletions(-) diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts index 30345e558..0ef7a16db 100644 --- a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts @@ -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([ diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index d6c76d4bc..68fccd23f 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -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 { diff --git a/tests/integration/intake-slot-dedup.test.ts b/tests/integration/intake-slot-dedup.test.ts index fcfd3f3ad..6e25f9cdd 100644 --- a/tests/integration/intake-slot-dedup.test.ts +++ b/tests/integration/intake-slot-dedup.test.ts @@ -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 { + 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();