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