mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(medications): pair off-time intraday doses to their own slot
The dose-to-slot pairing matched a logged intake against the slot's window *centre*. For a wide intraday window (a twice-daily 07:00–19:00 med spans 12h, so its 07:00 slot centres at 13:00 and its 19:00 slot at 01:00 the next day) the centre sits between the real dose times, so an off-time take paired to the wrong slot: a morning dose logged late could land on the evening slot, a deliberate user-skip could read as taken, and a late evening take could read as missed — distorting the compliance rate for an irregular twice-daily taker. The intake write path already snaps each dose's scheduledFor to its canonical slot instant, which identifies the slot unambiguously. Add an exact-anchor pass that claims the slot whose windowStart matches the intake's scheduledFor (±1 min) before the proximity fallback. Rows that don't line up on a slot instant (legacy un-snapped data, sparse off-day takes) keep the existing proximity match, so every prior cadence fixture is byte-stable.
This commit is contained in:
1 parent
6e49b57a6f
commit
311ac65f81
2 files changed
+282
-10
No files matched your search
@@ -1667,6 +1667,57 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
expect(missedDisplay.currentDose.status).toBe("missed");
|
||||
});
|
||||
|
||||
it("currentDose — twice-daily, both today's slots resolved → next open is tomorrow 07:00 (BUG 2)", () => {
|
||||
// v1.15.10 BUG 2 — afternoon, with today's 07:00 taken and today's 19:00
|
||||
// taken early. The open-dose search must skip both resolved slots and
|
||||
// surface tomorrow's 07:00 — the next genuinely-open slot — instead of
|
||||
// sticking on today's resolved 19:00.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
},
|
||||
];
|
||||
// NOW = 2025-06-15T12:00:00Z (UTC ctx). Today's slots: 07:00Z + 19:00Z.
|
||||
const slot0700 = new Date("2025-06-15T07:00:00Z");
|
||||
const slot1900 = new Date("2025-06-15T19:00:00Z");
|
||||
const events = [
|
||||
// 07:00 dose logged on time.
|
||||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||||
// 19:00 dose logged EARLY (before its slot) — snapped onto the 19:00 row.
|
||||
{ scheduledFor: slot1900, takenAt: new Date("2025-06-15T11:30:00Z"), skipped: false },
|
||||
];
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||||
// Next open slot is tomorrow 07:00 UTC, not today's resolved 19:00.
|
||||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||||
"2025-06-16T07:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("currentDose — twice-daily, only 07:00 resolved → next open is today 19:00 (BUG 2)", () => {
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
},
|
||||
];
|
||||
const slot0700 = new Date("2025-06-15T07:00:00Z");
|
||||
const events = [
|
||||
{ scheduledFor: slot0700, takenAt: new Date("2025-06-15T07:05:00Z"), skipped: false },
|
||||
];
|
||||
const display = buildComplianceDisplay(events, schedules, ctx(), { now: NOW });
|
||||
expect(display.currentCycle.nextDueAt).not.toBeNull();
|
||||
// Today's 19:00 is still open.
|
||||
expect(display.currentCycle.nextDueAt!.toISOString()).toBe(
|
||||
"2025-06-15T19:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("currentCycle — brand-new sparse med with zero closed cycles → hasClosedCycles false", () => {
|
||||
// v1.13.x Fix 4 — a med created today with no logged intake has no
|
||||
// closed dose cycle; the percentage rows are vacuous and the card should
|
||||
@@ -2066,6 +2117,186 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", ()
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.10 BUG 3 — verify the compliance % for an IRREGULAR twice-daily
|
||||
// taker (07:00 + 19:00) who logs at off-times plus the odd ad-hoc dose.
|
||||
// Pins the slot-pairing (an off-time morning take snaps to the 07:00 slot,
|
||||
// an evening take to the 19:00 slot — ±6h half-gap for a 12h-gap med), the
|
||||
// skip/missed/extra semantics, and the resulting rate = taken/(taken+missed).
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", () => {
|
||||
// Pin `now` so the 7-day window is deterministic.
|
||||
const NOW = new Date("2025-01-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Twice-daily: 07:00 + 19:00, every day. timesOfDay drives the two slots.
|
||||
const twiceDaily: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
},
|
||||
];
|
||||
|
||||
const ctxVal = buildComplianceMedicationContext(
|
||||
{
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
// Created well before the window so age never clamps the denominator.
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
null,
|
||||
"UTC",
|
||||
);
|
||||
|
||||
function slot(day: number, hhmm: "07:00" | "19:00"): Date {
|
||||
return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`);
|
||||
}
|
||||
|
||||
it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => {
|
||||
// The crux of BUG 3: a day with a late-morning take (09:13) and an
|
||||
// evening take (19:00) must read as two taken doses. The morning take
|
||||
// pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and
|
||||
// the evening to the 19:00 slot — NOT the morning take landing on 19:00
|
||||
// and the 07:00 slot reading falsely missed.
|
||||
//
|
||||
// A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW]
|
||||
// (a 1-day window starts at noon Jan-14, which is after the 07:00 slot).
|
||||
const events = [
|
||||
// 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row).
|
||||
{
|
||||
scheduledFor: slot(14, "07:00"),
|
||||
takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000),
|
||||
skipped: false,
|
||||
},
|
||||
// 19:00 slot, logged on time.
|
||||
{ scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false },
|
||||
// Jan-13's slots are covered too so the window has no stray miss.
|
||||
{ scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false },
|
||||
{ scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false },
|
||||
];
|
||||
const result = calculateCompliance(events, twiceDaily, 2, undefined, {
|
||||
now: NOW,
|
||||
medicationContext: ctxVal,
|
||||
});
|
||||
// Both of Jan-14's off-time/on-time takes pair to their own slot → the
|
||||
// morning slot is NOT falsely missed. No missed slot anywhere → 100%.
|
||||
expect(result.missed).toBe(0);
|
||||
expect(result.rate).toBe(100);
|
||||
// The morning take did not orphan the 07:00 slot: at least Jan-14's two
|
||||
// doses both register as taken.
|
||||
expect(result.taken).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => {
|
||||
// Window: NOW − 7d = Jan 8 12:00 .. Jan 15 12:00. The slots that fall in
|
||||
// [start, now] (07:00 + 19:00 each day; Jan-8 19:00 only since 07:00 is
|
||||
// before 12:00; Jan-15 07:00 only since 19:00 is after now):
|
||||
// Jan 8: 19:00 → 1 slot
|
||||
// Jan 9..14: 07:00 + 19:00 → 12 slots
|
||||
// Jan 15: 07:00 → 1 slot
|
||||
// = 14 expected slots.
|
||||
//
|
||||
// Pattern (mirrors the operator: irregular times + 2 real skips + 1
|
||||
// uncovered morning slot + a couple of ad-hoc extras that pair to no
|
||||
// slot):
|
||||
// - Jan 8 19:00 : taken (logged 20:10, off-time) → taken
|
||||
// - Jan 9 07:00 : taken 09:13 (off-time morning) → taken
|
||||
// - Jan 9 19:00 : USER-SKIP → skipped (excluded)
|
||||
// - Jan 10 07:00: taken 06:40 → taken
|
||||
// - Jan 10 19:00: taken 21:30 (late) → taken
|
||||
// - Jan 11 07:00: UNCOVERED (no intake, past cutoff) → missed
|
||||
// - Jan 11 19:00: taken 18:50 → taken
|
||||
// - Jan 12 07:00: taken 08:05 → taken
|
||||
// - Jan 12 19:00: taken 19:20 → taken
|
||||
// - Jan 13 07:00: USER-SKIP → skipped (excluded)
|
||||
// - Jan 13 19:00: taken 19:00 → taken
|
||||
// - Jan 14 07:00: taken 09:13 (off-time morning) → taken
|
||||
// - Jan 14 19:00: taken 19:00 → taken
|
||||
// - Jan 15 07:00: taken 07:30 → taken
|
||||
// Plus two ad-hoc extras (no slot within ±6h) that must NOT touch the
|
||||
// denominator:
|
||||
// - Jan 10 13:00 (a midday top-up, midpoint between both slots)
|
||||
// - Jan 12 03:00 (a small-hours dose, > 6h from either slot)
|
||||
//
|
||||
// Hand count over the 14 slots:
|
||||
// taken = 11
|
||||
// skipped = 2 (excluded)
|
||||
// missed = 1
|
||||
// denom = taken + missed = 12
|
||||
// rate = round(11/12 * 100) = round(91.67) = 92
|
||||
const mins = (h: number, m: number) => (h * 60 + m) * 60 * 1000;
|
||||
const takeAt = (s: Date, h: number, m: number) =>
|
||||
new Date(new Date(s).setUTCHours(0, 0, 0, 0) + mins(h, m));
|
||||
const taken = (s: Date, h: number, m: number) => ({
|
||||
scheduledFor: s,
|
||||
takenAt: takeAt(s, h, m),
|
||||
skipped: false,
|
||||
});
|
||||
const userSkip = (s: Date) => ({
|
||||
scheduledFor: s,
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
});
|
||||
const autoMiss = (s: Date) => ({
|
||||
scheduledFor: s,
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: true,
|
||||
});
|
||||
const extra = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
|
||||
|
||||
const events = [
|
||||
taken(slot(8, "19:00"), 20, 10),
|
||||
taken(slot(9, "07:00"), 9, 13),
|
||||
userSkip(slot(9, "19:00")),
|
||||
taken(slot(10, "07:00"), 6, 40),
|
||||
taken(slot(10, "19:00"), 21, 30),
|
||||
autoMiss(slot(11, "07:00")),
|
||||
taken(slot(11, "19:00"), 18, 50),
|
||||
taken(slot(12, "07:00"), 8, 5),
|
||||
taken(slot(12, "19:00"), 19, 20),
|
||||
userSkip(slot(13, "07:00")),
|
||||
taken(slot(13, "19:00"), 19, 0),
|
||||
taken(slot(14, "07:00"), 9, 13),
|
||||
taken(slot(14, "19:00"), 19, 0),
|
||||
taken(slot(15, "07:00"), 7, 30),
|
||||
// ad-hoc extras (must not enter the denominator)
|
||||
extra(new Date("2025-01-10T13:00:00Z")),
|
||||
extra(new Date("2025-01-12T03:00:00Z")),
|
||||
];
|
||||
|
||||
const result = calculateCompliance(events, twiceDaily, 7, undefined, {
|
||||
now: NOW,
|
||||
medicationContext: ctxVal,
|
||||
});
|
||||
|
||||
// The denominator counts only taken + missed (the two user-skips + the
|
||||
// two ad-hoc extras are excluded). With the v1.15.10 exact-anchor pairing
|
||||
// every off-time take lands on its OWN slot — the two user-skips read as
|
||||
// skipped (not taken), the late evening take reads as taken (not missed).
|
||||
expect(result.taken).toBe(11);
|
||||
expect(result.skipped).toBe(2);
|
||||
expect(result.missed).toBe(1);
|
||||
// rate = taken / (taken + missed) = 11/12 = 92% — the honest arithmetic.
|
||||
expect(result.rate).toBe(
|
||||
Math.round((result.taken / (result.taken + result.missed)) * 100),
|
||||
);
|
||||
expect(result.rate).toBe(92);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
|
||||
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
|
||||
|
||||
@@ -536,6 +536,43 @@ export function pairDoses(
|
||||
const centres = sorted.map(
|
||||
(s) => (s.windowStart.getTime() + s.windowEnd.getTime()) / 2,
|
||||
);
|
||||
// Per-slot match index, pre-claimed by the exact-anchor pass below.
|
||||
const matchFor: number[] = new Array(sorted.length).fill(-1);
|
||||
|
||||
// v1.15.10 BUG 3 — exact-anchor pass. An intake's `scheduledFor` is snapped
|
||||
// to the canonical slot instant (the engine occurrence's `windowStart`) by
|
||||
// the write path, so it unambiguously identifies WHICH slot it resolves.
|
||||
// The legacy proximity pass below matches `takenAt` against the slot's
|
||||
// *window centre*, which for a wide intraday window (a twice-daily med with
|
||||
// a 07:00–19:00 window spans 12h → its 07:00 slot centres at 13:00, its
|
||||
// 19:00 slot at 01:00 next day) lands between the real dose times: an
|
||||
// off-time morning take then pairs to the wrong slot, a user-skip reads as
|
||||
// taken, a late evening take reads as missed. Anchoring each event to the
|
||||
// slot whose `windowStart` equals its `scheduledFor` first removes that
|
||||
// ambiguity for every snapped row; rows that DON'T line up on a slot
|
||||
// instant (legacy un-snapped data, sparse cadences logged off-day) fall
|
||||
// through to the proximity pass unchanged.
|
||||
const ANCHOR_EPSILON_MS = 60_000; // sub-minute DST / rounding slop
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (claimed.has(i)) continue;
|
||||
const evt = events[i];
|
||||
const anchor = evt.scheduledFor.getTime();
|
||||
let bestSlot = -1;
|
||||
let bestDist = Infinity;
|
||||
for (let s = 0; s < sorted.length; s++) {
|
||||
if (matchFor[s] !== -1) continue;
|
||||
const dist = Math.abs(sorted[s].windowStart.getTime() - anchor);
|
||||
if (dist <= ANCHOR_EPSILON_MS && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestSlot = s;
|
||||
}
|
||||
}
|
||||
if (bestSlot !== -1) {
|
||||
matchFor[bestSlot] = i;
|
||||
claimed.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
const result: PairedDose[] = [];
|
||||
|
||||
for (let s = 0; s < sorted.length; s++) {
|
||||
@@ -553,16 +590,20 @@ export function pairDoses(
|
||||
s < centres.length - 1 ? centres[s + 1] : null,
|
||||
),
|
||||
);
|
||||
let bestIdx = -1;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (claimed.has(i)) continue;
|
||||
const evt = events[i];
|
||||
const t = (evt.takenAt ?? evt.scheduledFor).getTime();
|
||||
const dist = Math.abs(t - centre);
|
||||
if (dist <= radius && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
// Honour an exact-anchor claim from the first pass; otherwise fall back
|
||||
// to the proximity match (legacy un-snapped rows / sparse off-day takes).
|
||||
let bestIdx = matchFor[s];
|
||||
if (bestIdx === -1) {
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
if (claimed.has(i)) continue;
|
||||
const evt = events[i];
|
||||
const t = (evt.takenAt ?? evt.scheduledFor).getTime();
|
||||
const dist = Math.abs(t - centre);
|
||||
if (dist <= radius && dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user