fix(medications): count every weekday a monthly rule names in the supply runway

FREQ=MONTHLY;BYDAY=MO means every Monday of the month, but the runway
counted it as one dose a month, so a Monday medication's supply read about
four times longer than it lasts and its low-supply notice came late. A
weekday without an ordinal now counts as every such day in the month
(yearly rules per BYMONTH month), a weekday with one (1MO, -1FR) as one
day, and BYSETPOS as the number of positions it picks.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:15:16 +02:00
1 parent 612eedcbbb
commit 4d3a98ed43
2 files changed
+60 -5

No files matched your search

@@ -394,6 +394,26 @@ describe("estimateDailyDoseCount — cadence parts the rate used to drop", () =>
).toBeCloseTo(2 / 30, 10);
});
it.each([
// Every Monday of the month is a weekly dose, not one a month.
["FREQ=MONTHLY;BYDAY=MO", 1 / 7],
["FREQ=MONTHLY;BYDAY=MO,TH", 2 / 7],
// With an ordinal it is one day of the month.
["FREQ=MONTHLY;BYDAY=1MO", 1 / 30],
["FREQ=MONTHLY;BYDAY=1MO,-1FR", 2 / 30],
// The last weekday of the month: BYSETPOS picks one of the five.
["FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 1 / 30],
["FREQ=MONTHLY;INTERVAL=2;BYDAY=MO", 1 / 14],
// Every Monday in January, and the first Monday of March and September.
["FREQ=YEARLY;BYMONTH=1;BYDAY=MO", 30 / 7 / 365],
["FREQ=YEARLY;BYMONTH=3,9;BYDAY=1MO", 2 / 365],
])("counts the occurrences of %s", (rrule, perDay) => {
expect(estimateDailyDoseCount([schedule({ rrule })])).toBeCloseTo(
perDay,
10,
);
});
it("stretches a daily RRULE by its INTERVAL (every other day)", () => {
expect(
estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]),
@@ -51,6 +51,38 @@ function rruleListCount(rrule: string, key: string): number {
return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0;
}
/** The raw values of an RRULE list part (`BYDAY=1MO,FR` → `1MO`, `FR`). */
function rruleList(rrule: string, key: string): string[] {
const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule);
return m ? m[1].split(",").filter((v) => v.trim() !== "") : [];
}
/**
* Occurrences one period of a monthly or yearly rule holds, with the period
* `periodDays` long. `BYSETPOS` picks that many of the candidates. A
* `BYDAY` entry with an ordinal (`1MO`, `-1FR`) is one day of the period;
* one without (`MO`) is every such weekday in it, about `periodDays / 7`.
* Counting `FREQ=MONTHLY;BYDAY=MO` as one dose a month made a Monday
* medication's supply read four times longer than it lasts.
*/
function occurrencesPerPeriod(
rrule: string,
periodDays: number,
fallback: number,
): number {
const setPos = rruleListCount(rrule, "BYSETPOS");
if (setPos > 0) return setPos;
const monthDays = rruleListCount(rrule, "BYMONTHDAY");
if (monthDays > 0) return monthDays;
const byDay = rruleList(rrule, "BYDAY");
if (byDay.length === 0) return fallback;
let count = 0;
for (const entry of byDay) {
count += /^[+-]?\d/.test(entry.trim()) ? 1 : periodDays / 7;
}
return count;
}
/**
* Approximate doses per day for ONE schedule: times-of-day count, scaled
* down by the cadence (rolling interval, weekly day picks + interval
@@ -82,14 +114,17 @@ function cadenceDailyDoseCount(s: RunwaySchedule): number {
return times / interval;
}
if (/FREQ=MONTHLY/i.test(rrule)) {
const perMonth = Math.max(
1,
rruleListCount(rrule, "BYMONTHDAY") || rruleListCount(rrule, "BYDAY"),
);
const perMonth = occurrencesPerPeriod(rrule, 30, 1);
return (times * perMonth) / (30 * interval);
}
if (/FREQ=YEARLY/i.test(rrule)) {
const perYear = Math.max(1, rruleListCount(rrule, "BYMONTH"));
// Within each BYMONTH month (or the year, without one), the day parts
// count as they would for a monthly rule.
const months = rruleListCount(rrule, "BYMONTH");
const perYear =
months > 0
? months * occurrencesPerPeriod(rrule, 30, 1)
: occurrencesPerPeriod(rrule, 365, 1);
return (times * perYear) / (365 * interval);
}
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the