diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baaaedf9..e0a632d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 3dfd77106..10d44d818 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -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 diff --git a/src/lib/analytics/compliance/cycle.ts b/src/lib/analytics/compliance/cycle.ts index f80a9f2c2..8befa5271 100644 --- a/src/lib/analytics/compliance/cycle.ts +++ b/src/lib/analytics/compliance/cycle.ts @@ -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; } diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 9ef1241fc..29437694b 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -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"); + }); +}); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 562d60e11..80052a120 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -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 }, diff --git a/src/lib/medications/scheduling/slot-resolution.ts b/src/lib/medications/scheduling/slot-resolution.ts new file mode 100644 index 000000000..2b73adb6e --- /dev/null +++ b/src/lib/medications/scheduling/slot-resolution.ts @@ -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; +}