From cb239c2803e0ee814cd65a7930900938130f1b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 13:26:51 +0200 Subject: [PATCH] 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. --- .../api/medications/[id]/compliance/route.ts | 14 +- .../analytics/__tests__/compliance.test.ts | 262 ++++++++++++++++++ src/lib/analytics/compliance.ts | 206 +++++++++++++- src/lib/medications/scheduling/cadence.ts | 19 +- 4 files changed, 489 insertions(+), 12 deletions(-) diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 1afbdcccb..4a1cc2e85 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 = {}; for (let d = 0; d < 90; d++) { diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 35b22ef4b..444627038 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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"); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index b49779f2d..92ca8f95a 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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; + }, +): 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 diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 6dab236a7..05a2f48c2 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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";