From 381726f471acf83a3afb139b0fd590617e4d29d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 20:23:31 +0100 Subject: [PATCH] perf(medications): start the rrule walk just before the window between() iterates from DTSTART, so every compliance read walked a daily schedule from the day the medication was created: about 4 ms per rate, growing with the medication's age, and enough under a loaded test run to push a compliance property past its timeout. DAILY and WEEKLY rules repeat with a fixed period, so the start moves forward by whole periods to just before the window. A property test holds the occurrences in the window equal to the walk from the real anchor. --- .../aligned-rrule-start.property.test.ts | 99 +++++++++++++++++++ src/lib/medications/scheduling/recurrence.ts | 65 ++++++++++-- 2 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts diff --git a/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts b/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts new file mode 100644 index 000000000..d3f189056 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/aligned-rrule-start.property.test.ts @@ -0,0 +1,99 @@ +/** + * Moving an RRULE's start forward to just before the window must not move a + * single occurrence inside it. The reference is the rule iterated from its + * real anchor, exactly as the engine did before the start was aligned. + */ +import fc from "fast-check"; +import { RRule } from "rrule"; +import { describe, expect, it } from "vitest"; + +import { alignedRruleStart } from "@/lib/medications/scheduling/recurrence"; + +import { assertProperty } from "../../../../../tests/property/fast-check"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const BYDAY = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const; + +const dayArb = fc + .integer({ min: 0, max: 3 * 365 }) + .map((n) => new Date(Date.UTC(2024, 0, 1) + n * DAY_MS)); + +const ruleArb = fc + .record({ + freq: fc.constantFrom("DAILY", "WEEKLY", "MONTHLY"), + interval: fc.integer({ min: 1, max: 4 }), + byday: fc.option( + fc.uniqueArray(fc.constantFrom(...BYDAY), { minLength: 1, maxLength: 7 }), + ), + byhour: fc.option( + fc.uniqueArray(fc.integer({ min: 0, max: 23 }), { + minLength: 1, + maxLength: 3, + }), + ), + wkst: fc.option(fc.constantFrom("MO", "SU")), + count: fc.option(fc.integer({ min: 1, max: 40 })), + }) + .map(({ freq, interval, byday, byhour, wkst, count }) => + [ + `FREQ=${freq}`, + interval > 1 ? `INTERVAL=${interval}` : null, + byday ? `BYDAY=${byday.join(",")}` : null, + byhour ? `BYHOUR=${byhour.join(",")}` : null, + wkst ? `WKST=${wkst}` : null, + count ? `COUNT=${count}` : null, + ] + .filter(Boolean) + .join(";"), + ); + +function basic(d: Date): string { + return d + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}/, ""); +} + +function occurrences(rule: string, start: Date, after: Date, before: Date) { + return RRule.fromString(`DTSTART:${basic(start)}\nRRULE:${rule}`) + .between(after, before, true) + .map((d) => d.toISOString()); +} + +describe("alignedRruleStart", () => { + it("leaves every occurrence in the window where it was", () => { + assertProperty( + fc.property( + ruleArb, + dayArb, + fc.integer({ min: -30, max: 2 * 365 }), + fc.integer({ min: 1, max: 120 }), + (rule, anchor, offsetDays, spanDays) => { + const after = new Date(anchor.getTime() + offsetDays * DAY_MS); + const before = new Date(after.getTime() + spanDays * DAY_MS); + const aligned = alignedRruleStart(rule, anchor, after); + expect(aligned.getTime()).toBeGreaterThanOrEqual(anchor.getTime()); + expect(occurrences(rule, aligned, after, before)).toEqual( + occurrences(rule, anchor, after, before), + ); + }, + ), + 300, + ); + }); + + it("moves a daily or weekly start close to the window", () => { + const anchor = new Date(Date.UTC(2024, 5, 1)); + const after = new Date(Date.UTC(2026, 2, 1)); + for (const rule of ["FREQ=DAILY", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH"]) { + const aligned = alignedRruleStart(rule, anchor, after); + expect(after.getTime() - aligned.getTime()).toBeLessThanOrEqual( + 15 * DAY_MS, + ); + } + expect(alignedRruleStart("FREQ=DAILY;COUNT=5", anchor, after)).toEqual( + anchor, + ); + expect(alignedRruleStart("FREQ=MONTHLY", anchor, after)).toEqual(anchor); + }); +}); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index f2477ca41..40eb23ec7 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -787,7 +787,17 @@ function expandRrule( // to end — the anchor's day is read from its UTC fields and only then is // the HH:mm materialised in the user's zone. Reading the anchor in the // user's zone instead turned Monday 00:00Z into Sunday evening west of UTC. - const dtstartLine = `DTSTART:${formatUtcBasic(anchorCivilDay(ctx))}`; + // Walk a generously padded day-anchor window so a per-day timesOfDay + // expansion still hits the requested [from, to] after the time-of-day + // is applied: a calendar day spans instants up to 14 h either side of + // its UTC midnight, so widen the window by 2 days on each side. + const padded = 2 * DAY_MS; + const after = new Date(from.getTime() - padded); + const before = new Date(to.getTime() + padded); + + const dtstartLine = `DTSTART:${formatUtcBasic( + alignedRruleStart(rruleStr, anchorCivilDay(ctx), after), + )}`; // Skip the engine-side UNTIL suffix when the user's RRULE already // bounds the recurrence with COUNT or UNTIL — RFC 5545 forbids both // (and any two-UNTIL collision), and RRule.fromString throws on the @@ -808,13 +818,6 @@ function expandRrule( return []; } - // Walk a generously padded day-anchor window so a per-day timesOfDay - // expansion still hits the requested [from, to] after the time-of-day - // is applied: a calendar day spans instants up to 14 h either side of - // its UTC midnight, so widen the window by 2 days on each side. - const padded = 2 * DAY_MS; - const after = new Date(from.getTime() - padded); - const before = new Date(to.getTime() + padded); // An RRULE with BYHOUR (the measurement-reminder protocols) emits several // anchors per day; the times of day are expanded per calendar day, so // collapse the anchors to their days first. @@ -837,6 +840,52 @@ function expandRrule( return slots.sort((a, b) => a.at.getTime() - b.at.getTime()); } +/** + * The DTSTART to hand the rrule library for a window that opens at `after`. + * + * `between()` iterates from DTSTART, not from the window, so a schedule + * anchored at a medication created two years ago walked some 700 days on + * every call, and the compliance read calls it many times over: about 4 ms + * per rate, growing with the age of the medication. A DAILY or WEEKLY rule + * repeats with a fixed period of `INTERVAL` days or weeks, so moving the + * start forward by whole periods to just before the window leaves every + * occurrence inside it where it was: the weekday, the week parity of an + * every-other-week rule and the BYHOUR expansion all ride on the period. + * A rule with COUNT keeps its anchor (the count runs from the first + * occurrence), and so does any other frequency, whose period is not a fixed + * number of days. The day before `after` is the latest safe start, because + * a start the rule does not match is not an occurrence, and anything + * between it and `after` is outside the window anyway. + */ +export function alignedRruleStart( + rruleStr: string, + anchor: Date, + after: Date, +): Date { + let options: ReturnType; + try { + options = RRule.parseString(rruleStr); + } catch { + return anchor; + } + if (options.count != null) return anchor; + const interval = options.interval ?? 1; + if (!Number.isInteger(interval) || interval < 1) return anchor; + const periodDays = + options.freq === RRule.DAILY + ? interval + : options.freq === RRule.WEEKLY + ? 7 * interval + : null; + if (periodDays === null) return anchor; + const periodMs = periodDays * DAY_MS; + const periods = Math.floor( + (after.getTime() - DAY_MS - anchor.getTime()) / periodMs, + ); + if (periods <= 0) return anchor; + return new Date(anchor.getTime() + periods * periodMs); +} + // ──────────────────────────────────────────────────────────────────── // Dispatch — legacy fallback // ────────────────────────────────────────────────────────────────────