fix(medications): never snap a taken intake onto a future dose slot

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:22:07 +02:00
1 parent 212e8c9c04
commit 3ec80adbd2
7 files changed
+148 -2

No files matched your search

@@ -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"
@@ -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)
@@ -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.
@@ -238,6 +238,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// `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;
@@ -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
@@ -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) {
@@ -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,
});
}