feat(medications): traceable dose-history read-model

Reconstructs a complete, attributable ledger from the expected-slot bands +
the actual intakes: every expected slot carries a status (taken_on_time /
taken_late / skipped / missed / upcoming) even when never taken, and a taken
intake that matches no band is emitted as a tagged ad-hoc row at its real
time instead of vanishing.

A taken intake attributes by its real takenAt (not the stored, possibly
mis-snapped scheduledFor), so corrected bands converge forward over legacy
rows with no data migration. Skipped / auto-missed / pending rows bind to
their slot by anchor. Pure and instant-based. 9 tests.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 16:08:12 +02:00
1 parent 13d885b8b3
commit e5221b45e6
2 files changed
+315

No files matched your search

@@ -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>): 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));
});
});
@@ -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;
}