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++) {
@@ -14,11 +14,14 @@
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
DOSE_WINDOW_DEFAULTS,
MIN_STABLE_DOSES,
buildComplianceDisplay,
buildComplianceMedicationContext,
calculateCompliance,
classifyIntakeTiming,
deriveDoseStatus,
doseCadenceFamily,
expectedSlotCountForDay,
expectedSlotsBetween,
lastNonSkippedTakenAt,
@@ -1881,3 +1884,262 @@ describe("expectedSlotsBetween", () => {
expect(slots.length).toBeGreaterThanOrEqual(7);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.9 — forgotten doses count as MISSED (BUG #2) + user-skip vs
// auto-miss semantics (BUG #3). A never-acted dose the auto-miss cron
// flipped carries `autoMissed: true`; the engine must pair it to a
// `missed` slot (against the rate). A deliberate user skip
// (`skipped: true`) stays excluded from the denominator.
// ────────────────────────────────────────────────────────────────────
describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", () => {
const NOW = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
const daily: ComplianceSchedule[] = [
{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null },
];
it("a forgotten daily dose (autoMissed) lowers the rate — not neutralised", () => {
// Three days: two taken + one forgotten (autoMissed). Before v1.15.9 the
// auto-skip cron flipped the forgotten dose to skipped, which the engine
// excluded → 2/2 = 100% (inflated). Now it is a real miss → 2/3 = 67%.
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true,
},
eventAt(new Date("2025-01-12T08:30:00Z"), true),
];
const result = calculateCompliance(events, daily, 7);
expect(result.taken).toBe(2);
expect(result.skipped).toBe(0);
expect(result.missed).toBeGreaterThanOrEqual(1);
// The autoMissed day is inside the denominator and drags the rate below
// the inflated 100% the old skip-neutralisation produced.
expect(result.rate).toBeLessThan(100);
});
it("a deliberate user-skip is excluded; an auto-miss on the same day-grid counts (BUG #3)", () => {
// One taken, one user-skip (excluded), one auto-miss (counted). The
// user-skip must NOT appear in the denominator; the auto-miss must.
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: true, // deliberate pause
autoMissed: false,
},
{
scheduledFor: new Date("2025-01-12T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true, // forgotten
},
];
const result = calculateCompliance(events, daily, 7);
expect(result.skipped).toBe(1);
expect(result.taken).toBe(1);
// Denominator = taken + missed (the user-skip is out). At least the
// auto-miss counts, so the rate is taken/(taken+missed) < 100.
expect(result.missed).toBeGreaterThanOrEqual(1);
expect(result.rate).toBeLessThan(100);
// The user-skip alone (no auto-miss) would have kept 100% — confirm the
// auto-miss is what pulls it down by re-running with the skip only.
// Cover every slot the 7-day window emits (today's 08:00 is past NOW =
// 12:00, so the window holds Jan 9..15). Take all but one user-skip.
const skipOnlyEvents = [];
for (let day = 9; day <= 15; day++) {
const at = new Date(`2025-01-${String(day).padStart(2, "0")}T08:30:00Z`);
if (day === 13) {
skipOnlyEvents.push({
scheduledFor: at,
takenAt: null,
skipped: true,
autoMissed: false,
});
} else {
skipOnlyEvents.push(eventAt(at, true));
}
}
const skipOnly = calculateCompliance(skipOnlyEvents, daily, 7);
// Every non-skipped day taken → the deliberate skip never lowers the rate.
expect(skipOnly.rate).toBe(100);
});
it("buildComplianceDisplay surfaces taken / expected / missed counts per row", () => {
const events = [
eventAt(new Date("2025-01-14T08:30:00Z"), true),
{
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: null,
skipped: false,
autoMissed: true,
},
];
const ctxVal = buildComplianceMedicationContext(
{ startsOn: null, endsOn: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z") },
lastNonSkippedTakenAt(events),
"UTC",
);
const display = buildComplianceDisplay(events, daily, ctxVal, { now: NOW });
// `expected` is the rate denominator (taken + missed), `missed` counts the
// forgotten doses, `taken` the numerator — the "taken / expected" triple.
expect(display.short.expected).toBe(display.short.taken + display.short.missed);
expect(display.short.missed).toBeGreaterThanOrEqual(1);
expect(display.long.expected).toBe(display.long.taken + display.long.missed);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.9 — cadence-aware per-dose grace/miss window model. The card
// reads `deriveDoseStatus` to render on-time / overdue / missed states.
// ────────────────────────────────────────────────────────────────────
describe("deriveDoseStatus — daily / intraday windows", () => {
const target = new Date("2025-01-15T08:00:00Z");
it("on-time within ±60 min of target", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T08:30:00Z"))).toBe(
"on_time_window",
);
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T07:15:00Z"))).toBe(
"on_time_window",
);
});
it("upcoming before the on-time window opens", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T06:30:00Z"))).toBe(
"upcoming",
);
});
it("overdue between +60 min and +240 min", () => {
// +90 min → past on-time (60), before miss cutoff (240).
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"))).toBe(
"overdue",
);
// +239 min → still overdue (takeable).
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T11:59:00Z"))).toBe(
"overdue",
);
});
it("missed past +240 min", () => {
expect(deriveDoseStatus(target, "daily", new Date("2025-01-15T12:30:00Z"))).toBe(
"missed",
);
});
it("never overlaps the next dose's on-time window", () => {
// Next dose at 10:00; its on-time window opens at 09:00 (−60 min). The
// 08:00 dose's miss cutoff is therefore clamped to 09:00, well before its
// own +240 min (12:00). At 09:30 the 08:00 dose is already missed.
const nextDoseAt = new Date("2025-01-15T10:00:00Z");
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T09:30:00Z"), {
nextDoseAt,
}),
).toBe("missed");
});
it("taken inside the on-time window → taken_on_time, late → taken_late", () => {
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
takenAt: new Date("2025-01-15T08:30:00Z"),
}),
).toBe("taken_on_time");
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
takenAt: new Date("2025-01-15T10:00:00Z"),
}),
).toBe("taken_late");
});
it("a deliberate skip short-circuits to skipped", () => {
expect(
deriveDoseStatus(target, "daily", new Date("2025-01-15T20:00:00Z"), {
skipped: true,
}),
).toBe("skipped");
});
});
describe("deriveDoseStatus — weekly GLP-1 (4-day rule)", () => {
const target = new Date("2025-01-13T08:00:00Z"); // a Monday shot
it("on-time within ±1 day of the target day", () => {
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-13T20:00:00Z"))).toBe(
"on_time_window",
);
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-14T07:00:00Z"))).toBe(
"on_time_window",
);
});
it("overdue up to +4 days (still counts when taken — the clinical rule)", () => {
// +3 days from target → past the ±1-day on-time window, before +4 days.
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-16T12:00:00Z"))).toBe(
"overdue",
);
// Taken on day +3 still counts as late, not missed.
expect(
deriveDoseStatus(target, "weekly", new Date("2025-01-20T00:00:00Z"), {
takenAt: new Date("2025-01-16T12:00:00Z"),
}),
).toBe("taken_late");
});
it("missed past +4 days", () => {
expect(deriveDoseStatus(target, "weekly", new Date("2025-01-18T12:00:00Z"))).toBe(
"missed",
);
});
it("defaults match the documented 60-min / 240-min / 4-day boundaries", () => {
expect(DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes).toBe(60);
expect(
DOSE_WINDOW_DEFAULTS.dailyOnTimeMinutes + DOSE_WINDOW_DEFAULTS.dailyOverdueMinutes,
).toBe(240);
expect(DOSE_WINDOW_DEFAULTS.weeklyOverdueDays).toBe(4);
});
});
describe("doseCadenceFamily", () => {
it("rolling ≥ 2 days → weekly", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rollingIntervalDays: 7 }),
).toBe("weekly");
});
it("WEEKLY rrule → weekly", () => {
expect(
doseCadenceFamily({
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=WEEKLY;BYDAY=MO",
}),
).toBe("weekly");
});
it("daily rrule → daily", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", rrule: "FREQ=DAILY" }),
).toBe("daily");
});
it("plain legacy daily → daily", () => {
expect(
doseCadenceFamily({ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }),
).toBe("daily");
});
});
+197 -9
View File
@@ -40,6 +40,14 @@ interface IntakeEvent {
takenAt: Date | null;
skipped: boolean;
scheduledFor: Date;
/**
* v1.15.9 — true when the auto-miss cron marked this never-acted dose as
* a forgotten miss. Threaded into the cadence timeline so it counts as a
* `missed` slot (against the rate) instead of being neutralised. Optional
* so pre-v1.15.9 callers / fixtures default it to a normal pending/taken
* row.
*/
autoMissed?: boolean;
}
export type IntakeTimingClass =
@@ -164,6 +172,166 @@ export function classifyIntakeTiming(
return "very_late";
}
/**
* v1.15.9 — cadence-aware per-dose grace/miss window model.
*
* A medication's dose either is on-time, is still takeable but late
* (counts when taken), or has slipped past the point where a self-hoster
* could plausibly still take it (a MISS). The cutoffs differ by cadence:
* an intraday/daily dose has tight hour-scale windows; a weekly/rolling
* injectable (the GLP-1 case) follows the clinical "take it within 4 days,
* otherwise skip to the next scheduled dose" rule.
*
* The defaults below are centralised named constants so a future revision
* can expose them per-medication without re-deriving the boundaries. Make
* it correct first; configurability later.
*
* DAILY / INTRADAY (minute-scale windows around the target instant):
* - on-time: target − 60 min … target + 60 min
* - overdue: target + 60 min … target + 240 min (still takeable)
* - missed: > target + 240 min, OR the next dose's on-time window
* starts first (whichever is sooner — adjacent doses never
* overlap).
*
* WEEKLY / ROLLING injectable (day-scale windows; the 4-day clinical rule):
* - on-time: target day ± 1 day
* - overdue: up to target + 4 days (late-but-counts)
* - missed: > target + 4 days
*/
export const DOSE_WINDOW_DEFAULTS = {
/** Daily / intraday on-time half-width around the target instant (min). */
dailyOnTimeMinutes: 60,
/**
* Daily / intraday overdue tail past the on-time window (min). A dose
* taken inside `[onTime, onTime + overdue]` still counts; beyond it the
* dose is missed.
*/
dailyOverdueMinutes: 240 - 60,
/** Weekly / rolling on-time half-width around the target day (days). */
weeklyOnTimeDays: 1,
/**
* Weekly / rolling overdue tail past the target day (days) — the clinical
* 4-day GLP-1 rule. A shot inside `[target, target + 4d]` still counts;
* beyond it the dose is missed and the user skips to the next slot.
*/
weeklyOverdueDays: 4,
} as const;
/**
* v1.15.9 — the derived per-dose state the medication card renders.
*
* - `on_time_window` — due now, inside the on-time window (green).
* - `overdue` — past on-time, before the miss cutoff (still
* takeable; the card escalates the tint as it nears
* the cutoff — "stark überfällig").
* - `missed` — past the miss cutoff, never acted on.
* - `taken_on_time` — taken inside the on-time window.
* - `taken_late` — taken in the overdue window (still counts).
* - `skipped` — a deliberate user skip (excluded from the rate).
* - `upcoming` — the on-time window has not opened yet.
*
* PRN doses are never scheduled and so never produce a status — callers
* exclude them before calling {@link deriveDoseStatus}.
*/
export type DoseStatus =
| "upcoming"
| "on_time_window"
| "overdue"
| "missed"
| "taken_on_time"
| "taken_late"
| "skipped";
/** Cadence family the window math keys off. */
export type DoseCadenceFamily = "daily" | "weekly";
const HOUR_MS = 60 * 60 * 1000;
const ONE_DAY_MS = 24 * HOUR_MS;
/**
* v1.15.9 — derive a single dose's {@link DoseStatus} from its target
* instant, the cadence family, `now`, and (optionally) when it was taken /
* whether the user skipped it. Pure and deterministic.
*
* `nextDoseAt` (when supplied) caps a daily dose's miss cutoff at the next
* dose's on-time window start, so two adjacent intraday doses never claim
* overlapping overdue windows — the earlier dose flips to `missed` the
* moment the later one comes due if that is sooner than its own cutoff.
*
* The defaults come from {@link DOSE_WINDOW_DEFAULTS}; callers may override
* per-medication once that configurability lands.
*/
export function deriveDoseStatus(
targetAt: Date,
cadence: DoseCadenceFamily,
now: Date,
options?: {
takenAt?: Date | null;
skipped?: boolean;
nextDoseAt?: Date | null;
windows?: Partial<typeof DOSE_WINDOW_DEFAULTS>;
},
): DoseStatus {
if (options?.skipped) return "skipped";
const w = { ...DOSE_WINDOW_DEFAULTS, ...(options?.windows ?? {}) };
const target = targetAt.getTime();
let onTimeStart: number;
let onTimeEnd: number;
let overdueEnd: number;
if (cadence === "weekly") {
onTimeStart = target - w.weeklyOnTimeDays * ONE_DAY_MS;
onTimeEnd = target + w.weeklyOnTimeDays * ONE_DAY_MS;
overdueEnd = target + w.weeklyOverdueDays * ONE_DAY_MS;
} else {
onTimeStart = target - w.dailyOnTimeMinutes * 60_000;
onTimeEnd = target + w.dailyOnTimeMinutes * 60_000;
overdueEnd = onTimeEnd + w.dailyOverdueMinutes * 60_000;
}
// Never let the miss cutoff bleed into the next dose's on-time window.
if (options?.nextDoseAt) {
const nextOnTimeStart =
cadence === "weekly"
? options.nextDoseAt.getTime() - w.weeklyOnTimeDays * ONE_DAY_MS
: options.nextDoseAt.getTime() - w.dailyOnTimeMinutes * 60_000;
if (nextOnTimeStart < overdueEnd) overdueEnd = nextOnTimeStart;
}
const takenAt = options?.takenAt ?? null;
if (takenAt) {
const t = takenAt.getTime();
return t <= onTimeEnd ? "taken_on_time" : "taken_late";
}
const n = now.getTime();
if (n < onTimeStart) return "upcoming";
if (n <= onTimeEnd) return "on_time_window";
if (n <= overdueEnd) return "overdue";
return "missed";
}
/**
* v1.15.9 — classify a schedule's cadence into the {@link DoseCadenceFamily}
* the window model uses. A rolling cadence, or any RRULE / legacy weekly
* cadence that emits less often than daily, is `weekly` (day-scale windows +
* the 4-day rule); everything denser (daily, intraday multi-dose) is
* `daily` (minute-scale windows).
*/
export function doseCadenceFamily(schedule: ComplianceSchedule): DoseCadenceFamily {
if (schedule.rollingIntervalDays != null && schedule.rollingIntervalDays >= 2) {
return "weekly";
}
const rrule = schedule.rrule ?? "";
if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/i.test(rrule)) return "weekly";
// A legacy daysOfWeek restriction to specific weekdays (not every day)
// dosed once a day is still effectively daily-cadence on the days it
// fires; the per-slot window is minute-scale either way. Only the rolling
// / calendar-sparse shapes use the day-scale 4-day rule.
return "daily";
}
/**
* v1.5.0 — schedule view consumed by the cadence-aware adapter.
*
@@ -637,14 +805,16 @@ export interface ComplianceDisplay {
/** Echo of the density floor so a client can re-derive the rung. */
minStableDoses: number;
/**
* Compliance percentage + taken-dose count + day-streak over the short
* window. `taken` is the count of doses taken in the window (the numerator
* the card renders next to the rate so two identical percentages stay
* distinguishable and trustworthy).
* Compliance percentage + counts + day-streak over the short window.
* `taken` is the numerator the card renders next to the rate so two
* identical percentages stay distinguishable and trustworthy; `expected`
* is the rate denominator (taken + missed, EXCLUDING user skips) and
* `missed` the count counting against the rate. The card can render
* "26 / 30 · 87%" from `taken` / `expected`.
*/
short: { rate: number; taken: number; streak: number };
/** Compliance percentage + taken-dose count over the long window. */
long: { rate: number; taken: number };
short: { rate: number; taken: number; expected: number; missed: number; streak: number };
/** Compliance percentage + counts over the long window. */
long: { rate: number; taken: number; expected: number; missed: number };
/**
* v1.13.x Fix 4 — the open-cycle state, decoupled from the percentage
* rows so a between-doses sparse med never renders a scary red number.
@@ -790,8 +960,22 @@ export function buildComplianceDisplay(
expectedShort,
expectedLong,
minStableDoses: MIN_STABLE_DOSES,
short: { rate: short.rate, taken: short.taken, streak: short.streak },
long: { rate: long.rate, taken: long.taken },
// `expected` is the rate denominator (taken + missed) so the card can
// render the trustworthy "taken / expected · rate%" triple; user skips
// are excluded from it by construction (they never enter `missed`).
short: {
rate: short.rate,
taken: short.taken,
expected: short.taken + short.missed,
missed: short.missed,
streak: short.streak,
},
long: {
rate: long.rate,
taken: long.taken,
expected: long.taken + long.missed,
missed: long.missed,
},
currentCycle,
};
}
@@ -917,6 +1101,10 @@ export function calculateCompliance(
scheduledFor: e.scheduledFor,
takenAt: e.takenAt,
skipped: e.skipped,
// v1.15.9 — carry the forgotten-dose flag into the timeline so an
// auto-missed slot pairs to a `missed` status (counts against the
// rate) rather than a neutral `skipped`.
autoMissed: e.autoMissed ?? false,
}));
// v1.13.x — ROLLING retrospective expansion. A rolling cadence
+18 -1
View File
@@ -150,6 +150,14 @@ export interface IntakeEventLike {
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
/**
* v1.15.9 — true when the hourly auto-miss cron marked this never-acted
* dose as a forgotten miss (NOT a deliberate user skip). The pairing pass
* reads it so an auto-missed row counts as `missed` (against the rate)
* rather than `skipped` (excluded). Optional so every legacy caller /
* fixture that omits it keeps the user-skip-vs-taken-vs-missed contract.
*/
autoMissed?: boolean;
}
/** One slot the schedule expected the user to dose. */
@@ -563,7 +571,16 @@ export function pairDoses(
if (bestIdx >= 0) {
claimed.add(bestIdx);
match = events[bestIdx];
if (match.skipped) status = "skipped";
// v1.15.9 — an auto-missed dose (the cron marked a never-acted row
// past its miss cutoff) counts as `missed` even though it carries no
// `takenAt`. Check it BEFORE the user-skip branch: a forgotten dose
// must count against the rate, while a deliberate user skip stays
// `skipped` (excluded from the denominator). `autoMissed` and a
// user-`skipped` are mutually exclusive on a live row by construction
// (the cron only touches `skipped = false` pending rows), but the
// ordering makes the precedence explicit and regression-proof.
if (match.autoMissed) status = "missed";
else if (match.skipped) status = "skipped";
else if (match.takenAt) status = "taken";
else if (slot.windowEnd > now) status = "upcoming";
else status = "missed";