mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
b5f4153a42
commit
ad503c45c0
6 files changed
+515
-30
No files matched your search
@@ -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"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user