mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user