mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
d0fbdd388d
commit
2de9bccf9d
3 files changed
+163
-34
No files matched your search
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user