diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts index ede88e3a5..45be74e1a 100644 --- a/src/components/medications/__tests__/use-medication-intake.test.ts +++ b/src/components/medications/__tests__/use-medication-intake.test.ts @@ -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", diff --git a/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts new file mode 100644 index 000000000..79433d9b3 --- /dev/null +++ b/src/components/medications/card-parts/__tests__/displayed-slot-instant.test.ts @@ -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"); + }); +}); diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts new file mode 100644 index 000000000..8c0b8ff87 --- /dev/null +++ b/src/components/medications/card-parts/displayed-slot-instant.ts @@ -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; +} diff --git a/src/components/medications/card-parts/medication-intake-actions.tsx b/src/components/medications/card-parts/medication-intake-actions.tsx index a2f3b5327..0cf2fc6ca 100644 --- a/src/components/medications/card-parts/medication-intake-actions.tsx +++ b/src/components/medications/card-parts/medication-intake-actions.tsx @@ -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; } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 1d339193a..fdd184c2d 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -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({
recordIntake(skipped, displayedSlot)} />
)} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 13573dd12..66abd7be5 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -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({
recordIntake(skipped, displayedSlot)} />
)} diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index 0a9de8a31..acafdbec3 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -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; + /** + * 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; /** Reverse a just-recorded intake via the soft-delete route. */ undoIntake: (eventId: string) => Promise; } @@ -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, diff --git a/tests/integration/intake-scheduled-for-targeting.test.ts b/tests/integration/intake-scheduled-for-targeting.test.ts new file mode 100644 index 000000000..959747eeb --- /dev/null +++ b/tests/integration/intake-scheduled-for-targeting.test.ts @@ -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 { + 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(); + }); +});