diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index a802f37b9..11b25c5d1 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -2582,6 +2582,75 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { expect(tally.rate).toBe(100); }); + // v1.15.19 — intraday pending rows. The today-projector mints pending + // rows (takenAt = null, not skipped, not auto-missed) for every slot of + // the day up front; from slot time onward the old ledger read them as + // missed, so the rate dipped intraday although the dose was still inside + // its overdue window. + it("a pending row inside its overdue window reads upcoming — the intraday rate holds", () => { + // 07:00 taken; the 19:00 pending row was minted up front. At 20:00 the + // evening dose is still takeable (the late tail runs to 23:00), so it + // stays out of the denominator and the rate holds at 100, not 50. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + at(23, 59), + at(20, 0), + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.denominator).toBe(1); + expect(tally.rate).toBe(100); + }); + + it("a pending row past its overdueEnd IS a miss", () => { + // Same shape, but read at 23:59 — the 19:00 dose's tail has closed. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowEvening, + nowEvening, + ); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(1); + expect(tally.rate).toBe(50); + }); + + it("a pending row whose slot lies outside `to = now` never becomes a phantom ad-hoc", () => { + // The history view asks with `to = now`. The 19:00 band is not minted + // at 14:00, but the projector already wrote the evening pending row — + // it must be dropped, not surfaced as an ad-hoc row with takenAt=null. + const events = [ + { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, + { scheduledFor: at(19, 0), takenAt: null, skipped: false }, + ]; + const nowAfternoon = at(14, 0); + const tally = tallyComplianceFromLedger( + events, + twiceDaily, + ctxFor(), + from, + nowAfternoon, + nowAfternoon, + ); + expect(tally.adHoc).toBe(0); + expect(tally.taken).toBe(1); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + it("an auto-missed forgotten dose IS a miss (counts against the rate)", () => { const events = [ { scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, diff --git a/src/lib/medications/scheduling/__tests__/dose-history.test.ts b/src/lib/medications/scheduling/__tests__/dose-history.test.ts index 38f709960..0b52efba5 100644 --- a/src/lib/medications/scheduling/__tests__/dose-history.test.ts +++ b/src/lib/medications/scheduling/__tests__/dose-history.test.ts @@ -141,6 +141,96 @@ describe("reconstructDoseHistory", () => { expect(adhoc[0].intake?.id).toBe("b"); }); + // v1.15.19 — time-aware pending-row semantics. The today-projector and the + // reminder worker mint pending rows (takenAt = null, neither skipped nor + // auto-missed) for every slot of the day up front, so a pending row's + // status must be derived from the clock exactly like an unfilled slot's: + // missed only past the slot's miss cutoff, upcoming until then. + describe("pending (server-minted) rows", () => { + it("reads upcoming while now is inside the slot's overdue window", () => { + // 07:00 slot: on-time ends 08:00, late tail ends 11:00. At 08:30 the + // dose is still takeable — the pending row must not read missed. + const rows = reconstructDoseHistory( + bands, + [intake({ scheduledFor: at(7, 0) })], + at(8, 30), + ); + const morning = rows.find((r) => r.timeOfDay === "07:00"); + expect(morning?.status).toBe("upcoming"); + // The row stays bound to its slot (the intake annotates it). + expect(morning?.intake?.id).toBe("i"); + }); + + it("reads missed once now is past the slot's overdueEnd", () => { + // 07:00 band's miss cutoff is 11:00; at 12:00 the dose is gone. + const rows = reconstructDoseHistory( + bands, + [intake({ scheduledFor: at(7, 0) })], + at(12, 0), + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + + it("weekly slot: a pending row 2 days past the anchor stays upcoming inside the 4-day tail", () => { + const HOUR_MS = 60 * MIN; + const DAY_MS = 24 * HOUR_MS; + const weeklyAt = localHmAsUtc(day, TZ, 7, 0); + const weeklyBands = buildSlotBands([ + { + at: weeklyAt, + timeOfDay: "07:00", + onTimeStart: new Date(weeklyAt.getTime() - 12 * HOUR_MS), + onTimeEnd: new Date(weeklyAt.getTime() + 12 * HOUR_MS), + // Weekly injectable: the clinical 4-day late tail. + lateGraceMs: 4 * DAY_MS, + }, + ]); + const twoDaysLater = new Date(weeklyAt.getTime() + 2 * DAY_MS); + const rows = reconstructDoseHistory( + weeklyBands, + [intake({ scheduledFor: weeklyAt })], + twoDaysLater, + ); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("upcoming"); + }); + + it("drops a pending row matching no band instead of emitting a phantom ad-hoc row", () => { + // `to = now` early-afternoon read: the 19:00 band is outside the + // minted window, but the projector already minted the evening pending + // row. It is a server placeholder, not a user action — it must not + // surface as an ad-hoc row with takenAt = null. + const morningOnly = bands.filter((b) => b.timeOfDay === "07:00"); + const rows = reconstructDoseHistory( + morningOnly, + [intake({ scheduledFor: at(19, 0) })], + at(14, 0), + ); + expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0); + expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00"]); + }); + + it("a skip with no band match still surfaces ad-hoc (deliberate action)", () => { + const morningOnly = bands.filter((b) => b.timeOfDay === "07:00"); + const rows = reconstructDoseHistory( + morningOnly, + [intake({ skipped: true, scheduledFor: at(19, 0) })], + at(14, 0), + ); + expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(1); + }); + + it("auto-missed stays missed even while the overdue window is still open", () => { + // The cron's verdict is authoritative — the clock does not resurrect it. + const rows = reconstructDoseHistory( + bands, + [intake({ autoMissed: true, scheduledFor: at(7, 0) })], + at(8, 30), + ); + expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed"); + }); + }); + it("returns rows in chronological order (slots + ad-hoc interleaved)", () => { const rows = reconstructDoseHistory( bands, diff --git a/src/lib/medications/scheduling/dose-history.ts b/src/lib/medications/scheduling/dose-history.ts index 6a8d214e7..20f53a2a7 100644 --- a/src/lib/medications/scheduling/dose-history.ts +++ b/src/lib/medications/scheduling/dose-history.ts @@ -11,7 +11,11 @@ * 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; + * against a slot deliberately, so they annotate that slot. A pending row + * (neither skipped nor auto-missed) is status-derived by time — missed + * only past the slot's miss cutoff, upcoming until then — and a pending + * row matching no band at all is dropped (server-minted placeholder for + * a slot outside the queried window, not a user action); * - 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 @@ -85,14 +89,29 @@ export function reconstructDoseHistory( for (const i of anchored) { const band = nearestAnchorBand(i.scheduledFor, bands); - const status: DoseHistoryStatus = i.skipped ? "skipped" : "missed"; if (band && !claim.has(band)) { + // Status is time-aware for a pending row: the projector / reminder + // worker mint pending rows for every slot of the day up front, so a + // pending row on a slot whose miss cutoff hasn't passed is still + // takeable — it reads upcoming, not missed (mirrors the unfilled-slot + // branch below). A skip stays a skip; a cron-marked auto-miss stays + // missed regardless of the clock. + const status: DoseHistoryStatus = i.skipped + ? "skipped" + : i.autoMissed || now.getTime() > band.overdueEnd.getTime() + ? "missed" + : "upcoming"; 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. + } else if (i.skipped || i.autoMissed) { + // A deliberate skip / cron-marked miss with no matching slot (legacy + // off-grid) — surface it so nothing silently vanishes; tag it ad-hoc. adHoc.push(i); } + // A pending row with no matching band is dropped: it is a server-minted + // placeholder (no user action) for a slot outside the queried band + // window — e.g. today's evening slot when the caller asked `to = now`. + // The band set is the source of truth for which slots exist in the + // window; emitting the placeholder would fabricate a phantom ad-hoc row. } for (const i of taken) {