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

@@ -225,7 +225,9 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
MEDICATION_LIST: {
kind: "rendered",
data: {
medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }],
medications: [
{ id: "med-ramipril", name: "Ramipril", dose: "5 mg", schedules: [] },
],
},
marker: "Ramipril",
},
@@ -252,7 +254,13 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
kind: "rendered",
data: {
compliance: {
Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 },
"med-metformin": {
name: "Metformin",
total: 60,
taken: 57,
skipped: 1,
missed: 2,
},
},
},
marker: "Metformin",
+2 -1
View File
@@ -47,6 +47,7 @@ import {
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
type RouteParams = { params: Promise<{ id: string }> };
@@ -175,7 +176,7 @@ export const GET = apiHandler(
0,
);
const pensRemaining = usable.length;
const dosesRemaining = Math.floor(
const dosesRemaining = floorWhole(
unitsRemaining / (Number(medication.unitsPerDose) || 1),
);
const weeksOfSupply = dosesRemaining;
+2 -1
View File
@@ -46,6 +46,7 @@ import {
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
type RouteParams = { params: Promise<{ id: string }> };
@@ -216,7 +217,7 @@ export const GET = apiHandler(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
: Math.floor(
: floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(
schedulesDto,
+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 &&
Binary file not shown.
@@ -71,10 +71,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 },
"med-ramipril": {
name: "Ramipril",
total: 90,
taken: 85,
skipped: 3,
missed: 2,
},
},
medications: [
{
id: "med-ramipril",
name: "Ramipril",
dose: "5mg",
schedules: [
@@ -324,7 +331,8 @@ describe("doctor-report pagination", () => {
it("paginates a long compliance table across pages without throwing", () => {
const compliance: DoctorReportData["compliance"] = {};
for (let i = 0; i < 60; i++) {
compliance[`Medication ${i}`] = {
compliance[`med-${i}`] = {
name: `Medication ${i}`,
total: 90,
taken: 80,
skipped: 5,
@@ -899,7 +907,8 @@ describe("extracted doctor-report section boundaries", () => {
it("footer is drawn once on every dynamically added page", async () => {
const compliance: DoctorReportData["compliance"] = {};
for (let i = 0; i < 60; i += 1) {
compliance[`Medication ${i}`] = {
compliance[`med-${i}`] = {
name: `Medication ${i}`,
total: 90,
taken: 80,
skipped: 5,
+2 -1
View File
@@ -24,6 +24,7 @@ import { readNote } from "@/lib/crypto/note-cipher";
import { matchGlp1SideEffectTags } from "@/lib/medications/glp1-side-effect-tag-match";
import type { Glp1SideEffectTag } from "@/lib/medications/glp1-side-effect-tags";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
import { floorWhole } from "@/lib/medications/units-per-dose";
/**
* Recommended generic name for the canonical GLP-1 drug brand. The Coach
@@ -372,7 +373,7 @@ export async function buildGlp1SnapshotBlock(
0,
);
pensRemaining = usable.length;
dosesRemaining = Math.floor(
dosesRemaining = floorWhole(
unitsRemaining / (Number(med.unitsPerDose) || 1),
);
weeksOfSupplyApprox = Math.round(dosesRemaining / dosesPerWeek);
+4 -2
View File
@@ -344,7 +344,8 @@ export function normaliseDateRange(
* schedule, no expected dose — a fabricated 100 % on a clinical report).
*
* Pure / synchronous — bands come from pre-fetched schedules + intake
* instants. Keyed by medication name to match the renderer contract.
* instants. Keyed by medication id: two medications can share a name, and
* a name key let the second overwrite the first. The name rides in the value.
*/
export function buildLedgerCompliance(
medications: DoctorReportComplianceMedication[],
@@ -385,7 +386,8 @@ export function buildLedgerCompliance(
// `total = taken + missed` (the ledger denominator) so the renderer's
// `taken / total` equals `tally.rate` — the exact number the detail page
// shows. Deliberate skips are reported separately but stay out of `total`.
compliance[med.name] = {
compliance[med.id] = {
name: med.name,
total: tally.denominator,
taken: tally.taken,
skipped: tally.skipped,
@@ -67,7 +67,7 @@ export function buildMedicationMoodWellnessSection(
doc.text(t("doctorReport.complianceTitle"), margin, y);
y += 6;
const compRows = complianceEntries.map(([name, c]) => {
const compRows = complianceEntries.map(([, c]) => {
// v1.17 W1a — `total` is the ledger rate denominator (taken + missed,
// deliberate skips excluded), so `taken / total` matches the app's
// detail-page adherence %. The column is labelled "Expected" rather
@@ -77,7 +77,7 @@ export function buildMedicationMoodWellnessSection(
const ratePct = adherenceRatePercent(c.taken, c.total);
const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—";
return [
name,
c.name,
String(c.taken),
String(c.skipped),
String(c.missed),
+10
View File
@@ -40,7 +40,14 @@ export interface DoctorReportStats {
latest: number;
}
/**
* One medication's adherence over the report window. The report's
* `compliance` map is keyed by medication id, because two medications may
* share a name (an ended course and a new one, two strengths); `name` is the
* label every renderer prints.
*/
export interface DoctorReportCompliance {
name: string;
total: number;
taken: number;
skipped: number;
@@ -102,8 +109,11 @@ export interface DoctorReportData {
*/
glucoseClinical: GlucoseClinicalMetrics;
bmi: number | null;
/** Keyed by medication id; see `DoctorReportCompliance`. */
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
/** The medication id, the key into `compliance`. */
id: string;
name: string;
dose: string;
// v1.9.0 — optional user/clinician-asserted drug-classification
+1
View File
@@ -583,6 +583,7 @@ export async function collectDoctorReportData(
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
medications: gate.admits("MEDICATION_LIST")
? medications.map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
atcCode: m.atcCode,
+1 -1
View File
@@ -186,7 +186,7 @@ export function buildGlp1Block(params: {
medications: glp1Meds.map((m) => {
const latest = m.doseChanges[m.doseChanges.length - 1] ?? null;
const lastIntake = m.intakeEvents[0] ?? null;
const comp = params.compliance[m.name] ?? {
const comp = params.compliance[m.id] ?? {
taken: 0,
total: 0,
skipped: 0,
+26 -3
View File
@@ -112,10 +112,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
"Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
"med-example": {
name: "Example Drug",
total: 90,
taken: 85,
skipped: 3,
missed: 2,
},
},
medications: [
{
id: "med-example-drug",
name: "Example Drug",
dose: "5mg",
schedules: [
@@ -473,6 +480,7 @@ describe("buildFhirDocumentBundle", () => {
makeData({
medications: [
{
id: "med-mounjaro",
name: "Mounjaro",
dose: "10mg",
atcCode: "A10BX10",
@@ -510,7 +518,13 @@ describe("buildFhirDocumentBundle", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [
{ name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] },
{
id: "med-ramipril",
name: "Ramipril",
dose: "5mg",
atcCode: "C09AA05",
schedules: [],
},
],
}),
{ insuranceNumber: null },
@@ -782,6 +796,7 @@ describe("buildFhirDocumentBundle", () => {
makeData({
medications: [
{
id: "med-empagliflozin",
name: "Empagliflozin",
dose: "10mg",
atcCode: "A10BK03",
@@ -811,6 +826,7 @@ describe("buildFhirDocumentBundle", () => {
makeData({
medications: [
{
id: "med-empagliflozin",
name: "Empagliflozin",
dose: "10mg",
atcCode: "A10BK03",
@@ -867,7 +883,14 @@ describe("buildFhirDocumentBundle", () => {
it("never invents a BfArM coding when no ATC code is stored, even with germanAtc on", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [{ name: "Herbal mix", dose: "1 tab", schedules: [] }],
medications: [
{
id: "med-herbal",
name: "Herbal mix",
dose: "1 tab",
schedules: [],
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
+10 -2
View File
@@ -65,9 +65,17 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
"Example Drug": { total: 90, taken: 85, skipped: 3, missed: 2 },
"med-example": {
name: "Example Drug",
total: 90,
taken: 85,
skipped: 3,
missed: 2,
},
},
medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
medications: [
{ id: "med-example", name: "Example Drug", dose: "5mg", schedules: [] },
],
medicationAdministrations: [
{
medicationName: "Example Drug",
+2 -2
View File
@@ -177,7 +177,7 @@ function adherenceObservations(
): FhirObservation[] {
const observations: FhirObservation[] = [];
const period = reportingPeriod(data);
for (const [name, comp] of Object.entries(data.compliance)) {
for (const comp of Object.values(data.compliance)) {
if (comp.total <= 0) continue;
// Integer percent — the one canonical rounding the app card + PDF use, so
// a clinician sees the same adherence figure on every surface. The helper
@@ -201,7 +201,7 @@ function adherenceObservations(
"Adherence to prescribed medication instructions [Reported]",
},
],
text: `Medication adherence — ${name}`,
text: `Medication adherence — ${comp.name}`,
},
subject: patientRef,
effectivePeriod: period,
@@ -0,0 +1,119 @@
/**
* A multi-hour protocol (`FREQ=DAILY;BYHOUR=7,19`, the morning-and-evening
* blood pressure course) must be sent at both hours. The tick used to gate
* on `notifyHour` alone and claim one send per local day, so the evening
* slot sat due until the next morning and was never sent in the evening.
*
* Drives the real tick every 15 minutes across two local days in several
* zones, with a claim ledger that behaves like the real one (a key claims
* once), and a Prisma double whose updates land on the row the next tick
* reads.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const claimed = vi.hoisted(() => new Set<string>());
vi.mock("@/lib/notifications/reminder-dedup", async () => {
const actual = await vi.importActual<
typeof import("@/lib/notifications/reminder-dedup")
>("@/lib/notifications/reminder-dedup");
return {
...actual,
claimNotificationEvent: vi.fn(
async (_prisma: unknown, args: { dedupKey: string }) => {
if (claimed.has(args.dedupKey)) return false;
claimed.add(args.dedupKey);
return true;
},
),
};
});
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
import { runMeasurementReminderTick } from "../measurement-reminder";
const ZONES = [
"Europe/Berlin",
"America/New_York",
"America/Los_Angeles",
"Asia/Tokyo",
"Pacific/Auckland",
] as const;
beforeEach(() => {
claimed.clear();
});
function at(tz: string, day: number, hour: number, minute = 0): Date {
return zonedWallClockToUtc({ year: 2026, month: 9, day, hour, minute }, tz);
}
describe.each(ZONES)("BYHOUR=7,19 in %s", (tz) => {
it("sends at 07:00 and at 19:00 on each day", async () => {
const base = {
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: at(tz, 14, 0),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: at(tz, 13, 12),
};
const row: Record<string, unknown> = {
id: "r-bp",
userId: "u1",
origin: "COACH",
measurementType: null,
label: "BP",
location: null,
lastSkippedAt: null,
lastNotifiedAt: null,
enabled: true,
endsOn: null,
...base,
nextDueAt: computeReminderNextDueAt(base, tz, at(tz, 14, 0)),
user: { id: "u1", timezone: tz, locale: "en" },
};
const prisma = {
measurementReminder: {
findMany: vi.fn(async () =>
(row.nextDueAt as Date | null) === null ? [] : [row],
),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
Object.assign(row, data);
return row;
}),
updateMany: vi.fn(async () => ({ count: 0 })),
},
measurement: { findFirst: vi.fn(async () => null) },
labResult: { findFirst: vi.fn(async () => null) },
};
const sent: string[] = [];
const dispatch = vi.fn(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
for (
let t = at(tz, 14, 0).getTime();
t < at(tz, 16, 0).getTime();
t += 15 * 60_000
) {
const now = new Date(t);
const before = dispatch.mock.calls.length;
await runMeasurementReminderTick(prisma as never, now, {
dispatch: dispatch as never,
isModuleEnabled: async () => true,
});
if (dispatch.mock.calls.length > before) {
const p = wallClockInTz(now, tz);
sent.push(
`${p.day} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`,
);
}
}
expect(sent).toEqual(["14 07:00", "14 19:00", "15 07:00", "15 19:00"]);
});
});
@@ -851,3 +851,44 @@ describe("issue #664 — exact occurrence identity survives local midnight", ()
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
describe("handleReminderCheck — a snooze holds only the slot it answered", () => {
function snoozed(at: string): Record<string, unknown> {
return {
...medicationWithSchedule({ timesOfDay: ["08:00", "18:00"] }),
// A Telegram "skip" of the morning dose snoozes to the end of the day;
// the snooze write stamps the row's updatedAt.
snoozedUntil: new Date("2026-07-28T23:59:59.000Z"),
updatedAt: new Date(at),
};
}
it("still reminds the evening dose after the morning one was snoozed", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T18:40:00.000Z"));
prismaMock.medication.findMany.mockResolvedValue([
snoozed("2026-07-28T08:30:00.000Z"),
] as never);
await handleReminderCheck([]);
expect(dispatchNotification).toHaveBeenCalledTimes(1);
expect(dispatchNotification).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ timeOfDay: "18:00" }),
}),
);
});
it("holds the slot whose reminder was open when the snooze was set", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-28T08:40:00.000Z"));
prismaMock.medication.findMany.mockResolvedValue([
snoozed("2026-07-28T08:30:00.000Z"),
] as never);
await handleReminderCheck([]);
expect(dispatchNotification).not.toHaveBeenCalled();
});
});
+57 -7
View File
@@ -50,6 +50,7 @@ import { getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { ModuleKey } from "@/lib/modules/registry";
import {
byHourTimesOfDay,
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
@@ -169,12 +170,51 @@ async function cleanupExpiredCoachReminders(
}
/**
* Pure due-predicate: at this instant, is the reminder due AND inside its
* local notify-hour window?
* The local hours a reminder fires at: every `BYHOUR` of its rule (the
* twice-daily BP protocol `FREQ=DAILY;BYHOUR=7,19`), else its `notifyHour`.
*/
function reminderFireHours(reminder: {
notifyHour: number;
rrule?: string | null;
}): number[] {
const byHour = byHourTimesOfDay(reminder.rrule ?? null);
return byHour
? byHour.map((hhmm) => Number(hhmm.slice(0, 2)))
: [reminder.notifyHour];
}
/**
* The local hour the reminder's current slot belongs to: the hour of
* `nextDueAt` on the user's clock when that is one of its fire hours, else
* `notifyHour` (a slot stamped before the hour was edited, or a test
* fixture with no real slot). For a single-hour reminder this is always
* `notifyHour`; for `BYHOUR=7,19` the evening slot reads 19, so it is sent
* in the evening instead of waiting for the next morning.
*/
export function reminderSlotHour(
reminder: {
nextDueAt: Date | null;
notifyHour: number;
rrule?: string | null;
},
timezone: string,
): number {
const hours = reminderFireHours(reminder);
if (reminder.nextDueAt !== null && hours.length > 1) {
const slotHour = wallClockInTz(reminder.nextDueAt, timezone).hour;
if (hours.includes(slotHour)) return slotHour;
}
return reminder.notifyHour;
}
/**
* Pure due-predicate: at this instant, is the reminder due AND inside the
* local hour of its current slot?
*
* "Due" = `nextDueAt != null` and `now >= nextDueAt`. The hour gate keeps
* a reminder that became overdue overnight from firing at 03:00 — it
* waits for the user's chosen `notifyHour` to come round in their local
* waits for its slot's hour (`reminderSlotHour`: the `notifyHour`, or the
* matching `BYHOUR` of a multi-hour rule) to come round in the user's
* timezone. Pulled out so the unit tests can pin the window boundary
* (08:59 → no, 09:00 → yes, 09:59 → yes, 10:00 → no) without the DB.
*/
@@ -183,6 +223,7 @@ export function evaluateMeasurementReminderDue(
enabled: boolean;
nextDueAt: Date | null;
notifyHour: number;
rrule?: string | null;
},
timezone: string,
now: Date,
@@ -190,9 +231,10 @@ export function evaluateMeasurementReminderDue(
if (!reminder.enabled || reminder.nextDueAt === null) {
return { fire: false, inHourWindow: false, isDue: false };
}
const tz = timezone || "Europe/Berlin";
const isDue = now.getTime() >= reminder.nextDueAt.getTime();
const parts = wallClockInTz(now, timezone || "Europe/Berlin");
const inHourWindow = parts.hour === reminder.notifyHour;
const parts = wallClockInTz(now, tz);
const inHourWindow = parts.hour === reminderSlotHour(reminder, tz);
return { fire: isDue && inHourWindow, inHourWindow, isDue };
}
@@ -298,7 +340,8 @@ export async function runMeasurementReminderTick(
reminder.lastNotifiedAt != null &&
reminder.nextDueAt !== null &&
reminder.lastNotifiedAt.getTime() >= reminder.nextDueAt.getTime() &&
wallClockInTz(now, timezone).hour !== reminder.notifyHour
wallClockInTz(now, timezone).hour !==
reminderSlotHour(reminder, timezone)
) {
summary.skippedOutsideWindow += 1;
continue;
@@ -338,6 +381,7 @@ export async function runMeasurementReminderTick(
enabled: reminder.enabled,
nextDueAt: reminder.nextDueAt,
notifyHour: reminder.notifyHour,
rrule: reminder.rrule,
},
timezone,
now,
@@ -409,10 +453,16 @@ export async function runMeasurementReminderTick(
const localDate = new Date(now).toLocaleDateString("sv-SE", {
timeZone: timezone,
});
// A multi-hour rule sends more than once a day, so its claim is per
// slot hour; a single-hour reminder keeps the per-day key.
const slotSuffix =
reminderFireHours(reminder).length > 1
? `T${String(reminderSlotHour(reminder, timezone)).padStart(2, "0")}`
: "";
const claimed = await claimNotificationEvent(prisma, {
recordUserId: reminder.user.id,
eventType: "MEASUREMENT_REMINDER",
dedupKey: `measurement:${reminder.id}:${localDate}`,
dedupKey: `measurement:${reminder.id}:${localDate}${slotSuffix}`,
since: new Date(now.getTime() - REMINDER_DEDUP_LOOKBACK_MS),
});
if (!claimed) {
+5
View File
@@ -422,6 +422,11 @@ export async function runMedicationLowStockTick(
rollingIntervalDays: true,
// v1.37.19 — per-slot units feed the slot-aware burn rate.
unitsPerDose: true,
// A PRN schedule consumes nothing on a cadence, a CYCLIC one
// only in its "on" weeks.
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
},
},
},
@@ -141,6 +141,24 @@ export async function cleanupScheduledTelegramDeletions(): Promise<void> {
}
}
/**
* Whether a medication-level snooze holds the reminder of one slot. It
* holds only while it runs (`now < snoozedUntil`) and only for a slot whose
* reminder window had opened when the snooze was set (`snoozedAt`, the
* medication row's @updatedAt of the snooze write). A later edit to the
* row moves `snoozedAt` forward, which can only widen the hold back to the
* old medication-wide behaviour, never lose a snooze.
*/
export function snoozeHoldsSlot(
snoozedUntil: Date | null,
snoozedAt: Date,
slotWindowStart: Date,
now: Date,
): boolean {
if (!snoozedUntil || now.getTime() >= snoozedUntil.getTime()) return false;
return slotWindowStart.getTime() <= snoozedAt.getTime();
}
/**
* Check all active medications for each user and determine reminder phases.
* Uses phase-based logic (GREEN/YELLOW/ORANGE/RED) to send one notification
@@ -477,6 +495,7 @@ export async function handleReminderCheck(
return currentPhase
? {
slotScheduledFor: occurrence.at,
windowStart: phaseWindow.start,
minutesToEnd,
currentPhase,
}
@@ -489,8 +508,12 @@ export async function handleReminderCheck(
)[0];
if (!phaseCandidate) continue;
const { slotScheduledFor, minutesToEnd, currentPhase } =
phaseCandidate;
const {
slotScheduledFor,
windowStart,
minutesToEnd,
currentPhase,
} = phaseCandidate;
const slotInstant = slotScheduledFor.getTime();
if (liveEraStart !== null && slotInstant < liveEraStart) {
continue;
@@ -544,8 +567,16 @@ export async function handleReminderCheck(
continue;
}
// Skip if medication is snoozed
if (med.snoozedUntil && now < med.snoozedUntil) {
// A snooze answers the reminder of the slot that was open when it
// was set, so it holds only slots whose window had opened by then.
// `snoozedUntil` lives on the medication row and the snooze write
// stamps the row's @updatedAt, which dates the decision (the same
// reading the skip / pin suppression above uses). A later slot of
// the same medication, such as the evening dose after a morning
// "skip" that snoozes to the end of the day, is still reminded.
if (
snoozeHoldsSlot(med.snoozedUntil, med.updatedAt, windowStart, now)
) {
continue;
}
+2 -2
View File
@@ -73,8 +73,8 @@ export function summariseForVisit(
};
});
const compliance = Object.entries(data.compliance).map(([name, c]) => ({
medication: name,
const compliance = Object.values(data.compliance).map((c) => ({
medication: c.name,
takenDoses: c.taken,
expectedDoses: c.total,
missedDoses: c.missed,
@@ -0,0 +1,77 @@
/**
* A reminder's `anchorDate` / `endsOn` are instants (the form sends local
* midnight), while the recurrence engine takes calendar dates. The adapter
* must hand over the day the instant falls on in the user's zone: east of
* UTC local midnight is still the previous UTC day, and a weekly plan
* anchored there must not start a day early.
*/
import { describe, expect, it } from "vitest";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { computeReminderNextDueAt } from "../scheduling";
const ZONES = [
"America/New_York",
"America/Los_Angeles",
"America/Santiago",
"Europe/Berlin",
"Asia/Tokyo",
"Pacific/Auckland",
"Pacific/Tongatapu",
] as const;
function localMidnight(tz: string, y: number, m: number, d: number): Date {
return zonedWallClockToUtc(
{ year: y, month: m, day: d, hour: 0, minute: 0 },
tz,
);
}
function localLabel(at: Date | null, tz: string): string | null {
if (!at) return null;
const p = wallClockInTz(at, tz);
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")} ${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")} w${p.weekday}`;
}
describe.each(ZONES)("reminder anchors in %s", (tz) => {
it("a daily RRULE starts on the local anchor day", () => {
const anchor = localMidnight(tz, 2026, 9, 14);
const next = computeReminderNextDueAt(
{
intervalDays: null,
rrule: "FREQ=DAILY",
anchorDate: anchor,
notifyHour: 8,
lastSatisfiedAt: null,
createdAt: new Date("2026-09-01T12:00:00Z"),
},
tz,
new Date("2026-09-10T12:00:00Z"),
);
expect(localLabel(next, tz)).toBe("2026-09-14 08:00 w1");
});
it("a course that ends on a local day keeps that day's evening slot", () => {
const next = computeReminderNextDueAt(
{
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=7,19",
anchorDate: localMidnight(tz, 2026, 9, 14),
endsOn: zonedWallClockToUtc(
{ year: 2026, month: 9, day: 20, hour: 10, minute: 0 },
tz,
),
notifyHour: 7,
lastSatisfiedAt: null,
createdAt: new Date("2026-09-01T12:00:00Z"),
},
tz,
zonedWallClockToUtc(
{ year: 2026, month: 9, day: 20, hour: 8, minute: 0 },
tz,
),
);
expect(localLabel(next, tz)).toBe("2026-09-20 19:00 w0");
});
});
+23 -3
View File
@@ -29,6 +29,7 @@ import {
type RecurrenceContext,
nextOccurrenceAfter,
} from "@/lib/medications/scheduling/recurrence";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
* The reminder fields this module reads. A subset of the Prisma row so
@@ -50,6 +51,12 @@ export interface ReminderScheduleInput {
endsOn?: Date | null;
}
/** The calendar day `instant` falls on in `tz`, as UTC midnight of that date. */
function calendarDayInZone(instant: Date, tz: string): Date {
const p = wallClockInTz(instant, tz);
return new Date(Date.UTC(p.year, p.month - 1, p.day));
}
function hourToHhmm(hour: number): string {
const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9;
return `${safe.toString().padStart(2, "0")}:00`;
@@ -120,17 +127,30 @@ export function buildReminderRecurrence(
// First-due anchor when never satisfied: anchorDate ?? createdAt. After
// a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we
// feed `lastSatisfiedAt` through `lastIntakeAt`.
const startsOn = reminder.anchorDate ?? reminder.createdAt;
//
// The engine's `startsOn` / `endsOn` are calendar dates (the medication
// columns are `@db.Date`, UTC midnight of the date). A reminder's
// `anchorDate` / `endsOn` are instants instead (the form sends local
// midnight, a Coach course sends `now + days`), so hand the engine the
// calendar day each instant falls on in the user's zone.
const tz = timeZone || "Europe/Berlin";
const startsOn = calendarDayInZone(
reminder.anchorDate ?? reminder.createdAt,
tz,
);
const endsOn = reminder.endsOn
? calendarDayInZone(reminder.endsOn, tz)
: null;
const ctx: RecurrenceContext = {
medication: {
id: "measurement-reminder",
startsOn,
endsOn: reminder.endsOn ?? null,
endsOn,
oneShot: false,
createdAt: reminder.createdAt,
},
timeZone: timeZone || "Europe/Berlin",
timeZone: tz,
lastIntakeAt: reminder.lastSatisfiedAt,
};
@@ -195,3 +195,14 @@ describe("supply surfaces — server computes, the detail-page clients render th
expect(src).toContain("medications.detail.bestand.expiredSuffix");
});
});
describe("summariseSupply — whole doses survive binary floating point", () => {
it("2.4 mL at 0.8 mL per dose is three doses, not two", () => {
const summary = summariseSupply(
[{ state: "ACTIVE", unitsRemaining: 2.4, unitsTotal: 2.4 }] as never,
0.8,
);
expect(summary.dosesRemaining).toBe(3);
expect(summary.dosesTotal).toBe(3);
});
});
+4 -2
View File
@@ -30,6 +30,8 @@
* the client detail surfaces that also render from it.
*/
import { floorWhole } from "@/lib/medications/units-per-dose";
export type SupplyItemState = "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
export interface SupplyItem {
@@ -102,8 +104,8 @@ export function summariseSupply(
return {
unitsRemaining,
unitsTotal,
dosesRemaining: Math.floor(unitsRemaining / perDose),
dosesTotal: Math.floor(unitsTotal / perDose),
dosesRemaining: floorWhole(unitsRemaining / perDose),
dosesTotal: floorWhole(unitsTotal / perDose),
expiredUnits,
};
}
+2 -1
View File
@@ -34,6 +34,7 @@ import {
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
import { floorWhole } from "@/lib/medications/units-per-dose";
export type MedicationsListResult = Array<Record<string, unknown>>;
@@ -272,7 +273,7 @@ export async function buildMedicationsList(
const stockDosesRemaining =
stockUnitsRemaining === null
? null
: Math.floor(
: floorWhole(
stockUnitsRemaining /
effectiveUnitsPerDose(schedulesDto, Number(m.unitsPerDose)),
);
@@ -0,0 +1,364 @@
/**
* Calendar-day correctness of the recurrence engine across time zones.
*
* `Medication.startsOn` / `endsOn` are `@db.Date` columns: a calendar date
* carried as UTC midnight. The engine used to read that midnight in the
* user's zone, which west of UTC is the previous evening, so every
* calendar-anchored cadence landed one day early there: a Monday plan fired
* on Sundays in New York, a course starting on the 14th produced a dose on
* the 13th, and a dose on the last course day in the evening vanished.
*
* Every assertion here is read back on the user's wall clock, and the whole
* matrix runs under several HOST zones as well (the engine must not depend
* on the machine it runs on). The host zone is switched through
* `process.env.TZ` inside the test, because a `TZ=` prefix on the command
* line is overridden by the vitest config.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
advanceRollingOccurrence,
expandRollingRetrospective,
nextOccurrenceAfter,
occurrencesBetween,
type CanonicalSchedule,
type RecurrenceContext,
} from "../recurrence";
const USER_ZONES = [
"America/New_York",
"America/Los_Angeles",
"America/Santiago",
"Europe/Berlin",
"Asia/Tokyo",
"Pacific/Auckland",
"Pacific/Tongatapu",
"UTC",
] as const;
const HOST_ZONES = ["UTC", "America/Los_Angeles", "Pacific/Auckland"] as const;
function makeSchedule(
overrides: Partial<CanonicalSchedule> = {},
): CanonicalSchedule {
return {
id: "sched-tz",
rrule: null,
rollingIntervalDays: null,
timesOfDay: ["09:00"],
daysOfWeek: null,
windowStart: "09:00",
windowEnd: "10:00",
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
...overrides,
};
}
function makeCtx(
timeZone: string,
medication: Partial<RecurrenceContext["medication"]> = {},
lastIntakeAt: Date | null = null,
): RecurrenceContext {
return {
timeZone,
lastIntakeAt,
medication: {
id: "med-tz",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T12:00:00Z"),
...medication,
},
};
}
/** A calendar date the way Prisma hands back a `@db.Date` column. */
function date(ymd: string): Date {
return new Date(`${ymd}T00:00:00.000Z`);
}
function localYmd(at: Date, tz: string): string {
const p = wallClockInTz(at, tz);
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
}
function localHm(at: Date, tz: string): string {
const p = wallClockInTz(at, tz);
return `${String(p.hour).padStart(2, "0")}:${String(p.minute).padStart(2, "0")}`;
}
const WIDE_FROM = new Date("2026-07-25T00:00:00Z");
const WIDE_TO = new Date("2026-12-31T00:00:00Z");
describe.each(HOST_ZONES)("recurrence engine on a %s host", (hostZone) => {
const originalTz = process.env.TZ;
beforeAll(() => {
process.env.TZ = hostZone;
});
afterAll(() => {
process.env.TZ = originalTz;
});
describe.each(USER_ZONES)("user in %s", (tz) => {
it("FREQ=WEEKLY;BYDAY=MO fires on local Mondays at the local time", () => {
const slots = occurrencesBetween(
makeSchedule({ rrule: "FREQ=WEEKLY;BYDAY=MO" }),
WIDE_FROM,
WIDE_TO,
makeCtx(tz, { startsOn: date("2026-09-01") }),
);
expect(slots.length).toBeGreaterThan(10);
for (const s of slots) {
expect(wallClockInTz(s.at, tz).weekday).toBe(1);
expect(localHm(s.at, tz)).toBe("09:00");
}
expect(localYmd(slots[0].at, tz)).toBe("2026-09-07");
});
it("FREQ=DAILY starts on startsOn, never the day before", () => {
const slots = occurrencesBetween(
makeSchedule({ rrule: "FREQ=DAILY", timesOfDay: ["00:30", "23:30"] }),
WIDE_FROM,
new Date("2026-09-20T00:00:00Z"),
makeCtx(tz, { startsOn: date("2026-09-14") }),
);
expect(localYmd(slots[0].at, tz)).toBe("2026-09-14");
expect(localHm(slots[0].at, tz)).toBe("00:30");
// One slot per time per day: no duplicate, no missing day.
const days = slots.map((s) => localYmd(s.at, tz));
for (const day of ["2026-09-14", "2026-09-15", "2026-09-16"]) {
expect(days.filter((d) => d === day)).toHaveLength(2);
}
expect(days.every((d) => d >= "2026-09-14")).toBe(true);
});
it("FREQ=DAILY keeps the evening dose of the last course day", () => {
const ctx = makeCtx(tz, {
startsOn: date("2026-09-14"),
endsOn: date("2026-09-16"),
});
const schedule = makeSchedule({
rrule: "FREQ=DAILY",
timesOfDay: ["08:00", "23:00"],
});
const slots = occurrencesBetween(schedule, WIDE_FROM, WIDE_TO, ctx);
expect(
slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`),
).toEqual([
"2026-09-14 08:00",
"2026-09-14 23:00",
"2026-09-15 08:00",
"2026-09-15 23:00",
"2026-09-16 08:00",
"2026-09-16 23:00",
]);
// The next-due walk must reach the same last dose.
const lastMorning = slots[4].at;
const next = nextOccurrenceAfter(schedule, lastMorning, ctx);
expect(next?.at.toISOString()).toBe(slots[5].at.toISOString());
expect(nextOccurrenceAfter(schedule, slots[5].at, ctx)).toBeNull();
});
it("FREQ=MONTHLY;BYMONTHDAY=1 lands on the first of the local month", () => {
const slots = occurrencesBetween(
makeSchedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1" }),
WIDE_FROM,
WIDE_TO,
makeCtx(tz, { startsOn: date("2026-08-01") }),
);
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
"2026-08-01",
"2026-09-01",
"2026-10-01",
"2026-11-01",
"2026-12-01",
]);
});
it("legacy every-other-Monday honours startsOn and the week phase", () => {
const slots = occurrencesBetween(
makeSchedule({ daysOfWeek: "i2;1" }),
WIDE_FROM,
new Date("2026-10-31T00:00:00Z"),
makeCtx(tz, { startsOn: date("2026-09-14") }),
);
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
"2026-09-14",
"2026-09-28",
"2026-10-12",
"2026-10-26",
]);
});
it("legacy daily walk stops after the evening dose of endsOn", () => {
const slots = occurrencesBetween(
makeSchedule({ timesOfDay: ["21:00"] }),
WIDE_FROM,
WIDE_TO,
makeCtx(tz, {
startsOn: date("2026-09-14"),
endsOn: date("2026-09-15"),
}),
);
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
"2026-09-14",
"2026-09-15",
]);
});
it("a one-shot dose lands on its own date", () => {
const slots = occurrencesBetween(
makeSchedule({ timesOfDay: ["08:00"] }),
WIDE_FROM,
WIDE_TO,
makeCtx(tz, {
startsOn: date("2026-09-14"),
endsOn: date("2026-09-14"),
oneShot: true,
}),
);
expect(
slots.map((s) => `${localYmd(s.at, tz)} ${localHm(s.at, tz)}`),
).toEqual(["2026-09-14 08:00"]);
});
it("a rolling first dose is due on startsOn", () => {
const schedule = makeSchedule({ rollingIntervalDays: 7 });
const ctx = makeCtx(tz, { startsOn: date("2026-09-14") });
const next = nextOccurrenceAfter(
schedule,
new Date("2026-09-01T12:00:00Z"),
ctx,
);
expect(next && localYmd(next.at, tz)).toBe("2026-09-14");
expect(next && localHm(next.at, tz)).toBe("09:00");
});
it("a rolling cadence counts calendar days from a late-evening intake", () => {
// 23:30 local on 2026-10-30; +7 calendar days crosses the northern
// fall-back weekend and the southern spring-forward season.
const intake = zoned(tz, 2026, 10, 30, 23, 30);
const schedule = makeSchedule({ rollingIntervalDays: 7 });
const ctx = makeCtx(tz, { startsOn: date("2026-09-14") }, intake);
const next = nextOccurrenceAfter(schedule, intake, ctx);
expect(next && localYmd(next.at, tz)).toBe("2026-11-06");
const after = next && advanceRollingOccurrence(schedule, next, ctx);
expect(after && localYmd(after.at, tz)).toBe("2026-11-13");
});
it("a rolling retrospective back-fill keeps calendar days", () => {
const first = zoned(tz, 2026, 9, 1, 23, 30);
const last = zoned(tz, 2026, 9, 22, 23, 30);
const slots = expandRollingRetrospective(
makeSchedule({ rollingIntervalDays: 7 }),
makeCtx(tz, { startsOn: date("2026-09-01") }),
WIDE_FROM,
new Date("2026-09-25T00:00:00Z"),
[first, last],
new Date("2026-09-24T12:00:00Z"),
);
expect(slots.map((s) => localYmd(s.at, tz))).toEqual([
"2026-09-01",
"2026-09-08",
"2026-09-15",
"2026-09-22",
]);
});
it("CYCLIC 3 on / 1 off counts whole weeks from startsOn in the local calendar", () => {
// Starts on a Wednesday; the three "on" weeks are 21 consecutive days.
const slots = occurrencesBetween(
makeSchedule({
rrule: "FREQ=DAILY",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
timesOfDay: ["23:30"],
}),
WIDE_FROM,
new Date("2026-11-10T00:00:00Z"),
makeCtx(tz, { startsOn: date("2026-09-16") }),
);
const days = slots.map((s) => localYmd(s.at, tz));
expect(days[0]).toBe("2026-09-16");
expect(days).toContain("2026-10-06");
expect(days).not.toContain("2026-10-07");
expect(days).not.toContain("2026-10-13");
expect(days).toContain("2026-10-14");
expect(days.filter((d) => d < "2026-10-14")).toHaveLength(21);
});
});
describe("DST transition days", () => {
const cases: Array<[string, string, string]> = [
["America/New_York", "2026-03-08", "2026-11-01"],
["America/Los_Angeles", "2026-03-08", "2026-11-01"],
["Europe/Berlin", "2026-03-29", "2026-10-25"],
["America/Santiago", "2026-09-06", "2026-04-05"],
["Pacific/Auckland", "2026-09-27", "2026-04-05"],
];
it.each(cases)(
"%s keeps one dose per local day across %s and %s",
(tz, springDay, fallDay) => {
for (const day of [springDay, fallDay]) {
const start = date(day);
const slots = occurrencesBetween(
makeSchedule({
rrule: "FREQ=DAILY",
timesOfDay: ["00:30", "08:00", "23:30"],
}),
new Date(start.getTime() - 3 * 86_400_000),
new Date(start.getTime() + 3 * 86_400_000),
makeCtx(tz, { startsOn: date("2026-01-01") }),
);
const onDay = slots.filter((s) => localYmd(s.at, tz) === day);
expect(onDay).toHaveLength(3);
expect(localHm(onDay[1].at, tz)).toBe("08:00");
expect(localHm(onDay[2].at, tz)).toBe("23:30");
}
},
);
it.each(cases)(
"%s: a rolling cadence across the spring-forward day %s keeps calendar days",
(tz, springDay) => {
const [y, m, d] = springDay.split("-").map(Number);
// 23:30 local three days before the clocks go forward; seven times
// 24 h later is 00:30 on the day after the due day.
const intake = zoned(tz, y, m, d - 3, 23, 30);
const schedule = makeSchedule({ rollingIntervalDays: 7 });
const ctx = makeCtx(tz, { startsOn: date("2026-01-01") }, intake);
const next = nextOccurrenceAfter(schedule, intake, ctx);
const due = new Date(Date.UTC(y, m - 1, d - 3 + 7));
expect(next && localYmd(next.at, tz)).toBe(
due.toISOString().slice(0, 10),
);
},
);
});
});
function zoned(
tz: string,
year: number,
month: number,
day: number,
hour: number,
minute: number,
): Date {
// Solve the local wall clock to an instant without the engine under test.
let guess = Date.UTC(year, month - 1, day, hour, minute);
for (let i = 0; i < 3; i++) {
const p = wallClockInTz(new Date(guess), tz);
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute);
guess += Date.UTC(year, month - 1, day, hour, minute) - asUtc;
}
return new Date(guess);
}
+202 -93
View File
@@ -225,8 +225,8 @@ export function occurrencesBetween(
/**
* v1.7.0 — true when `instant` falls in an "on" week of the cyclic
* on/off phase. The anchor is the medication's `startsOn ?? createdAt`,
* snapped to its UTC week start. `phase = weeksFromAnchor mod
* on/off phase. The anchor is the local calendar day of the medication's
* `startsOn ?? createdAt`; a week is seven calendar days from it. `phase = weeksFromAnchor mod
* (on + off)`; the slot survives iff `phase < on`. A non-positive or
* missing `cyclicOnWeeks` keeps every slot (defensive — the route + Zod
* require a positive value for CYCLIC, but the engine never throws on a
@@ -243,11 +243,13 @@ function isInCyclicOnWeek(
const cycleLen = onWeeks + offWeeks;
if (cycleLen <= 0) return true;
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
const anchorWeekStart = startOfUtcWeek(anchor).getTime();
const instantWeekStart = startOfUtcWeek(instant).getTime();
const weeksFromAnchor = Math.round(
(instantWeekStart - anchorWeekStart) / WEEK_MS,
// Whole weeks counted in the user's calendar from the start day itself:
// "three weeks on" starting on a Wednesday is 21 consecutive days, and the
// slot's day is its local day, not the UTC day its instant falls on.
const anchorDay = anchorCivilDay(ctx).getTime();
const slotDay = civilDayOfInstant(instant, ctx.timeZone).getTime();
const weeksFromAnchor = Math.floor(
Math.round((slotDay - anchorDay) / DAY_MS) / 7,
);
const phase = ((weeksFromAnchor % cycleLen) + cycleLen) % cycleLen;
return phase < onWeeks;
@@ -272,10 +274,15 @@ export function nextOccurrenceAfter(
// v1.7.0 — PRN never has a next due instant.
if (schedule.scheduleType === "PRN") return null;
const endsOn = ctx.medication.endsOn;
const endsOnDay = endsOnCivilDay(ctx);
const hardCap = new Date(after.getTime() + 365 * 10 * DAY_MS);
const limit = endsOn
? new Date(Math.min(endOfUtcDay(endsOn).getTime(), hardCap.getTime()))
const limit = endsOnDay
? new Date(
Math.min(
endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime(),
hardCap.getTime(),
),
)
: hardCap;
const cyclic = schedule.scheduleType === "CYCLIC";
@@ -331,7 +338,7 @@ export function nextOccurrenceAfter(
// day short and re-clips the very slot it meant to admit. `endsOn` stays
// enforced inside `expandRolling`, so a course-window reminder still
// self-expires.
const rollingSlotInstant = applyTimeOfDayToDate(
const rollingSlotInstant = applyTimeOfDayToCivilDay(
rollingDoseAnchor(schedule, ctx),
schedule.timesOfDay[0] ?? schedule.windowStart,
ctx.timeZone,
@@ -403,11 +410,11 @@ function expandOneShot(
from: Date,
to: Date,
): Occurrence[] {
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
const anchor = anchorCivilDay(ctx);
const times = effectiveTimesOfDay(schedule);
const slots: Occurrence[] = [];
for (const time of times) {
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(anchor, time, ctx.timeZone);
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) continue;
slots.push(buildOccurrence(at, time, schedule));
}
@@ -460,17 +467,25 @@ function rollingDoseAnchor(
ctx: RecurrenceContext,
): Date {
const intervalDays = schedule.rollingIntervalDays ?? 0;
// N calendar days after the local day of the last intake, not N × 24 h:
// a late-evening dose across a DST change would otherwise slide onto the
// wrong day.
return ctx.lastIntakeAt !== null
? new Date(ctx.lastIntakeAt.getTime() + intervalDays * DAY_MS)
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
? addCivilDays(
civilDayOfInstant(ctx.lastIntakeAt, ctx.timeZone),
intervalDays,
)
: anchorCivilDay(ctx);
}
function rollingDoseDayFloor(
schedule: CanonicalSchedule,
ctx: RecurrenceContext,
): number {
const anchor = rollingDoseAnchor(schedule, ctx);
return startOfLocalDayInTz(anchor, ctx.timeZone).getTime();
return startOfCivilDayInstant(
rollingDoseAnchor(schedule, ctx),
ctx.timeZone,
).getTime();
}
function expandRolling(
@@ -486,16 +501,14 @@ function expandRolling(
// FIRST dose is the start date itself (no `+ N`) — see the doc comment.
const nextDue = rollingDoseAnchor(schedule, ctx);
// endsOn cap.
if (
ctx.medication.endsOn &&
nextDue.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
) {
// endsOn cap — both sides are calendar days.
const endsOnDay = endsOnCivilDay(ctx);
if (endsOnDay && nextDue.getTime() > endsOnDay.getTime()) {
return [];
}
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(nextDue, time, ctx.timeZone);
if (at.getTime() < from.getTime() || at.getTime() > to.getTime()) return [];
return [buildOccurrence(at, time, schedule)];
@@ -515,14 +528,15 @@ export function advanceRollingOccurrence(
const n = schedule.rollingIntervalDays;
if (n === null || n <= 0) return null;
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
const nextDay = new Date(occurrence.at.getTime() + n * DAY_MS);
const at = applyTimeOfDayToDate(nextDay, time, ctx.timeZone);
if (
ctx.medication.endsOn &&
at.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
) {
const nextDay = addCivilDays(
civilDayOfInstant(occurrence.at, ctx.timeZone),
n,
);
const endsOnDay = endsOnCivilDay(ctx);
if (endsOnDay && nextDay.getTime() > endsOnDay.getTime()) {
return null;
}
const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone);
return buildOccurrence(at, time, schedule);
}
@@ -586,8 +600,9 @@ export function expandRollingRetrospective(
// Tolerance: a gap up to 1.5·N is "on time" (a dose logged a little
// late). Only a gap strictly beyond this synthesizes skipped cycles.
const gapToleranceMs = 1.5 * cycleMs;
const endsCap = ctx.medication.endsOn
? endOfUtcDay(ctx.medication.endsOn).getTime()
const endsOnDay = endsOnCivilDay(ctx);
const endsCap = endsOnDay
? endOfCivilDayInstant(endsOnDay, ctx.timeZone).getTime()
: Infinity;
const inWindow = (at: Date): boolean =>
@@ -616,11 +631,21 @@ export function expandRollingRetrospective(
const gap = next.getTime() - prev.getTime();
if (gap <= gapToleranceMs) continue;
// Number of whole cycles the user skipped: floor(gap/N) − 1 missed
// cycles sit strictly between the two intakes.
const cyclesInGap = Math.floor(gap / cycleMs);
// cycles sit strictly between the two intakes. Counted in calendar
// days, so a gap spanning a DST change (one hour short) still counts
// its last cycle.
const prevDay = civilDayOfInstant(prev, ctx.timeZone);
const gapDays = Math.round(
(civilDayOfInstant(next, ctx.timeZone).getTime() - prevDay.getTime()) /
DAY_MS,
);
const cyclesInGap = Math.floor(gapDays / n);
for (let k = 1; k < cyclesInGap; k++) {
const anchor = new Date(prev.getTime() + k * cycleMs);
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(
addCivilDays(prevDay, k * n),
time,
ctx.timeZone,
);
if (inWindow(at)) {
slots.push(buildOccurrence(at, time, schedule));
}
@@ -639,12 +664,16 @@ export function expandRollingRetrospective(
const forwardToleranceMs = cycleMs / 2;
const lastInstant =
sorted.length > 0 ? sorted[sorted.length - 1] : ctx.lastIntakeAt;
const nextDueDay =
lastInstant !== null
? addCivilDays(civilDayOfInstant(lastInstant, ctx.timeZone), n)
: anchorCivilDay(ctx);
const nextDue =
lastInstant !== null
? new Date(lastInstant.getTime() + cycleMs)
: (ctx.medication.startsOn ?? ctx.medication.createdAt);
: startOfCivilDayInstant(nextDueDay, ctx.timeZone);
if (now.getTime() - nextDue.getTime() > forwardToleranceMs) {
const at = applyTimeOfDayToDate(nextDue, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(nextDueDay, time, ctx.timeZone);
// Dedupe: a forward slot already represented by a logged intake (the
// intake re-anchored exactly N days out) must not double-count.
const alreadyPresent = slots.some(
@@ -671,16 +700,21 @@ function expandRrule(
const rruleStr = schedule.rrule;
if (!rruleStr) return [];
const dtstart = ctx.medication.startsOn ?? ctx.medication.createdAt;
const dtstartLine = `DTSTART:${formatUtcBasic(startOfUtcDay(dtstart))}`;
// The rrule library works in "floating" UTC: DTSTART and every emitted
// anchor are calendar days carried as UTC midnight. Keep them that way end
// to end — the anchor's day is read from its UTC fields and only then is
// the HH:mm materialised in the user's zone. Reading the anchor in the
// user's zone instead turned Monday 00:00Z into Sunday evening west of UTC.
const dtstartLine = `DTSTART:${formatUtcBasic(anchorCivilDay(ctx))}`;
// 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
// duplicate, silently collapsing the schedule to zero slots.
const userBoundsRecurrence = /(?:^|;)(?:COUNT|UNTIL)=/.test(rruleStr);
const endsOnDay = endsOnCivilDay(ctx);
const untilSuffix =
ctx.medication.endsOn && !userBoundsRecurrence
? `;UNTIL=${formatUtcBasic(endOfUtcDay(ctx.medication.endsOn))}`
endsOnDay && !userBoundsRecurrence
? `;UNTIL=${formatUtcBasic(endOfUtcDay(endsOnDay))}`
: "";
const full = `${dtstartLine}\nRRULE:${rruleStr}${untilSuffix}`;
@@ -697,20 +731,25 @@ function expandRrule(
// Walk a generously padded day-anchor window so a per-day timesOfDay
// expansion still hits the requested [from, to] after the time-of-day
// is applied. The rrule lib's day-anchored dates land at midnight UTC
// of the BYDAY/BYMONTHDAY day; the time-of-day might push back across
// a day boundary in some timezones, so widen the window by 2 days on
// each side.
// is applied: a calendar day spans instants up to 14 h either side of
// its UTC midnight, so widen the window by 2 days on each side.
const padded = 2 * DAY_MS;
const after = new Date(from.getTime() - padded);
const before = new Date(to.getTime() + padded);
const dayAnchors = rule.between(after, before, true);
// An RRULE with BYHOUR (the measurement-reminder protocols) emits several
// anchors per day; the times of day are expanded per calendar day, so
// collapse the anchors to their days first.
const dayKeys = new Set<number>();
for (const anchor of rule.between(after, before, true)) {
dayKeys.add(startOfUtcDay(anchor).getTime());
}
const times = effectiveTimesOfDay(schedule);
const slots: Occurrence[] = [];
for (const anchor of dayAnchors) {
for (const dayKey of dayKeys) {
const day = new Date(dayKey);
for (const time of times) {
const at = applyTimeOfDayToDate(anchor, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone);
if (at.getTime() < from.getTime()) continue;
if (at.getTime() > to.getTime()) continue;
slots.push(buildOccurrence(at, time, schedule));
@@ -730,17 +769,19 @@ function expandLegacy(
to: Date,
): Occurrence[] {
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt;
const anchorWeekStartUtc = startOfUtcWeek(anchor).getTime();
const anchorWeekStart = startOfCivilWeek(anchorCivilDay(ctx)).getTime();
const times = effectiveTimesOfDay(schedule);
const endsOnDay = endsOnCivilDay(ctx);
const slots: Occurrence[] = [];
// Iterate every UTC day in [from-1, to+1] to cover any time-of-day
// that might land in [from, to] after applying the local-tz HH:mm.
// Iterate every calendar day (carried as UTC midnight) in [from-1, to+1]
// to cover any time-of-day that might land in [from, to] once the HH:mm
// is materialised in the user's zone. Every comparison below is between
// calendar days; only `applyTimeOfDayToCivilDay` touches the zone.
const start = startOfUtcDay(new Date(from.getTime() - DAY_MS));
const end = startOfUtcDay(new Date(to.getTime() + DAY_MS));
const startsOnFloor = ctx.medication.startsOn
? startOfUtcDay(ctx.medication.startsOn).getTime()
? civilDayOfDate(ctx.medication.startsOn).getTime()
: null;
for (
let day = start;
@@ -754,13 +795,12 @@ function expandLegacy(
if (startsOnFloor !== null && day.getTime() < startsOnFloor) {
continue;
}
// Day-of-week filter (empty = every day). Use the user's
// timezone weekday so the legacy "Mon = 1" encoding aligns with
// the user's local-Mon, not UTC-Mon.
const localWeekday = wallClockInTz(day, ctx.timeZone).weekday;
// Day-of-week filter (empty = every day). `day` IS the user's
// calendar day, so its UTC weekday is the local weekday.
const weekday = day.getUTCDay();
if (
recurrence.daysOfWeek.length > 0 &&
!recurrence.daysOfWeek.includes(localWeekday)
!recurrence.daysOfWeek.includes(weekday)
) {
continue;
}
@@ -768,9 +808,9 @@ function expandLegacy(
// Multi-week interval — the fix vs the legacy worker, which
// silently ignored intervalWeeks.
if (recurrence.intervalWeeks > 1) {
const dayWeekStart = startOfUtcWeek(day).getTime();
const dayWeekStart = startOfCivilWeek(day).getTime();
const weeksFromAnchor = Math.round(
(dayWeekStart - anchorWeekStartUtc) / WEEK_MS,
(dayWeekStart - anchorWeekStart) / WEEK_MS,
);
const phase =
((weeksFromAnchor % recurrence.intervalWeeks) +
@@ -780,15 +820,12 @@ function expandLegacy(
}
// endsOn cap.
if (
ctx.medication.endsOn &&
day.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime()
) {
if (endsOnDay && day.getTime() > endsOnDay.getTime()) {
continue;
}
for (const time of times) {
const at = applyTimeOfDayToDate(day, time, ctx.timeZone);
const at = applyTimeOfDayToCivilDay(day, time, ctx.timeZone);
if (at.getTime() < from.getTime()) continue;
if (at.getTime() > to.getTime()) continue;
slots.push(buildOccurrence(at, time, schedule));
@@ -807,37 +844,115 @@ function effectiveTimesOfDay(schedule: CanonicalSchedule): string[] {
: [schedule.windowStart];
}
// ────────────────────────────────────────────────────────────────────
// Calendar days
//
// A "civil day" is a calendar date carried as UTC midnight of its
// Y/M/D, the same shape Prisma returns for a `@db.Date` column and the
// rrule library works in. Its UTC fields ARE the calendar date; it is
// never read in the user's zone. Instants (createdAt, intake times,
// slot instants) become civil days through `civilDayOfInstant`, which
// reads them on the user's wall clock. Only `applyTimeOfDayToCivilDay`
// turns a civil day back into an instant.
// ────────────────────────────────────────────────────────────────────
/** A `@db.Date` value (UTC midnight of the calendar date) as a civil day. */
function civilDayOfDate(d: Date): Date {
return startOfUtcDay(d);
}
/** The calendar day an instant falls on in `tz`, as a civil day. */
function civilDayOfInstant(instant: Date, tz: string): Date {
const p = wallClockInTz(instant, tz);
return new Date(Date.UTC(p.year, p.month - 1, p.day));
}
function addCivilDays(day: Date, n: number): Date {
return new Date(
Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate() + n),
);
}
/**
* Apply an "HH:mm" time-of-day to `day` in the user's IANA timezone,
* returning the corresponding UTC instant. DST-aware via the two-pass
* solver pattern from `medication-schedule.ts`.
*
* `day` is interpreted as "the day in the user's timezone at which
* the time-of-day should land" — we read its wall-clock Y/M/D in the
* target zone, then materialise the instant for that Y/M/D + H/M in
* that zone. So passing midnight-UTC on the spring-forward day with
* Europe/Berlin gives the local-Berlin day, not the previous-Berlin
* day even if the UTC midnight technically falls before the local
* day boundary.
* The schedule's first calendar day: `startsOn` is a calendar date; the
* `createdAt` fallback is an instant and so is read on the user's clock.
*/
function applyTimeOfDayToDate(day: Date, hhmm: string, tz: string): Date {
function anchorCivilDay(ctx: RecurrenceContext): Date {
return ctx.medication.startsOn
? civilDayOfDate(ctx.medication.startsOn)
: civilDayOfInstant(ctx.medication.createdAt, ctx.timeZone);
}
function endsOnCivilDay(ctx: RecurrenceContext): Date | null {
return ctx.medication.endsOn ? civilDayOfDate(ctx.medication.endsOn) : null;
}
/** Local midnight of a civil day (DST-safe, via its local noon). */
function startOfCivilDayInstant(day: Date, tz: string): Date {
return startOfLocalDayInTz(applyTimeOfDayToCivilDay(day, "12:00", tz), tz);
}
/** The last millisecond of a civil day on the user's clock. */
function endOfCivilDayInstant(day: Date, tz: string): Date {
return new Date(
startOfCivilDayInstant(addCivilDays(day, 1), tz).getTime() - 1,
);
}
/** The Sunday that starts the calendar week of a civil day. */
function startOfCivilWeek(day: Date): Date {
return addCivilDays(day, -day.getUTCDay());
}
/**
* Materialise an "HH:mm" on a civil day in the user's IANA timezone,
* returning the corresponding UTC instant. DST-aware via the two-pass
* solver pattern from `medication-schedule.ts`: a wall-clock time that
* does not exist on a spring-forward day resolves forward (02:30 in
* Europe/Berlin on 2026-03-29 becomes 03:30).
*
* The calendar date comes from the civil day's UTC fields and is never
* re-read in `tz` — see the section comment above.
*/
function applyTimeOfDayToCivilDay(day: Date, hhmm: string, tz: string): Date {
const [hStr, mStr] = hhmm.split(":");
const h = Number(hStr);
const m = Number(mStr);
if (!Number.isFinite(h) || !Number.isFinite(m)) return day;
const parts = wallClockInTz(day, tz);
let guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0),
const wall = Date.UTC(
day.getUTCFullYear(),
day.getUTCMonth(),
day.getUTCDate(),
h,
m,
0,
0,
);
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, tz);
guess = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, h, m, 0, 0) -
offsetMin * 60_000,
);
// Try the zone offset in force a day before and a day after. A candidate
// is valid when it reads back as the requested wall clock. On a
// fall-back day both are valid and the earlier one wins; on a
// spring-forward day neither is, and the pre-transition offset moves the
// time forward past the gap. That also covers zones whose clocks skip
// midnight itself (America/Santiago), where an iterative solver settled
// on the previous evening.
const before = new Date(
wall - tzOffsetMinutes(new Date(wall - DAY_MS), tz) * 60_000,
);
const after = new Date(
wall - tzOffsetMinutes(new Date(wall + DAY_MS), tz) * 60_000,
);
const readsBack = (c: Date): boolean => {
const p = wallClockInTz(c, tz);
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute) === wall;
};
const validBefore = readsBack(before);
const validAfter = readsBack(after);
if (validBefore && validAfter) {
return before.getTime() <= after.getTime() ? before : after;
}
return guess;
if (validAfter) return after;
return before;
}
function tzOffsetMinutes(date: Date, tz: string): number {
@@ -916,12 +1031,6 @@ function endOfUtcDay(d: Date): Date {
);
}
function startOfUtcWeek(d: Date): Date {
const midnight = startOfUtcDay(d);
const weekday = midnight.getUTCDay(); // 0 = Sun
return new Date(midnight.getTime() - weekday * DAY_MS);
}
/** RFC 5545 basic-format UTC instant: `YYYYMMDDTHHMMSSZ`. */
function formatUtcBasic(d: Date): string {
const pad = (n: number, w = 2): string => String(n).padStart(w, "0");
+13
View File
@@ -136,3 +136,16 @@ export function formatUnitsPerDose(value: number, locale?: string): string {
useGrouping: false,
}).format(rounded);
}
/**
* `Math.floor` for the supply arithmetic, tolerant of binary floating point.
* 2.4 / 0.8 is 2.9999999999999996 in IEEE 754, so a plain floor reports two
* doses left where there are three. Units and units per dose carry at most
* four decimals (per dose at most 100), so a quotient that is not a whole
* number sits at least 1e-6 below the next one; a rounding error sits
* orders of magnitude closer. Anything within 1e-9 of the next whole
* number is that number.
*/
export function floorWhole(value: number): number {
return Math.floor(value + 1e-9);
}