mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user