fix(medications): record the viewed dose by scheduled time instead of snapping to now

The medication cards show a single actionable dose — the open/overdue
window, or the server's next-due slot. The take / skip button POSTed
`{ skipped }` only, so the route defaulted `scheduledFor` to `now()` and
snapped to whichever slot was nearest. For a twice-daily 07:00 / 19:00
medication a morning tap (before the 13:00 midpoint) recorded the 07:00
dose, and a non-deliberate morning tap mis-recorded the morning dose.

Thread the displayed dose's canonical slot instant onto the POST so the
server resolves the exact dose the user is looking at, independent of the
wall-clock. The card derives the instant from its current-window status
(the window's start time today) or the next-due instant; a PRN dose with
no slot omits `scheduledFor` and keeps the legacy now-snap path. The route
already accepts `scheduledFor` and snaps it through the canonical slot
resolver, so no server change is needed — the cards now always send it.

Add a unit suite for the slot resolver and the POST body, and an
integration test proving a morning `scheduledFor` records the 07:00 slot
and an evening one records 19:00, driven by the supplied instant rather
than the clock, with marking one dose never marking the other.

(cherry picked from commit 438832cd965574da1b5dabf6567f7bffa26e36f3)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 09:04:09 +02:00
1 parent 11f10df39e
commit 72c93108d7
8 files changed
+577 -6

No files matched your search

@@ -0,0 +1,157 @@
/**
* v1.12.3 — per-dose intake targeting via the supplied `scheduledFor`.
*
* Closes the "morning auto-taken" bug: the web medication card now threads
* the displayed dose's canonical slot instant onto the intake POST as
* `scheduledFor`, so the server records THAT slot rather than snapping
* "now" to the nearest one. These tests exercise the exact server path the
* route runs for a supplied `scheduledFor` — `resolveSlotInstantForWrite`
* (the canonical snap) feeding `applyCanonicalSlotWrite` — on real Postgres,
* for a twice-daily 07:00 / 19:00 medication.
*
* Proven invariants:
* 1. A `scheduledFor` in the morning capture zone resolves + writes the
* 07:00 slot; one in the evening zone resolves + writes the 19:00 slot
* — driven by the SUPPLIED instant, never the wall-clock.
* 2. Marking the morning dose does NOT mark the evening dose (the evening
* slot stays pending) and vice-versa.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
applyCanonicalSlotWrite,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { localHmAsUtc } from "@/lib/timezone";
const TEST_USER_ID = "user-intake-scheduledfor-targeting";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "intake-sf-targeting",
email: "intake-sf-targeting@example.test",
timezone: "Europe/Berlin",
},
});
});
// Fixed non-DST-boundary weekday so the slot instants are deterministic.
const DAY = new Date("2026-03-10T12:00:00.000Z");
async function createTwiceDailyMed(): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Bisoprolol",
dose: "2.5mg",
active: true,
schedules: {
create: {
windowStart: "07:00",
windowEnd: "19:00",
timesOfDay: ["07:00", "19:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
/** Mimic the route: snap the supplied `scheduledFor`, then upsert the slot. */
async function recordViaSchedule(medId: string, scheduledFor: Date) {
const prisma = getPrismaClient();
const canonicalSlot = await resolveSlotInstantForWrite({
userId: TEST_USER_ID,
medicationId: medId,
userTz: "Europe/Berlin",
incoming: scheduledFor,
});
expect(canonicalSlot).not.toBeNull();
const takenAt = new Date(); // "now" — the recording moment, NOT the slot.
const applied = await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: canonicalSlot!,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
});
return { canonicalSlot: canonicalSlot!, row: applied.row };
}
describe("intake scheduledFor targeting — record the viewed dose", () => {
it("a morning scheduledFor records the 07:00 slot, regardless of wall-clock", async () => {
const medId = await createTwiceDailyMed();
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
const { canonicalSlot, row } = await recordViaSchedule(medId, slot0700);
expect(canonicalSlot.getTime()).toBe(slot0700.getTime());
expect(row.scheduledFor.getTime()).toBe(slot0700.getTime());
expect(row.takenAt).not.toBeNull();
});
it("an evening scheduledFor records the 19:00 slot, regardless of wall-clock", async () => {
const medId = await createTwiceDailyMed();
const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
const { canonicalSlot, row } = await recordViaSchedule(medId, slot1900);
expect(canonicalSlot.getTime()).toBe(slot1900.getTime());
expect(row.scheduledFor.getTime()).toBe(slot1900.getTime());
expect(row.takenAt).not.toBeNull();
});
it("marking the morning dose does not mark the evening dose (and vice-versa)", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
const slot0700 = localHmAsUtc(DAY, "Europe/Berlin", 7, 0);
const slot1900 = localHmAsUtc(DAY, "Europe/Berlin", 19, 0);
// Record ONLY the morning dose by supplying its slot instant.
await recordViaSchedule(medId, slot0700);
const morning = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
});
const evening = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
});
// Exactly one taken morning row; the evening slot holds no taken row.
expect(morning).toHaveLength(1);
expect(morning[0]?.takenAt).not.toBeNull();
expect(evening.filter((e) => e.takenAt !== null)).toHaveLength(0);
// Now record the evening dose — the morning row stays as it was.
await recordViaSchedule(medId, slot1900);
const morningAfter = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot0700, deletedAt: null },
});
const eveningAfter = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId, scheduledFor: slot1900, deletedAt: null },
});
expect(morningAfter).toHaveLength(1);
expect(morningAfter[0]?.takenAt).not.toBeNull();
expect(eveningAfter).toHaveLength(1);
expect(eveningAfter[0]?.takenAt).not.toBeNull();
});
});