fix(medications): count only a recorded dose as taken in the calendar

The adherence calendar counted every off-schedule ledger row as a dose taken
on time. An orphaned skip or auto-miss, a row on an instant that is no slot
of the schedule, carries no taken time, yet coloured its day as taken. Such
a row now counts as taken only with a taken time; an orphaned skip reads as
skipped, and an orphaned auto-miss counts nothing, as it already does in the
rate.

Refs #1028
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 19:46:24 +02:00
1 parent fa86c9e46c
commit a6b8c61a82
2 files changed
+107 -10

No files matched your search

@@ -0,0 +1,86 @@
/**
* The compliance heatmap counts a day's ledger rows. An off-schedule row
* (one that matched no slot) is a real dose only when it carries a taken
* time; an orphaned skip or auto-miss on an instant that is no slot of the
* schedule records no dose and must never colour the day as taken.
*/
import { afterEach, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { medicationIntakeEvent: { findMany: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import { buildCompliancePayload } from "@/lib/medications/compliance-payload";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
const TZ = "Europe/Berlin";
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
it("counts an off-schedule row as taken only when it has a taken time", async () => {
const day = new Date("2026-06-10T12:00:00Z");
const at = (h: number, m = 0) => localHmAsUtc(day, TZ, h, m);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(at(23, 0));
const row = (
scheduledFor: Date,
takenAt: Date | null,
flags: { skipped?: boolean; autoMissed?: boolean } = {},
) => ({
scheduledFor,
takenAt,
skipped: flags.skipped ?? false,
autoMissed: flags.autoMissed ?? false,
attributionSource: "AUTO",
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
// The 08:00 slot, taken on time.
row(at(8), at(8)),
// A real off-schedule take in the afternoon.
row(at(15), at(15)),
// Orphans on instants that are no slot of the schedule.
row(at(11), null, { skipped: true }),
row(at(12), null, { autoMissed: true }),
] as never);
const payload = await buildCompliancePayload(
{
id: "med-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
},
"user-1",
TZ,
);
expect(payload.dailyCompliance[userDayKey(at(12), TZ)]).toMatchObject({
// The slot, the real off-schedule take and the orphaned skip.
expected: 3,
taken: 2,
onTime: 2,
skipped: 1,
});
});
+21 -10
View File
@@ -107,10 +107,14 @@ export function complianceCacheKey(
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
* slot is still due but not yet acted on. An ad-hoc row with a taken time is
* a real off-schedule take — it counts as `taken` AND adds its own `expected`
* slot (so the heatmap's `missed = expected − taken − skipped` math stays
* non-negative) and reads on-time (a logged dose colours green). An ad-hoc row
* without a taken time is an orphaned skip or auto-miss on an instant that is
* no slot of the schedule: it records no dose, so it never counts as taken. A
* skip still reads as skipped; an orphaned auto-miss has no slot to have
* missed and counts nothing, as it does in the rate.
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
@@ -145,12 +149,19 @@ function bucketLedgerRow(
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
if (row.intake?.takenAt) {
// An off-schedule take: a real taken dose with no scheduled slot.
// Count it as taken + its own expected slot so the heatmap missed
// math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
} else if (row.intake?.skipped) {
entry.expected++;
entry.expectedCount++;
entry.skipped++;
}
break;
}
}