fix(medications): unify compliance % and per-dose status onto one dose-history ledger

The medication compliance percentage and the history view drew their
per-dose attribution from four different matchers — the ±12h pairDoses
proximity used by calculateCompliance, the deriveDoseStatus bands (open
dose only), the 3h+120min classifyIntakeTiming heatmap heuristic, and a
raw takenAt-IS-NOT-NULL per-day rollup. They could and did disagree: a
dose could read "taken late" in the rate while the history called it an
off-schedule ad-hoc take.

Route the rate through the shared cadence-aware band minter +
reconstructDoseHistory so the percentage is a tally over the SAME ledger
the history view renders. The same intake can no longer be taken in the %
and ad-hoc in the ledger.

- calculateCompliance: when a medication context is supplied, the
  count fields (taken / skipped / missed / rate) come from
  tallyComplianceFromLedger. Numerator = taken_on_time + taken_late (a
  late dose still counts as taken — the taking-vs-timing split is kept
  available); denominator = taken + missed; skipped, ad-hoc, upcoming and
  whole PRN groups are excluded; rate capped at 100. The streak still
  walks the cadence timeline. Context-less pure-math callers keep the
  legacy timeline tally byte-stable.
- complianceChips + missedDoses: adherence rate + missed count come from
  the same ledger when an engine context is threaded.
- /api/medications/[id]/compliance heatmap: the per-day timing split and
  missed marks are bucketed from the one ledger instead of the per-day
  classifyIntakeTiming + expectedSlotCountForDay walk, so the cells agree
  with the headline rate.
- medication_compliance_rollups stays a raw cross-medication daily
  coverage count, documented as distinct from the band-attributed % (the
  band minter is per-cadence and cannot run inside a single SQL tally).

Downstream readers (health score, insight compliance status) keep calling
calculateCompliance; their numbers shift to the honest band-attributed
values. Some currently-counted off-time takes (e.g. an 11:29 take of a
07:00 dose) now read ad-hoc with the slot missed rather than taken-late —
the configurable per-dose window is the lever to widen the on-time band.

Tests: the irregular twice-daily, single-time-late heatmap, and legacy
parity fixtures are updated with rationale for the intended number
changes; new tally tests pin the taking/timing split, the skip / ad-hoc /
upcoming / PRN exclusions, auto-missed=missed, the 100% cap, rolling
retrospective families, and the cross-consistency invariant.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 17:50:06 +02:00
1 parent 56bc46417e
commit 79e7c866e4
7 files changed
+1128 -233

No files matched your search

@@ -169,7 +169,15 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
expect(entry!.onTime).toBe(2);
});
it("keeps single-time schedules byte-identical: a 12h-late dose is still very_late", async () => {
it("a 12h-late dose is an ad-hoc take + a missed 07:00 slot (band model)", async () => {
// v1.15.18 — the heatmap now buckets the unified dose-history ledger. A
// single 07:00 daily dose logged at 19:00 is 12h outside the 07:00 slot's
// on-time band (±60min) AND its late tail (to ~10:00), so it is an AD-HOC
// off-schedule take — a real logged dose (taken / on-time tone), NOT a
// "very late" attribution onto the 07:00 slot. The 07:00 slot itself reads
// missed (no intake inside its window past its cutoff). This is the audit's
// intent: the cell no longer mislabels an off-schedule take as a punctuality
// grade against a slot it never belonged to.
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
const dateKey = userDayKey(dayStart, TZ);
@@ -179,7 +187,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: [], // single-time: candidate = [windowStart]
timesOfDay: [], // single-time daily: the minter mints a 07:00 band
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
@@ -191,7 +199,7 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
]) as never,
);
// Single 07:00 schedule, dose taken at 19:00 → ~12h late → very_late.
// Single 07:00 schedule, dose logged at 19:00 → outside every band → ad-hoc.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
takenAt: atSlotUTC(dayStart, "19:00"),
@@ -203,9 +211,14 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
const daily = await callRoute();
const entry = daily[dateKey] as Record<string, number> | undefined;
expect(entry).toBeDefined();
expect(entry!.veryLate).toBe(1);
expect(entry!.onTime).toBe(0);
// The off-schedule take reads as a logged dose; no late-attribution.
expect(entry!.veryLate).toBe(0);
expect(entry!.late).toBe(0);
expect(entry!.onTime).toBe(1);
expect(entry!.taken).toBe(1);
// The 07:00 slot itself is missed (uncovered, past its cutoff).
// expected = the missed slot + the ad-hoc take's own slot.
expect(entry!.expected).toBe(2);
});
it("handles an overnight per-slot window (23:00 slot, 1h span) as on_time near the slot", async () => {
+196 -182
View File
@@ -6,83 +6,128 @@ import {
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
expectedSlotCountForDay,
lastNonSkippedTakenAt,
} from "@/lib/analytics/compliance";
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type DoseHistoryRow,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
/** A single effective dose slot with its derived punctuality window. */
interface SlotWindow {
/** Minute-of-day of the slot's start time (used for nearest-slot match). */
startMinutes: number;
/** "HH:mm" slot start — fed to `classifyIntakeTiming` as `windowStart`. */
type MedicationScheduleRow = {
id: string;
rrule: string | null;
rollingIntervalDays: number | null;
timesOfDay: string[];
daysOfWeek: string | null;
windowStart: string;
/** "HH:mm" slot end = slot start + the schedule's window span. */
windowEnd: string;
}
reminderGraceMinutes: number | null;
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
cyclicOnWeeks: number | null;
cyclicOffWeeks: number | null;
};
const MINUTES_PER_DAY = 24 * 60;
/** Parse "HH:mm" into minute-of-day. */
function hhmmToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
/** Format a minute-of-day (wrapped into 0..1439) back to "HH:mm". */
function minutesToHHmm(minutes: number): string {
const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) %
MINUTES_PER_DAY;
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
/**
* v1.15.18 — adapt a Prisma schedule row to the canonical engine shape the
* band minter consumes. A legacy daily row carrying only `windowStart`
* surfaces it as the single `timeOfDay` so the minter mints its daily band.
*/
function toCanonicalForBands(
s: MedicationScheduleRow,
oneShot: boolean,
): CanonicalSchedule {
const base: CanonicalSchedule = {
id: s.id,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
timesOfDay: s.timesOfDay,
daysOfWeek: s.daysOfWeek,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
};
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
}
/**
* v1.8.1 B15 follow-on — expand every schedule into its effective dose
* slots so timing classification matches against the right time-of-day.
* v1.15.18 — fold one dose-history ledger row into a per-day heatmap entry.
*
* Candidate slot times for a schedule are its `timesOfDay` when non-empty,
* else `[windowStart]` (so single-time schedules collapse to one slot at
* `windowStart` and stay byte-identical to the pre-fix matcher). Each
* slot's punctuality window is anchored at the slot time and spans the
* schedule's `windowEnd − windowStart` duration; overnight windows
* (`windowEnd ≤ windowStart`) add a day to the span before deriving the
* per-slot end. Multi-schedule meds union their slots — the matcher then
* picks the single closest slot across all of them.
* 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).
*/
function buildSlotCandidates(
schedules: { windowStart: string; windowEnd: string; timesOfDay: string[] }[],
): SlotWindow[] {
const slots: SlotWindow[] = [];
for (const sched of schedules) {
const startMin = hhmmToMinutes(sched.windowStart);
let endMin = hhmmToMinutes(sched.windowEnd);
// Overnight window: the end is on the following day.
if (endMin <= startMin) endMin += MINUTES_PER_DAY;
const spanMinutes = endMin - startMin;
const times =
sched.timesOfDay.length > 0 ? sched.timesOfDay : [sched.windowStart];
for (const time of times) {
const slotStartMin = hhmmToMinutes(time);
slots.push({
startMinutes: slotStartMin,
windowStart: minutesToHHmm(slotStartMin),
// `classifyIntakeTiming` re-detects the overnight wrap from the
// HH:mm pair, so a wrapped end formats back into 0..1439 cleanly.
windowEnd: minutesToHHmm(slotStartMin + spanMinutes),
});
}
function bucketLedgerRow(
entry: DailyComplianceEntry,
row: DoseHistoryRow,
): void {
switch (row.status) {
case "taken_on_time":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
break;
case "taken_late":
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.late++;
break;
case "missed":
entry.expected++;
entry.expectedCount++;
break;
case "skipped":
entry.expected++;
entry.expectedCount++;
entry.skipped++;
break;
case "upcoming":
// A future / still-takeable slot is due but not yet acted on. It counts
// toward the day's expected/due grid but not toward taken or missed.
entry.expected++;
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++;
break;
}
return slots;
}
export const GET = apiHandler(
@@ -165,134 +210,103 @@ export const GET = apiHandler(
{ now },
);
// Build daily compliance map for heatmap/line chart (90 days)
// v1.15.18 — the heatmap / line-chart daily map is now bucketed from the
// ONE unified dose-history ledger (the same bands the % + the history view
// read), so the per-day timing split (on-time / late) and the per-day
// missed marks can never disagree with the headline rate. The legacy
// per-day `classifyIntakeTiming` 3h-grace heuristic + `expectedSlotCount
// ForDay` walk are retired here in favour of band membership: a take inside
// a slot's on-time band reads on-time, inside its late tail reads late,
// outside every band reads ad-hoc; an unfilled slot past its miss cutoff
// reads missed; a future slot is upcoming (not yet due).
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {
// v1.7.0 code-correctness M1 — anchor each day's [start,end) on the
// user-tz local-day boundary, not a UTC-midnight slice. The engine
// applies `timesOfDay` in the user timezone, so for tz-distant users
// a UTC slice could attach `due` / `expectedCount` to the adjacent
// calendar cell. Anchor on a noon-local representative instant
// (dodges the DST midnight-ambiguity edge) and key off the user-tz
// day so the heatmap cell, the engine frame, and `dateKey` agree.
const representative = new Date(
now.getTime() - d * 24 * 60 * 60 * 1000 - 12 * 60 * 60 * 1000,
);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
);
// `getUserTodayBounds` returns an inclusive end (local 23:59:59.999);
// the slicing loops below use a half-open `[dayStart, dayEnd)`.
const dayEnd = new Date(dayEndInclusive.getTime() + 1);
// Build the bands across the whole 90-day heatmap window once. The window
// floor clamps to the medication's creation so pre-existence days never
// mint phantom slots.
const DAY_MS = 24 * 60 * 60 * 1000;
const windowFrom = new Date(
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
);
const bandMedication: BandMinterMedication = {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
};
const bandCtx: RecurrenceContext = {
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
timeZone: userTz,
lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = medication.schedules.map(
(s) => toCanonicalForBands(s, medication.oneShot),
);
const intakeInstants = mapped
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= now)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication: bandMedication,
schedules: canonicalSchedules,
ctx: bandCtx,
userTz,
range: { from: windowFrom, to: now },
now,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= windowFrom && e.scheduledFor <= now)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed,
}));
const ledgerRows = reconstructDoseHistory(bands, historyIntakes, now);
// Skip days before medication was created
if (dayEnd <= createdAt) continue;
const dateKey = userDayKey(dayStart, userTz);
const dayEvents = mapped.filter(
(e) => e.scheduledFor >= dayStart && e.scheduledFor < dayEnd,
);
const takenEvents = dayEvents.filter(
(e) => e.takenAt !== null && !e.skipped,
);
// Classify timing for each taken event against the best-matching schedule
let onTime = 0;
let late = 0;
let veryLate = 0;
let early = 0;
// v1.8.1 B15 follow-on — match each taken event to the closest
// effective time-of-day SLOT, not merely `windowStart`. A single
// schedule row carrying `timesOfDay = ["07:00","19:00"]` is two
// distinct dose slots; matching the evening dose against the lone
// `windowStart` "07:00" mis-classified it as `very_late` (~12h late)
// and painted the heatmap cell orange even when both doses were
// taken on time. Candidate slots for a schedule are its `timesOfDay`
// when non-empty, else `[windowStart]` — so single-time schedules
// collapse to exactly one candidate at `windowStart` and stay
// byte-identical to the pre-fix behaviour. The per-slot window
// mirrors the projector / cadence anchor: effective windowStart is
// the slot time, effective windowEnd is the slot time plus the
// schedule's `windowEnd − windowStart` span (overnight spans where
// `windowEnd ≤ windowStart` add a day).
const slotCandidates = buildSlotCandidates(medication.schedules);
for (const evt of takenEvents) {
if (slotCandidates.length === 0) {
// No schedule info: treat all taken as on_time
onTime++;
continue;
}
// Match event to the closest slot window by scheduledFor time.
const evtHour = evt.scheduledFor.getUTCHours();
const evtMin = evt.scheduledFor.getUTCMinutes();
const evtMinutes = evtHour * 60 + evtMin;
let bestSlot = slotCandidates[0];
let bestDist = Infinity;
for (const slot of slotCandidates) {
const dist = Math.abs(evtMinutes - slot.startMinutes);
if (dist < bestDist) {
bestDist = dist;
bestSlot = slot;
}
}
const timing = classifyIntakeTiming(
evt.takenAt,
bestSlot.windowStart,
bestSlot.windowEnd,
dayStart, // the scheduled date
);
// v1.4.34 IW-C — `early` is the new compliant bucket; it counts
// alongside `onTime` for the heatmap so a proactive logger reads
// green. The classifier still emits a distinct `"early"` value
// for downstream consumers that want to differentiate; the
// separate counter is surfaced on the daily entry below.
if (timing === "on_time") onTime++;
else if (timing === "early") early++;
else if (timing === "late") late++;
else veryLate++;
// Bucket each ledger row into its user-tz day. A slot row buckets on its
// anchor; an ad-hoc row on its real take time.
const byDay = new Map<string, DailyComplianceEntry>();
const ensureDay = (key: string): DailyComplianceEntry => {
let entry = byDay.get(key);
if (!entry) {
entry = {
expected: 0,
expectedCount: 0,
due: false,
taken: 0,
skipped: 0,
onTime: 0,
late: 0,
veryLate: 0,
early: 0,
};
byDay.set(key, entry);
}
return entry;
};
// v1.7.0 item 5 — the per-day expected count is the engine's actual
// due-slot count for THIS day, not the static schedule count. iOS
// history paints a "missed" mark only when `due === true`, so
// off-weeks / non-matching weekdays / PRN days no longer show a
// false miss. `expected` is kept populated (= expectedCount) for
// existing web consumers that read it; `due` + `expectedCount` are
// the explicit additive fields iOS keys off.
const expectedCount = expectedSlotCountForDay(
medication.schedules,
dayStart,
dayEnd,
medicationContext,
// v1.13.x — pass the intake history so a ROLLING schedule's per-day
// `due` flags come from the retrospective grid (each logged dose is a
// due day) instead of the engine's single forward slot — keeping the
// heatmap, the denominator, and the displayed rate in agreement.
mapped,
);
for (const row of ledgerRows) {
const key = userDayKey(row.at, userTz);
const entry = ensureDay(key);
bucketLedgerRow(entry, row);
}
dailyCompliance[dateKey] = {
expected: expectedCount,
expectedCount,
due: expectedCount > 0,
taken: takenEvents.length,
skipped: dayEvents.filter((e) => e.skipped).length,
onTime: onTime + early,
late,
veryLate,
early,
};
for (const [key, entry] of byDay) {
entry.due = entry.expectedCount > 0;
dailyCompliance[key] = entry;
}
annotate({
+413 -30
View File
@@ -25,6 +25,7 @@ import {
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
tallyComplianceFromLedger,
type ComplianceMedicationContext,
type ComplianceSchedule,
} from "../compliance";
@@ -447,20 +448,67 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
};
}
it("parity: a legacy daysOfWeek-only schedule yields identical numbers with no context", () => {
it("on-time takes: the ledger scores every logged 08:00 dose as taken (100%)", () => {
// v1.15.18 — the context path now tallies the unified dose-history
// ledger (band membership) instead of the legacy ±12h proximity timeline.
// An 08:00 daily dose taken at 08:00 sits inside the ±60min on-time band →
// taken_on_time. The seven logged days all score taken; the current-day
// 08:00 slot is still inside its takeable window at NOW (12:00, the late
// tail ends at 12:00) so it reads upcoming, not missed → a clean 100%.
// (The off-time / stricter behaviour is pinned in the next test.)
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
];
const events = Array.from({ length: 7 }, (_, i) =>
eventAt(new Date(NOW.getTime() - (i + 1) * DAY_MS), true),
);
const legacy = calculateCompliance(events, schedules, 7);
const onTime = (d: Date) => ({
scheduledFor: d,
takenAt: d,
skipped: false,
});
const events = Array.from({ length: 7 }, (_, i) => {
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
day.setUTCHours(8, 0, 0, 0);
return onTime(day);
});
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx(),
});
// Legacy daysOfWeek-only schedule has no canonical fields → both
// paths run the legacy walker → identical numbers.
expect(withCtx).toEqual(legacy);
// The window floors at NOW − 7d (Jan-8 12:00), so Jan-8's 08:00 dose falls
// just before it; the six in-window logged doses (Jan-9..Jan-14) all score
// taken and the current-day slot is still upcoming → 100%.
expect(withCtx.taken).toBe(6);
expect(withCtx.missed).toBe(0);
expect(withCtx.rate).toBe(100);
});
it("off-time take: the ledger is stricter than the legacy ±12h proximity matcher", () => {
// v1.15.18 — the keystone behaviour change. A daily 08:00 dose logged at
// 12:30 (4.5h late) USED to count as taken under the ±12h `pairDoses`
// proximity matcher. The unified band model attributes it honestly: 12:30
// is outside the 08:00 slot's on-time band (07:00–09:00) AND its late tail
// (to ~12:00), so it is an AD-HOC take and the slot reads MISSED. The
// percentage now agrees with the history view (the dose cannot read
// "taken" in the % while the ledger calls it ad-hoc).
const schedules: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, timesOfDay: ["08:00"] },
];
// takenAt = 08:00 slot + 4.5h = 12:30 (eventAt adds 30min onto a 12:00
// scheduledFor; here we pin the off-time take explicitly).
const events = Array.from({ length: 7 }, (_, i) => {
const day = new Date(NOW.getTime() - (i + 1) * DAY_MS);
day.setUTCHours(8, 0, 0, 0);
return {
scheduledFor: day,
takenAt: new Date(day.getTime() + 4.5 * 60 * 60 * 1000),
skipped: false,
};
});
const withCtx = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx(),
});
// Every off-time take is ad-hoc → its slot is missed → 0% taken.
expect(withCtx.taken).toBe(0);
expect(withCtx.missed).toBeGreaterThanOrEqual(6);
expect(withCtx.rate).toBe(0);
});
it("FREQ=WEEKLY;BYDAY=MO — took every Monday → 100%, denominator counts only Mondays", () => {
@@ -2163,39 +2211,40 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
return new Date(`2025-01-${String(day).padStart(2, "0")}T${hhmm}:00Z`);
}
it("snaps two off-time same-day takes to BOTH slots → 2 taken, not 1 taken + 1 missed", () => {
// The crux of BUG 3: a day with a late-morning take (09:13) and an
// evening take (19:00) must read as two taken doses. The morning take
// pairs to the 07:00 slot (2h13m away, well inside the ±6h half-gap) and
// the evening to the 19:00 slot — NOT the morning take landing on 19:00
// and the 07:00 slot reading falsely missed.
it("attributes two same-day takes to BOTH slots — a near-on-time morning take is taken_late, not orphaned", () => {
// v1.15.18 — the band model attributes each take to its OWN slot. A
// morning take at 08:13 (07:00 slot + 1h13m) is inside the 07:00 slot's
// late tail (07:00 + 60min on-time + 180min tail → to 10:00), so it reads
// taken_late and does NOT orphan the 07:00 slot onto the 19:00 row. The
// evening 19:00 take is on-time. The crux of the original BUG-3 fix
// survives the move off the ±6h snap: a same-day morning + evening take
// never collapse into one taken + one falsely-missed.
//
// A 2-day window so BOTH of Jan-14's slots fall inside [NOW-2d, NOW]
// (a 1-day window starts at noon Jan-14, which is after the 07:00 slot).
// Cover EVERY slot the window emits so there is no stray uncovered slot:
// a 3-day window (NOW − 3d = Jan-12 12:00 .. Jan-15 12:00) holds Jan-12
// 19:00, Jan-13 07:00+19:00, Jan-14 07:00+19:00, Jan-15 07:00 = 6 slots.
const onTime = (s: Date) => ({ scheduledFor: s, takenAt: s, skipped: false });
const events = [
// 07:00 slot, logged at 09:13 (off-time, snapped onto the 07:00 row).
onTime(slot(12, "19:00")),
onTime(slot(13, "07:00")),
onTime(slot(13, "19:00")),
// 07:00 slot, logged at 08:13 (within the late tail → taken_late).
{
scheduledFor: slot(14, "07:00"),
takenAt: new Date(slot(14, "07:00").getTime() + (2 * 60 + 13) * 60 * 1000),
takenAt: new Date(slot(14, "07:00").getTime() + (1 * 60 + 13) * 60 * 1000),
skipped: false,
},
// 19:00 slot, logged on time.
{ scheduledFor: slot(14, "19:00"), takenAt: slot(14, "19:00"), skipped: false },
// Jan-13's slots are covered too so the window has no stray miss.
{ scheduledFor: slot(13, "07:00"), takenAt: slot(13, "07:00"), skipped: false },
{ scheduledFor: slot(13, "19:00"), takenAt: slot(13, "19:00"), skipped: false },
onTime(slot(14, "19:00")),
onTime(slot(15, "07:00")),
];
const result = calculateCompliance(events, twiceDaily, 2, undefined, {
const result = calculateCompliance(events, twiceDaily, 3, undefined, {
now: NOW,
medicationContext: ctxVal,
});
// Both of Jan-14's off-time/on-time takes pair to their own slot → the
// morning slot is NOT falsely missed. No missed slot anywhere → 100%.
// Every slot is covered by its own take → no miss, 100%.
expect(result.missed).toBe(0);
expect(result.rate).toBe(100);
// The morning take did not orphan the 07:00 slot: at least Jan-14's two
// doses both register as taken.
expect(result.taken).toBeGreaterThanOrEqual(3);
expect(result.taken).toBe(6);
});
it("computes the rate as taken/(taken+missed) over a 7-day irregular pattern", () => {
@@ -2437,3 +2486,337 @@ describe("doseCadenceFamily", () => {
).toBe("daily");
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.18 — the UNIFIED ledger tally. `tallyComplianceFromLedger` is the
// ONE authority behind the compliance %: it builds the cadence-aware bands
// per schedule, reconstructs the dose-history ledger, and tallies it so the
// % and the history view can never contradict. These tests pin the
// taking-vs-timing split, the exclusions (skip / ad-hoc / upcoming / PRN),
// auto-missed=missed, and the cross-consistency invariant that the SAME
// intake can't be "taken" in the % while the ledger calls it "ad_hoc".
// ────────────────────────────────────────────────────────────────────
import { localHmAsUtc } from "@/lib/timezone";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import type { CanonicalSchedule, RecurrenceContext } from "@/lib/medications/scheduling/recurrence";
describe("tallyComplianceFromLedger — the unified % keystone", () => {
const TZ = "Europe/Berlin";
const dayRef = new Date("2026-06-08T12:00:00Z");
function at(h: number, m: number): Date {
return localHmAsUtc(dayRef, TZ, h, m);
}
function ctxFor(
over: Partial<ComplianceMedicationContext> = {},
): ComplianceMedicationContext {
return {
startsOn: new Date("2026-06-01T00:00:00Z"),
endsOn: null,
oneShot: false,
createdAt: new Date("2026-06-01T00:00:00Z"),
lastIntakeAt: null,
timeZone: TZ,
...over,
};
}
// Twice-daily 07:00 / 19:00, single window covering the whole day.
const twiceDaily: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
},
];
// Late evening "now" so both of the reference day's slots are past cutoff.
const nowEvening = at(23, 59);
// Window covers just the reference day.
const from = at(0, 0);
it("a late take counts as taken (taking adherence), with the timing split exposed", () => {
// 07:00 on-time + 19:00 logged at 20:30 (inside the 180min late tail).
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(2);
expect(tally.takenOnTime).toBe(1);
expect(tally.takenLate).toBe(1);
expect(tally.missed).toBe(0);
expect(tally.rate).toBe(100);
});
it("a deliberate user-skip is EXCLUDED from the denominator", () => {
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: null, skipped: true },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(1);
expect(tally.skipped).toBe(1);
expect(tally.missed).toBe(0);
expect(tally.denominator).toBe(1); // taken + missed, skip excluded
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 },
{
scheduledFor: at(19, 0),
takenAt: null,
skipped: false,
autoMissed: true,
},
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(1);
expect(tally.missed).toBe(1);
expect(tally.skipped).toBe(0);
expect(tally.rate).toBe(50);
});
it("an off-schedule (ad-hoc) take is EXCLUDED + leaves its slot missed", () => {
// Marc's case: 07:00 dose logged at 11:29 → outside the band → ad-hoc;
// the 07:00 slot is missed; the 19:00 slot is also missed (untouched).
const events = [
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.adHoc).toBe(1);
// The ad-hoc take is NOT in taken; both slots are missed.
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(2);
// Denominator = taken + missed; ad-hoc never enters it.
expect(tally.denominator).toBe(2);
expect(tally.rate).toBe(0);
});
it("a future slot is upcoming — excluded, never a premature miss", () => {
// Early morning now: both slots still ahead of their miss cutoff.
const earlyNow = at(5, 0);
const tally = tallyComplianceFromLedger(
[],
twiceDaily,
ctxFor(),
from,
earlyNow,
earlyNow,
);
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(0);
expect(tally.denominator).toBe(0);
expect(tally.rate).toBe(100); // empty-window contract
});
it("a PRN schedule contributes NO denominator (every intake excluded)", () => {
const prn: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
scheduleType: "PRN",
},
];
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(14, 0), takenAt: at(14, 0), skipped: false },
];
const tally = tallyComplianceFromLedger(
events,
prn,
ctxFor(),
from,
nowEvening,
nowEvening,
);
// PRN → hasExpectedSlots false → no bands → every take ad-hoc, no slots.
expect(tally.taken).toBe(0);
expect(tally.missed).toBe(0);
expect(tally.adHoc).toBe(2);
expect(tally.denominator).toBe(0);
expect(tally.rate).toBe(100);
});
it("the rate is capped at 100% (extra doses never inflate it)", () => {
// Both slots taken + an ad-hoc extra. Ad-hoc is excluded so the rate is a
// clean 100, never >100.
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
{ scheduledFor: at(13, 0), takenAt: at(13, 0), skipped: false }, // extra
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(tally.taken).toBe(2);
expect(tally.adHoc).toBe(1);
expect(tally.rate).toBe(100);
});
it("CROSS-CONSISTENCY: the same intake cannot be taken in the % and ad_hoc in the ledger", () => {
// The keystone invariant. Build the exact bands the tally builds, run the
// SAME reconstructDoseHistory, and assert the tally's per-status counts
// equal the ledger's per-status counts — so a dose graded "taken" by the %
// is a "taken_*" row in the history view, and an "ad_hoc" row never leaks
// into the taken numerator.
const events = [
{ scheduledFor: at(7, 0), takenAt: at(7, 0), skipped: false }, // on-time
{ scheduledFor: at(19, 0), takenAt: at(20, 30), skipped: false }, // late
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false }, // ad-hoc
];
const tally = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
// Re-derive the ledger independently the same way the tally does.
const medication: BandMinterMedication = {
id: "x",
startsOn: ctxFor().startsOn,
endsOn: null,
oneShot: false,
createdAt: ctxFor().createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "x",
startsOn: ctxFor().startsOn,
endsOn: null,
oneShot: false,
createdAt: ctxFor().createdAt,
},
timeZone: TZ,
lastIntakeAt: null,
};
const canonical: CanonicalSchedule = {
id: "s",
rrule: null,
rollingIntervalDays: null,
timesOfDay: ["07:00", "19:00"],
daysOfWeek: null,
windowStart: "07:00",
windowEnd: "19:00",
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
const groups = buildBandsForSchedules({
medication,
schedules: [canonical],
ctx: recurrenceCtx,
userTz: TZ,
range: { from, to: nowEvening },
now: nowEvening,
intakeInstants: events
.filter((e) => !e.skipped && e.takenAt)
.map((e) => e.takenAt as Date),
});
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
const intakes: HistoryIntake[] = events.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: false,
}));
const rows = reconstructDoseHistory(bands, intakes, nowEvening);
const fromLedger = {
onTime: rows.filter((r) => r.status === "taken_on_time").length,
late: rows.filter((r) => r.status === "taken_late").length,
missed: rows.filter((r) => r.status === "missed").length,
adHoc: rows.filter((r) => r.status === "ad_hoc").length,
};
expect(tally.takenOnTime).toBe(fromLedger.onTime);
expect(tally.takenLate).toBe(fromLedger.late);
expect(tally.missed).toBe(fromLedger.missed);
expect(tally.adHoc).toBe(fromLedger.adHoc);
// And the load-bearing claim: the ad-hoc take is NOT counted as taken.
expect(tally.taken).toBe(fromLedger.onTime + fromLedger.late);
expect(tally.adHoc).toBe(1);
});
it("rolling weekly (GLP-1) — each logged shot is a taken slot (retrospective family)", () => {
const rolling: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "08:00",
daysOfWeek: null,
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
];
const shot1 = new Date("2026-05-04T09:12:00Z");
const shot2 = new Date("2026-05-11T18:40:00Z");
const shot3 = new Date("2026-05-18T07:55:00Z");
const now = new Date("2026-05-20T12:00:00Z");
const events = [shot1, shot2, shot3].map((d) => ({
scheduledFor: d,
takenAt: d,
skipped: false,
}));
const tally = tallyComplianceFromLedger(
events,
rolling,
ctxFor({
startsOn: new Date("2026-05-01T00:00:00Z"),
createdAt: new Date("2026-05-01T00:00:00Z"),
lastIntakeAt: shot3,
}),
new Date("2026-05-01T00:00:00Z"),
now,
now,
);
// Each irregular shot anchors its own retrospective band → all taken.
expect(tally.taken).toBeGreaterThanOrEqual(3);
expect(tally.rate).toBeGreaterThanOrEqual(90);
});
});
+243 -14
View File
@@ -19,6 +19,12 @@
* daily compliance heatmap on `/api/medications/[id]/compliance`.
*/
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import {
buildCadenceTimeline,
type CadenceEngineContext,
@@ -26,6 +32,10 @@ import {
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
expandRollingRetrospective,
nextOccurrenceAfter,
@@ -1094,6 +1104,177 @@ function soonestCadenceFamily(
return "daily";
}
/**
* v1.15.18 — the unified compliance tally over the dose-history ledger.
*
* THE single source of the medication compliance %. It builds the
* cadence-aware `SlotBand[]` per schedule (the shared band minter — every
* cadence: daily, fixed-weekday, rolling-retrospective, one-shot, cyclic,
* PRN), reconstructs the ONE dose-history ledger
* (`reconstructDoseHistory`), and tallies it so the percentage and the
* history view are mathematically incapable of contradicting each other
* (audit CRITICAL-2). It replaces the ±12h `pairDoses` proximity matcher
* that `calculateCompliance` used for the engine-routed path.
*
* The tally follows the adherence literature's TAKING-vs-TIMING split:
* - numerator (taken) = `taken_on_time` + `taken_late` — a late dose is
* still a taken dose; "late" is NOT collapsed into "missed";
* - denominator = taken + `missed`;
* - EXCLUDED from the denominator: `skipped` (deliberate user decision),
* `ad_hoc` (off-schedule top-up — no defensible slot), `upcoming` (the
* window hasn't opened), and ENTIRE PRN groups (`hasExpectedSlots:false`
* — PRN has no defensible denominator per the literature);
* - the rate is capped at 100% (extra doses never inflate it).
*
* The on-time / late split is surfaced separately so a caller can show both
* the TAKING rate (the headline) and the TIMING quality.
*
* Pure / synchronous: the bands are minted from pre-fetched schedules +
* intake instants; no DB access.
*/
export interface LedgerComplianceTally {
/** Doses taken (on-time + late). The TAKING-adherence numerator. */
taken: number;
/** Of `taken`, the count inside the on-time band. */
takenOnTime: number;
/** Of `taken`, the count in the late tail (still counts as taken). */
takenLate: number;
/** Expected doses never acted on past their miss cutoff. */
missed: number;
/** Deliberate user skips — excluded from the denominator. */
skipped: number;
/** Off-schedule intakes (PRN groups + ad-hoc rows) — excluded. */
adHoc: number;
/** taken + missed (the rate denominator). */
denominator: number;
/** round(100 · taken / denominator), capped at 100. 100 on empty. */
rate: number;
}
export function tallyComplianceFromLedger(
events: IntakeEvent[],
schedules: ComplianceSchedule[],
ctx: ComplianceMedicationContext,
from: Date,
to: Date,
now: Date,
windowConfig?: DoseWindowConfig,
): LedgerComplianceTally {
const medication: BandMinterMedication = {
id: "compliance-tally",
startsOn: ctx.startsOn,
endsOn: ctx.endsOn,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
};
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-tally");
const canonicalSchedules = schedules.map((s, i) => {
const canonical = toCanonicalSchedule(s, `compliance-tally-${i}`);
// A legacy daily schedule carries only `windowStart` (no `timesOfDay`,
// no rrule, no rolling, no `daysOfWeek`). The engine's `expandLegacy`
// reads that as "every day at windowStart", but the band minter's
// cadence-detection gate needs an explicit time signal — surface
// `windowStart` as the single time-of-day so the daily band is minted.
if (
canonical.timesOfDay.length === 0 &&
canonical.rrule === null &&
canonical.rollingIntervalDays === null &&
canonical.scheduleType !== "PRN" &&
!ctx.oneShot
) {
return { ...canonical, timesOfDay: [canonical.windowStart] };
}
return canonical;
});
// Rolling cadences anchor their retrospective grid AT each logged intake;
// the bands need every non-skipped take in (or before) the window. Reuse
// the same instant-extraction the legacy rolling path uses so the
// numerator and denominator are built from one expansion.
const intakeInstants = intakeInstantsAtOrBefore(
events.map((e) => ({ takenAt: e.takenAt, skipped: e.skipped })),
to,
);
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz: ctx.timeZone,
range: { from, to },
now,
windowConfig,
intakeInstants,
});
// The ledger reads `scheduledFor` + `takenAt` + skip/auto-miss flags. The
// bands already partition the slot space, so the union of every
// non-PRN schedule's bands is fed to ONE reconstruction — `reconstruct
// DoseHistory` claims each slot by at most one intake, so pooling the
// (already correctly-minted) bands is safe. PRN groups (no expected
// slots) contribute no bands; their intakes surface as ad-hoc and are
// excluded from the denominator, exactly as the literature requires.
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= to)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, now);
let takenOnTime = 0;
let takenLate = 0;
let missed = 0;
let skipped = 0;
let adHoc = 0;
for (const row of rows) {
switch (row.status) {
case "taken_on_time":
takenOnTime++;
break;
case "taken_late":
takenLate++;
break;
case "missed":
missed++;
break;
case "skipped":
skipped++;
break;
case "ad_hoc":
adHoc++;
break;
// `upcoming` slots are future / still-takeable → excluded from every
// counter so a partial head-of-window day never pollutes the rate.
}
}
const taken = takenOnTime + takenLate;
const denominator = taken + missed;
const rate =
denominator > 0
? Math.min(100, Math.round((taken / denominator) * 100))
: 100;
return {
taken,
takenOnTime,
takenLate,
missed,
skipped,
adHoc,
denominator,
rate,
};
}
/**
* Calculate compliance for a medication over a given period.
*
@@ -1168,6 +1349,41 @@ export function calculateCompliance(
Math.ceil((now.getTime() - effectiveStart.getTime()) / DAY_MS),
);
// v1.15.18 — when a medication context is supplied, the count fields
// (taken / skipped / missed / rate) come from the UNIFIED dose-history
// ledger tally, NOT the ±12h `pairDoses` proximity matcher. This is the
// keystone unification: the percentage is a tally over the exact same
// ledger the history view renders, so the two can never contradict (a
// dose can't read "taken late" in the % while the ledger calls it
// "ad-hoc"). The numerator is on-time + late takes (a late dose still
// counts as taken); user skips + ad-hoc top-ups + PRN groups are excluded
// from the denominator. The streak still walks the cadence timeline below
// (its day-grain "every dose taken or skipped" rule is unchanged).
//
// Context-less callers (pure-math fixtures, pre-v1.7 surfaces) keep the
// legacy timeline tally byte-stable — they have no engine context to mint
// bands from.
const ledgerCtx = options?.medicationContext;
let ledgerCounts:
| { taken: number; skipped: number; missed: number; rate: number }
| null = null;
if (ledgerCtx) {
const tally = tallyComplianceFromLedger(
events,
schedules,
ledgerCtx,
effectiveStart,
now,
now,
);
ledgerCounts = {
taken: tally.taken,
skipped: tally.skipped,
missed: tally.missed,
rate: tally.rate,
};
}
// Normalise the schedule shape so legacy callers that pass only
// `{ windowStart, windowEnd }` still produce a usable `daysOfWeek`
// field (treated as daily by the cadence parser).
@@ -1252,23 +1468,36 @@ export function calculateCompliance(
retro,
);
let taken = 0;
let skipped = 0;
let missed = 0;
for (const slot of timeline) {
if (slot.status === "taken") taken++;
else if (slot.status === "skipped") skipped++;
else if (slot.status === "missed") missed++;
// `upcoming` slots (future window) are excluded from every counter
// so a partial day at the head of the window doesn't pollute the rate.
// v1.15.18 — the count fields come from the unified ledger tally when a
// medication context was supplied (the keystone unification), and from the
// legacy timeline tally otherwise (context-less pure-math callers). The
// streak below always walks the timeline — its day-grain rule is orthogonal
// to the per-dose attribution and stays byte-stable for every caller.
let taken: number;
let skipped: number;
let missed: number;
let rate: number;
if (ledgerCounts) {
({ taken, skipped, missed, rate } = ledgerCounts);
} else {
taken = 0;
skipped = 0;
missed = 0;
for (const slot of timeline) {
if (slot.status === "taken") taken++;
else if (slot.status === "skipped") skipped++;
else if (slot.status === "missed") missed++;
// `upcoming` slots (future window) are excluded from every counter
// so a partial day at the head of the window doesn't pollute the rate.
}
// Skipped doses are excluded from the denominator — they represent a
// deliberate user decision rather than a missed dose.
const denom = taken + missed;
rate =
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
}
const totalExpected = taken + skipped + missed;
// Skipped doses are excluded from the denominator — they represent a
// deliberate user decision rather than a missed dose.
const denom = taken + missed;
const rate =
denom > 0 ? Math.min(100, Math.round((taken / denom) * 100)) : 100;
// Streak: consecutive days, ending today, where every expected dose
// was taken or skipped. Days with no expected dose advance the
+99
View File
@@ -28,6 +28,15 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "@/lib/medications/scheduling/dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
@@ -790,6 +799,19 @@ export function missedDoses(
timeZone?: string,
engineCtx?: CadenceEngineContext,
): number {
// v1.15.18 — when an engine context is threaded the missed count comes from
// the unified band ledger (the same one the % and the history view read), so
// every "missed" surface agrees. Pure-math callers (no context) keep the
// legacy timeline-status count.
const ledger = missedFromLedger(
schedules,
events,
asOf,
windowDays,
timeZone,
engineCtx,
);
if (ledger !== null) return ledger;
const timeline = buildCadenceTimeline(
schedules,
events,
@@ -801,3 +823,80 @@ export function missedDoses(
);
return timeline.filter((d) => d.status === "missed").length;
}
/**
* v1.15.18 — the count of `missed` rows in the unified dose-history ledger
* over the trailing window. Returns null when no engine context is supplied
* (the caller then uses the legacy timeline tally). Shares the band minter +
* `reconstructDoseHistory` with the compliance % so the numbers agree.
*/
function missedFromLedger(
schedules: ScheduleLike[],
events: IntakeEventLike[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
): number | null {
if (!engineCtx) return null;
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const userTz = engineCtx.timeZone || timeZone || "UTC";
const medication: BandMinterMedication = {
id: "missed-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "missed-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
timeZone: userTz,
lastIntakeAt: engineCtx.lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => {
const base = toCanonical(s, i);
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!engineCtx.oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
const intakeInstants = events
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
now: asOf,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
return rows.filter((r) => r.status === "missed").length;
}
+140 -2
View File
@@ -19,6 +19,11 @@
* legacy system-local behaviour the W19e tests pin.
*/
import type { SlotBand } from "./attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "./band-minter";
import {
buildCadenceTimeline,
startOfLocalDay,
@@ -27,6 +32,14 @@ import {
type PairedDose,
type ScheduleLike,
} from "./cadence";
import {
reconstructDoseHistory,
type HistoryIntake,
} from "./dose-history";
import {
type CanonicalSchedule,
type RecurrenceContext,
} from "./recurrence";
export interface ComplianceChips {
/** 0-100, taken / (taken + missed). Skipped doses are excluded from
@@ -142,6 +155,116 @@ export function streaksFromTimeline(
return { current, longest };
}
/**
* v1.15.18 — band-ledger tally counts for the detail-page chips.
*
* When the route threads an engine context (RRULE / rolling / cyclic /
* one-shot / PRN / legacy) the adherence rate + missed count come from the
* SAME unified dose-history ledger the compliance % and the history view
* read — so the chips can never contradict either. The streak still walks
* the cadence timeline (its day-grain "every dose taken or skipped" rule is
* orthogonal to per-dose attribution). Returns null when there is no engine
* context (a pure-math caller) — the caller then falls back to the legacy
* timeline tally.
*/
function ledgerChipCounts(
schedules: ScheduleLike[],
events: IntakeEventLike[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
engineCtx: CadenceEngineContext | undefined,
): { taken: number; missed: number } | null {
if (!engineCtx) return null;
const DAY_MS = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const userTz = engineCtx.timeZone || timeZone || "UTC";
const medication: BandMinterMedication = {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "chips-tally",
startsOn: engineCtx.startsOn,
endsOn: engineCtx.endsOn,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
timeZone: userTz,
lastIntakeAt: engineCtx.lastIntakeAt,
};
const canonicalSchedules: CanonicalSchedule[] = schedules.map((s, i) => {
const base: CanonicalSchedule = {
id: s.id ?? `chips-${i}`,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
timesOfDay: s.timesOfDay ?? [],
daysOfWeek: s.daysOfWeek ?? null,
windowStart: s.windowStart,
windowEnd: s.windowEnd,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
};
// Surface windowStart as the single time-of-day for a legacy daily row
// so the band minter's cadence gate mints its daily band (see the
// analytics/compliance tally for the same normalisation).
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
!engineCtx.oneShot
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
const intakeInstants = events
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
medication,
schedules: canonicalSchedules,
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
now: asOf,
intakeInstants,
});
const bands: SlotBand[] = [];
for (const g of groups) {
if (g.hasExpectedSlots) bands.push(...g.bands);
}
const intakes: HistoryIntake[] = events
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= asOf)
.map((e) => ({
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
let taken = 0;
let missed = 0;
for (const row of rows) {
if (row.status === "taken_on_time" || row.status === "taken_late") taken++;
else if (row.status === "missed") missed++;
}
return { taken, missed };
}
export function complianceChips(
schedules: ScheduleLike[],
events: IntakeEventLike[],
@@ -160,8 +283,23 @@ export function complianceChips(
timeZone,
engineCtx,
);
const taken = timeline.filter((d) => d.status === "taken").length;
const missed = timeline.filter((d) => d.status === "missed").length;
// v1.15.18 — adherence + missed come from the unified band ledger when an
// engine context is present (so the chips agree with the % and the history
// view); the legacy timeline tally is the fallback for pure-math callers.
const ledger = ledgerChipCounts(
schedules,
events,
asOf,
windowDays,
timeZone,
engineCtx,
);
const taken = ledger
? ledger.taken
: timeline.filter((d) => d.status === "taken").length;
const missed = ledger
? ledger.missed
: timeline.filter((d) => d.status === "missed").length;
const denom = taken + missed;
const adherenceRate = denom === 0 ? null : Math.round((taken / denom) * 100);
const { current, longest } = streaksFromTimeline(
@@ -14,6 +14,25 @@
* coverage probe falls back to live + fires a boot backfill when a
* user has intake events but zero rollup rows.
*
* v1.15.18 DESIGN DECISION — this tier stays a RAW per-day COVERAGE count,
* NOT the band-attributed compliance %. It aggregates, across ALL of a
* user's medications, "of the intake rows whose `scheduled_for` lands on
* this day, how many carry a non-null `taken_at`" — a coarse daily
* cross-medication coverage strip for the dashboard mini-tiles. The
* unified band-membership attribution (the `tallyComplianceFromLedger`
* keystone) is per-medication and per-cadence: it depends on the slot
* bands of each schedule and the user timezone, which cannot be expressed
* in a single cross-medication SQL `SUM(CASE …)`. Re-attributing every
* intake against its cadence's bands before the tally would mean running
* the band minter per `(medication, day)` inside the rollup, which defeats
* the rollup's whole purpose (bounded SQL, no per-row Node walk). The
* detail page + the medication card already read the band-attributed % via
* `calculateCompliance` / `complianceChips`; this tier deliberately remains
* the cheaper coverage signal and is documented as such so the two are not
* confused. The `scheduled` / `taken` column names reflect this: a day's
* `taken` is "intake rows logged taken", not "doses inside their on-time
* band".
*
* Audit anchor: `.planning/round-v1438-perf-analysis.md` §2.5 + §5 P4.
*/
import { prisma } from "@/lib/db";