diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index d5dfdb2de..93e291a62 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -520,6 +520,8 @@ async function postBulk(request: NextRequest): Promise { canonicalSlot, takenAt: entry.takenAt as Date, externalId: entry.externalId, + scheduledFor: entry.scheduledFor ?? null, + userTz: user.timezone, })) ) { canonicalSlot = null; diff --git a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts index 67457ce71..715e568c7 100644 --- a/src/lib/medications/scheduling/__tests__/extra-dose.test.ts +++ b/src/lib/medications/scheduling/__tests__/extra-dose.test.ts @@ -10,18 +10,27 @@ 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"; +import { DOUBLE_ENTRY_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", () => { + // 22:00 in Berlin; the next local day starts two hours later. + const TZ = "Europe/Berlin"; + const external = (id: string, minutes: number, namesSlot = false) => ({ + takenAt: plus(minutes), + externalId: id, + namesSlot, + }); + it("a slot with nothing taken is never held by another dose", () => { expect( isAnotherDose( { takenAt: null, externalId: null }, - { takenAt: plus(600), externalId: "hk-2" }, + external("hk-2", 600), + TZ, ), ).toBe(false); }); @@ -30,40 +39,56 @@ describe("isAnotherDose", () => { expect( isAnotherDose( { takenAt: t0, externalId: "hk-1" }, - { takenAt: plus(600), externalId: "hk-1" }, + external("hk-1", 600), + TZ, ), ).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("a take on another local day is another dose, whoever recorded either", () => { + // 22:00 and 00:30 the next morning in Berlin: an hour and a half apart, + // two calendar days. + for (const held of [ + { takenAt: t0, externalId: "hk-1" }, + { takenAt: t0, externalId: null }, + ]) { + expect(isAnotherDose(held, external("hk-2", 150, true), TZ)).toBe(true); + expect(isAnotherDose(held, external("hk-2", 24 * 60 + 22), TZ)).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", () => { + it("a take logged in HealthLog converges with an external record of the same day", () => { const held = { takenAt: t0, externalId: null }; + expect(isAnotherDose(held, external("hk-2", 20), TZ)).toBe(false); + expect(isAnotherDose(held, external("hk-2", -90), TZ)).toBe(false); + }); + + it("a new record that names the slot is the slot's dose recorded again (an edit in Health)", () => { expect( - isAnotherDose(held, { - takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS), - externalId: "hk-2", - }), + isAnotherDose( + { takenAt: t0, externalId: "hk-1" }, + external("hk-1-edited", 30, true), + TZ, + ), + ).toBe(false); + }); + + it("an unscheduled record of the same day is another dose beyond a double entry", () => { + const held = { takenAt: t0, externalId: "hk-1" }; + expect(isAnotherDose(held, external("hk-2", 30), TZ)).toBe(true); + expect( + isAnotherDose( + held, + { + takenAt: new Date(t0.getTime() + DOUBLE_ENTRY_TOLERANCE_MS), + externalId: "hk-2", + namesSlot: false, + }, + TZ, + ), ).toBe(false); - expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe( - true, - ); }); }); diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index fa1d63343..07b35a7ff 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -18,6 +18,7 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; +import { userDayKey } from "@/lib/tz/format"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; import { attributeTakenToSlot, @@ -747,43 +748,64 @@ 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. + * How far apart two different external dose records may sit and still be one + * dose entered twice rather than two doses. */ -export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000; +export const DOUBLE_ENTRY_TOLERANCE_MS = 60_000; /** * Is `held` (the take a slot already carries) a different dose from the - * incoming external dose record? + * incoming external dose record, both attributed to the same slot by band? * * 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. + * replaced and the extra dose existed nowhere (#1221). The rule: * * - 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. + * - A take on a different local calendar day (the user's zone): another + * dose. A slot is one dose a day; the band only reaches across midnight + * so a late take still counts for its slot. + * - The same local day, and the held take was logged in HealthLog (no + * external record): the same dose. A tap at 08:00 and the same dose + * recorded in Health at 08:20 converge onto the slot, one dose and one + * stock consumption. + * - The same local day, two different external records: the record says + * which it is. One that names this slot as its scheduled time is the + * slot's dose, recorded again: editing a dose in Health replaces it with + * a new record (a new UUID, possibly a new time), and that must move the + * slot's dose, not add a second one. One without a scheduled time of its + * own (Health logs an extra dose as unscheduled, and the phone then sends + * its own time) is another dose, unless it sits within + * {@link DOUBLE_ENTRY_TOLERANCE_MS} of the held one, which is one dose + * entered twice. */ export function isAnotherDose( held: { takenAt: Date | null; externalId: string | null }, - incoming: { takenAt: Date; externalId: string | null }, + incoming: { + takenAt: Date; + externalId: string | null; + /** The record's scheduled time is the slot itself. */ + namesSlot: boolean; + }, + userTz: string, ): 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; + if ( + userDayKey(held.takenAt, userTz) !== userDayKey(incoming.takenAt, userTz) + ) { + return true; } - return gap > SAME_DOSE_TOLERANCE_MS; + if (held.externalId === null || incoming.externalId === null) return false; + if (incoming.namesSlot) return false; + const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime()); + return gap > DOUBLE_ENTRY_TOLERANCE_MS; } /** @@ -798,6 +820,9 @@ export async function slotHeldByAnotherDose(input: { canonicalSlot: Date; takenAt: Date; externalId: string | null; + /** The scheduled time the incoming record carries, if any. */ + scheduledFor: Date | null; + userTz: string; client?: Pick; }): Promise { const client = input.client ?? defaultPrisma; @@ -811,11 +836,20 @@ export async function slotHeldByAnotherDose(input: { }, select: { takenAt: true, externalId: true }, }); + const namesSlot = + input.scheduledFor !== null && + Math.abs(input.scheduledFor.getTime() - input.canonicalSlot.getTime()) <= + DOUBLE_ENTRY_TOLERANCE_MS; return rows.some((row) => - isAnotherDose(row, { - takenAt: input.takenAt, - externalId: input.externalId, - }), + isAnotherDose( + row, + { + takenAt: input.takenAt, + externalId: input.externalId, + namesSlot, + }, + input.userTz, + ), ); } diff --git a/tests/integration/medication-extra-dose-apple-health.test.ts b/tests/integration/medication-extra-dose-apple-health.test.ts index df8465703..4e58ba78d 100644 --- a/tests/integration/medication-extra-dose-apple-health.test.ts +++ b/tests/integration/medication-extra-dose-apple-health.test.ts @@ -354,3 +354,144 @@ describe("an extra dose from Apple Health is stored as an unscheduled dose", () expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]); }); }); + +/** Stock the medication with one open bottle of 100 units, one per dose. */ +async function stock(userId: string, medicationId: string): Promise { + const prisma = getPrismaClient(); + await prisma.medication.update({ + where: { id: medicationId }, + data: { unitsPerDose: 1 }, + }); + await prisma.medicationInventoryItem.create({ + data: { + userId, + medicationId, + state: "IN_USE", + containerType: "BOTTLE", + unitsTotal: 100, + unitsRemaining: 100, + firstUseAt: new Date(Date.now() - 60 * DAY_MS), + }, + }); +} + +async function unitsRemaining(medicationId: string): Promise { + const item = await getPrismaClient().medicationInventoryItem.findFirstOrThrow( + { where: { medicationId } }, + ); + return Number(item.unitsRemaining); +} + +/** A take tapped in HealthLog: no external record, no Apple provenance. */ +async function postAppTap(medicationId: string, slot: Date, takenAt: Date) { + 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: [ + { + medicationId, + scheduledFor: slot.toISOString(), + takenAt: takenAt.toISOString(), + }, + ], + }), + }), + ); + expect(response.status).toBe(200); +} + +describe("the same dose recorded twice is one dose", () => { + it("(d) a tap in HealthLog at 08:00 and the dose in Health at 08:20 the same day", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["08:00"], + daysOfWeek: null, + }); + await stock(userId, medicationId); + const day = pastLocalDay(4); + const slot = localHmAsUtc(day, TZ, 8, 0); + const tapAt = slot; + const appleAt = new Date(slot.getTime() + 20 * MIN); + + await postAppTap(medicationId, slot, tapAt); + expect(await unitsRemaining(medicationId)).toBe(99); + // Health sends it as the slot's scheduled dose, and once more as an + // unscheduled one: either way it is the tapped dose. + for (const [externalId, scheduledFor] of [ + ["hk-d-1", slot], + ["hk-d-2", appleAt], + ] as const) { + const results = await postBulk([ + appleDose(medicationId, appleAt, externalId, scheduledFor), + ]); + expect(results.map((r) => r.status)).toEqual(["updated"]); + } + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(1); + expect(rows[0]!.scheduledFor.getTime()).toBe(slot.getTime()); + // One dose, one unit off the bottle. + expect(await unitsRemaining(medicationId)).toBe(99); + const ledger = await readLedger( + medicationId, + new Date(slot.getTime() - 6 * 3_600_000), + new Date(slot.getTime() + 6 * 3_600_000), + ); + expect(ledger.filter((row) => row.kind === "ad_hoc")).toHaveLength(0); + }); + + it("(e) a dose edited in Health: a new record, moved by 30 minutes, inside the same slot", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: null, + }); + await stock(userId, medicationId); + const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0); + const firstAt = new Date(slot.getTime() + 2 * MIN); + const editedAt = new Date(slot.getTime() + 32 * MIN); + + await postBulk([appleDose(medicationId, firstAt, "hk-e-1", slot)]); + // Health replaces an edited dose with a new record; it still names the + // slot as its scheduled time. + const results = await postBulk([ + appleDose(medicationId, editedAt, "hk-e-2", slot), + ]); + expect(results.map((r) => r.status)).toEqual(["updated"]); + + const rows = await rowsOf(medicationId); + expect(rows).toHaveLength(1); + expect(rows[0]!.externalId).toBe("hk-e-2"); + expect(rows[0]!.takenAt?.getTime()).toBe(editedAt.getTime()); + expect(rows[0]!.scheduledFor.getTime()).toBe(slot.getTime()); + expect(await unitsRemaining(medicationId)).toBe(99); + }); + + it("(f) an extra dose the next day still stays its own dose, consuming its own unit", async () => { + const userId = await signedInUser(); + const medicationId = await seedMedication(userId, { + timesOfDay: ["22:00"], + daysOfWeek: "2,5", + }); + await stock(userId, medicationId); + const tuesday = pastLocalDay(2); + const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0); + const wednesdayDose = localHmAsUtc( + new Date(tuesday.getTime() + DAY_MS), + TZ, + 20, + 45, + ); + + await postAppTap(medicationId, tuesdaySlot, tuesdaySlot); + const results = await postBulk([ + appleDose(medicationId, wednesdayDose, "hk-f-wed"), + ]); + expect(results.map((r) => r.status)).toEqual(["inserted"]); + expect(await rowsOf(medicationId)).toHaveLength(2); + expect(await unitsRemaining(medicationId)).toBe(98); + }); +});