mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user