diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a632d9b..c8304e3b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 10d44d818..e7bebd2d5 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index 8befa5271..1ed582699 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -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; } diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 29437694b..6a6fb298f 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -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", () => { diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 80052a120..dac18cbbc 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -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, diff --git a/src/lib/medications/scheduling/slot-resolution.ts b/src/lib/medications/scheduling/slot-resolution.ts index 2b73adb6e..f1e2c8dfd 100644 --- a/src/lib/medications/scheduling/slot-resolution.ts +++ b/src/lib/medications/scheduling/slot-resolution.ts @@ -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; }