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
This commit is contained in:
Marc-André Bombeck committed 2026-07-29 14:33:17 +02:00
1 parent d0fbdd388d
commit 2de9bccf9d
3 files changed
+163 -34

No files matched your search

+24 -1
View File
@@ -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 };
});
+84 -15
View File
@@ -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
+55 -18
View File
@@ -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)),