From 2de9bccf9dbda94b89d5ca64bf2fcfb1f37d1a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:33:17 +0200 Subject: [PATCH] fix(01-18): retain rolling occurrence through due band - preserve prior rolling identity independently of local-day flooring - scope exact resolution by schedule and era while advancing resolved grids - include open rolling slots for display without widening compliance bands --- src/lib/medications/scheduling/band-minter.ts | 25 ++++- src/lib/medications/scheduling/next-due.ts | 99 ++++++++++++++++--- src/lib/medications/scheduling/recurrence.ts | 73 ++++++++++---- 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index 0acbc3e7a..b0ff3f352 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -112,6 +112,13 @@ export interface BuildBandsInput { * other cadence. Pre-fetched by the caller — the minter has no DB access. */ intakeInstants?: Date[]; + /** + * Include the single canonical unresolved rolling occurrence even before + * the compliance engine's half-cycle "genuinely missed" threshold. + * Display-due/reminder attribution opt in; retrospective compliance keeps + * its existing denominator semantics. + */ + includeOpenRollingOccurrence?: boolean; } export interface BandMinterResult { @@ -161,7 +168,7 @@ export function buildBandsForMedication( // Rolling (GLP-1) — retrospective bands anchored AT each logged intake. if (schedule.rollingIntervalDays !== null) { - const occ = expandRollingRetrospective( + const retrospective = expandRollingRetrospective( schedule, ctx, range.from, @@ -169,6 +176,20 @@ export function buildBandsForMedication( input.intakeInstants ?? [], now, ); + const open = input.includeOpenRollingOccurrence + ? occurrencesBetween(schedule, range.from, range.to, ctx) + : []; + const occ = [...retrospective]; + for (const candidate of open) { + if ( + !occ.some( + (existing) => existing.at.getTime() === candidate.at.getTime(), + ) + ) { + occ.push(candidate); + } + } + occ.sort((a, b) => a.at.getTime() - b.at.getTime()); // The canonical rolling case is a once-weekly+ injection (day-scale). A // degenerate ≤1-day rolling interval is effectively daily, so a ±1-day // on-time window would over-widen and let one take claim adjacent slots — @@ -211,6 +232,7 @@ export function buildBandsForSchedules(input: { windowConfig?: DoseWindowConfig; /** Per-medication intake instants (the rolling anchors). */ intakeInstants?: Date[]; + includeOpenRollingOccurrence?: boolean; }): ScheduleBandGroup[] { return input.schedules.map((schedule) => { const result = buildBandsForMedication({ @@ -222,6 +244,7 @@ export function buildBandsForSchedules(input: { now: input.now, windowConfig: input.windowConfig, intakeInstants: input.intakeInstants, + includeOpenRollingOccurrence: input.includeOpenRollingOccurrence, }); return { scheduleId: schedule.id, ...result }; }); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index c38ae1ffb..16d5a6e53 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -16,7 +16,10 @@ import { type WorkerMedicationRow, type WorkerScheduleRow, } from "@/lib/medications/scheduling/worker-helpers"; -import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence"; +import { + advanceRollingOccurrence, + nextOccurrenceAfter, +} from "@/lib/medications/scheduling/recurrence"; import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; @@ -51,6 +54,12 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; export interface ResolvedSlotMark { at: Date; slotAnchored: boolean; + /** Canonical schedule owner when attribution could identify it. */ + scheduleId?: string; + /** When the action was made; used to isolate replacement schedule eras. */ + actionAt?: Date; + /** Diagnostic state carried by exact occurrence-aware callers. */ + status?: "taken" | "skipped" | "autoMissed"; } /** @@ -73,10 +82,21 @@ export function toResolvedSlotMark(row: { function buildIsResolved( resolved: ResolvedSlotMark[], + identity?: { scheduleId: string; eraStart?: Date | null }, ): (slotAt: Date) => boolean { return (slotAt: Date): boolean => { const slot = slotAt.getTime(); for (const r of resolved) { + if (r.scheduleId !== undefined && r.scheduleId !== identity?.scheduleId) { + continue; + } + if ( + identity?.eraStart && + r.actionAt && + r.actionAt.getTime() < identity.eraStart.getTime() + ) { + continue; + } const radius = r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS; @@ -118,29 +138,57 @@ export function computeNextDueAt(input: { * anchoring shape; ad-hoc rows only resolve on a near-exact match. */ resolvedSlots?: ResolvedSlotMark[]; + eraStart?: Date | null; }): Date | null { + return computeNextDueCandidate(input)?.at ?? null; +} + +interface DueCandidate { + at: Date; + scheduleId: string; +} + +function computeNextDueCandidate(input: { + medication: WorkerMedicationRow; + schedules: WorkerScheduleRow[]; + now: Date; + userTz: string; + lastIntakeAt: Date | null; + resolvedSlots?: ResolvedSlotMark[]; + eraStart?: Date | null; +}): DueCandidate | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const isResolved = buildIsResolved(input.resolvedSlots ?? []); - const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); - let earliest: Date | null = null; + let earliest: DueCandidate | null = null; for (const schedule of schedules) { const canonical = buildCanonicalSchedule(schedule); + const isResolved = buildIsResolved(input.resolvedSlots ?? [], { + scheduleId: canonical.id, + eraStart: input.eraStart, + }); // Walk forward past slots the user has already resolved. Bounded so a // fully-logged-ahead history can't spin. let after = now; + let next = nextOccurrenceAfter(canonical, after, ctx); for (let step = 0; step < 64; step++) { - const next = nextOccurrenceAfter(canonical, after, ctx); if (next === null) break; if (!isResolved(next.at)) { - if (earliest === null || next.at.getTime() < earliest.getTime()) { - earliest = next.at; + if ( + earliest === null || + next.at.getTime() < earliest.at.getTime() + ) { + earliest = { at: next.at, scheduleId: canonical.id }; } break; } + if (canonical.rollingIntervalDays !== null) { + next = advanceRollingOccurrence(canonical, next, ctx); + continue; + } after = new Date(next.at.getTime()); + next = nextOccurrenceAfter(canonical, after, ctx); } } return earliest; @@ -149,6 +197,8 @@ export function computeNextDueAt(input: { /** The instant a medication card should surface, plus its canonical band state. */ export interface DisplayDue { at: Date; + /** Canonical owner of this occurrence (sibling schedules stay distinct). */ + scheduleId?: string; /** * Earliest instant at which the canonical attribution band accepts this * slot. This is cadence- and per-dose-window-aware. @@ -196,23 +246,27 @@ export function computeDisplayDue( ): DisplayDue | null { const open = findOpenOverdueSlot(input); if (open) return { ...open, overdue: true }; - const next = computeNextDueAt(input); + const next = computeNextDueCandidate(input); if (!next) return null; + // A rolling occurrence whose actionable band has closed remains the same + // unresolved identity until a real take re-anchors it. Do not relabel that + // stale past occurrence as a non-overdue "next" dose. + if (next.at.getTime() < input.now.getTime()) return null; return { - at: next, - availableFrom: findAvailabilityStart(input, next) ?? next, + at: next.at, + scheduleId: next.scheduleId, + availableFrom: + findAvailabilityStart(input, next.at, next.scheduleId) ?? next.at, overdue: false, }; } function findOpenOverdueSlot( input: ComputeDisplayDueInput, -): { at: Date; availableFrom: Date } | null { +): { at: Date; availableFrom: Date; scheduleId: string } | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const isResolved = buildIsResolved(input.resolvedSlots ?? []); - let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { floor = input.eraStart; @@ -220,8 +274,16 @@ function findOpenOverdueSlot( if (floor.getTime() >= now.getTime()) return null; const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); - let latest: { at: Date; availableFrom: Date } | null = null; + let latest: { + at: Date; + availableFrom: Date; + scheduleId: string; + } | null = null; for (const schedule of schedules) { + const isResolved = buildIsResolved(input.resolvedSlots ?? [], { + scheduleId: schedule.id, + eraStart: input.eraStart, + }); const { bands } = buildBandsForMedication({ medication, schedule: buildCanonicalSchedule(schedule), @@ -230,6 +292,7 @@ function findOpenOverdueSlot( range: { from: floor, to: now }, now, intakeInstants: lastIntakeAt ? [lastIntakeAt] : [], + includeOpenRollingOccurrence: true, }); for (const band of bands) { const anchor = band.at.getTime(); @@ -247,7 +310,11 @@ function findOpenOverdueSlot( (anchor === latest.at.getTime() && band.earlyStart.getTime() < latest.availableFrom.getTime()) ) { - latest = { at: band.at, availableFrom: band.earlyStart }; + latest = { + at: band.at, + availableFrom: band.earlyStart, + scheduleId: schedule.id, + }; } } } @@ -257,6 +324,7 @@ function findOpenOverdueSlot( function findAvailabilityStart( input: ComputeDisplayDueInput, at: Date, + scheduleId?: string, ): Date | null { const ctx = buildRecurrenceContext({ medication: input.medication, @@ -267,6 +335,7 @@ function findAvailabilityStart( let earliest: Date | null = null; for (const scheduleRow of input.schedules) { + if (scheduleId !== undefined && scheduleRow.id !== scheduleId) continue; const schedule = buildCanonicalSchedule(scheduleRow); const intakeInstants = schedule.rollingIntervalDays === null diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index f05322d56..f5236ffab 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -150,6 +150,8 @@ export interface RecurrenceContext { } 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 @@ -306,18 +308,17 @@ export function nextOccurrenceAfter( // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { const dayFloor = startOfLocalDayInTz(after, ctx.timeZone); - // With no intake logged the first dose anchors at `startsOn ?? createdAt`. - // When that anchor is on a PRIOR calendar day the dose is overdue by ≥1 - // day and must still surface ("take now"); flooring only to the start of - // the user's current day dropped it out of existence (null next-due AND - // no reminder). Reach the floor back to the first-dose instant in that - // case so a multi-day-overdue start dose is returned. After an intake the - // re-anchor (`lastIntakeAt + N`) is always in the future, so the dayFloor - // is the correct, tighter bound there. - const floor = - ctx.lastIntakeAt === null - ? new Date(Math.min(dayFloor.getTime(), firstRollingDoseDayFloor(ctx))) - : dayFloor; + // 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), + ), + ); const slots = expandRolling(schedule, ctx, floor, limit); for (const s of slots) { if (s.at.getTime() < floor.getTime()) continue; @@ -434,8 +435,21 @@ function expandOneShot( * 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 firstRollingDoseDayFloor(ctx: RecurrenceContext): number { - const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; +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(); } @@ -450,10 +464,7 @@ function expandRolling( // 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 = - ctx.lastIntakeAt !== null - ? new Date(ctx.lastIntakeAt.getTime() + n * DAY_MS) - : (ctx.medication.startsOn ?? ctx.medication.createdAt); + const nextDue = rollingDoseAnchor(schedule, ctx); // endsOn cap. if ( @@ -470,6 +481,31 @@ function expandRolling( 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. * @@ -803,6 +839,7 @@ function buildOccurrence( schedule: CanonicalSchedule, ): Occurrence { return { + scheduleId: schedule.id, at, timeOfDay, graceUntil: new Date(at.getTime() + graceWindowMs(schedule)),