mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge the skip parity between the two compliance engines
This commit is contained in:
commit
18302c64f8
3 files changed
+272
-9
No files matched your search
@@ -704,6 +704,49 @@ describe("v1.15.9 — schedule-anchored compliance buckets (BUG #1)", () => {
|
||||
if (day.scheduled > 0) expect(day.taken).toBe(day.scheduled);
|
||||
}
|
||||
});
|
||||
|
||||
it("a deliberately skipped dose leaves the denominator — a skip is a pause, not a miss, so the tile matches the ledger rate", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const twiceDaily = activeDailyMed(new Date(Date.now() - 60 * 86_400_000));
|
||||
twiceDaily.schedules[0].timesOfDay = ["08:00", "20:00"];
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
twiceDaily,
|
||||
] as never);
|
||||
|
||||
// Yesterday: the morning dose taken, the evening dose deliberately
|
||||
// skipped. The skipped slot must drop out of `scheduled` entirely, so the
|
||||
// day reads 1 of 1 (100%) — the figure the medication card, the dose
|
||||
// history and the doctor report already showed. Counting it as
|
||||
// expected-and-missed made the same day read 1 of 2 (50%) here.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date("2026-06-09T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-09T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
{
|
||||
medicationId: "m1",
|
||||
scheduledFor: new Date("2026-06-09T20:00:00.000Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/intake?scope=compliance&days=7",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ date: string; scheduled: number; taken: number }>;
|
||||
};
|
||||
const skipDay = body.data.find((d) => d.date === "2026-06-09");
|
||||
expect(skipDay).toEqual({ date: "2026-06-09", scheduled: 1, taken: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* The two compliance engines must report the SAME rate for the same day.
|
||||
*
|
||||
* `tallyComplianceFromLedger` (the dose-history ledger behind the medication
|
||||
* card, the history view, the doctor report and the Health Score pillar) has
|
||||
* excluded a deliberately skipped dose from the denominator since v1.15.9 —
|
||||
* a skip is a pause, not a miss. `buildScheduleAnchoredComplianceBuckets`
|
||||
* (the dashboard tile and the adherence storyline) counted the same slot as
|
||||
* expected-and-missed, because its denominator was the raw expected-slot
|
||||
* count with no skip term. One day, two figures: 67 % on the card, 50 % on
|
||||
* the tile.
|
||||
*
|
||||
* This test feeds ONE fixture to BOTH engines and pins them to one number, so
|
||||
* the divergence cannot come back through either side.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
medicationFindMany: vi.fn(),
|
||||
intakeFindMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: mocks.medicationFindMany },
|
||||
medicationIntakeEvent: { findMany: mocks.intakeFindMany },
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
tallyComplianceFromLedger,
|
||||
type ComplianceSchedule,
|
||||
type IntakeEvent,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance";
|
||||
|
||||
const TZ = "UTC";
|
||||
/** Late enough that every slot in the two-day window is past its cutoff. */
|
||||
const NOW = new Date("2026-06-10T23:00:00.000Z");
|
||||
const WINDOW_FROM = new Date("2026-06-09T00:00:00.000Z");
|
||||
|
||||
/** A twice-daily plan: 08:00 and 20:00, every day. */
|
||||
const twiceDaily: ComplianceSchedule = {
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
const medication = {
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
schedules: [twiceDaily],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Four expected slots across two days:
|
||||
* 09.06. 08:00 — taken
|
||||
* 09.06. 20:00 — SKIPPED (deliberate)
|
||||
* 10.06. 08:00 — missed (no row at all)
|
||||
* 10.06. 20:00 — taken
|
||||
*
|
||||
* Both engines must read 2 taken of 3 expected → 67 %. The pre-fix tile
|
||||
* counted the skip as expected-and-missed: 2 of 4 → 50 %.
|
||||
*/
|
||||
const events: IntakeEvent[] = [
|
||||
{
|
||||
scheduledFor: new Date("2026-06-09T08:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-09T08:05:00.000Z"),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
{
|
||||
scheduledFor: new Date("2026-06-09T20:00:00.000Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
},
|
||||
{
|
||||
scheduledFor: new Date("2026-06-10T20:00:00.000Z"),
|
||||
takenAt: new Date("2026-06-10T20:05:00.000Z"),
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const EXPECTED_RATE = 67;
|
||||
|
||||
describe("compliance engines agree on a deliberately skipped dose", () => {
|
||||
it("the ledger tally leaves the skip out of the denominator (2 taken of 3 expected)", () => {
|
||||
const ctx = buildComplianceMedicationContext(medication, null, TZ);
|
||||
const tally = tallyComplianceFromLedger(
|
||||
events,
|
||||
[twiceDaily],
|
||||
ctx,
|
||||
WINDOW_FROM,
|
||||
NOW,
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(tally.taken).toBe(2);
|
||||
expect(tally.skipped).toBe(1);
|
||||
expect(tally.missed).toBe(1);
|
||||
expect(tally.denominator).toBe(3);
|
||||
expect(tally.rate).toBe(EXPECTED_RATE);
|
||||
});
|
||||
|
||||
it("the schedule-anchored buckets read the same rate on the same fixture", async () => {
|
||||
mocks.medicationFindMany.mockResolvedValue([medication]);
|
||||
mocks.intakeFindMany.mockResolvedValue(
|
||||
events.map((e) => ({ ...e, medicationId: medication.id })),
|
||||
);
|
||||
|
||||
const buckets = await buildScheduleAnchoredComplianceBuckets(
|
||||
"user-1",
|
||||
2,
|
||||
TZ,
|
||||
NOW,
|
||||
);
|
||||
|
||||
const scheduled = buckets.reduce((s, b) => s + b.scheduled, 0);
|
||||
const taken = buckets.reduce((s, b) => s + b.taken, 0);
|
||||
|
||||
// The skipped slot is gone from the denominator, exactly as in the ledger.
|
||||
expect(scheduled).toBe(3);
|
||||
expect(taken).toBe(2);
|
||||
expect(Math.min(100, Math.round((taken / scheduled) * 100))).toBe(
|
||||
EXPECTED_RATE,
|
||||
);
|
||||
|
||||
// The day carrying the skip reads 1 of 1, not 1 of 2.
|
||||
const skipDay = buckets.find((b) => b.date === "2026-06-09");
|
||||
expect(skipDay).toEqual({ date: "2026-06-09", scheduled: 1, taken: 1 });
|
||||
});
|
||||
|
||||
it("a day whose every dose was skipped drops out of both denominators", async () => {
|
||||
const allSkipped: IntakeEvent[] = [
|
||||
{
|
||||
scheduledFor: new Date("2026-06-10T08:00:00.000Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
},
|
||||
{
|
||||
scheduledFor: new Date("2026-06-10T20:00:00.000Z"),
|
||||
takenAt: null,
|
||||
skipped: true,
|
||||
autoMissed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const ctx = buildComplianceMedicationContext(medication, null, TZ);
|
||||
const tally = tallyComplianceFromLedger(
|
||||
allSkipped,
|
||||
[twiceDaily],
|
||||
ctx,
|
||||
new Date("2026-06-10T00:00:00.000Z"),
|
||||
NOW,
|
||||
NOW,
|
||||
);
|
||||
expect(tally.denominator).toBe(0);
|
||||
|
||||
mocks.medicationFindMany.mockResolvedValue([medication]);
|
||||
mocks.intakeFindMany.mockResolvedValue(
|
||||
allSkipped.map((e) => ({ ...e, medicationId: medication.id })),
|
||||
);
|
||||
const buckets = await buildScheduleAnchoredComplianceBuckets(
|
||||
"user-1",
|
||||
1,
|
||||
TZ,
|
||||
NOW,
|
||||
);
|
||||
// `scheduled: 0` is what the chart filters out — no bar, not a 0 % bar.
|
||||
expect(buckets).toEqual([{ date: "2026-06-10", scheduled: 0, taken: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,17 @@
|
||||
* dashboard tile: both now consume this.
|
||||
*
|
||||
* `scheduled` is the canonical recurrence engine's expected-dose count for the
|
||||
* day, summed across the user's active medications, so days the schedule
|
||||
* expected a dose that the user missed pull the rate down — and the 7/30/90
|
||||
* windows genuinely diverge with partial adherence. `taken` stays the count of
|
||||
* taken (non-skipped, non-auto-missed) doses that day, capped at the day's
|
||||
* expected count so a duplicate log can't push a day above 100%.
|
||||
* day, summed across the user's active medications and MINUS the doses the
|
||||
* user deliberately skipped, so days the schedule expected a dose that the
|
||||
* user missed pull the rate down — and the 7/30/90 windows genuinely diverge
|
||||
* with partial adherence. `taken` stays the count of taken (non-skipped,
|
||||
* non-auto-missed) doses that day, capped at the day's expected count so a
|
||||
* duplicate log can't push a day above 100%.
|
||||
*
|
||||
* The skip exclusion is what keeps this engine and the ledger tally
|
||||
* (`tallyComplianceFromLedger`, the source of the rate on the medication card,
|
||||
* the dose history and the doctor report) reporting ONE number: both treat a
|
||||
* skip as a deliberate pause outside the denominator, never as a miss.
|
||||
*
|
||||
* Pure-ish over a single pinned `now` so a cached row is internally
|
||||
* consistent. Bounded: one active-medications query + one window-events query,
|
||||
@@ -147,6 +153,30 @@ export async function buildScheduleAnchoredComplianceBuckets(
|
||||
);
|
||||
if (scheduled === 0) continue;
|
||||
|
||||
const inDay = (at: Date) => at >= bounds.start && at < bounds.end;
|
||||
|
||||
// A deliberate skip is a pause, not a miss (the semantics the ledger
|
||||
// tally has carried since v1.15.9 and the only reading the "consistent
|
||||
// skip handling" contract admits). The skipped slot leaves the
|
||||
// denominator entirely instead of being counted as expected-and-missed
|
||||
// — otherwise the dashboard tile reported a lower rate than the card,
|
||||
// the history view and the doctor report for the very same day. The
|
||||
// count is capped at the expected slots so a stray skip row outside
|
||||
// the minted grid cannot drive the denominator negative; a day whose
|
||||
// every expected dose was skipped drops out of the buckets, exactly as
|
||||
// it drops out of the ledger denominator.
|
||||
//
|
||||
// `skipped` outranks `autoMissed` here for the same reason it does in
|
||||
// `reconstructDoseHistory`: the cron marks a forgotten dose
|
||||
// `autoMissed`, so a row still carrying `skipped` is a real user
|
||||
// decision.
|
||||
const skippedThisDay = Math.min(
|
||||
medEvents.filter((e) => e.skipped && inDay(e.scheduledFor)).length,
|
||||
scheduled,
|
||||
);
|
||||
const expected = scheduled - skippedThisDay;
|
||||
if (expected === 0) continue;
|
||||
|
||||
// Taken doses that landed in this day's window (non-skipped, non-auto-
|
||||
// missed), capped at the expected count so a duplicate log can't push a
|
||||
// single day above 100%.
|
||||
@@ -155,13 +185,12 @@ export async function buildScheduleAnchoredComplianceBuckets(
|
||||
e.takenAt !== null &&
|
||||
!e.skipped &&
|
||||
!e.autoMissed &&
|
||||
e.scheduledFor >= bounds.start &&
|
||||
e.scheduledFor < bounds.end,
|
||||
inDay(e.scheduledFor),
|
||||
).length;
|
||||
|
||||
const bucket = totals.get(key)!;
|
||||
bucket.scheduled += scheduled;
|
||||
bucket.taken += Math.min(takenThisDay, scheduled);
|
||||
bucket.scheduled += expected;
|
||||
bucket.taken += Math.min(takenThisDay, expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user