From 13d885b8b3b0e426cab7bba0c8f9473af4f8b7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 16:03:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(medications):=20window-band=20intake?= =?UTF-8?q?=E2=86=92slot=20attribution=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../scheduling/__tests__/attribution.test.ts | 168 ++++++++++++++++++ src/lib/medications/scheduling/attribution.ts | 116 ++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src/lib/medications/scheduling/__tests__/attribution.test.ts create mode 100644 src/lib/medications/scheduling/attribution.ts diff --git a/src/lib/medications/scheduling/__tests__/attribution.test.ts b/src/lib/medications/scheduling/__tests__/attribution.test.ts new file mode 100644 index 000000000..ccc59ce50 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/attribution.test.ts @@ -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"); + }); +}); diff --git a/src/lib/medications/scheduling/attribution.ts b/src/lib/medications/scheduling/attribution.ts new file mode 100644 index 000000000..7d8b705bb --- /dev/null +++ b/src/lib/medications/scheduling/attribution.ts @@ -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; +}