From 3405bb6fd39b4d958cb89519b979e7c0e532f29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:02 +0200 Subject: [PATCH] fix(medications): size weekly-cadence supply runway correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `estimateDailyDoseCount` only special-cased FREQ=MONTHLY and FREQ=YEARLY. A modern FREQ=WEEKLY rrule (the encoding the create path stores, with an empty daysOfWeek) fell through to the legacy branch at seven days per week, so a once-weekly injection counted as one dose per day. The detail card's "lasts about N days" and the daily low-stock job — which share this math — fired reorder alerts roughly seven times too early (fourteen for bi-weekly). Parse FREQ=WEEKLY honouring INTERVAL and BYDAY count: perDay += (times * bydayCount) / (7 * interval). --- .../__tests__/supply-runway.test.ts | 37 +++++++++++++++++++ .../medications/detail/supply-runway.ts | 19 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index d1c11ad56..77ea502d7 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -68,6 +68,43 @@ describe("estimateDailyDoseCount", () => { ).toBeCloseTo(1 / 30); }); + it("treats a once-weekly RRULE as one dose per 7 days", () => { + // FREQ=WEEKLY;BYDAY=MO is the modern once-weekly injection encoding; + // it must NOT fall through to the daysPerWeek=7 legacy branch. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO" }), + ]), + ).toBeCloseTo(1 / 7); + }); + + it("halves a bi-weekly RRULE via INTERVAL=2 (one dose per 14 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ + timesOfDay: ["08:00"], + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + }), + ]), + ).toBeCloseTo(1 / 14); + }); + + it("counts each BYDAY pick on a weekly RRULE (Mon+Thu → 2 per 7 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO,TH" }), + ]), + ).toBeCloseTo(2 / 7); + }); + + it("defaults a weekly RRULE with no BYDAY to one pick per week", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY" }), + ]), + ).toBeCloseTo(1 / 7); + }); + it("sums across multiple schedules", () => { expect( estimateDailyDoseCount([ diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index b4972e946..bbc36a58f 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -46,6 +46,25 @@ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { perDay += times / 365; continue; } + // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the + // create path stores the cadence on the rrule and leaves daysOfWeek + // empty). Honour the BYDAY pick count and the week INTERVAL: a + // once-weekly injection is one dose per 7 days, a bi-weekly one per + // 14. Without this branch a weekly rrule fell through to the legacy + // daysOfWeek fallback below with daysPerWeek=7, over-estimating the + // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. + if (/FREQ=WEEKLY/.test(rrule)) { + const byday = /BYDAY=([^;]+)/.exec(rrule); + const bydayCount = + byday && byday[1].length > 0 ? byday[1].split(",").length : 1; + const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); + const interval = + intervalMatch && Number(intervalMatch[1]) >= 1 + ? Number(intervalMatch[1]) + : 1; + perDay += (times * bydayCount) / (7 * interval); + continue; + } const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; const weeks = intervalWeeks >= 1 ? intervalWeeks : 1;