mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/medication-schedule-timezones' into release/v1.39.3
This commit is contained in:
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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user