feat(medications): window-band intake→slot attribution engine

Pure band-membership attribution replacing the wide nearest-neighbour
matchers that mis-assigned irregular intakes (a 13:02 take snapped onto
the 19:00 slot, an 15:46 take onto the 07:00 slot). A take pairs to a
slot only inside that slot's dose window plus a capped late tail:

  intake in [onTimeStart, onTimeEnd]  -> on_time
  intake in (onTimeEnd, overdueEnd]   -> late
  otherwise                           -> ad-hoc (standalone row)

The on-time band is the per-dose window; overdueEnd is capped at the next
slot's on-time start so adjacent slots never both claim one intake. Pure
and instant-based; callers mint the bounds DST-correctly via localHmAsUtc.
13 tests pin summer/winter, on-time/late/ad-hoc, the cap, and a widened
window.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 16:03:52 +02:00
1 parent b2f75ec8bb
commit 13d885b8b3
2 files changed
+284

No files matched your search

@@ -0,0 +1,168 @@
/**
* v1.15.18 — window-band intake→slot attribution.
*
* Replaces the wide nearest-neighbour matchers (the ±6h write-time snap in
* `resolve-slot-instant.ts` and the ±12h/half-gap read-time radius in
* `cadence.ts pairDoses`) that forced an irregular real intake onto the
* nearest configured slot — producing absurd rows like "07:00 dose taken
* 15:46". Attribution is now pure band membership:
*
* - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot)
* - intake ∈ (onTimeEnd, overdueEnd] → late (this slot)
* - otherwise → null (ad-hoc)
*
* `overdueEnd` is the late tail past `onTimeEnd`, capped so it can never
* bleed into the next slot's on-time start (adjacent slots never both claim
* one intake). The on-time band IS the user's configurable dose window; the
* default point window is ±60min on-time + a 180min late tail
* (`DOSE_WINDOW_DEFAULTS`). All math is in instants, DST handled by the
* caller minting the band bounds via `localHmAsUtc`.
*/
import { describe, expect, it } from "vitest";
import {
buildSlotBands,
attributeIntakeToSlot,
type SlotWindowInput,
} from "../attribution";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
const MIN = 60_000;
/** A point-time slot with a symmetric on-time half-width + a late tail. */
function pointSlot(
at: Date,
timeOfDay: string,
onTimeHalfMin = 60,
lateGraceMin = 180,
): SlotWindowInput {
return {
at,
timeOfDay,
onTimeStart: new Date(at.getTime() - onTimeHalfMin * MIN),
onTimeEnd: new Date(at.getTime() + onTimeHalfMin * MIN),
lateGraceMs: lateGraceMin * MIN,
};
}
/** {07:00, 19:00} Europe/Berlin for the local day implied by `dayRef`. */
function ramiprilSlots(dayRef: Date): SlotWindowInput[] {
return [
pointSlot(localHmAsUtc(dayRef, TZ, 7, 0), "07:00"),
pointSlot(localHmAsUtc(dayRef, TZ, 19, 0), "19:00"),
];
}
/** Berlin wall-clock HH:mm on the same local day → instant. */
function at(dayRef: Date, h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
describe("attributeIntakeToSlot — summer (CEST)", () => {
const day = new Date("2026-06-08T12:00:00Z");
const bands = buildSlotBands(ramiprilSlots(day));
it("07:00 dose taken 07:00 is on_time for the 07:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 7, 0), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
it("07:00 dose taken 08:30 is late for the 07:00 slot (inside the tail)", () => {
const r = attributeIntakeToSlot(at(day, 8, 30), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("late");
});
it("a take at 11:29 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
// The exact case Marc reported: an 11:29 take must NOT show as the 07:00 slot.
expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull();
});
it("a midday take at 13:02 is ad-hoc (not snapped onto 19:00)", () => {
expect(attributeIntakeToSlot(at(day, 13, 2), bands)).toBeNull();
});
it("19:00 dose taken 19:00 is on_time for the 19:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 19, 0), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("an early evening take at 18:30 is on_time for the 19:00 slot", () => {
const r = attributeIntakeToSlot(at(day, 18, 30), bands);
expect(r?.band.timeOfDay).toBe("19:00");
expect(r?.status).toBe("on_time");
});
it("a take at 17:30 is ad-hoc (past the 07:00 tail, before the 19:00 window)", () => {
expect(attributeIntakeToSlot(at(day, 17, 30), bands)).toBeNull();
});
});
describe("attributeIntakeToSlot — winter (CET, DST-robust)", () => {
const day = new Date("2026-01-15T12:00:00Z");
const bands = buildSlotBands(ramiprilSlots(day));
it("07:00 dose taken 07:00 is on_time in winter too", () => {
const r = attributeIntakeToSlot(at(day, 7, 0), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
it("11:29 take is ad-hoc in winter too", () => {
expect(attributeIntakeToSlot(at(day, 11, 29), bands)).toBeNull();
});
it("the 07:00 slot's true instant is 06:00Z in winter (CET = UTC+1)", () => {
expect(bands[0].at.toISOString()).toBe("2026-01-15T06:00:00.000Z");
});
});
describe("buildSlotBands — late tail never bleeds into the next slot", () => {
// Two close slots 08:00 / 12:00 with a generous 180min tail: 08:00's tail
// (08:00+60+180 = 11:00) is capped at 12:00's on-time start (11:00). With
// an even larger tail the cap binds.
const day = new Date("2026-06-08T12:00:00Z");
const slots: SlotWindowInput[] = [
pointSlot(localHmAsUtc(day, TZ, 8, 0), "08:00", 60, 600), // 10h tail
pointSlot(localHmAsUtc(day, TZ, 12, 0), "12:00", 60, 180),
];
const bands = buildSlotBands(slots);
it("caps the 08:00 overdueEnd at the 12:00 on-time start (11:00)", () => {
expect(bands[0].overdueEnd.toISOString()).toBe(
bands[1].onTimeStart.toISOString(),
);
});
it("a take at 11:30 belongs to the 12:00 slot, not the over-long 08:00 tail", () => {
const r = attributeIntakeToSlot(at(day, 11, 30), bands);
expect(r?.band.timeOfDay).toBe("12:00");
expect(r?.status).toBe("on_time");
});
});
describe("attributeIntakeToSlot — configurable window widens on-time", () => {
const day = new Date("2026-06-08T12:00:00Z");
it("an explicit 07:00–12:00 window makes an 11:29 take on_time for that dose", () => {
// Marc's lever: a real morning window of 07:00–12:00 (not a ±60 point)
// means his habitual ~11:00 intake counts as on-time, not ad-hoc.
const slots: SlotWindowInput[] = [
{
at: localHmAsUtc(day, TZ, 7, 0),
timeOfDay: "07:00",
onTimeStart: localHmAsUtc(day, TZ, 7, 0),
onTimeEnd: localHmAsUtc(day, TZ, 12, 0),
lateGraceMs: 180 * MIN,
},
pointSlot(localHmAsUtc(day, TZ, 19, 0), "19:00"),
];
const bands = buildSlotBands(slots);
const r = attributeIntakeToSlot(at(day, 11, 29), bands);
expect(r?.band.timeOfDay).toBe("07:00");
expect(r?.status).toBe("on_time");
});
});
@@ -0,0 +1,116 @@
/**
* v1.15.18 — window-band intake→slot attribution.
*
* The medical core of the traceable dose-history model. It replaces the two
* wide nearest-neighbour matchers that mis-attributed irregular real intakes:
*
* - WRITE-time: the ±6h `snapToleranceMs` snap in `resolve-slot-instant.ts`
* (½ the inter-slot gap), which pulled a 13:02 take onto the 19:00 slot
* and an 15:46 take onto the 07:00 slot.
* - READ-time: the ±12h/half-gap `slotMatchRadius` in `cadence.ts`.
*
* Attribution is now pure band membership against each slot's configurable
* dose window (the on-time band) plus a late tail:
*
* - intake ∈ [onTimeStart, onTimeEnd] → on_time (this slot)
* - intake ∈ (onTimeEnd, overdueEnd] → late (this slot)
* - otherwise → null (ad-hoc: standalone row,
* the slot stays missed if unfilled)
*
* `overdueEnd` (the late tail past `onTimeEnd`) is capped at the next slot's
* `onTimeStart`, so two adjacent same-day slots can never both claim one
* intake. The on-time band IS the user's per-dose window: a point time maps to
* a symmetric ±half-width, an explicit window to `[start, end]`. All bounds are
* instants — the caller mints them DST-correctly via `localHmAsUtc`, keeping
* this module timezone-agnostic and trivially testable.
*/
/** Per-slot window the caller supplies (bounds already minted as instants). */
export interface SlotWindowInput {
/** Canonical anchor instant for the slot (labelling / next-due / pairing). */
at: Date;
/** "HH:mm" the slot represents (for the history label + dedup key). */
timeOfDay: string;
/** On-time band lower bound (inclusive). */
onTimeStart: Date;
/** On-time band upper bound (inclusive). */
onTimeEnd: Date;
/** Late tail past `onTimeEnd` that still counts as taken-late, in ms. */
lateGraceMs: number;
}
/** A slot window with its (capped) late-tail cutoff resolved. */
export interface SlotBand extends SlotWindowInput {
/** Cutoff past which a take no longer pairs to this slot (capped). */
overdueEnd: Date;
}
export type AttributionStatus = "on_time" | "late";
export interface AttributionResult {
band: SlotBand;
status: AttributionStatus;
}
/**
* Resolve each slot's `overdueEnd` = `onTimeEnd + lateGraceMs`, capped at the
* next slot's `onTimeStart` so adjacent slots' capture bands stay disjoint.
* The input is sorted by anchor instant; the returned bands preserve that order.
*/
export function buildSlotBands(slots: SlotWindowInput[]): SlotBand[] {
const sorted = [...slots].sort((a, b) => a.at.getTime() - b.at.getTime());
return sorted.map((slot, i) => {
let overdueEnd = slot.onTimeEnd.getTime() + slot.lateGraceMs;
const next = sorted[i + 1];
if (next) {
// Never let one slot's late tail bleed into the next slot's on-time
// window — the cap keeps the two capture bands disjoint.
overdueEnd = Math.min(overdueEnd, next.onTimeStart.getTime());
}
// A degenerate/negative tail collapses to the on-time end.
overdueEnd = Math.max(overdueEnd, slot.onTimeEnd.getTime());
return { ...slot, overdueEnd: new Date(overdueEnd) };
});
}
/**
* Attribute an intake instant to the slot whose window contains it, or `null`
* when it falls in no slot's band (an ad-hoc / off-schedule intake).
*
* On overlap-free bands at most one slot matches. As a defensive tie-break
* (custom windows a caller hand-builds could overlap), an on-time match wins
* over a late match, then the nearer anchor wins.
*/
export function attributeIntakeToSlot(
intakeAt: Date,
bands: SlotBand[],
): AttributionResult | null {
const t = intakeAt.getTime();
let best: { result: AttributionResult; rank: number; dist: number } | null =
null;
for (const band of bands) {
const onStart = band.onTimeStart.getTime();
const onEnd = band.onTimeEnd.getTime();
const overdueEnd = band.overdueEnd.getTime();
let status: AttributionStatus | null = null;
if (t >= onStart && t <= onEnd) status = "on_time";
else if (t > onEnd && t <= overdueEnd) status = "late";
if (status === null) continue;
// on_time (rank 0) beats late (rank 1); within a rank the nearer anchor
// wins. Keeps a deterministic pick if a caller's custom bands overlap.
const rank = status === "on_time" ? 0 : 1;
const dist = Math.abs(t - band.at.getTime());
if (
best === null ||
rank < best.rank ||
(rank === best.rank && dist < best.dist)
) {
best = { result: { band, status }, rank, dist };
}
}
return best?.result ?? null;
}