mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
This commit is contained in:
1 parent
540f4ff84f
commit
381726f471
2 files changed
+156
-8
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof RRule.parseString>;
|
||||
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
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user