diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 464d57691..c3ac9d2a3 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", }); it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => { - // Dedup pre-check first (empty), then the canonical-slot find returns - // the worker-minted pending REMINDER row for the 19:00 slot. + // Dedup pre-check first (empty), then the held-dose probe (no take on + // the slot yet), then the canonical-slot find returns the worker-minted + // pending REMINDER row for the 19:00 slot. vi.mocked(prisma.medicationIntakeEvent.findMany) + .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 69fd01a1f..37202fac4 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,6 +65,7 @@ import { resolveForcedSlotForWrite, resolveSlotForWriteByBand, resolveSlotInstantForWrite, + slotHeldByAnotherDose, } from "@/lib/medications/scheduling/slot-upsert"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import { @@ -338,6 +339,9 @@ async function postBulk(request: NextRequest): Promise { let inserted = 0; let updated = 0; let duplicates = 0; + // External dose records kept as their own unscheduled dose because their + // band slot already carried a different one. + let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; // v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs @@ -443,6 +447,9 @@ async function postBulk(request: NextRequest): Promise { // a band decision. Pending echoes / skips carry no decision // (undefined → the upsert leaves an existing USER_PIN untouched). let attributionSource: "AUTO" | "USER_PIN" | undefined; + // Set when the band slot is held by a different dose and this take is + // recorded unscheduled; it must not converge onto any slot row below. + let heldByAnotherDose = false; if (isExplicitTaken && entry.forceSlotInstant !== undefined) { canonicalSlot = await resolveForcedSlotForWrite({ userId: user.id, @@ -486,6 +493,26 @@ async function postBulk(request: NextRequest): Promise { }); canonicalSlot = attribution.slotInstant; attributionSource = "AUTO"; + // An external dose record (a HealthKit dose event) whose band slot + // already carries a different recorded dose is an extra dose, not a + // correction of that one. Converging would overwrite the slot's + // take last-write-wins and the extra dose would exist nowhere; it is + // recorded as its own unscheduled dose instead. + if ( + canonicalSlot !== null && + entry.externalId !== undefined && + (await slotHeldByAnotherDose({ + userId: user.id, + medicationId: entry.medicationId, + canonicalSlot, + takenAt: entry.takenAt as Date, + externalId: entry.externalId, + })) + ) { + canonicalSlot = null; + heldByAnotherDose = true; + extraDosesUnscheduled += 1; + } } else if (isExplicitSkip && entry.scheduledFor === undefined) { // v1.16.9 — a slot-less deliberate skip resolves by band // membership on the skip moment, exactly like a take does. The @@ -666,6 +693,7 @@ async function postBulk(request: NextRequest): Promise { // late-morning dose consuming the evening slot). It records // standalone (ad-hoc) instead. const existingSlotRow = + !heldByAnotherDose && entry.scheduledFor !== undefined && mayConvergeOntoSuppliedSlot({ skipped: entry.skipped, @@ -878,6 +906,7 @@ async function postBulk(request: NextRequest): Promise { updated, duplicates, skipped: skipped.length, + extra_doses_unscheduled: extraDosesUnscheduled, ...(unstableShapes.length > 0 ? unstableExternalIdMeta("medication.intake.bulk", unstableShapes) : {}), diff --git a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts new file mode 100644 index 000000000..67457ce71 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts @@ -0,0 +1,114 @@ +/** + * An extra dose next to a slot that already carries one. + * + * Two ends: the write path keeps a different external dose record off a slot + * that is already taken (`isAnotherDose`), and the read ledger lets the dose + * stored on the slot claim it before an extra one stored on its own instant, + * whatever order the rows were read in. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { reconstructDoseHistory, type HistoryIntake } from "../dose-history"; +import { SAME_DOSE_TOLERANCE_MS, isAnotherDose } from "../slot-upsert"; + +const MIN = 60_000; +const t0 = new Date("2026-06-09T20:00:00Z"); +const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN); + +describe("isAnotherDose", () => { + it("a slot with nothing taken is never held by another dose", () => { + expect( + isAnotherDose( + { takenAt: null, externalId: null }, + { takenAt: plus(600), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("the same external record is the same dose", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(600), externalId: "hk-1" }, + ), + ).toBe(false); + }); + + it("two external records a day apart are two doses", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: plus(24 * 60 + 22), externalId: "hk-2" }, + ), + ).toBe(true); + }); + + it("two external records within a minute are one dose entered twice", () => { + expect( + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + { takenAt: new Date(t0.getTime() + 30_000), externalId: "hk-2" }, + ), + ).toBe(false); + }); + + it("a take with no external record merges within the tolerance only", () => { + const held = { takenAt: t0, externalId: null }; + expect( + isAnotherDose(held, { + takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS), + externalId: "hk-2", + }), + ).toBe(false); + expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe( + true, + ); + }); +}); + +describe("reconstructDoseHistory: the slot's own dose claims the slot", () => { + const slot: SlotWindowInput = { + at: t0, + timeOfDay: "22:00", + onTimeStart: plus(-60), + onTimeEnd: plus(60), + lateGraceMs: 180 * MIN, + }; + const bands = buildSlotBands([slot]); + const onSlot: HistoryIntake = { + id: "on-slot", + scheduledFor: t0, + takenAt: plus(5), + skipped: false, + }; + const extra: HistoryIntake = { + id: "extra", + scheduledFor: plus(30), + takenAt: plus(30), + skipped: false, + }; + + it.each([ + ["slot row first", [onSlot, extra]], + ["extra row first (newest scheduledFor first)", [extra, onSlot]], + ])("%s", (_label, intakes) => { + const rows = reconstructDoseHistory(bands, intakes, plus(600), null); + const slotRow = rows.find((row) => row.kind === "slot"); + const adHoc = rows.filter((row) => row.kind === "ad_hoc"); + expect(slotRow?.intake?.id).toBe("on-slot"); + expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]); + }); + + it("among takes not stored on the slot, the nearer one claims it", () => { + const far: HistoryIntake = { ...extra, id: "far" }; + const near: HistoryIntake = { + id: "near", + scheduledFor: plus(10), + takenAt: plus(10), + skipped: false, + }; + const rows = reconstructDoseHistory(bands, [far, near], plus(600), null); + expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near"); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index b4d31aef3..a126eff2c 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -273,8 +273,33 @@ export function reconstructDoseHistory( } } - for (const i of taken) { + // The best fit claims a slot first, whatever order the caller read the rows + // in. A take the write path stored ON the slot it attributes to goes before + // one stored on its own instant (an extra dose recorded unscheduled), then + // on-time before late, then the nearer take. Processing in the caller's + // order (newest `scheduledFor` first on the history route) let an extra dose + // logged half an hour after the slot claim it and pushed the slot's own + // dose out as the off-schedule one. + const ranked = taken.map((i) => { const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (!matched) return { i, matched, key: [2, 0, 0] as const }; + const storedOnSlot = + Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <= + ANCHOR_EPSILON_MS; + return { + i, + matched, + key: [ + storedOnSlot ? 0 : 1, + matched.status === "on_time" ? 0 : 1, + Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()), + ] as const, + }; + }); + ranked.sort( + (a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2], + ); + for (const { i, matched } of ranked) { if (matched && !claim.has(matched.band)) { claim.set(matched.band, { intake: i, diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 55eef2a2b..fa1d63343 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -746,6 +746,79 @@ export async function findPinConflict(input: { }); } +/** + * How far apart two recorded takes of one slot may sit and still be the same + * dose logged twice (a tap in HealthLog and an entry in another app a few + * minutes later). Further apart, they are two doses. + */ +export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000; + +/** + * Is `held` (the take a slot already carries) a different dose from the + * incoming external dose record? + * + * Band attribution maps a take to the slot whose band contains it, and a + * weekly schedule's band reaches a day either side of the slot. Without this + * check an extra dose logged the day after a Tuesday dose landed on the + * Tuesday slot and overwrote it last-write-wins: the Tuesday time was + * replaced and the extra dose existed nowhere. + * + * - Nothing taken on the slot yet (pending, skipped): not another dose, the + * incoming take is the slot's dose. + * - The same external record: the same dose. + * - Two different external records: different doses, unless they sit within + * a minute of each other, which is a double entry rather than two doses. + * - Otherwise (the held take has no external record): the same dose within + * {@link SAME_DOSE_TOLERANCE_MS}, a different one beyond it. + */ +export function isAnotherDose( + held: { takenAt: Date | null; externalId: string | null }, + incoming: { takenAt: Date; externalId: string | null }, +): boolean { + if (held.takenAt === null) return false; + if (held.externalId !== null && held.externalId === incoming.externalId) { + return false; + } + const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime()); + if (held.externalId !== null && incoming.externalId !== null) { + return gap > 60_000; + } + return gap > SAME_DOSE_TOLERANCE_MS; +} + +/** + * Does the slot at `canonicalSlot` already carry a recorded take that is a + * different dose from the incoming one (see {@link isAnotherDose})? The + * caller records the incoming take as its own unscheduled dose instead of + * converging onto the slot. + */ +export async function slotHeldByAnotherDose(input: { + userId: string; + medicationId: string; + canonicalSlot: Date; + takenAt: Date; + externalId: string | null; + client?: Pick; +}): Promise { + const client = input.client ?? defaultPrisma; + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId: input.userId, + medicationId: input.medicationId, + scheduledFor: input.canonicalSlot, + deletedAt: null, + takenAt: { not: null }, + }, + select: { takenAt: true, externalId: true }, + }); + return rows.some((row) => + isAnotherDose(row, { + takenAt: input.takenAt, + externalId: input.externalId, + }), + ); +} + export interface ResolveForcedSlotInput { userId: string; medicationId: string; diff --git a/tests/integration/medication-extra-dose-apple-health.test.ts b/tests/integration/medication-extra-dose-apple-health.test.ts new file mode 100644 index 000000000..df8465703 --- /dev/null +++ b/tests/integration/medication-extra-dose-apple-health.test.ts @@ -0,0 +1,356 @@ +/** + * An extra (off-schedule) dose recorded in Apple Health must arrive as its own + * unscheduled dose. + * + * The phone posts every HealthKit dose event to the bulk route with the + * event's UUID as `externalId`, `scheduledFor` set to the event's scheduled + * date or, for an extra dose that has none, its own time. A taken write binds + * to a slot by window band, and a weekly schedule's band reaches a day either + * side of its slot. An extra dose on the Wednesday after a Tuesday dose + * therefore landed on the Tuesday slot and overwrote it: the Tuesday time was + * replaced by the Wednesday one and the extra dose existed nowhere. + * + * Three shapes, each through the shipped `POST` and `GET` exports: an extra + * dose far from any slot, one half an hour after a slot already taken, and one + * on a day the schedule has no slot. Each asserts the stored rows AND the dose + * history the app reads back, where the extra dose must show as off-schedule + * and the slot keep its own dose. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +import { localHmAsUtc } from "@/lib/tz/local-day"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const DAY_MS = 24 * 60 * 60 * 1000; +const MIN = 60_000; + +let counter = 0; + +async function signedInUser(): Promise { + const prisma = getPrismaClient(); + const suffix = `extra-dose-${counter++}`; + const user = await prisma.user.create({ + data: { + username: suffix, + email: `${suffix}@example.test`, + role: "USER", + timezone: TZ, + }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user.id; +} + +async function seedMedication( + userId: string, + schedule: { timesOfDay: string[]; daysOfWeek: string | null }, +): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId, + name: "Magnesium", + dose: "400mg", + active: true, + // Dose events from Apple Health only target a mirrored medication. + externalSource: "APPLE_HEALTH", + externalId: `hk-medication-${userId}`, + startsOn: new Date(Date.now() - 60 * DAY_MS), + // The ledger expects slots only from the medication's creation on. + createdAt: new Date(Date.now() - 60 * DAY_MS), + schedules: { + create: { + windowStart: schedule.timesOfDay[0], + windowEnd: schedule.timesOfDay[0], + timesOfDay: schedule.timesOfDay, + daysOfWeek: schedule.daysOfWeek, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + return med.id; +} + +/** A local day at least two weeks back with the given weekday (0 = Sunday). */ +function pastLocalDay(weekday: number): Date { + const day = new Date(Date.now() - 21 * DAY_MS); + day.setUTCHours(12, 0, 0, 0); + while (day.getUTCDay() !== weekday) { + day.setTime(day.getTime() - DAY_MS); + } + return day; +} + +interface BulkEntry { + medicationId: string; + scheduledFor: string; + takenAt: string; + externalId: string; +} + +/** The phone's shape: one HealthKit dose event per entry. */ +function appleDose( + medicationId: string, + takenAt: Date, + externalId: string, + scheduledFor: Date = takenAt, +): BulkEntry { + return { + medicationId, + scheduledFor: scheduledFor.toISOString(), + takenAt: takenAt.toISOString(), + externalId, + }; +} + +async function postBulk(entries: BulkEntry[]) { + const { POST } = await import("@/app/api/medications/intake/bulk/route"); + const response = await POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: entries.map((entry) => ({ + ...entry, + source: "APPLE_HEALTH", + })), + }), + }), + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { entries: Array<{ status: string; id?: string }> }; + }; + return body.data.entries; +} + +type Handler = ( + request: NextRequest, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: { params: Promise }, +) => Promise; + +interface LedgerRow { + kind: "slot" | "ad_hoc"; + at: string; + status: string; + intake: { id: string | null; takenAt: string | null } | null; +} + +async function readLedger( + medicationId: string, + from: Date, + to: Date, +): Promise { + const { GET } = await import("@/app/api/medications/[id]/dose-history/route"); + const search = new URLSearchParams({ + from: from.toISOString(), + to: to.toISOString(), + }); + const response = await (GET as Handler)( + new NextRequest( + `http://localhost/api/medications/${medicationId}/dose-history?${search}`, + { method: "GET" }, + ), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: { rows: LedgerRow[] } }; + return body.data.rows; +} + +async function rowsOf(medicationId: string) { + return getPrismaClient().medicationIntakeEvent.findMany({ + where: { medicationId, deletedAt: null }, + orderBy: { takenAt: "asc" }, + }); +} + +function takenAtOf(row: LedgerRow | undefined): number | null { + const taken = row?.intake?.takenAt; + return taken ? new Date(taken).getTime() : null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("an extra dose from Apple Health is stored as an unscheduled dose", () => { + it("(a) far from any slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(3); + const slot = localHmAsUtc(day, TZ, 8, 0); + const extraAt = localHmAsUtc(day, TZ, 15, 0); + + const results = await postBulk([ + appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"), + appleDose(medicationId, extraAt, "hk-a-2"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const extra = rows.find((row) => row.externalId === "hk-a-2"); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 18 * 3_600_000), + ); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + }); + + it("(b) 30 minutes after a slot already taken", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const day = pastLocalDay(2); + const slot = localHmAsUtc(day, TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + const extraAt = new Date(slot.getTime() + 30 * MIN); + + // The slot's own dose first, then the extra one in a later sync. + expect( + (await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + expect( + (await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map( + (r) => r.status, + ), + ).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const own = rows.find((row) => row.externalId === "hk-b-1"); + const extra = rows.find((row) => row.externalId === "hk-b-2"); + // The slot keeps its own dose, time unchanged. + expect(own?.scheduledFor.getTime()).toBe(slot.getTime()); + expect(own?.takenAt?.getTime()).toBe(ownAt.getTime()); + // The extra dose sits on its own instant. + expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime()); + expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + const slotRow = ledger.find((row) => row.kind === "slot"); + expect(slotRow?.status).toBe("taken_on_time"); + expect(takenAtOf(slotRow)).toBe(ownAt.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc).toHaveLength(1); + expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime()); + }); + + it("(b2) a re-sync of the same dose event stays one dose", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const ownAt = new Date(slot.getTime() + 2 * MIN); + + await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]); + const again = await postBulk([ + appleDose(medicationId, ownAt, "hk-b2-1", slot), + ]); + expect(again.map((r) => r.status)).toEqual(["duplicate"]); + expect(await rowsOf(medicationId)).toHaveLength(1); + }); + + it("(c) on a day without a slot, next to the weekly slot the day before", async () => { + const userId = await signedInUser(); + // Tuesdays and Fridays at 22:00. + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + const tuesday = pastLocalDay(2); + const wednesday = new Date(tuesday.getTime() + DAY_MS); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23); + const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45); + + await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-c-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(2); + const tue = rows.find((row) => row.externalId === "hk-c-tue"); + const wed = rows.find((row) => row.externalId === "hk-c-wed"); + // Tuesday keeps its own time. + expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime()); + expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime()); + // Wednesday is its own unscheduled dose. + expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime()); + expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime()); + + const ledger = await readLedger( + medicationId, + new Date(tuesday.getTime() - 2 * DAY_MS), + new Date(tuesday.getTime() + 2 * DAY_MS), + ); + const tuesdayRow = ledger.find( + (row) => + row.kind === "slot" && + new Date(row.at).getTime() === tuesdaySlot.getTime(), + ); + expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime()); + const adHoc = ledger.filter((row) => row.kind === "ad_hoc"); + expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]); + }); +});