feat(medications): cadence-aware dose-state windows + miss-counting engine

Thread the auto_missed flag through the cadence timeline so a forgotten
dose pairs to a missed slot (against the rate) rather than a neutral skip;
a deliberate user skip stays excluded. This unifies the skip semantics
across every compliance surface: user skip = excluded, forgotten = missed.

Add a per-dose grace/miss window model the card will render:
deriveDoseStatus derives on_time_window / overdue / missed / taken_on_time
/ taken_late / skipped / upcoming from the target instant, cadence family,
and now. Daily/intraday uses minute-scale windows (on-time +/-60min,
overdue to +240min, capped at the next dose); weekly/rolling uses the
clinical 4-day rule (on-time +/-1 day, overdue to +4 days). Defaults are
centralised named constants for later per-medication configurability.

Surface taken / expected / missed counts on each compliance-display row so
the card can show the trustworthy 'taken / expected . rate%' triple, and
pin a single now through the per-medication compliance route so the two
windows can't straddle a day boundary on a slow request.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 13:26:51 +02:00
1 parent 29fa982e11
commit cb239c2803
4 files changed
+489 -12

No files matched your search

@@ -113,6 +113,9 @@ export const GET = apiHandler(
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
// v1.15.9 — a forgotten dose the auto-miss cron flipped counts as a
// miss, not a neutral skip.
autoMissed: e.autoMissed,
}));
const createdAt = medication.createdAt;
@@ -131,7 +134,14 @@ export const GET = apiHandler(
userTz,
);
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation. The two `calculateCompliance` calls + the display windows
// can otherwise straddle a day boundary on a slow request, so a dose
// would count in one window and not the next within the same response.
const now = new Date();
const compliance7 = calculateCompliance(mapped, medication.schedules, 7, createdAt, {
now,
medicationContext,
});
const compliance30 = calculateCompliance(
@@ -139,7 +149,7 @@ export const GET = apiHandler(
medication.schedules,
30,
createdAt,
{ medicationContext },
{ now, medicationContext },
);
// v1.8.6 — the two-row compliance display. The card always shows two
@@ -152,10 +162,10 @@ export const GET = apiHandler(
mapped,
medication.schedules,
medicationContext,
{ now },
);
// Build daily compliance map for heatmap/line chart (90 days)
const now = new Date();
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
for (let d = 0; d < 90; d++) {