mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
A rolling reminder at the ten-year maximum interval lost its next due when the satisfy instant fell in the late-UTC evening. The next-occurrence search bounded the single deterministic rolling slot by the end of the anchor's UTC day, but the applied notify-hour slot lands on the following UTC day whenever the anchor sits in the evening (the local morning is the next calendar day in a positive-offset zone). The anchor-day ceiling was then a day short and clipped the very slot it meant to admit, so logging the next dose set nextDueAt to null and the booster silently disappeared. Key the ceiling off the applied slot instant instead; endsOn stays enforced inside expandRolling.
933 lines
38 KiB
TypeScript
933 lines
38 KiB
TypeScript
/**
|
||
* v1.5.0 — canonical recurrence engine for medication scheduling.
|
||
*
|
||
* Single source of truth for "what dose slots does this schedule emit
|
||
* between A and B?" The canonical engine is introduced in this release;
|
||
* the v1.5.0 cut wires only the reminder worker (via
|
||
* `worker-helpers.ts`) through it. The today-projector
|
||
* (`expandTodayIntakes`), the cadence chart (`expandScheduleSlots`),
|
||
* and the form-level helpers continue on the legacy walker through
|
||
* v1.5.x and migrate per the read-flip plan. The medication card's
|
||
* "next intake" line reads the server-computed `nextDueAt` (this engine
|
||
* via `computeNextDueAt`) directly as of v1.8.4.
|
||
*
|
||
* Dispatch tiers (first matching tier wins):
|
||
*
|
||
* 1. **One-shot** (`medication.oneShot === true`) — single slot at
|
||
* the anchor date, one per `timesOfDay` entry.
|
||
* 2. **Rolling** (`schedule.rollingIntervalDays !== null`) — next
|
||
* slot is `lastIntakeAt + N days`. With no intake yet the FIRST
|
||
* dose is due AT `startsOn ?? createdAt` (not `+ N`); the `+ N`
|
||
* cadence only begins once the first intake is logged. Only the
|
||
* immediately next slot is emitted; further slots depend on the
|
||
* user actually logging the intake.
|
||
* 3. **RRULE** (`schedule.rrule !== null`) — RFC 5545 expansion
|
||
* via the `rrule` npm lib. Day-anchored dates are returned in
|
||
* UTC by the library; each `timesOfDay` entry is then applied
|
||
* to that day in the user's IANA timezone (DST-aware via
|
||
* `wallClockInTz`).
|
||
* 4. **Legacy fallback** — both `rrule` and `rollingIntervalDays`
|
||
* are NULL and `oneShot` is false. Decodes the legacy
|
||
* `daysOfWeek` string via `parseScheduleRecurrence` and emits
|
||
* weekly slots. **Honours `intervalWeeks > 1` correctly** —
|
||
* the existing `expandTodayIntakes` skipped it (the legacy
|
||
* bi-weekly worker bug R-3 finding 5 calls out); this engine
|
||
* anchors the week phase to `startsOn ?? createdAt` and emits
|
||
* on the matching weeks only.
|
||
*
|
||
* Pure functions, no DB access. Caller fetches `lastIntakeAt`
|
||
* (latest `MedicationIntakeEvent.takenAt`) and threads it through
|
||
* `RecurrenceContext`.
|
||
*
|
||
* **As-needed medications (v1.16.11, #316).** A medication with
|
||
* `Medication.asNeeded = true` carries ZERO schedule rows (enforced at
|
||
* the create/update routes), so this engine is never consulted for it:
|
||
* `computeNextDueAt` / `computeDisplayDue` return null on an empty
|
||
* schedule list by construction, the reminder worker iterates no
|
||
* schedules, the projector mints no slots, and compliance expands no
|
||
* expected doses. The MEDICATION_LOW_STOCK runway is likewise null (no
|
||
* consuming schedule) — that is correct and intended: an as-needed
|
||
* supply is not falling on a predictable cadence, so no runway-based
|
||
* alert can be honest. Intakes for such medications are ad-hoc rows
|
||
* (`scheduledFor = takenAt`, the documented standalone-insert
|
||
* contract); the schedule-level `scheduleType = "PRN"` early-returns
|
||
* below are the per-schedule prior art for the same semantics.
|
||
*/
|
||
|
||
import { RRule } from "rrule";
|
||
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
|
||
|
||
/**
|
||
* v1.7.0 — schedule-type discriminator on the canonical schedule.
|
||
* SCHEDULED keeps the rrule / rolling / legacy dispatch unchanged. PRN
|
||
* emits no slots (it is as-needed — never projected, reminded, or
|
||
* counted in compliance-expected, but still loggable). CYCLIC gates
|
||
* whichever inner cadence (rrule / legacy) the schedule describes by an
|
||
* N-weeks-on / M-weeks-off phase anchored to `startsOn ?? createdAt`.
|
||
*/
|
||
export const SCHEDULE_TYPES = ["SCHEDULED", "PRN", "CYCLIC"] as const;
|
||
export type ScheduleType = (typeof SCHEDULE_TYPES)[number];
|
||
|
||
export interface CanonicalSchedule {
|
||
id: string;
|
||
rrule: string | null;
|
||
rollingIntervalDays: number | null;
|
||
timesOfDay: string[];
|
||
/**
|
||
* Legacy fallback fields. Read-only — the engine consults these
|
||
* only when `rrule` and `rollingIntervalDays` are both NULL and
|
||
* `oneShot` is false. Pre-v1.5 rows always populate these.
|
||
*/
|
||
daysOfWeek: string | null;
|
||
windowStart: string;
|
||
windowEnd: string;
|
||
reminderGraceMinutes: number | null;
|
||
/**
|
||
* v1.7.0 — schedule type. Defaults to SCHEDULED for every pre-v1.7
|
||
* row. PRN short-circuits the engine to zero slots; CYCLIC wraps the
|
||
* inner cadence with an on/off-week phase gate.
|
||
*/
|
||
scheduleType: ScheduleType;
|
||
/** v1.7.0 — cyclic "on" weeks. Only read when `scheduleType === "CYCLIC"`. */
|
||
cyclicOnWeeks: number | null;
|
||
/** v1.7.0 — cyclic "off" weeks. Only read when `scheduleType === "CYCLIC"`. */
|
||
cyclicOffWeeks: number | null;
|
||
/**
|
||
* v1.15.18 — per-dose configurable on-time intake windows. The recurrence
|
||
* engine itself never reads these (slot timing is unchanged); they ride on
|
||
* the canonical schedule purely so the band minter can build a slot's
|
||
* on-time band from an explicit `[start, end]` range for the matching
|
||
* `timeOfDay`. NULL / empty = every slot uses the default ±1h derivation.
|
||
*/
|
||
doseWindows?: DoseWindowEntry[] | null;
|
||
}
|
||
|
||
/**
|
||
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
|
||
* the window applies to (matching a `timesOfDay` entry); `start`/`end` are the
|
||
* HH:mm on-time bounds in the user's wall clock (`start <= end`, same local day).
|
||
*/
|
||
export interface DoseWindowEntry {
|
||
timeOfDay: string;
|
||
start: string;
|
||
end: string;
|
||
}
|
||
|
||
export interface RecurrenceContext {
|
||
/** The parent medication. */
|
||
medication: {
|
||
id: string;
|
||
startsOn: Date | null;
|
||
endsOn: Date | null;
|
||
oneShot: boolean;
|
||
createdAt: Date;
|
||
};
|
||
/** The user's IANA timezone (e.g. "Europe/Berlin"). */
|
||
timeZone: string;
|
||
/**
|
||
* Latest `MedicationIntakeEvent.takenAt` for this medication.
|
||
* Used only by rolling schedules. Null when the user has never
|
||
* logged an intake (rolling then anchors on `startsOn ?? createdAt`).
|
||
*
|
||
* Skip semantics (documented decision, v1.16.9): a deliberate SKIP
|
||
* does NOT advance the rolling anchor. A skipped row carries
|
||
* `takenAt: null`, so every `lastIntakeAt` feeder query
|
||
* (`takenAt: { not: null }`) excludes it by construction, and the
|
||
* next due stays `previous take + N` — the skipped dose keeps
|
||
* surfacing as due until a real take re-anchors the grid. Advancing
|
||
* the anchor from the skipped instant instead would silently shift
|
||
* the whole future grid off the user's established rhythm; if that
|
||
* trade-off is ever revisited, every `lastIntakeAt` feeder (the
|
||
* projector, the reminder worker, the list route, the write-path
|
||
* resolvers, the dedup pass) must change together.
|
||
*/
|
||
lastIntakeAt: Date | null;
|
||
}
|
||
|
||
export interface Occurrence {
|
||
/** Canonical schedule owner; part of the occurrence identity. */
|
||
scheduleId: string;
|
||
/**
|
||
* The start instant of this occurrence. The `timeOfDay` is applied
|
||
* to the day in the user's timezone, with DST handled correctly via
|
||
* the existing `wallClockInTz` two-pass solver.
|
||
*/
|
||
at: Date;
|
||
/**
|
||
* The grace-window end instant. Computed from
|
||
* `reminderGraceMinutes` when set, otherwise from the legacy
|
||
* `windowEnd - windowStart` span.
|
||
*/
|
||
graceUntil: Date;
|
||
/**
|
||
* Which `timesOfDay` entry this occurrence corresponds to (or
|
||
* `windowStart` when falling back to legacy). Lets the UI label
|
||
* "Morning dose" vs "Evening dose" of the same day.
|
||
*/
|
||
timeOfDay: string;
|
||
}
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const WEEK_MS = 7 * DAY_MS;
|
||
const DEFAULT_GRACE_MINUTES = 60;
|
||
const ONE_MINUTE_MS = 60_000;
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Public API
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Emit every occurrence in `[from, to]` (inclusive of both ends) in
|
||
* chronological order. Pure: no DB access, no side effects.
|
||
*/
|
||
export function occurrencesBetween(
|
||
schedule: CanonicalSchedule,
|
||
from: Date,
|
||
to: Date,
|
||
ctx: RecurrenceContext,
|
||
): Occurrence[] {
|
||
if (to.getTime() < from.getTime()) return [];
|
||
|
||
// v1.7.0 — PRN (as-needed) emits no scheduled slots. It is loggable
|
||
// through the intake route but never projected, reminded, or counted
|
||
// in compliance-expected. The early return keeps every downstream
|
||
// surface (projector, worker, compliance) PRN-aware in one place.
|
||
if (schedule.scheduleType === "PRN") return [];
|
||
|
||
let slots: Occurrence[];
|
||
if (ctx.medication.oneShot) {
|
||
slots = expandOneShot(schedule, ctx, from, to);
|
||
} else if (schedule.rollingIntervalDays !== null) {
|
||
slots = expandRolling(schedule, ctx, from, to);
|
||
} else if (schedule.rrule !== null) {
|
||
slots = expandRrule(schedule, ctx, from, to);
|
||
} else {
|
||
slots = expandLegacy(schedule, ctx, from, to);
|
||
}
|
||
|
||
// v1.7.0 — CYCLIC gate. Drop any slot that lands in an "off" week of
|
||
// the N-on / M-off cycle. The inner cadence (rrule / legacy / rolling
|
||
// / one-shot) still decides which days emit within an "on" week; this
|
||
// only suppresses the off-week slots. Composes with `intervalWeeks`
|
||
// (the inner legacy stride) because the two phase computations are
|
||
// independent: the legacy stride filters weeks first, the cyclic gate
|
||
// filters the surviving slots second.
|
||
if (schedule.scheduleType === "CYCLIC") {
|
||
return slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx));
|
||
}
|
||
return slots;
|
||
}
|
||
|
||
/**
|
||
* v1.7.0 — true when `instant` falls in an "on" week of the cyclic
|
||
* on/off phase. The anchor is the medication's `startsOn ?? createdAt`,
|
||
* snapped to its UTC week start. `phase = weeksFromAnchor mod
|
||
* (on + off)`; the slot survives iff `phase < on`. A non-positive or
|
||
* missing `cyclicOnWeeks` keeps every slot (defensive — the route + Zod
|
||
* require a positive value for CYCLIC, but the engine never throws on a
|
||
* malformed row).
|
||
*/
|
||
function isInCyclicOnWeek(
|
||
instant: Date,
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
): boolean {
|
||
const onWeeks = schedule.cyclicOnWeeks;
|
||
const offWeeks = schedule.cyclicOffWeeks ?? 0;
|
||
if (onWeeks === null || onWeeks <= 0) return true;
|
||
const cycleLen = onWeeks + offWeeks;
|
||
if (cycleLen <= 0) return true;
|
||
|
||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||
const anchorWeekStart = startOfUtcWeek(anchor).getTime();
|
||
const instantWeekStart = startOfUtcWeek(instant).getTime();
|
||
const weeksFromAnchor = Math.round(
|
||
(instantWeekStart - anchorWeekStart) / WEEK_MS,
|
||
);
|
||
const phase = ((weeksFromAnchor % cycleLen) + cycleLen) % cycleLen;
|
||
return phase < onWeeks;
|
||
}
|
||
|
||
/**
|
||
* Compute the next occurrence strictly after `after`. Returns null
|
||
* when the schedule has terminated (one-shot anchor in the past;
|
||
* `endsOn` already crossed; rolling with neither a last intake nor a
|
||
* `startsOn` to anchor against).
|
||
*
|
||
* Implementation: walks forward in chunks (90 days for RRULE/legacy,
|
||
* one rolling cycle for rolling) until an occurrence is found or the
|
||
* `endsOn` boundary is reached. Caps at ~10 years out so a broken
|
||
* RRULE never spins.
|
||
*/
|
||
export function nextOccurrenceAfter(
|
||
schedule: CanonicalSchedule,
|
||
after: Date,
|
||
ctx: RecurrenceContext,
|
||
): Occurrence | null {
|
||
// v1.7.0 — PRN never has a next due instant.
|
||
if (schedule.scheduleType === "PRN") return null;
|
||
|
||
const endsOn = ctx.medication.endsOn;
|
||
const hardCap = new Date(after.getTime() + 365 * 10 * DAY_MS);
|
||
const limit = endsOn
|
||
? new Date(Math.min(endOfUtcDay(endsOn).getTime(), hardCap.getTime()))
|
||
: hardCap;
|
||
|
||
const cyclic = schedule.scheduleType === "CYCLIC";
|
||
|
||
// One-shot is single-slot — fast path. (One-shot is never cyclic, but
|
||
// gate defensively so a malformed row can't surface an off-week slot.)
|
||
if (ctx.medication.oneShot) {
|
||
const slots = expandOneShot(
|
||
schedule,
|
||
ctx,
|
||
new Date(after.getTime() + 1),
|
||
limit,
|
||
);
|
||
for (const s of slots) {
|
||
if (s.at.getTime() <= after.getTime()) continue;
|
||
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
|
||
return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Rolling is also single-slot (only emits the immediately next).
|
||
//
|
||
// v1.8.5 — floor the search window to the start of the user's current
|
||
// day rather than the strict `after` instant. A rolling dose whose slot
|
||
// lands earlier today (a past `startsOn` with no intake, or an overdue
|
||
// `lastIntakeAt + N`) is DUE/OVERDUE and must surface as "take now",
|
||
// not be skipped by a strict `> now` filter. Flooring to start-of-day
|
||
// keeps a slot the user has not yet acted on visible through the rest
|
||
// of its due day instead of silently rolling it forward by N.
|
||
if (schedule.rollingIntervalDays !== null) {
|
||
const dayFloor = startOfLocalDayInTz(after, ctx.timeZone);
|
||
// The immediately-next rolling occurrence is an identity, not "whatever
|
||
// falls on the worker's current day". Keep its local day in the search
|
||
// even after midnight; display/band callers decide whether its actionable
|
||
// tail is still open. Flooring only to today created a gap where an
|
||
// unresolved weekly dose vanished on the morning after it was due.
|
||
const floor = new Date(
|
||
Math.min(dayFloor.getTime(), rollingDoseDayFloor(schedule, ctx)),
|
||
);
|
||
// The rolling slot is a single deterministic instant — `lastIntakeAt + N`
|
||
// (or the anchor) at the notify-hour — not an open-ended walk, so the
|
||
// 10-year anti-spin `hardCap` must never gate it. It collides at the
|
||
// fencepost: a reminder at the maximum ten-year rolling interval, satisfied
|
||
// so that `after === lastIntakeAt`, produces a next slot exactly `after +
|
||
// 3650 days` — equal to `hardCap` to the millisecond, so the notify-hour
|
||
// slot spills past the raw boundary and `expandRolling` clips it to nothing
|
||
// (a ten-year booster silently lost its next due on the very dose that
|
||
// should re-anchor it). Bound the search by the deterministic slot's OWN
|
||
// end-of-day. The bound must key off the applied notify-hour instant, not
|
||
// the raw anchor: when the anchor sits in the late-UTC evening its local
|
||
// notify-hour slot falls on the NEXT UTC day, so an anchor-day ceiling is a
|
||
// day short and re-clips the very slot it meant to admit. `endsOn` stays
|
||
// enforced inside `expandRolling`, so a course-window reminder still
|
||
// self-expires.
|
||
const rollingSlotInstant = applyTimeOfDayToDate(
|
||
rollingDoseAnchor(schedule, ctx),
|
||
schedule.timesOfDay[0] ?? schedule.windowStart,
|
||
ctx.timeZone,
|
||
);
|
||
const rollingCeil = new Date(
|
||
Math.max(limit.getTime(), endOfUtcDay(rollingSlotInstant).getTime()),
|
||
);
|
||
const slots = expandRolling(schedule, ctx, floor, rollingCeil);
|
||
for (const s of slots) {
|
||
if (s.at.getTime() < floor.getTime()) continue;
|
||
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
|
||
return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Calendar cadences — walk in 90-day chunks.
|
||
//
|
||
// Defence-in-depth alongside the 10-year `hardCap`: every chunk
|
||
// costs one `rrule.between(...)` invocation (the rrule lib's
|
||
// internal MAXYEAR=9999 bounds each call, but the chunk count
|
||
// itself is unbounded). Cap the walk at MAX_CHUNKS so a pathological
|
||
// RRULE that emits very rarely (e.g. `FREQ=YEARLY;BYMONTHDAY=29;
|
||
// BYMONTH=2`) cannot spend an open-ended amount of time iterating
|
||
// the engine. Returns null on cap-hit, matching the "schedule has
|
||
// terminated" contract.
|
||
const MAX_CHUNKS = 80;
|
||
const chunkMs = 90 * DAY_MS;
|
||
let cursor = new Date(after.getTime() + 1);
|
||
let chunks = 0;
|
||
while (cursor.getTime() <= limit.getTime()) {
|
||
if (chunks++ >= MAX_CHUNKS) return null;
|
||
const chunkEnd = new Date(
|
||
Math.min(cursor.getTime() + chunkMs, limit.getTime()),
|
||
);
|
||
const slots = occurrencesBetween(schedule, cursor, chunkEnd, ctx);
|
||
for (const s of slots) {
|
||
if (s.at.getTime() > after.getTime()) return s;
|
||
}
|
||
cursor = new Date(chunkEnd.getTime() + 1);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Convenience predicate: does this schedule emit an occurrence at
|
||
* exactly `instant` (within a 1-minute tolerance)?
|
||
*/
|
||
export function matchesInstant(
|
||
schedule: CanonicalSchedule,
|
||
instant: Date,
|
||
ctx: RecurrenceContext,
|
||
): boolean {
|
||
const from = new Date(instant.getTime() - ONE_MINUTE_MS);
|
||
const to = new Date(instant.getTime() + ONE_MINUTE_MS);
|
||
const slots = occurrencesBetween(schedule, from, to, ctx);
|
||
return slots.some(
|
||
(s) => Math.abs(s.at.getTime() - instant.getTime()) <= ONE_MINUTE_MS,
|
||
);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Dispatch — one-shot
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
function expandOneShot(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
from: Date,
|
||
to: Date,
|
||
): Occurrence[] {
|
||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||
const times = effectiveTimesOfDay(schedule);
|
||
const slots: Occurrence[] = [];
|
||
for (const time of times) {
|
||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) continue;
|
||
slots.push(buildOccurrence(at, time, schedule));
|
||
}
|
||
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Dispatch — rolling
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Rolling cadence — "every N days from last intake".
|
||
*
|
||
* Anchoring (v1.8.5 — medically-correct first dose):
|
||
* - After an intake → next slot is `lastIntakeAt + N days` (the
|
||
* v1.8.4 re-anchor). Logging a dose pushes the next one out by N.
|
||
* - No intake yet → the FIRST dose is due AT `startsOn ?? createdAt`,
|
||
* NOT `start + N`. A rolling course's first dose lands on the start
|
||
* date; the `+ N` cadence only kicks in once the first intake is
|
||
* logged. The pre-v1.8.5 engine emitted `start + N` here, which
|
||
* silently skipped the start-date dose (and suppressed its reminder,
|
||
* since the worker shares this engine).
|
||
*
|
||
* A past `startsOn` with no intake therefore surfaces the start dose as
|
||
* DUE/OVERDUE ("take now") via `nextOccurrenceAfter`'s rolling branch,
|
||
* rather than rolling it forward.
|
||
*
|
||
* Design choice: only the FIRST time-of-day entry is emitted even
|
||
* when multiple are configured. The rolling semantic — "every N days
|
||
* from when I last took it" — doesn't compose cleanly with multiple
|
||
* per-day intakes; the canonical rolling case is a once-weekly
|
||
* injection. A future revision could fan out, but that's a UX
|
||
* decision (does logging the morning intake re-anchor the evening
|
||
* intake?), not an engine one.
|
||
*
|
||
* Only the immediately-next slot is returned. Past rolling slots are
|
||
* historical intake events, not "expected slots" the schedule
|
||
* predicts forward.
|
||
*/
|
||
/**
|
||
* Start-of-day (in the user's timezone) of the no-intake first rolling dose
|
||
* anchor — `startsOn ?? createdAt`. Used by `nextOccurrenceAfter` to clamp the
|
||
* search floor so a multi-day-overdue first dose still surfaces. Mirrors the
|
||
* anchor `expandRolling` emits in the `lastIntakeAt === null` branch; flooring
|
||
* to the anchor's start-of-day keeps the time-of-day slot (e.g. 08:00) within
|
||
* the window rather than ahead of a raw-instant floor.
|
||
*/
|
||
function rollingDoseAnchor(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
): Date {
|
||
const intervalDays = schedule.rollingIntervalDays ?? 0;
|
||
return ctx.lastIntakeAt !== null
|
||
? new Date(ctx.lastIntakeAt.getTime() + intervalDays * DAY_MS)
|
||
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
|
||
}
|
||
|
||
function rollingDoseDayFloor(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
): number {
|
||
const anchor = rollingDoseAnchor(schedule, ctx);
|
||
return startOfLocalDayInTz(anchor, ctx.timeZone).getTime();
|
||
}
|
||
|
||
function expandRolling(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
from: Date,
|
||
to: Date,
|
||
): Occurrence[] {
|
||
const n = schedule.rollingIntervalDays;
|
||
if (n === null || n <= 0) return [];
|
||
|
||
// After an intake the next dose is N days out. With no intake yet the
|
||
// FIRST dose is the start date itself (no `+ N`) — see the doc comment.
|
||
const nextDue = rollingDoseAnchor(schedule, ctx);
|
||
|
||
// endsOn cap.
|
||
if (
|
||
ctx.medication.endsOn &&
|
||
nextDue.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||
) {
|
||
return [];
|
||
}
|
||
|
||
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
|
||
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
|
||
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) return [];
|
||
|
||
return [buildOccurrence(at, time, schedule)];
|
||
}
|
||
|
||
/**
|
||
* Advance from one canonical rolling occurrence to the next grid occurrence
|
||
* without pretending a skip/auto-miss was a new intake anchor. Used only after
|
||
* the current exact occurrence is resolved; unresolved recurrence remains the
|
||
* single `lastIntakeAt + N` slot.
|
||
*/
|
||
export function advanceRollingOccurrence(
|
||
schedule: CanonicalSchedule,
|
||
occurrence: Occurrence,
|
||
ctx: RecurrenceContext,
|
||
): Occurrence | null {
|
||
const n = schedule.rollingIntervalDays;
|
||
if (n === null || n <= 0) return null;
|
||
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
|
||
const nextDay = new Date(occurrence.at.getTime() + n * DAY_MS);
|
||
const at = applyTimeOfDayToDate(nextDay, time, ctx.timeZone);
|
||
if (
|
||
ctx.medication.endsOn &&
|
||
at.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||
) {
|
||
return null;
|
||
}
|
||
return buildOccurrence(at, time, schedule);
|
||
}
|
||
|
||
/**
|
||
* v1.13.x compliance — RETROSPECTIVE rolling expansion.
|
||
*
|
||
* `expandRolling` is forward-only by contract (the projector + reminder
|
||
* worker depend on it emitting ONLY the immediately-next slot). That is
|
||
* correct for "what's next" surfaces but wrong for the compliance grid,
|
||
* which must reconstruct the *historical* expected-dose slots a rolling
|
||
* cadence implies over a trailing window so a faithfully-adherent weekly
|
||
* injection reads its true rate instead of the vacuous 100%-or-0% flip
|
||
* the forward-only path produced.
|
||
*
|
||
* Medically-correct reading: a rolling "every N days from last dose"
|
||
* cadence re-anchors on each intake, so the retrospective expected grid
|
||
* *is* the observed intake history (each logged dose is exactly one
|
||
* satisfied expected slot) PLUS:
|
||
*
|
||
* - back-filled `missed` slots for genuinely skipped whole cycles: when
|
||
* two consecutive intakes are more than `1.5 · N` apart the user
|
||
* skipped one or more cycles → emit a slot at `prevIntake + k·N` for
|
||
* each whole cycle the gap spans (the `1.5·N` tolerance gate means a
|
||
* dose logged a day late never synthesizes a phantom miss); and
|
||
* - the single forward next-due slot, emitted ONLY when it is past-due
|
||
* (`nextDue ≤ now`). A not-yet-due forward slot is `upcoming` and is
|
||
* excluded so the open current cycle never counts against the
|
||
* denominator.
|
||
*
|
||
* Because the back-filled / forward slots carry no nearby intake, they
|
||
* pair to nothing in `pairDoses` and read `missed`; the per-intake slots
|
||
* are anchored AT the intake instant so they pair (distance 0) and read
|
||
* `taken`. The same grid feeds the count-only callers
|
||
* (`expectedSlotsBetween` / `expectedSlotCountForDay`) so numerator,
|
||
* denominator, and the heatmap `due` flags route through ONE expansion
|
||
* (the v1.7.3 B15 numerator/denominator-convergence rule).
|
||
*
|
||
* Kept here (not folded into `expandRolling`) so the engine's forward
|
||
* contract is untouched; compliance opts in explicitly via
|
||
* `expandRollingRetrospective`.
|
||
*
|
||
* @param intakeInstants Non-skipped intake instants for THIS medication
|
||
* (ascending or unsorted — sorted internally). The compliance layer
|
||
* supplies these; the engine has no DB access.
|
||
* @param now The wall-clock anchor (the compliance window's `to`-side
|
||
* "now"), used to decide whether the forward next-due slot is past-due.
|
||
*/
|
||
export function expandRollingRetrospective(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
from: Date,
|
||
to: Date,
|
||
intakeInstants: Date[],
|
||
now: Date,
|
||
): Occurrence[] {
|
||
const n = schedule.rollingIntervalDays;
|
||
if (n === null || n <= 0) return [];
|
||
|
||
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
|
||
const cycleMs = n * DAY_MS;
|
||
// Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little
|
||
// late). Only a gap strictly beyond this synthesizes skipped cycles.
|
||
const gapToleranceMs = 1.5 * cycleMs;
|
||
const endsCap = ctx.medication.endsOn
|
||
? endOfUtcDay(ctx.medication.endsOn).getTime()
|
||
: Infinity;
|
||
|
||
const inWindow = (at: Date): boolean =>
|
||
at.getTime() >= from.getTime() &&
|
||
at.getTime() <= to.getTime() &&
|
||
at.getTime() <= endsCap;
|
||
|
||
const slots: Occurrence[] = [];
|
||
|
||
// Each logged intake is a satisfied expected slot at its own instant.
|
||
// Sort ascending so the gap walk between consecutive intakes is correct.
|
||
const sorted = [...intakeInstants].sort((a, b) => a.getTime() - b.getTime());
|
||
for (const instant of sorted) {
|
||
if (inWindow(instant)) {
|
||
slots.push(buildOccurrence(instant, time, schedule));
|
||
}
|
||
}
|
||
|
||
// Back-fill missed slots for genuinely skipped whole cycles between
|
||
// consecutive intakes (gap > 1.5·N). The synthesized slots land at the
|
||
// schedule's time-of-day on each skipped cycle's day so they read as a
|
||
// real expected dose in the user's timezone.
|
||
for (let i = 1; i < sorted.length; i++) {
|
||
const prev = sorted[i - 1];
|
||
const next = sorted[i];
|
||
const gap = next.getTime() - prev.getTime();
|
||
if (gap <= gapToleranceMs) continue;
|
||
// Number of whole cycles the user skipped: floor(gap/N) − 1 missed
|
||
// cycles sit strictly between the two intakes.
|
||
const cyclesInGap = Math.floor(gap / cycleMs);
|
||
for (let k = 1; k < cyclesInGap; k++) {
|
||
const anchor = new Date(prev.getTime() + k * cycleMs);
|
||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||
if (inWindow(at)) {
|
||
slots.push(buildOccurrence(at, time, schedule));
|
||
}
|
||
}
|
||
}
|
||
|
||
// The single forward next-due slot — emitted as a missed slot ONLY when
|
||
// the open cycle is GENUINELY overdue, i.e. `now` is more than half a
|
||
// cycle past `nextDue`. A dose merely a little late (within `0.5·N`) is
|
||
// the current open cycle still in its window — it must NOT synthesize a
|
||
// phantom miss (the N-tolerance gate). A not-yet-due forward slot
|
||
// (`nextDue > now`) is excluded entirely (upcoming → out of the
|
||
// denominator). This keeps the open current cycle off the percentage rows
|
||
// until it is unambiguously missed; the `currentCycle` descriptor carries
|
||
// the softer on_track / due / missed state for the card.
|
||
const forwardToleranceMs = cycleMs / 2;
|
||
const lastInstant =
|
||
sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt;
|
||
const nextDue =
|
||
lastInstant !== null
|
||
? new Date(lastInstant.getTime() + cycleMs)
|
||
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
|
||
if (now.getTime() - nextDue.getTime() > forwardToleranceMs) {
|
||
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
|
||
// Dedupe: a forward slot already represented by a logged intake (the
|
||
// intake re-anchored exactly N days out) must not double-count.
|
||
const alreadyPresent = slots.some(
|
||
(s) => Math.abs(s.at.getTime() - at.getTime()) < cycleMs / 2,
|
||
);
|
||
if (inWindow(at) && !alreadyPresent) {
|
||
slots.push(buildOccurrence(at, time, schedule));
|
||
}
|
||
}
|
||
|
||
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Dispatch — RRULE
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
function expandRrule(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
from: Date,
|
||
to: Date,
|
||
): Occurrence[] {
|
||
const rruleStr = schedule.rrule;
|
||
if (!rruleStr) return [];
|
||
|
||
const dtstart = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||
const dtstartLine = `DTSTART:${formatUtcBasic(startOfUtcDay(dtstart))}`;
|
||
// Skip the engine-side UNTIL suffix when the user's RRULE already
|
||
// bounds the recurrence with COUNT or UNTIL — RFC 5545 forbids both
|
||
// (and any two-UNTIL collision), and RRule.fromString throws on the
|
||
// duplicate, silently collapsing the schedule to zero slots.
|
||
const userBoundsRecurrence = /(?:^|;)(?:COUNT|UNTIL)=/.test(rruleStr);
|
||
const untilSuffix =
|
||
ctx.medication.endsOn && !userBoundsRecurrence
|
||
? `;UNTIL=${formatUtcBasic(endOfUtcDay(ctx.medication.endsOn))}`
|
||
: "";
|
||
const full = `${dtstartLine}\nRRULE:${rruleStr}${untilSuffix}`;
|
||
|
||
let rule: RRule;
|
||
try {
|
||
rule = RRule.fromString(full);
|
||
} catch {
|
||
annotate({
|
||
action: { name: "medication.recurrence.parse_error" },
|
||
meta: { rrule: rruleStr },
|
||
});
|
||
return [];
|
||
}
|
||
|
||
// Walk a generously padded day-anchor window so a per-day timesOfDay
|
||
// expansion still hits the requested [from, to] after the time-of-day
|
||
// is applied. The rrule lib's day-anchored dates land at midnight UTC
|
||
// of the BYDAY/BYMONTHDAY day; the time-of-day might push back across
|
||
// a day boundary in some timezones, so widen the window by 2 days on
|
||
// each side.
|
||
const padded = 2 * DAY_MS;
|
||
const after = new Date(from.getTime() - padded);
|
||
const before = new Date(to.getTime() + padded);
|
||
const dayAnchors = rule.between(after, before, true);
|
||
|
||
const times = effectiveTimesOfDay(schedule);
|
||
const slots: Occurrence[] = [];
|
||
for (const anchor of dayAnchors) {
|
||
for (const time of times) {
|
||
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
|
||
if (at.getTime() < from.getTime()) continue;
|
||
if (at.getTime() > to.getTime()) continue;
|
||
slots.push(buildOccurrence(at, time, schedule));
|
||
}
|
||
}
|
||
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Dispatch — legacy fallback
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
function expandLegacy(
|
||
schedule: CanonicalSchedule,
|
||
ctx: RecurrenceContext,
|
||
from: Date,
|
||
to: Date,
|
||
): Occurrence[] {
|
||
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
|
||
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
|
||
const anchorWeekStartUtc = startOfUtcWeek(anchor).getTime();
|
||
const times = effectiveTimesOfDay(schedule);
|
||
|
||
const slots: Occurrence[] = [];
|
||
// Iterate every UTC day in [from-1, to+1] to cover any time-of-day
|
||
// that might land in [from, to] after applying the local-tz HH:mm.
|
||
const start = startOfUtcDay(new Date(from.getTime() - DAY_MS));
|
||
const end = startOfUtcDay(new Date(to.getTime() + DAY_MS));
|
||
const startsOnFloor = ctx.medication.startsOn
|
||
? startOfUtcDay(ctx.medication.startsOn).getTime()
|
||
: null;
|
||
for (
|
||
let day = start;
|
||
day.getTime() <= end.getTime();
|
||
day = new Date(day.getTime() + DAY_MS)
|
||
) {
|
||
// startsOn floor — every other dispatch tier honours it; the
|
||
// legacy walker used to iterate from creation regardless, so a
|
||
// legacy-shape schedule with a future startsOn emitted historical
|
||
// slots. Mirror the endsOn cap below.
|
||
if (startsOnFloor !== null && day.getTime() < startsOnFloor) {
|
||
continue;
|
||
}
|
||
// Day-of-week filter (empty = every day). Use the user's
|
||
// timezone weekday so the legacy "Mon = 1" encoding aligns with
|
||
// the user's local-Mon, not UTC-Mon.
|
||
const localWeekday = wallClockInTz(day, ctx.timeZone).weekday;
|
||
if (
|
||
recurrence.daysOfWeek.length > 0 &&
|
||
!recurrence.daysOfWeek.includes(localWeekday)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
// Multi-week interval — the fix vs the legacy worker, which
|
||
// silently ignored intervalWeeks.
|
||
if (recurrence.intervalWeeks > 1) {
|
||
const dayWeekStart = startOfUtcWeek(day).getTime();
|
||
const weeksFromAnchor = Math.round(
|
||
(dayWeekStart - anchorWeekStartUtc) / WEEK_MS,
|
||
);
|
||
const phase =
|
||
((weeksFromAnchor % recurrence.intervalWeeks) +
|
||
recurrence.intervalWeeks) %
|
||
recurrence.intervalWeeks;
|
||
if (phase !== 0) continue;
|
||
}
|
||
|
||
// endsOn cap.
|
||
if (
|
||
ctx.medication.endsOn &&
|
||
day.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
for (const time of times) {
|
||
const at = applyTimeOfDayToDate(day, time, ctx.timeZone);
|
||
if (at.getTime() < from.getTime()) continue;
|
||
if (at.getTime() > to.getTime()) continue;
|
||
slots.push(buildOccurrence(at, time, schedule));
|
||
}
|
||
}
|
||
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Shared helpers
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] {
|
||
return schedule.timesOfDay.length > 0
|
||
? schedule.timesOfDay
|
||
: [schedule.windowStart];
|
||
}
|
||
|
||
/**
|
||
* Apply an "HH:mm" time-of-day to `day` in the user's IANA timezone,
|
||
* returning the corresponding UTC instant. DST-aware via the two-pass
|
||
* solver pattern from `medication-schedule.ts`.
|
||
*
|
||
* `day` is interpreted as "the day in the user's timezone at which
|
||
* the time-of-day should land" — we read its wall-clock Y/M/D in the
|
||
* target zone, then materialise the instant for that Y/M/D + H/M in
|
||
* that zone. So passing midnight-UTC on the spring-forward day with
|
||
* Europe/Berlin gives the local-Berlin day, not the previous-Berlin
|
||
* day even if the UTC midnight technically falls before the local
|
||
* day boundary.
|
||
*/
|
||
function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
|
||
const [hStr, mStr] = hhmm.split(":");
|
||
const h = Number(hStr);
|
||
const m = Number(mStr);
|
||
if (!Number.isFinite(h) || !Number.isFinite(m)) return day;
|
||
|
||
const parts = wallClockInTz(day, tz);
|
||
let guess = new Date(
|
||
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0),
|
||
);
|
||
for (let i = 0; i < 2; i++) {
|
||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||
guess = new Date(
|
||
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0) -
|
||
offsetMin * 60_000,
|
||
);
|
||
}
|
||
return guess;
|
||
}
|
||
|
||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||
const parts = wallClockInTz(date, tz);
|
||
const asIfUtc = Date.UTC(
|
||
parts.year,
|
||
parts.month - 1,
|
||
parts.day,
|
||
parts.hour,
|
||
parts.minute,
|
||
parts.second,
|
||
);
|
||
return Math.round((asIfUtc - date.getTime()) / 60_000);
|
||
}
|
||
|
||
function buildOccurrence(
|
||
at: Date,
|
||
timeOfDay: string,
|
||
schedule: CanonicalSchedule,
|
||
): Occurrence {
|
||
return {
|
||
scheduleId: schedule.id,
|
||
at,
|
||
timeOfDay,
|
||
graceUntil: new Date(at.getTime() + graceWindowMs(schedule)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve the grace window for an occurrence:
|
||
*
|
||
* - `reminderGraceMinutes` when set on the schedule
|
||
* - else `windowEnd - windowStart` span (in minutes)
|
||
* - else `DEFAULT_GRACE_MINUTES` (60) when the legacy fields agree
|
||
* (single-time window like "08:00..08:00")
|
||
*
|
||
* Handles overnight legacy windows (`windowEnd < windowStart`) by
|
||
* pushing windowEnd to the next day.
|
||
*/
|
||
function graceWindowMs(schedule: CanonicalSchedule): number {
|
||
if (schedule.reminderGraceMinutes !== null) {
|
||
return schedule.reminderGraceMinutes * ONE_MINUTE_MS;
|
||
}
|
||
const startMin = hhmmToMinutesOrNull(schedule.windowStart);
|
||
const endMin = hhmmToMinutesOrNull(schedule.windowEnd);
|
||
if (startMin === null || endMin === null) {
|
||
return DEFAULT_GRACE_MINUTES * ONE_MINUTE_MS;
|
||
}
|
||
let span = endMin - startMin;
|
||
if (span < 0) span += 24 * 60; // overnight window
|
||
if (span === 0) span = DEFAULT_GRACE_MINUTES;
|
||
return span * ONE_MINUTE_MS;
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// UTC date helpers (RRULE day-anchor + endsOn cap arithmetic)
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
function startOfUtcDay(d: Date): Date {
|
||
return new Date(
|
||
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0),
|
||
);
|
||
}
|
||
|
||
function endOfUtcDay(d: Date): Date {
|
||
return new Date(
|
||
Date.UTC(
|
||
d.getUTCFullYear(),
|
||
d.getUTCMonth(),
|
||
d.getUTCDate(),
|
||
23,
|
||
59,
|
||
59,
|
||
999,
|
||
),
|
||
);
|
||
}
|
||
|
||
function startOfUtcWeek(d: Date): Date {
|
||
const midnight = startOfUtcDay(d);
|
||
const weekday = midnight.getUTCDay(); // 0 = Sun
|
||
return new Date(midnight.getTime() - weekday * DAY_MS);
|
||
}
|
||
|
||
/** RFC 5545 basic-format UTC instant: `YYYYMMDDTHHMMSSZ`. */
|
||
function formatUtcBasic(d: Date): string {
|
||
const pad = (n: number, w = 2): string => String(n).padStart(w, "0");
|
||
return (
|
||
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
||
);
|
||
}
|