Merge branch 'fix/medication-schedule-timezones' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:46 +02:00
commit bd8cf5d83f
33 files changed
+1184 -157

No files matched your search

+6 -6
View File
@@ -309,14 +309,14 @@ export function MedicationsSection({
empty={medications.length === 0 && complianceEntries.length === 0}
>
{medications.map((med) => {
const comp = report.compliance[med.name];
const comp = report.compliance[med.id];
const rate =
complianceOn && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
key={med.id}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
@@ -327,11 +327,11 @@ export function MedicationsSection({
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
.filter(([id]) => !medications.some((m) => m.id === id))
.map(([id, c]) => (
<StatRow
key={name}
label={name}
key={id}
label={c.name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
@@ -371,3 +371,78 @@ describe("units runway with a mixed units-per-dose value (#1034)", () => {
expect(estimateUnitsRunwayDays(10, [morning], 1.2)).toBe(8);
});
});
describe("estimateDailyDoseCount — cadence parts the rate used to drop", () => {
it("stretches a monthly RRULE by its INTERVAL (every third month)", () => {
const perDay = estimateDailyDoseCount([
schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
]);
expect(perDay).toBeCloseTo(1 / 90, 10);
// Two doses left last about half a year, not two months.
expect(
estimateRunwayDays(2, [
schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
]),
).toBe(180);
});
it("counts each BYMONTHDAY of a monthly RRULE", () => {
expect(
estimateDailyDoseCount([
schedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,15" }),
]),
).toBeCloseTo(2 / 30, 10);
});
it("stretches a daily RRULE by its INTERVAL (every other day)", () => {
expect(
estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]),
).toBeCloseTo(1 / 2, 10);
});
it("stretches a yearly RRULE by its INTERVAL and counts BYMONTH", () => {
expect(
estimateDailyDoseCount([
schedule({ rrule: "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYMONTHDAY=1" }),
]),
).toBeCloseTo(1 / 730, 10);
expect(
estimateDailyDoseCount([
schedule({ rrule: "FREQ=YEARLY;BYMONTH=3,9;BYMONTHDAY=1" }),
]),
).toBeCloseTo(2 / 365, 10);
});
it("scales a CYCLIC schedule by its on share (3 weeks on, 1 off)", () => {
const cyclic = schedule({
rrule: "FREQ=DAILY",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
});
expect(estimateDailyDoseCount([cyclic])).toBeCloseTo(3 / 4, 10);
expect(estimateRunwayDays(21, [cyclic])).toBe(28);
});
it("counts nothing for an as-needed (PRN) schedule", () => {
expect(estimateDailyDoseCount([schedule({ scheduleType: "PRN" })])).toBe(0);
});
});
describe("whole counts survive binary floating point", () => {
it("2.4 units at 0.8 per dose is three doses", () => {
// 2.4 / 0.8 === 2.9999999999999996 in IEEE 754.
expect(effectiveUnitsPerDose([schedule({})], 0.8)).toBe(0.8);
expect(estimateUnitsRunwayDays(2.4, [schedule({})], 0.8)).toBe(3);
});
it("a dose-count runway that divides exactly is not a day short", () => {
// Three doses on Mon/Wed/Fri: 9/7 a day, and 9 / (9/7) is
// 6.999999999999999 in IEEE 754 — a week of supply, not six days.
const s = schedule({
daysOfWeek: "1,3,5",
timesOfDay: ["08:00", "12:00", "20:00"],
});
expect(estimateRunwayDays(9, [s])).toBe(7);
});
});
@@ -10,6 +10,7 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { floorWhole } from "@/lib/medications/units-per-dose";
/** The schedule fields the runway estimate reads. */
export interface RunwaySchedule {
@@ -24,6 +25,30 @@ export interface RunwaySchedule {
* `unitsPerDose`, exactly like the consumption path's resolver.
*/
unitsPerDose?: number | null;
/**
* Schedule type. PRN consumes nothing on a cadence; CYCLIC consumes only
* in its "on" weeks, so its rate is scaled by on / (on + off).
*/
scheduleType?: "SCHEDULED" | "PRN" | "CYCLIC" | string | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
}
/** A positive integer RRULE part (`INTERVAL=3`), or `fallback`. */
function rrulePositiveInt(
rrule: string,
key: string,
fallback: number,
): number {
const m = new RegExp(`(?:^|;)${key}=(\\d+)(?:;|$)`, "i").exec(rrule);
const n = m ? Number(m[1]) : NaN;
return Number.isInteger(n) && n >= 1 ? n : fallback;
}
/** How many comma-separated values an RRULE list part carries, or 0. */
function rruleListCount(rrule: string, key: string): number {
const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule);
return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0;
}
/**
@@ -32,17 +57,40 @@ export interface RunwaySchedule {
* weeks, monthly/yearly RRULEs).
*/
function scheduleDailyDoseCount(s: RunwaySchedule): number {
if (s.scheduleType === "PRN") return 0;
const rate = cadenceDailyDoseCount(s);
if (s.scheduleType === "CYCLIC") {
// Doses fall only in the "on" weeks of the cycle.
const on = s.cyclicOnWeeks ?? 0;
const off = s.cyclicOffWeeks ?? 0;
if (on > 0 && off > 0) return (rate * on) / (on + off);
}
return rate;
}
function cadenceDailyDoseCount(s: RunwaySchedule): number {
const times =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 1) {
return times / s.rollingIntervalDays;
}
const rrule = s.rrule ?? "";
if (/FREQ=MONTHLY/.test(rrule)) {
return times / 30;
// INTERVAL stretches every calendar frequency: FREQ=MONTHLY;INTERVAL=3 is
// one dose a quarter, not one a month.
const interval = rrulePositiveInt(rrule, "INTERVAL", 1);
if (/FREQ=DAILY/i.test(rrule)) {
return times / interval;
}
if (/FREQ=YEARLY/.test(rrule)) {
return times / 365;
if (/FREQ=MONTHLY/i.test(rrule)) {
const perMonth = Math.max(
1,
rruleListCount(rrule, "BYMONTHDAY") || rruleListCount(rrule, "BYDAY"),
);
return (times * perMonth) / (30 * interval);
}
if (/FREQ=YEARLY/i.test(rrule)) {
const perYear = Math.max(1, rruleListCount(rrule, "BYMONTH"));
return (times * perYear) / (365 * interval);
}
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
// create path stores the cadence on the rrule and leaves daysOfWeek
@@ -52,14 +100,7 @@ function scheduleDailyDoseCount(s: RunwaySchedule): number {
// 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;
const bydayCount = Math.max(1, rruleListCount(rrule, "BYDAY"));
return (times * bydayCount) / (7 * interval);
}
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
@@ -137,7 +178,7 @@ export function estimateUnitsRunwayDays(
medicationUnitsPerDose,
);
if (perDayUnits <= 0) return null;
return Math.floor(Math.max(0, unitsRemaining) / perDayUnits);
return floorWhole(Math.max(0, unitsRemaining) / perDayUnits);
}
/**
@@ -151,7 +192,7 @@ export function estimateRunwayDays(
if (dosesRemaining <= 0) return null;
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
return Math.floor(dosesRemaining / perDay);
return floorWhole(dosesRemaining / perDay);
}
/**
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
floorWhole,
formatUnitsPerDose,
parseUnitsPerDoseInput,
} from "@/lib/medications/units-per-dose";
@@ -267,10 +268,10 @@ export function AddInventoryDialog({
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
Math.floor(parsed / unitsPerDose) === 0
floorWhole(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
doses: floorWhole(parsed / unitsPerDose),
})}
</p>
)}
@@ -60,7 +60,10 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
import {
floorWhole,
formatUnitsPerDose,
} from "@/lib/medications/units-per-dose";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import {
@@ -382,8 +385,8 @@ export function InventorySection({
{item.unitsRemaining == null || item.unitsTotal == null
? t("medications.detail.bestand.unknown")
: t("medications.detail.bestand.doses", {
remaining: Math.floor(item.unitsRemaining / perDose),
total: Math.floor(item.unitsTotal / perDose),
remaining: floorWhole(item.unitsRemaining / perDose),
total: floorWhole(item.unitsTotal / perDose),
})}
{perDose !== 1 &&
item.unitsRemaining != null &&