mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user