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