mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
refactor(medications): keep the rrule start alignment in its own module
recurrence.ts sits on the source-size ratchet; the helper and its reasoning read on their own.
This commit is contained in:
1 parent
775933b7d5
commit
270c6ca619
3 files changed
+53
-50
No files matched your search
@@ -7,7 +7,7 @@ import fc from "fast-check";
|
||||
import { RRule } from "rrule";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { alignedRruleStart } from "@/lib/medications/scheduling/recurrence";
|
||||
import { alignedRruleStart } from "@/lib/medications/scheduling/aligned-rrule-start";
|
||||
|
||||
import { assertProperty } from "../../../../../tests/property/fast-check";
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { RRule } from "rrule";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { alignedRruleStart } from "@/lib/medications/scheduling/aligned-rrule-start";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
|
||||
import { tzOffsetMinutes } from "@/lib/tz/format";
|
||||
@@ -795,9 +796,8 @@ function expandRrule(
|
||||
const after = new Date(from.getTime() - padded);
|
||||
const before = new Date(to.getTime() + padded);
|
||||
|
||||
const dtstartLine = `DTSTART:${formatUtcBasic(
|
||||
alignedRruleStart(rruleStr, anchorCivilDay(ctx), after),
|
||||
)}`;
|
||||
const start = alignedRruleStart(rruleStr, anchorCivilDay(ctx), after);
|
||||
const dtstartLine = `DTSTART:${formatUtcBasic(start)}`;
|
||||
// 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
|
||||
@@ -840,52 +840,6 @@ 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