fix(medications): keep a late take off a future slot the card supplied

A twice-daily card advances its display-due to the evening slot once the
morning slot's catch-up window has lapsed, so a late-morning "Genommen"
tap posts `scheduledFor = <evening slot>` with `takenAt = now`. Band
attribution correctly refuses the take (it lands in no window), but the
intake routes' source-agnostic convergence probe then bound it to the
evening slot's pending REMINDER row — recording the morning dose as the
evening one, silently consuming a slot the user had not reached, and
jumping the dashboard "next intake" to the following day.

Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken
write may not converge onto a slot whose anchor is in the future relative
to the take (the same forward-grace invariant `resolveCanonicalSlotInstant`
already enforces). Off-window late takes now record standalone (ad-hoc),
leaving both real slots intact; skips and pending echoes still name their
slot deliberately. Applied to both the per-medication and bulk intake
routes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 15:55:50 +02:00
1 parent b5f4153a42
commit ad503c45c0
6 files changed
+515 -30

No files matched your search

+16 -1
View File
@@ -29,6 +29,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -344,8 +345,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
// index carries `source` and cannot catch it — inflating the
// compliance rollup's scheduled count. A defaulted anchor (takenAt /
// now) never names a slot, so the probe is skipped on that hot path.
//
// Dose-safety guard: a TAKEN write must not converge onto a slot whose
// anchor is in the future relative to the take. The card advances its
// display-due to the evening slot once the morning slot's catch-up
// window lapses, so a late-morning "Genommen" posts the evening slot as
// `scheduledFor`; band attribution already rejected the take, and the
// probe must not re-bind it forward onto the evening pending row (a
// late-morning dose silently consuming the 21:00 slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
scheduledFor !== undefined
scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped,
takenAt: resolvedTakenAt,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -476,26 +476,16 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
vi.useRealTimers();
});
it("converges an early taken-write onto the pre-minted pending REMINDER row instead of inserting a sibling", async () => {
// Source-agnostic probe finds the live REMINDER row on the incoming
// instant…
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce({
id: "row-reminder",
} as never);
// …and the shared slot upsert re-reads + updates it in place.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-reminder",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-reminder",
it("records an early taken-write standalone; it does NOT snap forward onto the future slot's pending row", async () => {
// Dose-safety invariant: a taken dose must never be attributed to a slot
// whose anchor is still in the future (a late-morning take must not
// consume the evening slot just because the card / client supplied it as
// `scheduledFor`). Band attribution already refused this off-window take
// (resolver null); the convergence probe must honour the same
// `mayConvergeOntoSuppliedSlot` guard rather than re-binding the take
// forward onto the pre-minted REMINDER row. It records standalone instead.
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-standalone",
} as never);
const res = await POST(
@@ -517,14 +507,22 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
entries: Array<{ status: string; id?: string }>;
};
};
// The slot did NOT fork into a second live event.
expect(body.data.updated).toBe(1);
expect(body.data.inserted).toBe(0);
expect(body.data.entries[0].status).toBe("updated");
expect(body.data.entries[0].id).toBe("row-reminder");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "row-reminder" } }),
// Records as a fresh ad-hoc row — the future slot is left untouched.
expect(body.data.inserted).toBe(1);
expect(body.data.updated).toBe(0);
expect(body.data.entries[0].status).toBe("inserted");
// The guard short-circuits the convergence probe (no findFirst) and the
// future pending row is never updated.
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
// Ad-hoc contract: the row anchors on its own intake instant.
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
scheduledFor: new Date("2026-06-15T00:30:00.000Z"),
takenAt: new Date("2026-06-15T00:30:00.000Z"),
}),
}),
);
});
+15 -1
View File
@@ -53,6 +53,7 @@ import {
import {
applyCanonicalSlotWrite,
findPinConflict,
mayConvergeOntoSuppliedSlot,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
@@ -487,8 +488,21 @@ async function postBulk(request: NextRequest): Promise<Response> {
// Only an explicit client `scheduledFor` names a slot a row could
// already sit on; a defaulted anchor (takenAt / now) never does, so
// the probe is skipped on that path.
//
// Dose-safety guard: a TAKEN entry must not converge onto a slot
// whose anchor is in the future relative to the take. The resolver's
// forward guard already refused to snap a late take onto a future
// slot; the probe must honour the same invariant rather than binding
// the take forward onto that slot's pending REMINDER row (a
// late-morning dose consuming the evening slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
entry.scheduledFor !== undefined
entry.scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped: entry.skipped,
takenAt: entry.takenAt ?? null,
suppliedSlot: incomingScheduledFor,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -0,0 +1,81 @@
/**
* Dose-safety guard for the intake routes' ad-hoc convergence probe.
*
* Regression for the "late-morning take consumes the evening slot" bug: the
* medication card advances its display-due to the 21:00 slot once the 09:00
* slot's catch-up window has lapsed, so a "Genommen" tap at 13:08 posts
* `scheduledFor = 21:00` with `takenAt = now`. Band attribution correctly
* refuses the take (it falls in no window), but the source-agnostic
* convergence probe used to bind it to the 21:00 pending REMINDER row —
* recording a morning dose as the evening one and silently consuming a slot
* the user had not reached. The guard keeps a taken write off any slot whose
* anchor is in the future relative to the take.
*/
import { describe, expect, it } from "vitest";
import { mayConvergeOntoSuppliedSlot } from "@/lib/medications/scheduling/slot-upsert";
const DAY = "2026-03-10";
const at = (hm: string) => new Date(`${DAY}T${hm}:00.000Z`);
describe("mayConvergeOntoSuppliedSlot", () => {
it("refuses a taken write onto a slot in the future (the 13:08 → 21:00 bug)", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("13:08"),
suppliedSlot: at("21:00"),
}),
).toBe(false);
});
it("allows a taken write onto a PAST slot (ledger 'mark the missed 09:00 slot taken')", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("13:08"),
suppliedSlot: at("09:00"),
}),
).toBe(true);
});
it("allows a taken write onto the current slot within the forward clock-skew grace", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("08:58"),
suppliedSlot: at("09:00"),
}),
).toBe(true);
});
it("refuses a taken write onto a slot just beyond the forward grace", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: at("08:50"),
suppliedSlot: at("09:00"),
}),
).toBe(false);
});
it("always allows a deliberate skip to name its slot (past or future)", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: true,
takenAt: null,
suppliedSlot: at("21:00"),
}),
).toBe(true);
});
it("always allows a pending echo (no takenAt) to target a future slot", () => {
expect(
mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt: null,
suppliedSlot: at("21:00"),
}),
).toBe(true);
});
});
@@ -114,6 +114,47 @@ const SLOT_ROW_SELECT = {
createdAt: true,
} as const;
/**
* Forward grace a TAKEN write may still snap onto a slot whose anchor sits
* ahead of the take — the same clock-skew / early-dose margin the resolver's
* `TAKEN_FORWARD_GRACE_MS` uses.
*/
export const TAKEN_SLOT_FORWARD_GRACE_MS = 5 * 60 * 1000;
/**
* Whether a write may converge onto a live row already sitting on the
* client-supplied `scheduledFor` slot (the intake routes' "ad-hoc / off-slot"
* fallback probe, after band/canonical attribution returned null).
*
* A TAKEN dose must never fill a slot whose anchor is meaningfully in the
* FUTURE relative to when it was taken. The medication card shows the next
* open slot as its display-due; once a morning slot's catch-up window has
* lapsed the card advances to the EVENING slot, and a "Genommen" tap then
* posts `scheduledFor = <evening slot>` with `takenAt = now`. Band attribution
* correctly refuses that late-morning take (it lands in no window), but the
* source-agnostic convergence probe would otherwise bind it to the evening
* slot's pending REMINDER row — recording the take as the evening dose and
* silently consuming a slot the user has not reached. This is the exact
* "late morning dose snapping forward onto the evening slot" failure the band
* model and the resolver's forward guard already forbid; the probe must honour
* the same invariant.
*
* Skips and pending echoes (no `takenAt`) name their slot deliberately — a
* deliberate future skip or an offline sync echo legitimately targets a
* future slot — so they are unaffected.
*/
export function mayConvergeOntoSuppliedSlot(input: {
skipped: boolean;
takenAt: Date | null;
suppliedSlot: Date;
}): boolean {
if (input.skipped || input.takenAt === null) return true;
return (
input.suppliedSlot.getTime() <=
input.takenAt.getTime() + TAKEN_SLOT_FORWARD_GRACE_MS
);
}
/**
* Deterministically pick the slot row a write should converge onto when
* the canonical instant carries more than one live row (pre-existing