feat(medications): build the on-time band from the persisted dose window

Thread the persisted `doseWindows` through the shared band minter so the
on-time band for a dose time uses its explicit `[start, end]` range
(minted DST-correctly on the slot's local day) instead of the symmetric
±1h default; the late tail stays cadence-derived. A `normaliseDoseWindows`
helper coerces the stored JSON into a clean entry list, dropping anything
malformed so the read/write paths never defend against an arbitrary shape.

Every band consumer reads the same field: the dose-history endpoint, the
compliance %, the cadence timeline (card last/next dose), and the
write/edit slot attribution all resolve through one minter, so the
history view and the rate can never disagree. The write/edit and
health-score schedule selects now pull the column.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:29:17 +02:00
1 parent 9bb68ade8a
commit 6526780f3b
12 files changed
+438 -19

No files matched your search

@@ -23,6 +23,7 @@ import {
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { userDayKey } from "@/lib/tz/format";
@@ -40,6 +41,8 @@ type MedicationScheduleRow = {
scheduleType: "SCHEDULED" | "PRN" | "CYCLIC";
cyclicOnWeeks: number | null;
cyclicOffWeeks: number | null;
/** v1.15.18 — per-dose configurable on-time windows (persisted JSON). */
doseWindows: unknown;
};
/**
@@ -63,6 +66,7 @@ function toCanonicalForBands(
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
if (
base.timesOfDay.length === 0 &&
@@ -2819,4 +2819,51 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
expect(tally.taken).toBeGreaterThanOrEqual(3);
expect(tally.rate).toBeGreaterThanOrEqual(90);
});
// v1.15.18 W7 — the persisted per-dose window reaches the % through the
// SAME minter the history view + write path use. An 11:29 take that the
// default ±1h leaves ad-hoc (slot missed) is counted on-time once the
// morning slot carries an explicit 07:00–12:00 window.
it("the persisted doseWindows widens the on-time band feeding the %", () => {
const events = [
{ scheduledFor: at(7, 0), takenAt: at(11, 29), skipped: false },
{ scheduledFor: at(19, 0), takenAt: at(19, 0), skipped: false },
];
// DEFAULT (no doseWindows): 11:29 is past the 11:00 cutoff → ad-hoc,
// morning slot missed.
const dflt = tallyComplianceFromLedger(
events,
twiceDaily,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(dflt.missed).toBe(1);
expect(dflt.adHoc).toBe(1);
// WITH the persisted 07:00–12:00 window: 11:29 lands on-time, nothing
// missed, nothing orphaned.
const windowed: ComplianceSchedule[] = [
{
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
},
];
const wt = tallyComplianceFromLedger(
events,
windowed,
ctxFor(),
from,
nowEvening,
nowEvening,
);
expect(wt.missed).toBe(0);
expect(wt.adHoc).toBe(0);
expect(wt.takenOnTime).toBe(2);
expect(wt.rate).toBe(100);
});
});
+11
View File
@@ -32,6 +32,7 @@ import {
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import {
reconstructDoseHistory,
type HistoryIntake,
@@ -371,6 +372,15 @@ export interface ComplianceSchedule {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose configurable on-time windows. Threaded onto the
* canonical schedule so `tallyComplianceFromLedger` builds the on-time band
* from the explicit `[start, end]` range — keeping the % exactly aligned with
* the dose-history view and the write/edit attribution (all consume the same
* minter). Accepts the raw persisted JSON (a full Prisma row drops straight
* in); `toCanonicalSchedule` normalises it via `normaliseDoseWindows`.
*/
doseWindows?: unknown;
}
/**
@@ -499,6 +509,7 @@ function toCanonicalSchedule(
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
}
@@ -332,6 +332,9 @@ export async function computeUserHealthScoreFastPath(
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.15.18 — per-dose window so the score's compliance honours the
// same on-time band the detail % + history use.
doseWindows: true,
},
},
},
@@ -27,6 +27,7 @@ import {
type CanonicalSchedule,
type RecurrenceContext,
} from "../recurrence";
import { attributeIntakeToSlot } from "../attribution";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
@@ -468,3 +469,102 @@ describe("multi-schedule — bands built PER SCHEDULE (no cross-clip)", () => {
expect(span).toBe(2 * 60 * MIN); // ±60min
});
});
// ────────────────────────────────────────────────────────────────────
// 9. Per-dose configurable window (Marc's "07:00–09:00" lever, v1.15.18 W7)
// ────────────────────────────────────────────────────────────────────
describe("explicit per-dose window from schedule.doseWindows", () => {
const m = med();
const day = new Date("2026-06-08T12:00:00Z");
// Morning dose carries an explicit 07:00–12:00 window; the evening dose
// none, so it keeps the symmetric ±60min default.
const sched = schedule({
timesOfDay: ["07:00", "19:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "12:00" }],
});
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
const morning = out.bands.find((b) => b.timeOfDay === "07:00")!;
const evening = out.bands.find((b) => b.timeOfDay === "19:00")!;
it("builds the on-time band from the explicit [start,end] range", () => {
expect(morning.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime());
expect(morning.onTimeEnd.getTime()).toBe(at(day, 12, 0).getTime());
});
it("an 11:29 take lands inside the widened on-time window", () => {
expect(attributeIntakeToSlot(at(day, 11, 29), out.bands)).toMatchObject({
band: { timeOfDay: "07:00" },
status: "on_time",
});
});
it("a dose with no entry keeps the symmetric ±60min default", () => {
expect(evening.onTimeStart.getTime()).toBe(at(day, 19, 0).getTime() - 60 * MIN);
expect(evening.onTimeEnd.getTime()).toBe(at(day, 19, 0).getTime() + 60 * MIN);
});
it("under the DEFAULT (no doseWindows) the same 11:29 take is ad-hoc", () => {
const dflt = buildBandsForMedication({
medication: m,
schedule: schedule({ timesOfDay: ["07:00", "19:00"] }),
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
// 11:29 is > 07:00 + on-time(60) + tail(180) = 11:00 cutoff → no band.
expect(attributeIntakeToSlot(at(day, 11, 29), dflt.bands)).toBeNull();
});
it("a malformed doseWindows shape falls back to the default derivation", () => {
const out2 = buildBandsForMedication({
medication: m,
// start > end is dropped by the normaliser; an unrelated timeOfDay is
// ignored. Neither widens the 07:00 band.
schedule: schedule({
timesOfDay: ["07:00", "19:00"],
doseWindows: [
{ timeOfDay: "07:00", start: "12:00", end: "07:00" },
] as never,
}),
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(day, 0, 0), to: at(day, 23, 59) },
now: at(day, 23, 59),
});
const morn = out2.bands.find((b) => b.timeOfDay === "07:00")!;
expect(morn.onTimeStart.getTime()).toBe(at(day, 7, 0).getTime() - 60 * MIN);
expect(morn.onTimeEnd.getTime()).toBe(at(day, 7, 0).getTime() + 60 * MIN);
});
});
describe("explicit per-dose window is DST-correct (winter)", () => {
const m = med();
const winterDay = new Date("2026-01-15T12:00:00Z");
const sched = schedule({
timesOfDay: ["07:00"],
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
});
const out = buildBandsForMedication({
medication: m,
schedule: sched,
ctx: ctxFor(m),
userTz: TZ,
range: { from: at(winterDay, 0, 0), to: at(winterDay, 23, 59) },
now: at(winterDay, 23, 59),
});
it("mints the [start,end] bounds on the slot's local winter day (CET = +1)", () => {
const band = out.bands[0];
expect(band.onTimeStart.toISOString()).toBe("2026-01-15T06:00:00.000Z");
expect(band.onTimeEnd.toISOString()).toBe("2026-01-15T08:00:00.000Z");
});
});
@@ -0,0 +1,78 @@
/**
* v1.15.18 — `normaliseDoseWindows` coerces the persisted `dose_windows` JSON
* into a clean `DoseWindowEntry[]`, dropping anything malformed so the band
* paths never have to defend against an arbitrary JSON shape.
*/
import { describe, expect, it } from "vitest";
import {
buildCanonicalSchedule,
normaliseDoseWindows,
type WorkerScheduleRow,
} from "../worker-helpers";
describe("normaliseDoseWindows", () => {
it("returns null for null / non-array input", () => {
expect(normaliseDoseWindows(null)).toBeNull();
expect(normaliseDoseWindows(undefined)).toBeNull();
expect(normaliseDoseWindows("nope")).toBeNull();
expect(normaliseDoseWindows({ timeOfDay: "07:00" })).toBeNull();
});
it("keeps a well-formed entry", () => {
expect(
normaliseDoseWindows([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]),
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
});
it("drops entries with start > end, bad HH:mm, or missing keys", () => {
expect(
normaliseDoseWindows([
{ timeOfDay: "07:00", start: "12:00", end: "07:00" }, // start > end
{ timeOfDay: "7:00", start: "07:00", end: "09:00" }, // bad HH:mm
{ timeOfDay: "19:00", start: "19:00" }, // missing end
{ timeOfDay: "08:00", start: "25:00", end: "26:00" }, // out of range
]),
).toBeNull();
});
it("keeps the good entries and drops the bad ones in a mixed array", () => {
expect(
normaliseDoseWindows([
{ timeOfDay: "07:00", start: "07:00", end: "09:00" }, // good
{ timeOfDay: "19:00", start: "20:00", end: "19:00" }, // start > end
]),
).toEqual([{ timeOfDay: "07:00", start: "07:00", end: "09:00" }]);
});
it("threads through buildCanonicalSchedule onto the canonical schedule", () => {
const row: WorkerScheduleRow = {
id: "s1",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
};
expect(buildCanonicalSchedule(row).doseWindows).toEqual([
{ timeOfDay: "07:00", start: "07:00", end: "09:00" },
]);
});
it("a row with no doseWindows yields null on the canonical schedule", () => {
const row: WorkerScheduleRow = {
id: "s1",
windowStart: "07:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["07:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
};
expect(buildCanonicalSchedule(row).doseWindows).toBeNull();
});
});
+115 -19
View File
@@ -47,6 +47,7 @@ import {
expandRollingRetrospective,
occurrencesBetween,
type CanonicalSchedule,
type DoseWindowEntry,
type Occurrence,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
@@ -172,7 +173,7 @@ export function buildBandsForMedication(
// here (intakes drive the anchors), so the interval is the right signal.
const family: "daily" | "weekly" =
schedule.rollingIntervalDays >= 2 ? "weekly" : "daily";
const bands = mintBands(occ, family, input.windowConfig, userTz);
const bands = mintBands(occ, family, input.windowConfig, userTz, schedule);
return { bands, hasExpectedSlots: true, family };
}
@@ -182,7 +183,7 @@ export function buildBandsForMedication(
// field-shape `doseCadenceFamily` heuristic.
const occurrences = occurrencesBetween(schedule, range.from, range.to, ctx);
const family = realisedFamily(schedule, ctx, range);
const bands = mintBands(occurrences, family, input.windowConfig, userTz);
const bands = mintBands(occurrences, family, input.windowConfig, userTz, schedule);
return { bands, hasExpectedSlots: true, family };
}
@@ -231,30 +232,106 @@ function mintBands(
family: "daily" | "weekly",
windowConfig: DoseWindowConfig | undefined,
userTz: string,
schedule: CanonicalSchedule,
): SlotBand[] {
const w = resolveWindows(windowConfig);
// The persisted per-dose windows (Marc's "07:00–09:00" lever). When a slot's
// `timeOfDay` matches an entry, the on-time band is the explicit `[start,
// end]` range; otherwise the default symmetric ±width applies. The late tail
// stays cadence-derived for every slot.
const explicit = indexDoseWindows(schedule.doseWindows ?? null);
const inputs: SlotWindowInput[] = occurrences.map((occ) =>
family === "weekly"
? weeklyWindow(occ, w, userTz)
: dailyWindow(occ, w),
? weeklyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null)
: dailyWindow(occ, w, userTz, explicit.get(occ.timeOfDay) ?? null),
);
return buildSlotBands(inputs);
}
/** Minute-scale band around a slot instant (daily / intraday cadences). */
/**
* Index the schedule's `doseWindows` by `timeOfDay` for O(1) per-slot lookup.
* A duplicate `timeOfDay` keeps the first entry (the write path Zod-validates
* uniqueness, so this is purely defensive).
*/
function indexDoseWindows(
windows: DoseWindowEntry[] | null,
): Map<string, DoseWindowEntry> {
const map = new Map<string, DoseWindowEntry>();
if (!windows) return map;
for (const w of windows) {
// Defence-in-depth: only index well-formed `start <= end` HH:mm entries.
// `buildCanonicalSchedule` already normalises the persisted JSON, but a
// directly-constructed `CanonicalSchedule` (tests, compliance helpers)
// must never widen a band from a malformed range.
if (!isUsableDoseWindow(w)) continue;
if (!map.has(w.timeOfDay)) map.set(w.timeOfDay, w);
}
return map;
}
/** True when a dose window is well-formed HH:mm with `start <= end`. */
function isUsableDoseWindow(w: DoseWindowEntry): boolean {
const start = localHourMinute(w.start);
const end = localHourMinute(w.end);
const startMin = start.hour * 60 + start.minute;
const endMin = end.hour * 60 + end.minute;
return (
HHMM_BAND_RE.test(w.timeOfDay) &&
HHMM_BAND_RE.test(w.start) &&
HHMM_BAND_RE.test(w.end) &&
startMin <= endMin
);
}
const HHMM_BAND_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* Minute-scale band around a slot instant (daily / intraday cadences). When an
* explicit per-dose window is configured for this `timeOfDay`, the on-time band
* is the literal `[start, end]` range minted on the slot's local day (DST-correct
* via `localHmAsUtc`); otherwise the symmetric ±`dailyOnTimeMinutes` default.
*/
function dailyWindow(
occ: Occurrence,
w: ResolvedWindows,
userTz: string,
explicit: DoseWindowEntry | null,
): SlotWindowInput {
const onTime = explicit
? explicitOnTimeBounds(occ.at, explicit, userTz)
: {
onTimeStart: new Date(
occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS,
),
onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS),
};
return {
at: occ.at,
timeOfDay: occ.timeOfDay,
onTimeStart: new Date(occ.at.getTime() - w.dailyOnTimeMinutes * MINUTE_MS),
onTimeEnd: new Date(occ.at.getTime() + w.dailyOnTimeMinutes * MINUTE_MS),
onTimeStart: onTime.onTimeStart,
onTimeEnd: onTime.onTimeEnd,
lateGraceMs: w.dailyOverdueMinutes * MINUTE_MS,
};
}
/**
* Mint an explicit `[start, end]` on-time range on the slot's local calendar
* day. `localHmAsUtc` keys off the local day of its first argument (`occ.at`),
* so the bounds land on the same day as the slot, DST-correct.
*/
function explicitOnTimeBounds(
occAt: Date,
window: DoseWindowEntry,
userTz: string,
): { onTimeStart: Date; onTimeEnd: Date } {
const start = localHourMinute(window.start);
const end = localHourMinute(window.end);
return {
onTimeStart: localHmAsUtc(occAt, userTz, start.hour, start.minute),
onTimeEnd: localHmAsUtc(occAt, userTz, end.hour, end.minute),
};
}
/**
* Day-scale band around a slot instant (weekly / rolling / sparse cadences).
* The ±N-day bounds are minted via `localHmAsUtc` on the calendar days N days
@@ -265,20 +342,39 @@ function weeklyWindow(
occ: Occurrence,
w: ResolvedWindows,
userTz: string,
explicit: DoseWindowEntry | null,
): SlotWindowInput {
const { hour, minute } = localHourMinute(occ.timeOfDay);
const onTimeStart = localHmAsUtc(
new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
const onTimeEnd = localHmAsUtc(
new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
// An explicit per-dose window pins the on-time band to the literal `[start,
// end]` range on the slot's own local day (a within-day window the user set);
// otherwise the default day-scale ±`weeklyOnTimeDays` envelope. Either way the
// late tail below stays day-scale + DST-correct.
const onTimeStart = explicit
? localHmAsUtc(
occ.at,
userTz,
localHourMinute(explicit.start).hour,
localHourMinute(explicit.start).minute,
)
: localHmAsUtc(
new Date(occ.at.getTime() - w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
const onTimeEnd = explicit
? localHmAsUtc(
occ.at,
userTz,
localHourMinute(explicit.end).hour,
localHourMinute(explicit.end).minute,
)
: localHmAsUtc(
new Date(occ.at.getTime() + w.weeklyOnTimeDays * DAY_MS),
userTz,
hour,
minute,
);
// The late tail is also day-scale: the instant N overdue-days past the
// on-time end, minted on the calendar day for DST-correctness, then expressed
// as a millisecond grace `buildSlotBands` consumes.
+10
View File
@@ -44,6 +44,7 @@ import {
expandRollingRetrospective,
occurrencesBetween,
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
import { wallClockInTz } from "@/lib/tz/wall-clock";
/**
@@ -96,6 +97,14 @@ export interface ScheduleLike {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose configurable on-time windows. Carried onto the
* canonical schedule so the cadence-timeline bands (the card last/next dose)
* honour the same explicit window the % and the history view use. Accepts the
* raw persisted JSON (a full Prisma row's `Json?` column drops straight in);
* `toCanonical` normalises it via `normaliseDoseWindows`.
*/
doseWindows?: unknown;
/** Stable id for the engine occurrence; defaults to a synthetic value. */
id?: string;
}
@@ -135,6 +144,7 @@ function toCanonical(
scheduleType: schedule.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(schedule.doseWindows),
};
}
@@ -40,6 +40,7 @@ import {
type CanonicalSchedule,
type RecurrenceContext,
} from "./recurrence";
import { normaliseDoseWindows } from "./worker-helpers";
export interface ComplianceChips {
/** 0-100, taken / (taken + missed). Skipped doses are excluded from
@@ -211,6 +212,7 @@ function ledgerChipCounts(
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(s.doseWindows),
};
// Surface windowStart as the single time-of-day for a legacy daily row
// so the band minter's cadence gate mints its daily band (see the
@@ -81,6 +81,25 @@ export interface CanonicalSchedule {
cyclicOnWeeks: number | null;
/** v1.7.0 — cyclic "off" weeks. Only read when `scheduleType === "CYCLIC"`. */
cyclicOffWeeks: number | null;
/**
* v1.15.18 — per-dose configurable on-time intake windows. The recurrence
* engine itself never reads these (slot timing is unchanged); they ride on
* the canonical schedule purely so the band minter can build a slot's
* on-time band from an explicit `[start, end]` range for the matching
* `timeOfDay`. NULL / empty = every slot uses the default ±1h derivation.
*/
doseWindows?: DoseWindowEntry[] | null;
}
/**
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
* the window applies to (matching a `timesOfDay` entry); `start`/`end` are the
* HH:mm on-time bounds in the user's wall clock (`start <= end`, same local day).
*/
export interface DoseWindowEntry {
timeOfDay: string;
start: string;
end: string;
}
export interface RecurrenceContext {
@@ -299,6 +299,9 @@ const SCHEDULE_SELECT = {
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
// v1.15.18 — the persisted per-dose window the band attribution honours so
// the write/edit slot binding uses the SAME on-time window as the % + history.
doseWindows: true,
} as const;
const MEDICATION_SELECT = {
@@ -18,6 +18,7 @@
*/
import {
type CanonicalSchedule,
type DoseWindowEntry,
type RecurrenceContext,
type ScheduleType,
occurrencesBetween,
@@ -48,6 +49,13 @@ export interface WorkerScheduleRow {
scheduleType?: ScheduleType | null;
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
/**
* v1.15.18 — per-dose on-time windows. The Prisma column is `Json?`, so a
* selected row surfaces it as `Prisma.JsonValue` (or `null`). The adapter
* normalises it to a `DoseWindowEntry[]` (dropping malformed entries) so the
* band minter never has to defend against an arbitrary JSON shape.
*/
doseWindows?: unknown;
}
/** Minimal `Medication` projection used by the worker. */
@@ -78,9 +86,47 @@ export function buildCanonicalSchedule(
scheduleType: schedule.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: schedule.cyclicOnWeeks ?? null,
cyclicOffWeeks: schedule.cyclicOffWeeks ?? null,
doseWindows: normaliseDoseWindows(schedule.doseWindows),
};
}
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/**
* v1.15.18 — coerce the persisted `dose_windows` JSON into a clean
* `DoseWindowEntry[]`. Drops anything that isn't an `{ timeOfDay, start, end }`
* triple of well-formed HH:mm strings with `start <= end` — the column is
* Zod-validated on write, but a hand-edited or legacy row must never crash the
* read/write band paths. Returns `null` (the default-derivation signal) when
* nothing usable survives.
*/
export function normaliseDoseWindows(raw: unknown): DoseWindowEntry[] | null {
if (!Array.isArray(raw)) return null;
const out: DoseWindowEntry[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) continue;
const { timeOfDay, start, end } = item as Record<string, unknown>;
if (
typeof timeOfDay !== "string" ||
typeof start !== "string" ||
typeof end !== "string" ||
!HHMM_RE.test(timeOfDay) ||
!HHMM_RE.test(start) ||
!HHMM_RE.test(end) ||
hhmmToMinutes(start) > hhmmToMinutes(end)
) {
continue;
}
out.push({ timeOfDay, start, end });
}
return out.length > 0 ? out : null;
}
function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":");
return Number(h) * 60 + Number(m);
}
/** Build the canonical engine context from worker-loop state. */
export function buildRecurrenceContext(input: {
medication: WorkerMedicationRow;