mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(medications): read pending doses as upcoming until their window closes
A server-minted pending row (taken_at null, not skipped, not auto-missed) was scored missed the moment its slot time passed, so the history tab and the compliance rate punished doses that were still takeable under the band model. Pending rows now read upcoming until the band's overdue end; auto-missed keeps its terminal verdict. Pending rows outside the minted band window are dropped instead of surfacing as phantom ad-hoc lines.
This commit is contained in:
1 parent
a431b2346c
commit
eb2c7ced73
3 files changed
+183
-5
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user