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,