diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 993dc1ac2..01b00319e 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -155,6 +155,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { medicationId: id, userTz: user.timezone, incoming: incomingScheduledFor, + // Only a client-supplied `scheduledFor` names a real slot. A + // `takenAt`-only / defaulted-now write must not snap across the wide + // ±halfGap window onto a far slot (phantom morning dose). + instantIsExplicit: scheduledFor !== undefined, }); // Idempotency check (explicit key or server-side dedup window) diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 6cfb38f89..ad665c30b 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -234,6 +234,10 @@ async function postBulk(request: NextRequest): Promise { medicationId: entry.medicationId, userTz: user.timezone, incoming: incomingScheduledFor, + // Only a client-supplied `scheduledFor` names a real slot. A + // `takenAt`-only / defaulted-now write must not snap across the + // wide ±halfGap window onto a far slot (phantom morning dose). + instantIsExplicit: entry.scheduledFor !== undefined, }); const scheduledFor = canonicalSlot ?? incomingScheduledFor; 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 0ef7a16db..62809faa2 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,62 @@ describe("resolveCanonicalSlotInstant — multi-time", () => { }); }); +describe("resolveCanonicalSlotInstant — defaulted-now midday (phantom morning dose)", () => { + // The phantom morning-dose bug: a 07:00 / 19:00 med whose 07:00 slot got + // auto-marked taken because a slot-less midday "taken now" write + // (no client `scheduledFor`) snapped across the ±6h half-gap window onto + // the 07:00 slot. A DEFAULTED-now write (`instantIsExplicit: false`) + // must NOT reach a far slot — only the tight dose-grace window snaps, + // and 11:00 / 12:00 / 13:00 are well outside it, so the result is `null` + // (PRN standalone row). + const med = makeMedication([ + makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }), + ]); + + for (const [label, hourUtc] of [ + ["11:00", 9], + ["12:00", 10], + ["13:00", 11], + ] as const) { + it(`returns null for a defaulted-now ${label}-local write`, () => { + // CEST = UTC+2, so 11:00 local = 09:00Z, etc. + const incoming = new Date( + `2026-06-15T${String(hourUtc).padStart(2, "0")}:00:00.000Z`, + ); + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming, + instantIsExplicit: false, + }); + expect(result).toBeNull(); + }); + } + + it("still snaps an EXPLICIT scheduledFor:07:00 write to the 07:00 slot", () => { + const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming, + instantIsExplicit: true, + }); + const canonical = localHmAsUtc(incoming, TZ, 7, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); + + it("defaults to explicit snap when the flag is omitted (legacy behaviour)", () => { + const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming, + }); + const canonical = localHmAsUtc(incoming, TZ, 7, 0); + expect(result?.toISOString()).toBe(canonical.toISOString()); + }); +}); + 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 diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index 68fccd23f..814faa473 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -72,6 +72,24 @@ export interface ResolveSlotInput { * rolling schedules; pass `null` when unknown / not loaded. */ lastIntakeAt?: Date | null; + /** + * Whether the client sent an explicit `scheduledFor` for this write. + * + * `true` — the instant names a real dose slot; keep the full ±halfGap + * snap so iOS-vs-server minute drift still collapses onto one row. + * + * `false` — the instant is a DEFAULTED `now` / `takenAt` (the client + * sent no `scheduledFor`). A slot-less "taken now" write must NOT snap + * across the wide ±halfGap window onto a far-away slot (the phantom + * morning-dose bug: a midday write capturing the 07:00 slot of a + * 07:00 / 19:00 med). Resolve only when the instant falls inside the + * tight dose-grace window of a slot; otherwise return `null` (PRN) so + * it records as a standalone "taken now" row. + * + * Defaults to `true` (explicit) so callers that don't thread the flag + * keep the legacy snap behaviour unchanged. + */ + instantIsExplicit?: boolean; } /** @@ -88,6 +106,7 @@ export function resolveCanonicalSlotInstant( input: ResolveSlotInput, ): Date | null { const { medication, userTz, incoming } = input; + const instantIsExplicit = input.instantIsExplicit ?? true; const schedules = medication.schedules ?? []; if (schedules.length === 0) return null; @@ -117,7 +136,13 @@ export function resolveCanonicalSlotInstant( windowEnd, ctx, ); - const tolerance = snapToleranceMs(canonical); + // Explicit `scheduledFor` writes keep the full ±halfGap snap. A + // defaulted-`now` write (no client `scheduledFor`) uses only the tight + // dose-grace window, so a slot-less midday "taken now" cannot reach a + // far-away morning/evening slot — it falls through to `null` (PRN). + const tolerance = instantIsExplicit + ? snapToleranceMs(canonical) + : graceToleranceMs(canonical); for (const occ of occurrences) { // Only consider slots that land on the SAME local calendar day as @@ -235,6 +260,44 @@ function snapToleranceMs(schedule: CanonicalSchedule): number { return Math.max(toleranceMs, ONE_MINUTE_MS); } +/** + * Tight snap tolerance for a DEFAULTED-`now` write (client sent no + * `scheduledFor`). Only a write that lands inside the slot's dose-grace + * window may collapse onto that slot; anything further out is treated as + * unscheduled (PRN) and keeps its own "taken now" row. + * + * Uses the schedule's `reminderGraceMinutes` when set, capped at half the + * minimum inter-slot gap so two distinct same-day doses can never share a + * capture zone (mirrors `snapToleranceMs`). Floors at the minute-drift + * floor so a tight window still absorbs sub-minute drift, but never widens + * to the ±halfGap window the explicit path uses. + */ +function graceToleranceMs(schedule: CanonicalSchedule): number { + const graceMin = schedule.reminderGraceMinutes; + let toleranceMs = + graceMin !== null && Number.isFinite(graceMin) && graceMin > 0 + ? graceMin * ONE_MINUTE_MS + : ONE_MINUTE_MS; + + const slots = effectiveTimesOfDay(schedule) + .map(hhmmToMinutes) + .filter((m): m is number => m !== null) + .sort((a, b) => a - b); + if (slots.length > 1) { + 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); + } + } + + return Math.max(toleranceMs, ONE_MINUTE_MS); +} + /** * Effective dose times for tolerance purposes — mirrors the recurrence * engine's `effectiveTimesOfDay`: the explicit `timesOfDay` when set, diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 55cb3d6a8..32118019c 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -314,6 +314,14 @@ export interface ResolveSlotForWriteInput { * client omitted `scheduledFor`. */ incoming: Date; + /** + * Whether the client sent an explicit `scheduledFor`. `false` means + * `incoming` is a defaulted `now` / `takenAt` — the resolver then only + * snaps inside the tight dose-grace window and otherwise returns `null` + * (PRN), so a slot-less midday "taken now" cannot back-fill a far slot. + * Defaults to `true` to keep the legacy snap for callers that omit it. + */ + instantIsExplicit?: boolean; /** Inject a Prisma client/tx in tests; defaults to the app client. */ client?: PrismaLike; } @@ -366,5 +374,6 @@ export async function resolveSlotInstantForWrite( userTz: input.userTz, incoming: input.incoming, lastIntakeAt, + instantIsExplicit: input.instantIsExplicit ?? true, }); } diff --git a/tests/integration/intake-slot-dedup.test.ts b/tests/integration/intake-slot-dedup.test.ts index 6e25f9cdd..f04478697 100644 --- a/tests/integration/intake-slot-dedup.test.ts +++ b/tests/integration/intake-slot-dedup.test.ts @@ -419,6 +419,74 @@ describe("intake write path — multi-dose wide window does not collapse", () => }); }); +describe("intake write path — defaulted-now midday does not back-fill the morning slot", () => { + // The phantom morning-dose regression, end-to-end through the DB-backed + // resolver: a bulk/sync entry that carries ONLY `takenAt` (no client + // `scheduledFor`) around midday must NOT snap onto the 07:00 slot of a + // 07:00 / 19:00 med and stamp it taken. With `instantIsExplicit:false` + // the resolver returns null (PRN) so the morning slot stays untouched. + it("a takenAt-only 12:30 write resolves to null and leaves the 07:00 slot untaken", async () => { + const prisma = getPrismaClient(); + const medId = await createTwiceDailyMed(); + + const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0); + + // Server-minted pending 07:00 reminder row — the row the phantom take + // would have stamped. + const pending = await prisma.medicationIntakeEvent.create({ + data: { + userId: TEST_USER_ID, + medicationId: medId, + scheduledFor: slot0700, + takenAt: null, + skipped: false, + source: "REMINDER", + }, + }); + + // Bulk/sync entry: no `scheduledFor`, only `takenAt` at 12:30 local + // (10:30Z in CET). This mirrors `intake/bulk` defaulting the instant + // to `takenAt` with `instantIsExplicit: entry.scheduledFor !== undefined`. + const middayTakenAt = new Date("2026-03-10T11:30:00.000Z"); // 12:30 CET + const canonicalSlot = await resolveSlotInstantForWrite({ + userId: TEST_USER_ID, + medicationId: medId, + userTz: "Europe/Berlin", + incoming: middayTakenAt, + instantIsExplicit: false, + client: prisma, + }); + + // The defaulted-now write does NOT snap onto the far 07:00 slot. + expect(canonicalSlot).toBeNull(); + + // The 07:00 reminder row stays untaken — no phantom morning dose. + const morning = await prisma.medicationIntakeEvent.findUniqueOrThrow({ + where: { id: pending.id }, + }); + expect(morning.takenAt).toBeNull(); + expect(morning.skipped).toBe(false); + expect(morning.deletedAt).toBeNull(); + }); + + it("an EXPLICIT scheduledFor:07:00 write still snaps onto the 07:00 slot", async () => { + const prisma = getPrismaClient(); + const medId = await createTwiceDailyMed(); + const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0); + + const explicitWrite = new Date("2026-03-10T06:00:00.000Z"); // 07:00 CET + const canonicalSlot = await resolveSlotInstantForWrite({ + userId: TEST_USER_ID, + medicationId: medId, + userTz: "Europe/Berlin", + incoming: explicitWrite, + instantIsExplicit: true, + client: prisma, + }); + expect(canonicalSlot?.toISOString()).toBe(slot0700.toISOString()); + }); +}); + describe("intake-slot-dedup — boot discovery scoping", () => { it("returns 0 when no user holds a duplicate pair", async () => { const prisma = getPrismaClient();