mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
fix(medications): measure a row's nearest occurrence across sibling schedules
The nearest test enumerated only the schedule under test, and production rows carry no schedule identity. On a medication composed of sibling schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's own occurrences alone never showed that A's 08:00 is nearer: next-due, the display due and the compliance cycle all answered tomorrow's 08:00 at 09:00 local, the same swallow one level up. The neighbour set is now the union of every schedule's occurrences within the radius, memoised per row, since it depends on the row alone and a predicate asks about many slots. The rolling short-circuit still looks at the schedule under test only. The era filter in the next-due predicate compared a field no caller ever populated, so it is gone along with the test that pinned it; the era floor lives in the overdue search's band range and in the forward walk's start at now, as it always did in production. Tests: sibling schedules in both next-due and the compliance cycle, the exact tie between two slots, the 25-hour fall-back day (a pair across the repeated hour, and a row anchored in the old offset), and the ad-hoc epsilon case now stands on the ad-hoc row alone.
This commit is contained in:
1 parent
95140e5aa7
commit
550a7b87d7
6 files changed
+225
-62
No files matched your search
+2
-1
@@ -3,7 +3,8 @@
|
||||
## [1.38.17] — 2026-09-10
|
||||
|
||||
A webhook or ntfy target on your own network can be reached again, with the
|
||||
operator's say-so, and the test button says why a private target was refused.
|
||||
operator's say-so, the test button says why a private target was refused,
|
||||
and the second of two close doses can be logged from the card again.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -1701,6 +1701,41 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("currentCycle — a row on sibling schedule A's 08:00 leaves sibling B's 12:00 due", () => {
|
||||
// Two schedule rows on one medication (08:00 daily plus 12:00 daily).
|
||||
// Berlin (CEST): 06:00Z + 10:00Z. The row carries no schedule identity;
|
||||
// B's slot must lose to A's nearer occurrence, not read resolved.
|
||||
const schedules: ComplianceSchedule[] = [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["08:00"],
|
||||
},
|
||||
{
|
||||
windowStart: "12:00",
|
||||
windowEnd: "12:00",
|
||||
daysOfWeek: null,
|
||||
rollingIntervalDays: null,
|
||||
timesOfDay: ["12:00"],
|
||||
},
|
||||
];
|
||||
const slot = new Date("2025-06-15T06:00:00Z");
|
||||
const takenAt = new Date("2025-06-15T06:03:00Z");
|
||||
const events = [{ scheduledFor: slot, takenAt, skipped: false }];
|
||||
const display = buildComplianceDisplay(
|
||||
events,
|
||||
schedules,
|
||||
ctx({ lastIntakeAt: takenAt, timeZone: "Europe/Berlin" }),
|
||||
{ now: new Date("2025-06-15T07:00:00Z") },
|
||||
);
|
||||
expect(display.currentCycle.state).toBe("on_track");
|
||||
expect(display.currentCycle.nextDueAt?.toISOString()).toBe(
|
||||
"2025-06-15T10:00: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
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ADHOC_RESOLVE_EPSILON_MS,
|
||||
anchorResolvesOccurrence,
|
||||
RESOLVE_RADIUS_MS,
|
||||
type NearestOccurrenceCache,
|
||||
} from "@/lib/medications/scheduling/slot-resolution";
|
||||
import {
|
||||
lastNonSkippedTakenAt,
|
||||
@@ -88,7 +89,9 @@ function slotIsResolved(
|
||||
slotAt: Date,
|
||||
intakes: ComplianceIntakeInstant[],
|
||||
schedule: CanonicalSchedule,
|
||||
schedules: CanonicalSchedule[],
|
||||
ctx: RecurrenceContext,
|
||||
cache: NearestOccurrenceCache,
|
||||
): boolean {
|
||||
for (const e of intakes) {
|
||||
const isResolved = e.skipped || e.autoMissed === true || e.takenAt !== null;
|
||||
@@ -104,7 +107,9 @@ function slotIsResolved(
|
||||
occurrenceAt: slotAt,
|
||||
radiusMs: adHoc ? ADHOC_RESOLVE_EPSILON_MS : RESOLVE_RADIUS_MS,
|
||||
schedule,
|
||||
schedules,
|
||||
ctx,
|
||||
cache,
|
||||
});
|
||||
if (resolves) return true;
|
||||
}
|
||||
@@ -143,9 +148,11 @@ export function buildCurrentCycle(
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < schedules.length; i++) {
|
||||
const s = schedules[i];
|
||||
const canonical = toCanonicalSchedule(s, `compliance-cycle-${i}`);
|
||||
const canonicals = schedules.map((s, i) =>
|
||||
toCanonicalSchedule(s, `compliance-cycle-${i}`),
|
||||
);
|
||||
const nearestCache: NearestOccurrenceCache = new Map();
|
||||
for (const canonical of canonicals) {
|
||||
if (canonical.scheduleType === "PRN") continue;
|
||||
const n = canonical.rollingIntervalDays;
|
||||
if (n !== null && n > 0) {
|
||||
@@ -171,7 +178,16 @@ export function buildCurrentCycle(
|
||||
for (let step = 0; step < 64; step++) {
|
||||
const occ = nextOccurrenceAfter(canonical, after, recurrenceCtx);
|
||||
if (!occ) break;
|
||||
if (!slotIsResolved(occ.at, allIntakes, canonical, recurrenceCtx)) {
|
||||
if (
|
||||
!slotIsResolved(
|
||||
occ.at,
|
||||
allIntakes,
|
||||
canonical,
|
||||
canonicals,
|
||||
recurrenceCtx,
|
||||
nearestCache,
|
||||
)
|
||||
) {
|
||||
picked = occ;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -672,30 +672,6 @@ describe("Mounjaro rolling weekly — canonical overdue band after day change",
|
||||
scheduleId: "mounjaro-sibling",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let an action from the archived era suppress the live-era occurrence", () => {
|
||||
const liveEraStart = d("2026-07-27T12:00:00.000Z");
|
||||
const display = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [schedule],
|
||||
now: d("2026-07-29T10:00:00.000Z"),
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: lastTaken,
|
||||
eraStart: liveEraStart,
|
||||
resolvedSlots: [
|
||||
identifiedMark(missedOccurrence, {
|
||||
scheduleId: "mounjaro-weekly",
|
||||
actionAt: d("2026-07-27T11:59:59.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(display).toMatchObject({
|
||||
at: missedOccurrence,
|
||||
overdue: true,
|
||||
scheduleId: "mounjaro-weekly",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -859,21 +835,98 @@ describe("resolved-slot marks — a row resolves only its nearest occurrence", (
|
||||
});
|
||||
|
||||
it("an ad-hoc row seconds off a close slot still resolves that slot only", () => {
|
||||
// Only the ad-hoc row is present: at 12:30 the 12:00 band is still open,
|
||||
// so the slot reads resolved through the epsilon path alone.
|
||||
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({
|
||||
const due = computeDisplayDue({
|
||||
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(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00
|
||||
});
|
||||
|
||||
it("a row exactly midway between two slots resolves the earlier one", () => {
|
||||
// 08:00 + 12:00 Berlin, a row anchored at 10:00: the tie goes to 08:00,
|
||||
// so 12:00 stays due.
|
||||
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-10T08:00:00Z"),
|
||||
resolvedSlots: [mark(d("2026-06-10T08:00:00Z"))], // 10:00 Berlin
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-06-11T06:00:00.000Z"); // tomorrow 08:00
|
||||
expect(next!.toISOString()).toBe("2026-06-10T10:00:00.000Z"); // 12:00 Berlin
|
||||
});
|
||||
|
||||
it("a row on sibling schedule A's 08:00 does not resolve sibling B's 12:00", () => {
|
||||
// Two schedule rows on one medication (08:00 daily plus 12:00 daily), the
|
||||
// wizard's compose shape. Rows carry no schedule identity, so B's slot
|
||||
// must lose to A's nearer occurrence, not be tested against B alone.
|
||||
const a: WorkerScheduleRow = { ...closeSlots(["08:00"]), id: "sched-a" };
|
||||
const b: WorkerScheduleRow = { ...closeSlots(["12:00"]), id: "sched-b" };
|
||||
const now = d("2026-06-10T07:00:00Z"); // 09:00 Berlin
|
||||
const input = {
|
||||
medication,
|
||||
schedules: [a, b],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-06-10T06:00:00Z"),
|
||||
resolvedSlots: [mark(d("2026-06-10T06:00:00Z"))], // A's 08:00 exactly
|
||||
};
|
||||
expect(computeNextDueAt(input)?.toISOString()).toBe(
|
||||
"2026-06-10T10:00:00.000Z",
|
||||
);
|
||||
const due = computeDisplayDue(input);
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-06-10T10:00:00.000Z");
|
||||
expect(due!.scheduleId).toBe("sched-b");
|
||||
});
|
||||
|
||||
it("on the 25-hour fall-back day a slot across the repeated hour stays due", () => {
|
||||
// 2026-10-25 Berlin falls back at 03:00 CEST to 02:00 CET. 01:00 local
|
||||
// is 2026-10-24T23:00Z (CEST); 03:00 local is 02:00Z (CET), three hours
|
||||
// on. Taking the 01:00 dose must leave 03:00 due.
|
||||
const now = d("2026-10-25T00:00:00Z");
|
||||
const next = computeNextDueAt({
|
||||
medication,
|
||||
schedules: [closeSlots(["01:00", "03:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-10-24T23:00:00Z"),
|
||||
resolvedSlots: [mark(d("2026-10-24T23:00:00Z"))],
|
||||
});
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.toISOString()).toBe("2026-10-25T02:00:00.000Z");
|
||||
});
|
||||
|
||||
it("on the fall-back day a row anchored in the old offset still resolves 08:00", () => {
|
||||
// 08:00 CET on 2026-10-25 is 07:00Z; a row computed in the CEST offset
|
||||
// sits at 06:00Z, an hour off, and still stands for that slot.
|
||||
const now = d("2026-10-25T07:30:00Z"); // 08:30 CET
|
||||
const due = computeDisplayDue({
|
||||
medication,
|
||||
schedules: [closeSlots(["08:00", "20:00"])],
|
||||
now,
|
||||
userTz: BERLIN,
|
||||
lastIntakeAt: d("2026-10-25T06:00:00Z"),
|
||||
resolvedSlots: [mark(d("2026-10-25T06:00:00Z"))],
|
||||
});
|
||||
expect(due).not.toBeNull();
|
||||
expect(due!.overdue).toBe(false);
|
||||
expect(due!.at.toISOString()).toBe("2026-10-25T19:00:00.000Z"); // 20:00 CET
|
||||
});
|
||||
|
||||
it("the overdue search agrees: 23:59 stays open overdue after midnight", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ADHOC_RESOLVE_EPSILON_MS,
|
||||
anchorResolvesOccurrence,
|
||||
RESOLVE_RADIUS_MS,
|
||||
type NearestOccurrenceCache,
|
||||
} from "@/lib/medications/scheduling/slot-resolution";
|
||||
|
||||
/**
|
||||
@@ -46,8 +47,6 @@ export interface ResolvedSlotMark {
|
||||
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,35 +72,34 @@ 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).
|
||||
* its nearest occurrence only, measured across every schedule of the
|
||||
* medication, so a sibling slot closer than the drift radius is never
|
||||
* swallowed (see `slot-resolution.ts` for the rule). The era floor is
|
||||
* enforced by the band range in the overdue search and by `now` in the
|
||||
* forward walk, not here.
|
||||
*/
|
||||
function buildIsResolved(
|
||||
resolved: ResolvedSlotMark[],
|
||||
identity: {
|
||||
schedule: CanonicalSchedule;
|
||||
schedules: CanonicalSchedule[];
|
||||
ctx: RecurrenceContext;
|
||||
eraStart?: Date | null;
|
||||
},
|
||||
): (slotAt: Date) => boolean {
|
||||
const cache: NearestOccurrenceCache = new Map();
|
||||
return (slotAt: Date): boolean => {
|
||||
for (const r of resolved) {
|
||||
if (r.scheduleId !== undefined && r.scheduleId !== identity.schedule.id) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
identity.eraStart &&
|
||||
r.actionAt &&
|
||||
r.actionAt.getTime() < identity.eraStart.getTime()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const resolves = anchorResolvesOccurrence({
|
||||
anchor: r.at,
|
||||
occurrenceAt: slotAt,
|
||||
radiusMs: r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS,
|
||||
schedule: identity.schedule,
|
||||
schedules: identity.schedules,
|
||||
ctx: identity.ctx,
|
||||
cache,
|
||||
});
|
||||
if (resolves) return true;
|
||||
}
|
||||
@@ -164,13 +162,13 @@ function computeNextDueCandidate(input: {
|
||||
if (schedules.length === 0) return null;
|
||||
|
||||
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
|
||||
const canonicals = schedules.map(buildCanonicalSchedule);
|
||||
let earliest: DueCandidate | null = null;
|
||||
for (const schedule of schedules) {
|
||||
const canonical = buildCanonicalSchedule(schedule);
|
||||
for (const canonical of canonicals) {
|
||||
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
|
||||
schedule: canonical,
|
||||
schedules: canonicals,
|
||||
ctx,
|
||||
eraStart: input.eraStart,
|
||||
});
|
||||
// Walk forward past slots the user has already resolved. Bounded so a
|
||||
// fully-logged-ahead history can't spin.
|
||||
@@ -276,6 +274,7 @@ function findOpenOverdueSlot(
|
||||
|
||||
const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt });
|
||||
const intakeInstants = lastIntakeAt ? [lastIntakeAt] : [];
|
||||
const canonicals = schedules.map(buildCanonicalSchedule);
|
||||
let latest: {
|
||||
at: Date;
|
||||
availableFrom: Date;
|
||||
@@ -285,8 +284,8 @@ function findOpenOverdueSlot(
|
||||
const canonical = buildCanonicalSchedule(schedule);
|
||||
const isResolved = buildIsResolved(input.resolvedSlots ?? [], {
|
||||
schedule: canonical,
|
||||
schedules: canonicals,
|
||||
ctx,
|
||||
eraStart: input.eraStart,
|
||||
});
|
||||
const { bands } = buildBandsForMedication({
|
||||
medication,
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
* 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.
|
||||
* The neighbours are every schedule's occurrences, not only the schedule
|
||||
* under test: a medication composed of sibling schedules (08:00 daily plus
|
||||
* 12:00 daily) is one cadence to the person taking it. 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,
|
||||
@@ -46,6 +49,53 @@ export const RESOLVE_RADIUS_MS = 6 * 60 * 60 * 1000;
|
||||
*/
|
||||
export const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Nearest occurrence to `anchor` within `radiusMs`, across EVERY schedule
|
||||
* of the medication: `at` and its distance, or null when no schedule emits
|
||||
* inside the window. On an exact tie the earlier occurrence wins. Memoised
|
||||
* per anchor in `cache` when one is passed, because the set depends on the
|
||||
* row alone and a predicate asks about many slots per row.
|
||||
*/
|
||||
function nearestOccurrenceWithin(input: {
|
||||
anchor: number;
|
||||
radiusMs: number;
|
||||
schedules: CanonicalSchedule[];
|
||||
ctx: RecurrenceContext;
|
||||
cache?: NearestOccurrenceCache;
|
||||
}): { at: number; distance: number } | null {
|
||||
const key = `${input.anchor}:${input.radiusMs}`;
|
||||
const cached = input.cache?.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
let nearest: { at: number; distance: number } | null = null;
|
||||
const from = new Date(input.anchor - input.radiusMs);
|
||||
const to = new Date(input.anchor + input.radiusMs);
|
||||
for (const schedule of input.schedules) {
|
||||
for (const occurrence of occurrencesBetween(
|
||||
schedule,
|
||||
from,
|
||||
to,
|
||||
input.ctx,
|
||||
)) {
|
||||
const at = occurrence.at.getTime();
|
||||
const distance = Math.abs(input.anchor - at);
|
||||
if (
|
||||
nearest === null ||
|
||||
distance < nearest.distance ||
|
||||
(distance === nearest.distance && at < nearest.at)
|
||||
) {
|
||||
nearest = { at, distance };
|
||||
}
|
||||
}
|
||||
}
|
||||
input.cache?.set(key, nearest);
|
||||
return nearest;
|
||||
}
|
||||
|
||||
export type NearestOccurrenceCache = Map<
|
||||
string,
|
||||
{ at: number; distance: number } | null
|
||||
>;
|
||||
|
||||
export function anchorResolvesOccurrence(input: {
|
||||
/** The resolved row's anchor (`scheduledFor`, or the take instant). */
|
||||
anchor: Date;
|
||||
@@ -53,8 +103,18 @@ export function anchorResolvesOccurrence(input: {
|
||||
occurrenceAt: Date;
|
||||
/** Reach of this row: the drift radius, or the ad-hoc epsilon. */
|
||||
radiusMs: number;
|
||||
/** The schedule that emitted `occurrenceAt`. */
|
||||
schedule: CanonicalSchedule;
|
||||
/**
|
||||
* Every schedule of the medication. The nearest test enumerates all of
|
||||
* them: production rows carry no schedule identity, so a row on sibling
|
||||
* A's 08:00 is also asked about sibling B's 12:00, and B's own
|
||||
* occurrences alone would never show that A's 08:00 is nearer.
|
||||
*/
|
||||
schedules: CanonicalSchedule[];
|
||||
ctx: RecurrenceContext;
|
||||
/** Optional per-predicate memo for the nearest-occurrence enumeration. */
|
||||
cache?: NearestOccurrenceCache;
|
||||
}): boolean {
|
||||
const anchor = input.anchor.getTime();
|
||||
const slot = input.occurrenceAt.getTime();
|
||||
@@ -66,17 +126,16 @@ export function anchorResolvesOccurrence(input: {
|
||||
// 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,
|
||||
const nearest = nearestOccurrenceWithin({
|
||||
anchor,
|
||||
radiusMs: input.radiusMs,
|
||||
schedules: input.schedules,
|
||||
ctx: input.ctx,
|
||||
cache: input.cache,
|
||||
});
|
||||
if (nearest === null || nearest.at === slot) return true;
|
||||
return !(
|
||||
nearest.distance < distance ||
|
||||
(nearest.distance === distance && nearest.at < slot)
|
||||
);
|
||||
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