From a6b8c61a82efa8c331c0759c93995c2285041ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 19:46:24 +0200 Subject: [PATCH] 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 --- .../compliance-payload-heatmap.test.ts | 86 +++++++++++++++++++ src/lib/medications/compliance-payload.ts | 31 ++++--- 2 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/lib/medications/__tests__/compliance-payload-heatmap.test.ts diff --git a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts new file mode 100644 index 000000000..0ded6d91f --- /dev/null +++ b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts @@ -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, + }); +}); diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 363335b94..d201ca1aa 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -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; } }