mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user