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

@@ -139,6 +139,99 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
expect(onRecorded).toHaveBeenCalledWith("evt-99", false);
});
it("sends the displayed dose's scheduledFor on the POST so the server marks THAT slot (v1.12.3)", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt-am" } }),
});
vi.stubGlobal("fetch", fetchMock);
// The morning (07:00) slot the card is showing — Berlin 07:00 = 05:00 UTC.
const morningSlot = new Date("2026-06-05T05:00:00.000Z");
await runRecordIntake({
medication,
skipped: false,
scheduledFor: morningSlot,
t,
queryClient: fakeQueryClient(),
setIntakeLoading: vi.fn(),
undoIntake: vi.fn(),
});
const [, init] = fetchMock.mock.calls[0];
const body = JSON.parse((init as RequestInit).body as string);
expect(body).toEqual({
skipped: false,
scheduledFor: "2026-06-05T05:00:00.000Z",
});
});
it("targets the morning vs evening slot purely from the supplied scheduledFor, not the wall-clock", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt" } }),
});
vi.stubGlobal("fetch", fetchMock);
const morningSlot = new Date("2026-06-05T05:00:00.000Z"); // 07:00 Berlin
const eveningSlot = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
await runRecordIntake({
medication,
skipped: false,
scheduledFor: morningSlot,
t,
queryClient: fakeQueryClient(),
setIntakeLoading: vi.fn(),
undoIntake: vi.fn(),
});
await runRecordIntake({
medication,
skipped: false,
scheduledFor: eveningSlot,
t,
queryClient: fakeQueryClient(),
setIntakeLoading: vi.fn(),
undoIntake: vi.fn(),
});
const firstBody = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
);
const secondBody = JSON.parse(
(fetchMock.mock.calls[1][1] as RequestInit).body as string,
);
// Each call carries its OWN slot — marking one dose never targets the
// other.
expect(firstBody.scheduledFor).toBe("2026-06-05T05:00:00.000Z");
expect(secondBody.scheduledFor).toBe("2026-06-05T17:00:00.000Z");
});
it("omits scheduledFor entirely on a PRN / unscheduled dose (null), preserving the server now-snap path", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: { id: "evt-prn" } }),
});
vi.stubGlobal("fetch", fetchMock);
await runRecordIntake({
medication,
skipped: false,
scheduledFor: null,
t,
queryClient: fakeQueryClient(),
setIntakeLoading: vi.fn(),
undoIntake: vi.fn(),
});
const body = JSON.parse(
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
);
expect(body).toEqual({ skipped: false });
expect(body).not.toHaveProperty("scheduledFor");
});
it("omits the Undo action when the POST body carries no event id", async () => {
vi.stubGlobal(
"fetch",
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
/**
* v1.12.3 — the card-side slot resolver that closes the
* "morning auto-taken" intake bug. The medication card shows ONE
* actionable dose; its "Genommen" button must record THAT dose, not let
* the server snap "now" to the nearest slot. This resolver derives the
* displayed dose's slot instant from the card's current-window status (the
* open/overdue window's `windowStart` today) or the server's `nextDueAt`.
*/
describe("resolveDisplayedSlotInstant", () => {
// A point in the late morning, Berlin (CEST = UTC+2 in June). 09:00 Berlin.
const morningNow = new Date("2026-06-05T07:00:00.000Z");
it("targets the open window's slot on today's calendar day", () => {
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "07:00", windowEnd: "19:00" },
},
nextDueAt: null,
now: morningNow,
timeZone: "Europe/Berlin",
});
expect(slot).not.toBeNull();
// 07:00 Berlin on 2026-06-05 is 05:00 UTC (CEST = +2).
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
});
it("targets the morning window when the card surfaces the morning dose, regardless of a later wall-clock", () => {
// Even if the user taps in the afternoon, the morning window's slot is
// what the card surfaced (e.g. an overdue 07:00 dose), so the resolved
// instant must still be the 07:00 slot — NOT a now-snap.
const afternoonNow = new Date("2026-06-05T13:00:00.000Z"); // 15:00 Berlin
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "very_late",
schedule: { windowStart: "07:00", windowEnd: "08:00" },
},
nextDueAt: null,
now: afternoonNow,
timeZone: "Europe/Berlin",
});
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
});
it("targets the evening window's slot when the card surfaces the evening dose", () => {
const eveningNow = new Date("2026-06-05T17:00:00.000Z"); // 19:00 Berlin
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "19:00", windowEnd: "20:00" },
},
nextDueAt: null,
now: eveningNow,
timeZone: "Europe/Berlin",
});
// 19:00 Berlin on 2026-06-05 is 17:00 UTC.
expect(slot!.toISOString()).toBe("2026-06-05T17:00:00.000Z");
});
it("falls back to the server's next-due instant when no window is currently actionable", () => {
const nextDueAt = "2026-06-06T05:00:00.000Z";
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: { status: null, schedule: null },
nextDueAt,
now: morningNow,
});
expect(slot!.toISOString()).toBe(nextDueAt);
});
it("prefers the open window over next-due (the user acts on the dose in front of them)", () => {
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "07:00", windowEnd: "19:00" },
},
nextDueAt: "2026-06-06T05:00:00.000Z",
now: morningNow,
timeZone: "Europe/Berlin",
});
expect(slot!.toISOString()).toBe("2026-06-05T05:00:00.000Z");
});
it("returns null for a PRN dose (no current window, no next-due) so the server keeps the now-snap path", () => {
expect(
resolveDisplayedSlotInstant({
currentWindowStatus: { status: null, schedule: null },
nextDueAt: null,
now: morningNow,
}),
).toBeNull();
});
it("returns null on a malformed next-due rather than an Invalid Date", () => {
expect(
resolveDisplayedSlotInstant({
currentWindowStatus: { status: null, schedule: null },
nextDueAt: "not-a-date",
now: morningNow,
}),
).toBeNull();
});
it("handles a standard-time (CET = UTC+1) winter slot", () => {
const winterNow = new Date("2026-01-15T06:00:00.000Z"); // 07:00 Berlin (CET)
const slot = resolveDisplayedSlotInstant({
currentWindowStatus: {
status: "in_window",
schedule: { windowStart: "07:00", windowEnd: "19:00" },
},
nextDueAt: null,
now: winterNow,
timeZone: "Europe/Berlin",
});
// 07:00 Berlin on 2026-01-15 is 06:00 UTC (CET = +1).
expect(slot!.toISOString()).toBe("2026-01-15T06:00:00.000Z");
});
});
@@ -0,0 +1,148 @@
/**
* v1.12.3 — resolve the canonical slot instant of the dose a medication
* card is currently surfacing, so the card's "Genommen" / "Skip" buttons
* record THAT dose rather than letting the server snap "now" to the
* nearest slot.
*
* Background: the medication cards show a single actionable dose — either
* the current/overdue window (when `currentWindowStatus` resolves to
* in_window / late / very_late) or the next-due dose (`nextDueAt`). The
* intake POST historically carried `{ skipped }` only, so the server
* 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) therefore marked the 07:00 dose even when the user
* meant a different slot, and a non-deliberate morning tap mis-recorded the
* morning dose. Threading the displayed dose's slot instant onto the POST
* lets the server resolve the exact dose the user is looking at,
* deterministically and independently of the wall-clock.
*
* The returned instant is fed to the intake route's `scheduledFor`, which
* runs it through `resolveSlotInstantForWrite` (the canonical ±tolerance
* snap). It only needs to fall inside the target slot's capture zone, so a
* sub-minute DST-offset slop is harmless — the snap collapses it onto the
* exact canonical slot row.
*/
export interface DisplayedSlotSchedule {
windowStart: string;
windowEnd: string;
}
export interface ResolveDisplayedSlotInstantInput {
/**
* The card's current-window status. When its `status` is non-null the
* card is surfacing that schedule's window today (in_window / late /
* very_late), so the recorded dose is that window's slot today.
*/
currentWindowStatus: {
status: "in_window" | "late" | "very_late" | null;
schedule: DisplayedSlotSchedule | null;
};
/**
* The server-computed next-due instant (`computeNextDueAt`, the canonical
* recurrence engine). The card renders this when no window is currently
* actionable; it is already a canonical slot instant.
*/
nextDueAt: string | null | undefined;
/** Wall-clock reference (injected for testability). */
now: Date;
/**
* IANA timezone the card's day boundary is reasoned in. The cards model
* the user's day in Europe/Berlin (see `toBerlinDate`), so this defaults
* to that; passing the user's actual zone keeps the slot on the right
* calendar day across the rare cross-midnight edge.
*/
timeZone?: string;
}
/**
* Build a UTC `Date` for `HH:MM` on `now`'s calendar day in `timeZone`.
*
* Computes the zone's offset at `now` from `Intl` parts and applies it, so
* the result is the instant that reads as `HH:MM` local on today's date.
* Exactness to the minute is not required downstream (the server snap has a
* multi-hour tolerance), but this is correct to the minute regardless.
*/
function localHmTodayAsUtc(
now: Date,
timeZone: string,
hour: number,
minute: number,
): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(now);
const get = (type: string) =>
Number(parts.find((p) => p.type === type)?.value ?? "0");
// Intl renders 24:xx for midnight in some engines; normalise to 0.
const localHour = get("hour") % 24;
const localNowAsUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
localHour,
get("minute"),
get("second"),
);
const offsetMs =
Math.round((localNowAsUtc - now.getTime()) / 60_000) * 60_000;
const localTargetAsUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
hour,
minute,
0,
0,
);
return new Date(localTargetAsUtc - offsetMs);
}
function parseHm(value: string): { hour: number; minute: number } | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(value);
if (!m) return null;
const hour = Number(m[1]);
const minute = Number(m[2]);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
/**
* Resolve the displayed dose's `scheduledFor` instant, or `null` when the
* card cannot identify a specific slot (no current window AND no next-due —
* e.g. a PRN medication). A `null` return preserves the legacy server
* behaviour: the POST omits `scheduledFor` and the route handles the
* unscheduled / PRN insert path.
*/
export function resolveDisplayedSlotInstant(
input: ResolveDisplayedSlotInstantInput,
): Date | null {
const { currentWindowStatus, nextDueAt, now, timeZone = "Europe/Berlin" } =
input;
// The card surfaces the current/overdue window — record that window's
// slot on today's calendar day.
if (currentWindowStatus.status && currentWindowStatus.schedule) {
const hm = parseHm(currentWindowStatus.schedule.windowStart);
if (hm) return localHmTodayAsUtc(now, timeZone, hm.hour, hm.minute);
}
// Otherwise the card surfaces the next-due dose, already a canonical
// slot instant from the server.
if (nextDueAt) {
const d = new Date(nextDueAt);
if (!Number.isNaN(d.getTime())) return d;
}
return null;
}
@@ -6,6 +6,11 @@ import { useTranslations } from "@/lib/i18n/context";
interface MedicationIntakeActionsProps {
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
/**
* Record the displayed dose. The card binds the second `scheduledFor`
* argument to the slot it is currently showing (v1.12.3), so the buttons
* here just forward the skip flag.
*/
onRecordIntake: (skipped: boolean) => void;
}
@@ -38,6 +38,7 @@ import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
/**
@@ -309,6 +310,16 @@ export function Glp1MedicationCard({
todayEventCount: medication.todayEventCount ?? 0,
});
// v1.12.3 — slot instant of the injection this card is showing (the
// open/overdue window, else the server's next-due). Threaded onto the
// take / skip POST so the server records THIS dose deterministically
// rather than snapping "now" to the nearest slot.
const displayedSlot = resolveDisplayedSlotInstant({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now,
});
const recentInjections = details?.recentIntakes ?? [];
const lastSite =
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
@@ -490,7 +501,7 @@ export function Glp1MedicationCard({
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
+13 -1
View File
@@ -26,6 +26,7 @@ import {
reduceCurrentWindowStatus,
toBerlinDate,
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import {
invalidateKeys,
@@ -242,6 +243,17 @@ export function MedicationCard({
todayEventCount: medication.todayEventCount ?? 0,
});
// v1.12.3 — the slot instant of the dose this card is currently showing
// (the open/overdue window, else the server's next-due). Threaded onto
// the take / skip POST so the server records THIS dose rather than
// snapping "now" to the nearest slot — a morning tap on a 07:00 / 19:00
// medication used to mis-record the 07:00 dose.
const displayedSlot = resolveDisplayedSlotInstant({
currentWindowStatus,
nextDueAt: medication.nextDueAt,
now: new Date(),
});
function formatLastTakenAt(value: string): string {
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
@@ -399,7 +411,7 @@ export function MedicationCard({
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
/>
</div>
)}
@@ -48,8 +48,14 @@ interface UseMedicationIntakeParams {
interface UseMedicationIntakeResult {
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
/** Record a take (skipped=false) or skip (skipped=true). */
recordIntake: (skipped: boolean) => Promise<void>;
/**
* Record a take (skipped=false) or skip (skipped=true). The optional
* `scheduledFor` is the slot instant of the dose the card is displaying;
* when supplied the server marks THAT slot instead of snapping "now" to
* the nearest one (v1.12.3). Omit it (or pass null) for an unscheduled /
* PRN dose to keep the legacy now-snap path.
*/
recordIntake: (skipped: boolean, scheduledFor?: Date | null) => Promise<void>;
/** Reverse a just-recorded intake via the soft-delete route. */
undoIntake: (eventId: string) => Promise<void>;
}
@@ -64,6 +70,14 @@ interface UseMedicationIntakeResult {
export async function runRecordIntake(deps: {
medication: MedicationIntakeIdentity;
skipped: boolean;
/**
* v1.12.3 — slot instant of the dose the card is showing. Threaded onto
* the POST so the server marks the displayed dose deterministically
* instead of snapping "now" to the nearest slot (a morning tap on a
* 07:00 / 19:00 med no longer mis-records the 07:00 dose). `null` /
* undefined keeps the legacy now-snap (unscheduled / PRN doses).
*/
scheduledFor?: Date | null;
t: Translator;
queryClient: QueryClient;
setIntakeLoading: (value: string | null) => void;
@@ -73,6 +87,7 @@ export async function runRecordIntake(deps: {
const {
medication,
skipped,
scheduledFor,
t,
queryClient,
setIntakeLoading,
@@ -85,7 +100,15 @@ export async function runRecordIntake(deps: {
const res = await fetch(`/api/medications/${medication.id}/intake`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skipped }),
// The route's Zod schema accepts an ISO `scheduledFor`; the server
// snaps it to the canonical slot. Only send it when the card
// identified the displayed dose's slot — otherwise omit so the
// PRN / unscheduled now-snap path is preserved.
body: JSON.stringify(
scheduledFor
? { skipped, scheduledFor: scheduledFor.toISOString() }
: { skipped },
),
});
// v1.11.3 C1 — a failed POST used to clear the spinner silently, so the
// user believed the dose was logged when it was not. Surface the failure
@@ -170,10 +193,11 @@ export function useMedicationIntake({
const undoIntake = (eventId: string) =>
runUndoIntake({ medication, eventId, t, queryClient });
const recordIntake = (skipped: boolean) =>
const recordIntake = (skipped: boolean, scheduledFor?: Date | null) =>
runRecordIntake({
medication,
skipped,
scheduledFor,
t,
queryClient,
setIntakeLoading,
@@ -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();
});
});