From 3ec80adbd244da35c452240e2b89f0cec5154fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 16:22:07 +0200 Subject: [PATCH] fix(medications): never snap a taken intake onto a future dose slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A taken intake whose write named (or snapped near) a slot still in the future was attributed to that slot and rendered as taken before its time — a late morning dose collapsing onto the evening slot of a twice-daily med. The slot resolver now takes an isTakenWrite flag and a now reference: a taken write skips any candidate slot more than a small clock-skew grace into the future, so it snaps to a current/past slot or keeps its own row. Pending sync echoes are unaffected and still map to future slots. --- .../[id]/intake/__tests__/route.test.ts | 15 ++++- src/app/api/medications/[id]/intake/route.ts | 3 + .../intake/bulk/__tests__/route.test.ts | 13 +++- src/app/api/medications/intake/bulk/route.ts | 4 ++ .../__tests__/resolve-slot-instant.test.ts | 64 +++++++++++++++++++ .../scheduling/resolve-slot-instant.ts | 41 ++++++++++++ src/lib/medications/scheduling/slot-upsert.ts | 10 +++ 7 files changed, 148 insertions(+), 2 deletions(-) diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index f3f692ada..fc87a09b3 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -19,7 +19,7 @@ * cares about the Prisma `where` fragment derived from the new knob. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ @@ -391,6 +391,19 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" ], }; + beforeEach(() => { + // Pin the clock past the 07:00 / 19:00 CEST slots of 2026-06-15 so the + // dose-safety future-slot guard treats these taken writes as landing on + // a current/past slot. Otherwise the fixtures sit days ahead of the + // real test clock and the guard refuses to snap a taken write forward. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("does NOT decrement inventory on a re-post of an already-taken slot (M2)", async () => { const { consumeOneDose } = await import( "@/lib/medications/inventory/service" diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 01b00319e..dc4d03b66 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -159,6 +159,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // `takenAt`-only / defaulted-now write must not snap across the wide // ±halfGap window onto a far slot (phantom morning dose). instantIsExplicit: scheduledFor !== undefined, + // Dose-safety: a taken write (any non-skip on this route) must never + // snap forward onto a future slot — see resolve-slot-instant.ts. + isTakenWrite: isExplicitTaken, }); // Idempotency check (explicit key or server-side dedup window) diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 4dd6a8b8c..30c2a2dd4 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -2,7 +2,7 @@ * v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk. * Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough. */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ @@ -186,6 +186,13 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { }; beforeEach(() => { + // Pin the clock past both fixture slots (07:00 / 19:00 CEST on + // 2026-06-15) so the dose-safety future-slot guard treats these taken + // writes as landing on a current/past slot, not a future one. Without + // the pin these fixtures are dated days ahead of the real test clock + // and the guard (correctly) refuses to snap a taken write forward. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-06-15T18:00:00.000Z")); vi.mocked(prisma.medication.findMany).mockResolvedValue([ { id: "med-1" }, ] as never); @@ -194,6 +201,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { ); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => { // Existing TAKEN row at the 07:00 slot. The bulk entry is a pending // echo (no takenAt, skipped false) — must NOT clear takenAt. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index ad665c30b..7eb9ca0e9 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -238,6 +238,10 @@ async function postBulk(request: NextRequest): Promise { // `takenAt`-only / defaulted-now write must not snap across the // wide ±halfGap window onto a far slot (phantom morning dose). instantIsExplicit: entry.scheduledFor !== undefined, + // Dose-safety: a taken write (has `takenAt`, not skipped) must never + // snap forward onto a future slot. A pending sync echo (no `takenAt`) + // legitimately maps to a future slot, so the guard stays off for it. + isTakenWrite: !entry.skipped && entry.takenAt !== 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 62809faa2..81852027e 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,70 @@ describe("resolveCanonicalSlotInstant — multi-time", () => { }); }); +describe("resolveCanonicalSlotInstant — taken write never snaps to a future slot (dose-safety)", () => { + // Daily 07:00 / 19:00 med. A late morning dose taken at 15:00 CEST must + // not snap forward onto the 19:00 slot (which is still in the future) and + // render as "taken" before its time — the iOS v0.13 dose-safety report. + const med = makeMedication([ + makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }), + ]); + // iOS posts the reminder-action write carrying the slot's `scheduledFor` + // (19:00) while the dose is actually actioned earlier (17:00) — the + // explicit instant names the future evening slot exactly. + const slot1900Instant = new Date("2026-06-15T17:00:00.000Z"); // 19:00 CEST + const beforeEvening = new Date("2026-06-15T15:00:00.000Z"); // 17:00 CEST + + it("LEGACY (no flag): an explicit 19:00 write snaps to the 19:00 slot", () => { + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: slot1900Instant, + }); + const slot1900 = localHmAsUtc(slot1900Instant, TZ, 19, 0); + expect(result?.toISOString()).toBe(slot1900.toISOString()); + }); + + it("taken write naming the 19:00 slot, but actioned at 17:00, does NOT snap onto the future slot", () => { + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: slot1900Instant, + isTakenWrite: true, + now: beforeEvening, + }); + // 19:00 is still in the future relative to now (17:00) → excluded; + // 07:00 is 12h from the incoming instant → beyond tolerance → null. + expect(result).toBeNull(); + }); + + it("taken write AT the 19:00 slot still snaps (slot is not in the future)", () => { + const evening = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: evening, + isTakenWrite: true, + now: evening, + }); + const slot1900 = localHmAsUtc(evening, TZ, 19, 0); + expect(result?.toISOString()).toBe(slot1900.toISOString()); + }); + + it("taken write a few minutes early still snaps onto its slot (skew grace)", () => { + // 06:57 CEST, slot 07:00 — within the 5-minute forward grace. + const earlyTake = new Date("2026-06-15T04:57:00.000Z"); + const result = resolveCanonicalSlotInstant({ + medication: med, + userTz: TZ, + incoming: earlyTake, + isTakenWrite: true, + now: earlyTake, + }); + const slot0700 = localHmAsUtc(earlyTake, TZ, 7, 0); + expect(result?.toISOString()).toBe(slot0700.toISOString()); + }); +}); + 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 diff --git a/src/lib/medications/scheduling/resolve-slot-instant.ts b/src/lib/medications/scheduling/resolve-slot-instant.ts index 814faa473..f411390b8 100644 --- a/src/lib/medications/scheduling/resolve-slot-instant.ts +++ b/src/lib/medications/scheduling/resolve-slot-instant.ts @@ -39,6 +39,19 @@ const ONE_MINUTE_MS = 60_000; const ONE_HOUR_MS = 60 * ONE_MINUTE_MS; const ONE_DAY_MS = 24 * ONE_HOUR_MS; +/** + * Forward grace for a TAKEN write (dose-safety, iOS v0.13 report). + * + * A `.taken` intake must never be attributed to a slot whose instant is in + * the future — a dose the user has not yet taken would otherwise render as + * "taken" before its time (a late morning dose snapping forward onto the + * evening slot of a twice-daily med). We still allow a small forward window + * so a dose taken a few minutes early, or with minor client/server clock + * skew, snaps onto its own slot as intended. Anything further into the + * future is rejected as a snap target and the write keeps its own row. + */ +const TAKEN_FORWARD_GRACE_MS = 5 * ONE_MINUTE_MS; + /** * Default snap tolerance when a schedule has no usable window span. ±2h * is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift @@ -90,6 +103,23 @@ export interface ResolveSlotInput { * keep the legacy snap behaviour unchanged. */ instantIsExplicit?: boolean; + /** + * Whether this write records a TAKEN dose (has a `takenAt`). When `true`, + * the snap will never pick a slot whose instant is meaningfully in the + * future (> `now` + `TAKEN_FORWARD_GRACE_MS`) — a taken dose can never be + * attributed to a slot the user has not reached yet. A future slot is + * simply not a candidate, so the write either snaps to a past/current + * slot within tolerance or falls through to `null` (standalone row). + * + * Defaults to `false` so read-only / projection callers (UI display, + * pending-row dedup) keep snapping to future slots as before. + */ + isTakenWrite?: boolean; + /** + * Reference "now" for the `isTakenWrite` future-slot guard. Defaults to + * the current time; injectable for deterministic tests. + */ + now?: Date; } /** @@ -107,6 +137,8 @@ export function resolveCanonicalSlotInstant( ): Date | null { const { medication, userTz, incoming } = input; const instantIsExplicit = input.instantIsExplicit ?? true; + const isTakenWrite = input.isTakenWrite ?? false; + const nowMs = (input.now ?? new Date()).getTime(); const schedules = medication.schedules ?? []; if (schedules.length === 0) return null; @@ -170,6 +202,15 @@ export function resolveCanonicalSlotInstant( // and keep the canonical engine only for DECIDING which slots exist // (PRN / cyclic / rrule / rolling gating). const slotInstant = canonicalSlotInstant(occ.at, occ.timeOfDay, userTz); + + // Dose-safety: a taken write may never be attributed to a slot that is + // still in the future (beyond a small clock-skew/early-dose grace). A + // late morning dose must not snap forward onto the evening slot of a + // twice-daily med and render as "taken" before its time. + if (isTakenWrite && slotInstant.getTime() > nowMs + TAKEN_FORWARD_GRACE_MS) { + continue; + } + const deltaMs = Math.abs(slotInstant.getTime() - incoming.getTime()); if (deltaMs > tolerance) continue; if (best === null || deltaMs < best.deltaMs) { diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 32118019c..85a135037 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -322,6 +322,14 @@ export interface ResolveSlotForWriteInput { * Defaults to `true` to keep the legacy snap for callers that omit it. */ instantIsExplicit?: boolean; + /** + * Whether this write records a TAKEN dose. When `true`, the snap never + * targets a future slot (dose-safety: a taken dose can't be attributed to + * a slot the user hasn't reached). Defaults to `false`. + */ + isTakenWrite?: boolean; + /** Reference "now" for the taken future-slot guard; defaults to current time. */ + now?: Date; /** Inject a Prisma client/tx in tests; defaults to the app client. */ client?: PrismaLike; } @@ -375,5 +383,7 @@ export async function resolveSlotInstantForWrite( incoming: input.incoming, lastIntakeAt, instantIsExplicit: input.instantIsExplicit ?? true, + isTakenWrite: input.isTakenWrite ?? false, + now: input.now, }); }