mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Regression: a late-morning take must not consume the evening slot.
|
||||
*
|
||||
* The medication card threads the DISPLAYED dose's slot onto the intake POST
|
||||
* as `scheduledFor`. Once a morning slot's catch-up window has lapsed the card
|
||||
* advances its display-due to the EVENING slot, so a "Genommen" tap for the
|
||||
* (missed) morning dose posts `scheduledFor = 21:00` with `takenAt = now`
|
||||
* (13:08). Band attribution correctly refuses the take (it lands in no
|
||||
* window), but the route's source-agnostic convergence probe used to bind it
|
||||
* to the 21:00 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 tomorrow.
|
||||
*
|
||||
* These tests drive the SAME shipped helpers the per-medication intake route
|
||||
* runs for a taken write (`resolveSlotForWriteByBand` → the
|
||||
* `mayConvergeOntoSuppliedSlot` guard → the convergence probe /
|
||||
* `applyCanonicalSlotWrite` / standalone insert) against real Postgres for a
|
||||
* twice-daily 09:00 / 21:00 medication, then assert the read model the
|
||||
* dashboard composes (`computeDisplayDue` + the taken/scheduled tally) reflects
|
||||
* the correct state.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
applyCanonicalSlotWrite,
|
||||
mayConvergeOntoSuppliedSlot,
|
||||
resolveSlotForWriteByBand,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
toResolvedSlotMark,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
const TEST_USER_ID = "user-late-take-future-slot";
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Fixed non-DST-boundary weekday so the slot instants are deterministic.
|
||||
const DAY = new Date("2026-03-10T12:00:00.000Z");
|
||||
const slot0900 = () => localHmAsUtc(DAY, TZ, 9, 0);
|
||||
const slot2100 = () => localHmAsUtc(DAY, TZ, 21, 0);
|
||||
/** The real recording moment: 13:08, i.e. 8 minutes past the 09:00 slot's
|
||||
* +4h catch-up cutoff (09:00 on-time ±1h → 10:00, overdue tail → 13:00). */
|
||||
const takenAt1308 = () => localHmAsUtc(DAY, TZ, 13, 8);
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "late-take-future-slot",
|
||||
email: "late-take-future-slot@example.test",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function createTwiceDailyMed(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
startsOn: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
/** Mint the day's two pending REMINDER rows exactly as the projector would. */
|
||||
async function projectPendingSlots(medId: string): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.medicationIntakeEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot0900(),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot2100(),
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
source: "REMINDER",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate the per-medication intake route's taken-write branch for a card
|
||||
* "Genommen" tap: band attribution on `takenAt`, then the ad-hoc fallback
|
||||
* (the `mayConvergeOntoSuppliedSlot` guard gating the convergence probe, else
|
||||
* a standalone insert). Uses the real shipped helpers so the guard under test
|
||||
* is the one shipped in the route.
|
||||
*/
|
||||
async function recordViaCard(
|
||||
prisma: PrismaClient,
|
||||
medId: string,
|
||||
suppliedSlot: Date,
|
||||
takenAt: Date,
|
||||
): Promise<void> {
|
||||
const attribution = await resolveSlotForWriteByBand({
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: takenAt,
|
||||
});
|
||||
const canonicalSlot = attribution.slotInstant;
|
||||
|
||||
if (canonicalSlot) {
|
||||
await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
canonicalSlot,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
attributionSource: "AUTO",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSlotRow = mayConvergeOntoSuppliedSlot({
|
||||
skipped: false,
|
||||
takenAt,
|
||||
suppliedSlot,
|
||||
})
|
||||
? await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: suppliedSlot,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingSlotRow) {
|
||||
await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
canonicalSlot: suppliedSlot,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
attributionSource: "AUTO",
|
||||
});
|
||||
} else {
|
||||
// Genuinely standalone (ad-hoc): anchor on the intake instant.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: takenAt,
|
||||
takenAt,
|
||||
skipped: false,
|
||||
source: "WEB",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readDisplayDue(medId: string, now: Date): Promise<Date | null> {
|
||||
const prisma = getPrismaClient();
|
||||
const med = await prisma.medication.findUniqueOrThrow({
|
||||
where: { id: medId },
|
||||
include: { schedules: true },
|
||||
});
|
||||
// Mirror `buildMedsTodayBlock`: resolved slots = taken / skipped / autoMissed
|
||||
// rows, each carrying its anchoring shape.
|
||||
const resolvedEvents = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
deletedAt: null,
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }],
|
||||
},
|
||||
select: { scheduledFor: true, takenAt: true },
|
||||
});
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: med.id,
|
||||
startsOn: med.startsOn,
|
||||
endsOn: med.endsOn,
|
||||
oneShot: med.oneShot,
|
||||
createdAt: med.createdAt,
|
||||
},
|
||||
schedules: med.schedules,
|
||||
now,
|
||||
userTz: TZ,
|
||||
lastIntakeAt: null,
|
||||
resolvedSlots: resolvedEvents.map(toResolvedSlotMark),
|
||||
});
|
||||
return display?.at ?? null;
|
||||
}
|
||||
|
||||
describe("late-morning take does not consume the evening slot", () => {
|
||||
it("records the 13:08 take standalone; the 21:00 slot stays pending", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
await projectPendingSlots(medId);
|
||||
|
||||
// Card shows 21:00 (09:00 catch-up lapsed); the tap posts scheduledFor=21:00.
|
||||
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
|
||||
|
||||
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot2100(),
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// The evening slot must NOT have been consumed by the morning take.
|
||||
expect(eveningRow.takenAt).toBeNull();
|
||||
expect(eveningRow.skipped).toBe(false);
|
||||
|
||||
// Exactly one taken row, anchored ad-hoc on its own 13:08 instant.
|
||||
const takenRows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
deletedAt: null,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
});
|
||||
expect(takenRows).toHaveLength(1);
|
||||
expect(takenRows[0]!.takenAt!.getTime()).toBe(takenAt1308().getTime());
|
||||
// Ad-hoc contract: scheduledFor === takenAt (not a slot anchor).
|
||||
expect(takenRows[0]!.scheduledFor.getTime()).toBe(takenAt1308().getTime());
|
||||
|
||||
// The morning slot stays open too (nothing filled it).
|
||||
const morningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot0900(),
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(morningRow.takenAt).toBeNull();
|
||||
});
|
||||
|
||||
it("next intake stays 21:00 today — it does not jump to tomorrow", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
await projectPendingSlots(medId);
|
||||
|
||||
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
|
||||
|
||||
const due = await readDisplayDue(medId, takenAt1308());
|
||||
expect(due).not.toBeNull();
|
||||
// The still-open evening slot today, NOT tomorrow's 09:00.
|
||||
expect(due!.getTime()).toBe(slot2100().getTime());
|
||||
});
|
||||
|
||||
it("the pre-fix convergence would have consumed 21:00 and jumped to tomorrow (mechanism)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const medId = await createTwiceDailyMed();
|
||||
await projectPendingSlots(medId);
|
||||
|
||||
// Reproduce the OLD behaviour explicitly: converge the 13:08 take onto the
|
||||
// 21:00 pending row (what the ungated probe did).
|
||||
await applyCanonicalSlotWrite({
|
||||
client: prisma,
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
canonicalSlot: slot2100(),
|
||||
takenAt: takenAt1308(),
|
||||
skipped: false,
|
||||
isExplicitTaken: true,
|
||||
isExplicitSkip: false,
|
||||
idempotencyKey: null,
|
||||
createSource: "WEB",
|
||||
attributionSource: "AUTO",
|
||||
});
|
||||
|
||||
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
medicationId: medId,
|
||||
scheduledFor: slot2100(),
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
// Confirms the bug shape: the evening slot carries the morning take.
|
||||
expect(eveningRow.takenAt!.getTime()).toBe(takenAt1308().getTime());
|
||||
|
||||
// And the dashboard next-due jumps past today to tomorrow's 09:00.
|
||||
const due = await readDisplayDue(medId, takenAt1308());
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.getTime()).toBeGreaterThan(slot2100().getTime());
|
||||
const tomorrow0900 = localHmAsUtc(
|
||||
new Date(DAY.getTime() + 24 * 60 * 60 * 1000),
|
||||
TZ,
|
||||
9,
|
||||
0,
|
||||
);
|
||||
expect(due!.getTime()).toBe(tomorrow0900.getTime());
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user