fix(medications): converge a same-day Apple dose onto the slot's take

The extra-dose rule split a dose that HealthLog already recorded: a tap
at 08:00 and the same dose arriving from Health at 08:20 became two
doses and consumed stock twice, and a dose edited in Health (a new
record, a new time) became a second dose next to the first. The rule
now follows the calendar and the record: a take on another local day
is another dose; on the same day, a take logged in HealthLog converges
with the incoming record, a new record that names the slot as its
scheduled time is the slot's dose recorded again, and only an
unscheduled record more than a minute from the held one is an extra
dose.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 16:38:12 +02:00
1 parent 1e07ecaaaa
commit f665a42789
4 files changed
+248 -46

No files matched your search

@@ -520,6 +520,8 @@ async function postBulk(request: NextRequest): Promise<Response> {
canonicalSlot,
takenAt: entry.takenAt as Date,
externalId: entry.externalId,
scheduledFor: entry.scheduledFor ?? null,
userTz: user.timezone,
}))
) {
canonicalSlot = null;
@@ -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,
);
});
});
+53 -19
View File
@@ -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<PrismaClient, "medicationIntakeEvent">;
}): Promise<boolean> {
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,
),
);
}
@@ -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<void> {
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<number> {
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);
});
});