diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index ef845778a..7f75efd1a 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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 && diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 95ee18abd..a802f37b9 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -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); + }); }); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index d6f5c0d23..6d6723dd4 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -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), }; } diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index b20902e20..c746367eb 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -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, }, }, }, diff --git a/src/lib/medications/scheduling/__tests__/band-minter.test.ts b/src/lib/medications/scheduling/__tests__/band-minter.test.ts index e2975302f..bdbb16447 100644 --- a/src/lib/medications/scheduling/__tests__/band-minter.test.ts +++ b/src/lib/medications/scheduling/__tests__/band-minter.test.ts @@ -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"); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts new file mode 100644 index 000000000..648c4baf2 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/worker-helpers.test.ts @@ -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(); + }); +}); diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index 4d62ba424..90326471a 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -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 { + const map = new Map(); + 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. diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index 63b6bff73..e608c23b7 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -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), }; } diff --git a/src/lib/medications/scheduling/compliance.ts b/src/lib/medications/scheduling/compliance.ts index e06061609..cc8e9ccff 100644 --- a/src/lib/medications/scheduling/compliance.ts +++ b/src/lib/medications/scheduling/compliance.ts @@ -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 diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 600835ee5..527026b53 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -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 { diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 88d0d6d4b..cdd81e3e0 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -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 = { diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index 6ae899a31..fa66b78ed 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -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; + 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;