fix(medications): keep the rrule anchor for a rule with BYSETPOS

The library drops a period's candidates before DTSTART before BYSETPOS
picks among them, so a start moved into the middle of a week chose a
different day. The property now generates BYSETPOS rules and kept red
without the guard.
This commit is contained in:
Marc-André Bombeck committed 2026-11-03 23:30:20 +01:00
1 parent b975e86b39
commit 55a3a9b53d
2 files changed
+39 -23

No files matched your search

@@ -9,7 +9,10 @@ import { describe, expect, it } from "vitest";
import { alignedRruleStart } from "@/lib/medications/scheduling/aligned-rrule-start";
import { assertProperty } from "../../../../../tests/property/fast-check";
import {
assertProperty,
propertyTimeout,
} from "../../../../../tests/property/fast-check";
const DAY_MS = 24 * 60 * 60 * 1000;
const BYDAY = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
@@ -33,8 +36,9 @@ const ruleArb = fc
),
wkst: fc.option(fc.constantFrom("MO", "SU")),
count: fc.option(fc.integer({ min: 1, max: 40 })),
bysetpos: fc.option(fc.constantFrom(1, 2, -1)),
})
.map(({ freq, interval, byday, byhour, wkst, count }) =>
.map(({ freq, interval, byday, byhour, wkst, count, bysetpos }) =>
[
`FREQ=${freq}`,
interval > 1 ? `INTERVAL=${interval}` : null,
@@ -42,6 +46,7 @@ const ruleArb = fc
byhour ? `BYHOUR=${byhour.join(",")}` : null,
wkst ? `WKST=${wkst}` : null,
count ? `COUNT=${count}` : null,
bysetpos && byday && !byhour ? `BYSETPOS=${bysetpos}` : null,
]
.filter(Boolean)
.join(";"),
@@ -61,26 +66,32 @@ function occurrences(rule: string, start: Date, after: Date, before: Date) {
}
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(
"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,
);
// The reference walks from the real anchor, which is the cost this
// module removes from the engine.
},
propertyTimeout(30_000),
);
it("moves a daily or weekly start close to the window", () => {
const anchor = new Date(Date.UTC(2024, 5, 1));
@@ -14,7 +14,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
* 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
* occurrence), as does one with BYSETPOS (see below), 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.
@@ -31,6 +32,10 @@ export function alignedRruleStart(
return anchor;
}
if (options.count != null) return anchor;
// BYSETPOS picks among a period's candidates, and the library drops the
// candidates before DTSTART in the first period before it picks, so a
// start moved into the middle of a week would choose a different day.
if (options.bysetpos != null) return anchor;
const interval = options.interval ?? 1;
if (!Number.isInteger(interval) || interval < 1) return anchor;
const periodDays =