mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(medications): resolve one occurrence per intake row, the nearest
A taken, skipped or auto-missed row counted as resolving every occurrence within six hours of its anchor. The radius was sized for the twice-daily 12 h gap, so on any schedule whose neighbouring doses sit closer than that (19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row resolved the second occurrence too: the next-due walk jumped to tomorrow, the card offered tomorrow's first dose, and a skip pressed next landed there. The second dose could not be logged from the card at all. Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take left the 23:59 row pending and the skip was written to tomorrow's 19:49. The compliance-journey e2e spec exposed the same thing in every run that started after noon, when its twin slot is 23:59. Rule, in slot-resolution.ts and shared by the list's next-due, the card's overdue search and the compliance current-cycle descriptor: a row resolves exactly one occurrence, the one nearest its anchor, and only within the drift radius. An exact match short-circuits; a drifted anchor is measured against every occurrence the schedule emits within the radius and loses to any nearer one (ties go to the earlier occurrence), so the effective reach is min(radius, half the gap to the neighbour) and a sibling can never be swallowed. Rolling cadences skip the nearest test, their occurrences are at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the cycle descriptor as well, so the card's due slot and its cycle state agree. The drift tolerance itself stays: it covers a row whose anchor sits off the canonical instant, a pre-snap row or an instant that moved under it across a DST shift, which is what the radius was introduced for in v1.15.10.
This commit is contained in:
1 parent
80171436e3
commit
95140e5aa7
6 files changed
+384
-52
No files matched your search
@@ -38,6 +38,19 @@ operator's say-so, and the test button says why a private target was refused.
|
||||
open it, since telling the user to ask the operator would only send them
|
||||
in a circle.
|
||||
|
||||
- **The second of two close doses can be logged from the card again.** On a
|
||||
schedule whose neighbouring doses sit closer than twelve hours, 19:49 and
|
||||
23:59 say, or 08:00 and 12:00, taking the first dose made the card offer
|
||||
tomorrow's first dose, and a skip pressed next landed on tomorrow. A
|
||||
taken, skipped or auto-missed row counted as resolving every occurrence
|
||||
within six hours of its anchor, a tolerance sized for the twice-daily
|
||||
gap, so the first row swallowed its sibling and the next-due walk went
|
||||
past it. A row now resolves exactly one occurrence, the one nearest its
|
||||
anchor, within that tolerance; the compliance cycle on the card follows
|
||||
the same rule, so the due slot and the cycle state cannot disagree. The
|
||||
tolerance still covers a row whose anchor drifted from the canonical
|
||||
instant, a pre-snap row or a DST shift, which is what it was for.
|
||||
|
||||
## [1.38.16] — 2026-09-10
|
||||
|
||||
The API says what it refused and why, on every route, and the admin page
|
||||
|
||||
@@ -1673,6 +1673,34 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
expect(display.currentCycle.hasClosedCycles).toBe(true);
|
||||
});
|
||||
|
||||
it("currentCycle — taking the first of two close doses leaves the second due", () => {
|
||||
// 19:49 + 23:59 Berlin (CEST): 17:49Z + 21:59Z. The 19:49 take used to
|
||||
// resolve the 23:59 slot too (flat ±6 h radius), so the open cycle
|
||||
// pointed at tomorrow's 19:49 while tonight's dose was still due.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "19:49",
|
||||
windowEnd: "23:59",
|
||||
daysOfWeek: null,
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["19:49", "23:59"],
|
||||
},
|
||||
];
|
||||
const slot = new Date("2025-06-15T17:49:00Z");
|
||||
const takenAt = new Date("2025-06-15T17:49:12Z");
|
||||
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
|
||||
const display = buildComplianceDisplay(
|
||||
events,
|
||||
schedules,
|
||||
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
|
||||
{ now: new Date("2025-06-15T18:00:00Z") },
|
||||
);
|
||||
expect(display.currentCycle.state).toBe("on_track");
|
||||
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
|
||||
"2025-06-15T21:59:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("currentCycle — rolling weekly overdue past grace → missed", () => {
|
||||
// v1.13.x Fix 4 — last shot ~10 days ago (cadence 7) → next due was 3
|
||||
// days ago, well past grace → the open cycle is `missed` (the only red
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
|
||||
import {
|
||||
nextOccurrenceAfter,
|
||||
type CanonicalSchedule,
|
||||
type Occurrence,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import {
|
||||
ADHOC_RESOLVE_EPSILON_MS,
|
||||
anchorResolvesOccurrence,
|
||||
RESOLVE_RADIUS_MS,
|
||||
} from "@/lib/medications/scheduling/slot-resolution";
|
||||
import {
|
||||
lastNonSkippedTakenAt,
|
||||
type ComplianceIntakeInstant,
|
||||
@@ -64,40 +71,42 @@ export interface CurrentCycle {
|
||||
* selection: a window holding zero expected (closed) doses means the
|
||||
* percentage rows are vacuous and the card should show a neutral state.
|
||||
*/
|
||||
/**
|
||||
* v1.15.10 — half-window an intake may sit from a slot's canonical instant
|
||||
* and still count as "resolving" that slot. The intake write paths snap
|
||||
* `scheduledFor` to the exact engine slot instant, so an exact (or
|
||||
* sub-minute) match is the common case; the ±6h tolerance also catches an
|
||||
* off-time take on a dense intraday cadence (e.g. a 07:00 dose logged 09:13)
|
||||
* whose snapped slot is the 07:00 row but whose raw instant we compare
|
||||
* defensively. It is the same ±half-gap radius the cadence pairer uses for a
|
||||
* 12h-gap (twice-daily) med, floored so a single-dose-a-day cadence still
|
||||
* matches its one slot.
|
||||
*/
|
||||
const OPEN_DOSE_RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.15.10 — true when an intake event has already resolved the slot at
|
||||
* `slotAt`. A resolved slot is one the user took, deliberately skipped, or
|
||||
* the auto-miss cron flagged — any of which means the card must NOT keep
|
||||
* surfacing that slot as the next dose. Matches on the snapped `scheduledFor`
|
||||
* first (the canonical, exact path) and falls back to the take/skip instant
|
||||
* within the resolve radius for rows that predate the snap or drifted.
|
||||
* surfacing that slot as the next dose. Anchors on the snapped
|
||||
* `scheduledFor` (the canonical, exact path), falling back to the take
|
||||
* instant for a legacy row without one. A drifted anchor resolves its
|
||||
* nearest occurrence only, within the shared drift radius, so a sibling
|
||||
* slot closer than that radius is never swallowed; an ad-hoc row
|
||||
* (`scheduledFor === takenAt`) resolves only the slot it sits on. Same rule
|
||||
* as the list's next-due (`slot-resolution.ts`), so the cycle descriptor
|
||||
* and the card's due slot cannot disagree.
|
||||
*/
|
||||
function slotIsResolved(
|
||||
slotAt: Date,
|
||||
intakes: ComplianceIntakeInstant[],
|
||||
schedule: CanonicalSchedule,
|
||||
ctx: RecurrenceContext,
|
||||
): boolean {
|
||||
const slot = slotAt.getTime();
|
||||
for (const e of intakes) {
|
||||
const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null;
|
||||
if (!isResolved) continue;
|
||||
const ref = (e.scheduledFor ?? e.takenAt) as Date | undefined;
|
||||
if (!ref) continue;
|
||||
if (Math.abs(ref.getTime() - slot) <= OPEN_DOSE_RESOLVE_RADIUS_MS) {
|
||||
return true;
|
||||
}
|
||||
const adHoc =
|
||||
e.scheduledFor !== undefined &&
|
||||
e.takenAt !== null &&
|
||||
e.scheduledFor.getTime() === e.takenAt.getTime();
|
||||
const resolves = anchorResolvesOccurrence({
|
||||
anchor: ref,
|
||||
occurrenceAt: slotAt,
|
||||
radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS,
|
||||
schedule,
|
||||
ctx,
|
||||
});
|
||||
if (resolves) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -162,7 +171,7 @@ export function buildCurrentCycle(
|
||||
for (let step = 0; step < 64; step++) {
|
||||
const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx);
|
||||
if (!occ) break;
|
||||
if (!slotIsResolved(occ.at, allIntakes)) {
|
||||
if (!slotIsResolved(occ.at, allIntakes, canonical, recurrenceCtx)) {
|
||||
picked = occ;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -697,3 +697,197 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A resolved row stands for exactly ONE occurrence: the one nearest to its
|
||||
* anchor, and only within the drift radius. The former flat ±6 h radius
|
||||
* reached the sibling slot on any cadence closer than twelve hours, so the
|
||||
* first dose's row resolved the second occurrence too, the walk jumped to
|
||||
* tomorrow, and the second dose could never be logged from the card.
|
||||
*
|
||||
* Berlin is UTC+2 in June / September: 19:49 + 23:59 local are 17:49Z +
|
||||
* 21:59Z; 08:00 + 12:00 local are 06:00Z + 10:00Z.
|
||||
*/
|
||||
describe("resolved-slot marks — a row resolves only its nearest occurrence", () => {
|
||||
const medication = makeMedication();
|
||||
function closeSlots(timesOfDay: string[]): WorkerScheduleRow {
|
||||
return {
|
||||
id: "sched-close",
|
||||
windowStart: timesOfDay[0],
|
||||
windowEnd: timesOfDay[timesOfDay.length - 1],
|
||||
daysOfWeek: null,
|
||||
timesOfDay,
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
};
|
||||
}
|
||||
|
||||
it("19:49 taken leaves 23:59 the same day due, not tomorrow's 19:49", () => {
|
||||
const now = d("2026-09-10T18:00:00Z"); // 20:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["19:49", "23:59"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-09-10T17:49:12Z"),
|
||||
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-09-10T21:59:00.000Z");
|
||||
});
|
||||
|
||||
it("the card offers the 23:59 slot after the 19:49 take (display due)", () => {
|
||||
const now = d("2026-09-10T18:00:00Z");
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [closeSlots(["19:49", "23:59"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-09-10T17:49:12Z"),
|
||||
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z");
|
||||
});
|
||||
|
||||
it("08:00 taken, 12:00 pending → next due is 12:00", () => {
|
||||
const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "12:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-06-10T06:03:00Z"),
|
||||
resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("every four hours: the first take leaves the second slot due", () => {
|
||||
const now = d("2026-06-10T05:00:00Z"); // 07:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [
|
||||
closeSlots(["02:00", "06:00", "10:00", "14:00", "18:00", "22:00"]),
|
||||
],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-06-10T04:02:00Z"),
|
||||
resolvedSlots: [
|
||||
mark(d("2026-06-10T00:00:00Z")), // 02:00 Berlin
|
||||
mark(d("2026-06-10T04:00:00Z")), // 06:00 Berlin
|
||||
],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-10T08:00:00.000Z"); // 10:00 Berlin
|
||||
});
|
||||
|
||||
it("a drifted slot-anchored row still resolves its nearest occurrence", () => {
|
||||
// The case the radius always protected: a row whose anchor sits off
|
||||
// the canonical instant (a pre-snap row, or an instant that moved
|
||||
// under it) resolves the occurrence it is nearest to.
|
||||
const now = d("2026-06-10T08:00:00Z"); // 10:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "20:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-06-10T07:13:00Z"),
|
||||
// Anchored 09:13 Berlin, 1 h 13 min off the 08:00 slot.
|
||||
resolvedSlots: [mark(d("2026-06-10T07:13:00Z"))],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-10T18:00:00.000Z"); // 20:00 Berlin
|
||||
});
|
||||
|
||||
it("a drifted row an hour off across a DST shift still resolves the slot", () => {
|
||||
// 2026-03-29 Berlin springs forward at 02:00; a 08:00 row computed in
|
||||
// the old offset sits at 07:00Z where the engine now says 06:00Z.
|
||||
const now = d("2026-03-29T07:30:00Z"); // 09:30 Berlin (CEST)
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "20:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-03-29T07:00:00Z"),
|
||||
resolvedSlots: [mark(d("2026-03-29T07:00:00Z"))],
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-03-29T18:00:00.000Z"); // 20:00 CEST
|
||||
});
|
||||
|
||||
it("a drifted row nearer the sibling resolves the sibling, not both", () => {
|
||||
// 08:00 + 12:00 Berlin. A skip anchored at 10:30 is nearer 12:00 than
|
||||
// 08:00, so 08:00 stays open overdue at 09:30 (its band runs to 12:00).
|
||||
const now = d("2026-06-10T07:30:00Z"); // 09:30 Berlin
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "12:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: null,
|
||||
resolvedSlots: [mark(d("2026-06-10T08:30:00Z"))], // 10:30 Berlin
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(true);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-10T06:00:00.000Z"); // 08:00 Berlin
|
||||
});
|
||||
|
||||
it("an ad-hoc row between two close slots resolves neither", () => {
|
||||
const adhoc = d("2026-06-10T08:30:00Z"); // 10:30 Berlin
|
||||
const now = d("2026-06-10T09:00:00Z"); // 11:00 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "12:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: adhoc,
|
||||
resolvedSlots: [
|
||||
mark(d("2026-06-10T06:00:00Z")),
|
||||
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
|
||||
],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin
|
||||
});
|
||||
|
||||
it("an ad-hoc row seconds off a close slot still resolves that slot only", () => {
|
||||
const adhoc = d("2026-06-10T09:59:30Z"); // 11:59:30 Berlin
|
||||
const now = d("2026-06-10T10:30:00Z"); // 12:30 Berlin
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "12:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: adhoc,
|
||||
resolvedSlots: [
|
||||
mark(d("2026-06-10T06:00:00Z")),
|
||||
toResolvedSlotMark({ scheduledFor: adhoc, takenAt: adhoc }),
|
||||
],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00
|
||||
});
|
||||
|
||||
it("the overdue search agrees: 23:59 stays open overdue after midnight", () => {
|
||||
const now = d("2026-09-10T22:30:00Z"); // 00:30 Berlin, next day
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [closeSlots(["19:49", "23:59"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-09-10T17:49:12Z"),
|
||||
resolvedSlots: [mark(d("2026-09-10T17:49:00Z"))],
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(true);
|
||||
expect(due!.at.toISOString()).toBe("2026-09-10T21:59:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -19,27 +19,16 @@ import {
|
||||
import {
|
||||
advanceRollingOccurrence,
|
||||
nextOccurrenceAfter,
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
|
||||
/**
|
||||
* v1.15.10 — radius an intake may sit from a slot's canonical instant and
|
||||
* still count as resolving it. Intake writes snap `scheduledFor` to the exact
|
||||
* engine slot instant, so the match is normally exact; the ±6h tolerance is
|
||||
* the same defensive half-gap the compliance open-dose search uses (a 12h-gap
|
||||
* twice-daily med splits cleanly at ±6h).
|
||||
*/
|
||||
const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
|
||||
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
|
||||
* slot it actually sits on (sub-minute drift absorbed); letting it use
|
||||
* the ±6h radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
|
||||
* tonight's 20:00 dose while the ledger still counted that slot missed.
|
||||
*/
|
||||
const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
|
||||
import {
|
||||
ADHOC_RESOLVE_EPSILON_MS,
|
||||
anchorResolvesOccurrence,
|
||||
RESOLVE_RADIUS_MS,
|
||||
} from "@/lib/medications/scheduling/slot-resolution";
|
||||
|
||||
/**
|
||||
* A row that resolves a slot (taken / deliberately skipped /
|
||||
@@ -47,9 +36,10 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
|
||||
*
|
||||
* `slotAnchored: false` marks the ad-hoc shape (`scheduledFor ===
|
||||
* takenAt`): such a row resolves a slot only on a near-exact anchor
|
||||
* match, never across the ±6h radius. Slot-anchored rows (the write
|
||||
* match, never across the drift radius. Slot-anchored rows (the write
|
||||
* paths snap their `scheduledFor` to the canonical slot instant) keep
|
||||
* the defensive radius.
|
||||
* the drift radius, and within it resolve their nearest occurrence only
|
||||
* (`anchorResolvesOccurrence`).
|
||||
*/
|
||||
export interface ResolvedSlotMark {
|
||||
at: Date;
|
||||
@@ -80,27 +70,40 @@ export function toResolvedSlotMark(row: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate over the canonical occurrences of ONE schedule: true when a
|
||||
* resolved row stands for the occurrence at `slotAt`. Each row resolves
|
||||
* its nearest occurrence only, so a sibling slot closer than the drift
|
||||
* radius is never swallowed (see `slot-resolution.ts` for the rule).
|
||||
*/
|
||||
function buildIsResolved(
|
||||
resolved: ResolvedSlotMark[],
|
||||
identity?: { scheduleId: string; eraStart?: Date | null },
|
||||
identity: {
|
||||
schedule: CanonicalSchedule;
|
||||
ctx: RecurrenceContext;
|
||||
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) {
|
||||
if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
identity?.eraStart &&
|
||||
identity.eraStart &&
|
||||
r.actionAt &&
|
||||
r.actionAt.getTime() < identity.eraStart.getTime()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const radius = r.slotAnchored
|
||||
? RESOLVE_RADIUS_MS
|
||||
: ADHOC_RESOLVE_EPSILON_MS;
|
||||
if (Math.abs(r.at.getTime() - slot) <= radius) return true;
|
||||
const resolves = anchorResolvesOccurrence({
|
||||
anchor: r.at,
|
||||
occurrenceAt: slotAt,
|
||||
radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS,
|
||||
schedule: identity.schedule,
|
||||
ctx: identity.ctx,
|
||||
});
|
||||
if (resolves) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -165,7 +168,8 @@ function computeNextDueCandidate(input: {
|
||||
for (const schedule of schedules) {
|
||||
const canonical = buildCanonicalSchedule(schedule);
|
||||
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
|
||||
scheduleId: canonical.id,
|
||||
schedule: canonical,
|
||||
ctx,
|
||||
eraStart: input.eraStart,
|
||||
});
|
||||
// Walk forward past slots the user has already resolved. Bounded so a
|
||||
@@ -278,13 +282,15 @@ function findOpenOverdueSlot(
|
||||
scheduleId: string;
|
||||
} | null = null;
|
||||
for (const schedule of schedules) {
|
||||
const canonical = buildCanonicalSchedule(schedule);
|
||||
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
|
||||
scheduleId: schedule.id,
|
||||
schedule: canonical,
|
||||
ctx,
|
||||
eraStart: input.eraStart,
|
||||
});
|
||||
const { bands } = buildBandsForMedication({
|
||||
medication,
|
||||
schedule: buildCanonicalSchedule(schedule),
|
||||
schedule: canonical,
|
||||
ctx,
|
||||
userTz,
|
||||
range: { from: floor, to: now },
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Which occurrence a resolved intake row (taken / deliberately skipped /
|
||||
* cron-auto-missed) stands for.
|
||||
*
|
||||
* The intake write paths snap a slot-anchored row's `scheduledFor` to the
|
||||
* engine's canonical occurrence instant, so row and occurrence normally meet
|
||||
* exactly. The tolerance beyond an exact match exists for a row whose anchor
|
||||
* drifted off the canonical instant: a row written before the snap, or an
|
||||
* instant that moved under it through a DST or schedule-time shift. From
|
||||
* v1.15.10 that tolerance was a flat ±6 h radius, sized for the twice-daily
|
||||
* 12 h gap. On any cadence whose neighbouring slots are closer than twelve
|
||||
* hours (19:49 + 23:59, 08:00 + 12:00, every four hours) the radius reached
|
||||
* the sibling: the first dose's row resolved the second occurrence too, the
|
||||
* next-due walk jumped to tomorrow, the card offered tomorrow's slot, and the
|
||||
* second dose could never be logged from the card.
|
||||
*
|
||||
* Rule: a row resolves exactly ONE occurrence, the one nearest to its anchor,
|
||||
* and only when that occurrence lies within the row's radius. An exact match
|
||||
* short-circuits; a drifted row is measured against every occurrence the
|
||||
* schedule emits within the radius around the anchor, and resolves the slot
|
||||
* under test only when no other occurrence is nearer (an exact tie goes to
|
||||
* the earlier occurrence). So the effective reach is `min(radius, half the
|
||||
* gap to the neighbouring occurrence)`, and a sibling can never be swallowed.
|
||||
* Rolling cadences emit at most one occurrence per interval day, which is
|
||||
* farther than any radius here, so the nearest test is skipped for them.
|
||||
*/
|
||||
import {
|
||||
occurrencesBetween,
|
||||
type CanonicalSchedule,
|
||||
type RecurrenceContext,
|
||||
} from "@/lib/medications/scheduling/recurrence";
|
||||
|
||||
/**
|
||||
* Drift a slot-anchored row may sit from a slot's canonical instant and
|
||||
* still count as resolving it, provided the slot is the row's nearest
|
||||
* occurrence.
|
||||
*/
|
||||
export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.16.9 — exact-match slop for an AD-HOC row. An ad-hoc take anchors
|
||||
* `scheduledFor = takenAt` on its own instant, so it can only resolve a
|
||||
* slot it actually sits on (sub-minute drift absorbed); letting it use
|
||||
* the drift radius hid genuinely-due slots — a 14:30 ad-hoc take resolved
|
||||
* tonight's 20:00 dose while the ledger still counted that slot missed.
|
||||
*/
|
||||
export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
|
||||
|
||||
export function anchorResolvesOccurrence(input: {
|
||||
/** The resolved row's anchor (`scheduledFor`, or the take instant). */
|
||||
anchor: Date;
|
||||
/** The canonical occurrence instant under test. */
|
||||
occurrenceAt: Date;
|
||||
/** Reach of this row: the drift radius, or the ad-hoc epsilon. */
|
||||
radiusMs: number;
|
||||
schedule: CanonicalSchedule;
|
||||
ctx: RecurrenceContext;
|
||||
}): boolean {
|
||||
const anchor = input.anchor.getTime();
|
||||
const slot = input.occurrenceAt.getTime();
|
||||
const distance = Math.abs(anchor - slot);
|
||||
if (distance === 0) return true;
|
||||
if (distance > input.radiusMs) return false;
|
||||
if (input.schedule.rollingIntervalDays !== null) return true;
|
||||
|
||||
// Drifted: the row stands for its nearest occurrence only. Any occurrence
|
||||
// strictly nearer than the slot under test, or equally near but earlier,
|
||||
// claims the row instead.
|
||||
const neighbours = occurrencesBetween(
|
||||
input.schedule,
|
||||
new Date(anchor - input.radiusMs),
|
||||
new Date(anchor + input.radiusMs),
|
||||
input.ctx,
|
||||
);
|
||||
for (const occurrence of neighbours) {
|
||||
const at = occurrence.at.getTime();
|
||||
if (at === slot) continue;
|
||||
const other = Math.abs(anchor - at);
|
||||
if (other < distance || (other === distance && at < slot)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user