diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts new file mode 100644 index 000000000..38f709960 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -0,0 +1,153 @@ +/** + * v1.15.18 — traceable dose-history reconstruction (spec B). + * + * Produces a complete, attributable ledger from the expected-slot bands + + * the actual intake rows: + * - every expected slot appears with a status (taken_on_time / taken_late / + * skipped / missed / upcoming) even when never taken; + * - a TAKEN intake is attributed to a slot by its real `takenAt` band + * membership (NOT the stored, possibly-snapped `scheduledFor`), so the + * corrected bands converge forward over legacy mis-snapped rows; + * - skipped / auto-missed / pending rows bind to their slot by anchor + * (they were written against a slot deliberately); + * - a taken intake matching no slot band is emitted as a tagged `ad_hoc` + * row at its real time — never silently snapped onto a scheduled slot. + */ +import { describe, expect, it } from "vitest"; + +import { buildSlotBands, type SlotWindowInput } from "../attribution"; +import { + reconstructDoseHistory, + type HistoryIntake, +} from "../dose-history"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; +const MIN = 60_000; +const day = new Date("2026-06-08T12:00:00Z"); + +function pointSlot(at: Date, timeOfDay: string): SlotWindowInput { + return { + at, + timeOfDay, + onTimeStart: new Date(at.getTime() - 60 * MIN), + onTimeEnd: new Date(at.getTime() + 60 * MIN), + lateGraceMs: 180 * MIN, + }; +} +const bands = buildSlotBands([ + pointSlot(localHmAsUtc(day, TZ, 7, 0), "07:00"), + pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"), +]); + +function at(h: number, m: number): Date { + return localHmAsUtc(day, TZ, h, m); +} +function intake(over: Partial): HistoryIntake { + return { + id: "i", + scheduledFor: at(7, 0), + takenAt: null, + skipped: false, + autoMissed: false, + ...over, + }; +} + +// Late "now" so both of today's slots are past their miss cutoff. +const nowEvening = at(23, 59); + +describe("reconstructDoseHistory", () => { + it("emits one row per expected slot even with no intakes", () => { + const rows = reconstructDoseHistory(bands, [], nowEvening); + expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]); + expect(rows.every((r) => r.kind === "slot")).toBe(true); + expect(rows.map((r) => r.status)).toEqual(["missed", "missed"]); + }); + + it("attributes an on-time take to its slot", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(7, 0), scheduledFor: at(7, 0) })], + nowEvening, + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("taken_on_time"); + expect(morning?.intake?.takenAt?.toISOString()).toBe(at(7, 0).toISOString()); + }); + + it("attributes a take inside the late tail as taken_late", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(8, 30), scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("taken_late"); + }); + + it("emits an 11:29 take as an ad-hoc row and leaves 07:00 missed", () => { + // Marc's case: the stored scheduledFor was snapped to 07:00 by the old + // write path, but the real takenAt (11:29) is outside every band → ad-hoc. + const rows = reconstructDoseHistory( + bands, + [intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })], + nowEvening, + ); + const adhoc = rows.find((r) => r.kind === "ad_hoc"); + expect(adhoc?.status).toBe("ad_hoc"); + expect(adhoc?.at.toISOString()).toBe(at(11, 29).toISOString()); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("binds an explicit skip to its slot by anchor", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ skipped: true, scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped"); + }); + + it("counts an auto-missed pending row as missed", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ autoMissed: true, scheduledFor: at(7, 0) })], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("marks a future slot upcoming, not missed", () => { + const earlyMorning = at(5, 0); // before every slot's miss cutoff + const rows = reconstructDoseHistory(bands, [], earlyMorning); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("upcoming"); + expect(rows.find((r) => r.timeOfDay === "19:00")?.status).toBe("upcoming"); + }); + + it("a second take near an already-filled slot becomes ad-hoc", () => { + const rows = reconstructDoseHistory( + bands, + [ + intake({ id: "a", takenAt: at(7, 0), scheduledFor: at(7, 0) }), + intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }), + ], + nowEvening, + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe( + "taken_on_time", + ); + const adhoc = rows.filter((r) => r.kind === "ad_hoc"); + expect(adhoc).toHaveLength(1); + expect(adhoc[0].intake?.id).toBe("b"); + }); + + it("returns rows in chronological order (slots + ad-hoc interleaved)", () => { + const rows = reconstructDoseHistory( + bands, + [intake({ id: "x", takenAt: at(13, 0), scheduledFor: at(13, 0) })], + nowEvening, + ); + const times = rows.map((r) => r.at.getTime()); + expect(times).toEqual([...times].sort((a, b) => a - b)); + }); +}); diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts new file mode 100644 index 000000000..6a8d214e7 --- /dev/null +++ b/src/lib/medications/scheduling/dose-history.ts @@ -0,0 +1,162 @@ +/** + * v1.15.18 — traceable dose-history reconstruction (spec B). + * + * Builds a complete, attributable ledger for a medication's day(s): every + * expected slot with a status, plus every off-schedule intake tagged ad-hoc. + * It is the read-model behind the medication history view + the card's + * last/next dose, and it converges forward over legacy mis-snapped rows + * because it attributes a TAKEN intake by its real `takenAt`, not the stored + * `scheduledFor` the old write path may have snapped to a slot. + * + * Attribution rules: + * - skipped / auto-missed / pending rows (no `takenAt`) bind to the slot + * whose anchor equals their `scheduledFor` (±epsilon): these were written + * against a slot deliberately, so they annotate that slot; + * - a TAKEN intake is attributed by `attributeIntakeToSlot(takenAt, bands)`; + * inside a band → that slot (on-time / late), outside every band → ad-hoc; + * - each slot is claimed by at most one intake (first/best wins); extra + * intakes near a filled slot fall through to ad-hoc. + * + * Pure / synchronous / instant-based — the caller mints the bands DST-correctly + * via `localHmAsUtc` and supplies the per-dose window. + */ +import { + attributeIntakeToSlot, + type SlotBand, +} from "@/lib/medications/scheduling/attribution"; + +/** The minimal intake shape the ledger reads. */ +export interface HistoryIntake { + id?: string; + /** Stored slot anchor (may be a legacy mis-snapped instant for takes). */ + scheduledFor: Date; + /** Real intake time, or null for skipped / pending / auto-missed rows. */ + takenAt: Date | null; + skipped: boolean; + /** Cron-marked forgotten dose (counts as missed, not skipped). */ + autoMissed?: boolean; +} + +export type DoseHistoryStatus = + | "taken_on_time" + | "taken_late" + | "skipped" + | "missed" + | "upcoming" + | "ad_hoc"; + +export interface DoseHistoryRow { + /** A scheduled slot, or a standalone off-schedule intake. */ + kind: "slot" | "ad_hoc"; + /** The slot anchor instant, or the ad-hoc take's own time. */ + at: Date; + /** The slot's "HH:mm" label, or null for an ad-hoc row. */ + timeOfDay: string | null; + status: DoseHistoryStatus; + /** The intake attributed to this row, if any. */ + intake: HistoryIntake | null; +} + +/** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */ +const ANCHOR_EPSILON_MS = 60_000; + +/** + * Reconstruct the dose-history ledger for the slots described by `bands` and + * the supplied intakes. Returns rows in chronological order. + */ +export function reconstructDoseHistory( + bands: SlotBand[], + intakes: HistoryIntake[], + now: Date, +): DoseHistoryRow[] { + // Per-band claim: the intake attributed to it + the resolved status. + const claim = new Map< + SlotBand, + { intake: HistoryIntake; status: DoseHistoryStatus } + >(); + const adHoc: HistoryIntake[] = []; + + // Partition: anchored rows (skip / auto-missed / pending — no takenAt) bind + // by scheduledFor; taken rows attribute by real takenAt band membership. + // Process anchored first so a deliberate skip/miss owns its slot before a + // stray take could. + const anchored = intakes.filter((i) => i.takenAt === null); + const taken = intakes.filter((i) => i.takenAt !== null); + + for (const i of anchored) { + const band = nearestAnchorBand(i.scheduledFor, bands); + const status: DoseHistoryStatus = i.skipped ? "skipped" : "missed"; + if (band && !claim.has(band)) { + claim.set(band, { intake: i, status }); + } else { + // A skip/miss with no matching slot (legacy off-grid) — surface it so + // nothing silently vanishes; tag it ad-hoc. + adHoc.push(i); + } + } + + for (const i of taken) { + const matched = attributeIntakeToSlot(i.takenAt as Date, bands); + if (matched && !claim.has(matched.band)) { + claim.set(matched.band, { + intake: i, + status: matched.status === "on_time" ? "taken_on_time" : "taken_late", + }); + } else { + adHoc.push(i); + } + } + + const rows: DoseHistoryRow[] = bands.map((band) => { + const c = claim.get(band); + if (c) { + return { + kind: "slot", + at: band.at, + timeOfDay: band.timeOfDay, + status: c.status, + intake: c.intake, + }; + } + // Unfilled slot: missed only once the miss cutoff (the late tail's end) + // has passed; until then the dose is still takeable, so it reads upcoming + // rather than prematurely missed. + const status: DoseHistoryStatus = + now.getTime() > band.overdueEnd.getTime() ? "missed" : "upcoming"; + return { + kind: "slot", + at: band.at, + timeOfDay: band.timeOfDay, + status, + intake: null, + }; + }); + + for (const i of adHoc) { + rows.push({ + kind: "ad_hoc", + at: i.takenAt ?? i.scheduledFor, + timeOfDay: null, + status: "ad_hoc", + intake: i, + }); + } + + rows.sort((a, b) => a.at.getTime() - b.at.getTime()); + return rows; +} + +/** The band whose anchor is within epsilon of `instant`, nearest wins. */ +function nearestAnchorBand(instant: Date, bands: SlotBand[]): SlotBand | null { + const t = instant.getTime(); + let best: SlotBand | null = null; + let bestDist = Infinity; + for (const band of bands) { + const dist = Math.abs(band.at.getTime() - t); + if (dist <= ANCHOR_EPSILON_MS && dist < bestDist) { + bestDist = dist; + best = band; + } + } + return best; +}