Files
HealthLog/src/lib/validations/medication/schedule.ts
T

221 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod/v4";
import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence";
import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base";
export const scheduleSchema = z
.object({
windowStart: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe(
"Legacy single-time-of-intake (HH:mm, user local). v1.5 keeps the field for backwards compatibility with pre-wizard iOS clients; the new `timesOfDay` array supersedes it.",
),
windowEnd: z
.string()
.regex(timeRegex, "Format: HH:mm")
.describe(
"Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.",
),
label: z
.string()
.max(50)
.optional()
.describe('Optional human label (e.g. "Morning", "Evening").'),
dose: z
.string()
.max(50)
.optional()
.describe(
"Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.",
),
daysOfWeek: z
.array(z.number().int().min(0).max(6))
.optional()
.describe(
"Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` string.",
),
intervalWeeks: z
.number()
.int()
.min(1)
.max(4)
.optional()
.describe(
"Legacy multi-week stride (1..4). Bi-weekly + tri-weekly were broken in the pre-v1.5 reminder worker; new writes encode the same intent via `rrule` (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`).",
),
/**
* v1.5 — first-class times-of-day. One or more HH:mm entries in
* the user's wall-clock; the engine applies them per matched day.
* Empty array falls back to `[windowStart]` for backwards-compat.
*/
timesOfDay: z
.array(z.string().regex(timeRegex, "Format: HH:mm"))
.max(8)
.optional()
.describe(
"v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Up to 8 entries. Absent or empty means the route stamps `[windowStart]` so the new engine always sees a populated array.",
),
/**
* v1.5 — reminder grace window (minutes). Replaces the implicit
* `windowEnd - windowStart` span for late-classification. NULL
* falls back to the legacy span. Capped at 24 hours.
*/
reminderGraceMinutes: z
.number()
.int()
.min(1)
.max(24 * 60)
.optional()
.describe(
"Reminder grace window in minutes. Caps at 24h. NULL falls back to the legacy `windowEnd - windowStart` span.",
),
/**
* v1.5 — RFC 5545 RRULE string for calendar-anchored cadences.
* Mutually exclusive with `rollingIntervalDays`.
*/
rrule: z
.string()
.max(200)
.regex(RRULE_PROPS, "Invalid RRULE")
.optional()
.describe(
"RFC 5545 RRULE string (subset). Use for daily / weekly-with-BYDAY / multi-week / monthly / yearly cadences. Mutually exclusive with `rollingIntervalDays`. Examples: `FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO,WE,FR`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`.",
),
/**
* v1.5 — flexible-rolling interval in days, counted from the
* latest MedicationIntakeEvent.takenAt. Mutually exclusive with
* `rrule`. Range 1..365 days.
*/
rollingIntervalDays: z
.number()
.int()
.min(1)
.max(365)
.optional()
.describe(
"Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt` (the dose re-anchors when logged). Mutually exclusive with `rrule`. Range 1..365.",
),
/**
* v1.7.0 — schedule-type discriminator. SCHEDULED (default) keeps the
* rrule / rolling / legacy cadence. PRN is as-needed (never projected,
* reminded, or counted in compliance expected; still loggable).
* CYCLIC wraps the inner cadence with an N-on / M-off week phase.
*/
scheduleType: z
.enum(SCHEDULE_TYPES)
.optional()
.describe(
"Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.",
),
/** v1.7.0 — cyclic "on" weeks. Required when `scheduleType === "CYCLIC"`. */
cyclicOnWeeks: z
.number()
.int()
.min(1)
.max(52)
.optional()
.describe(
'Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise.',
),
/** v1.7.0 — cyclic "off" weeks. Required when `scheduleType === "CYCLIC"`. */
cyclicOffWeeks: z
.number()
.int()
.min(0)
.max(52)
.optional()
.describe(
'Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.',
),
/**
* v1.15.18 — per-dose configurable on-time intake window (the maintainer's
* "07:00–09:00" lever). One entry per dose time the user wants an explicit
* range for; a `timeOfDay` with no entry keeps the symmetric ±1h default.
* Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the
* legacy `windowStart`), and `start <= end` within the day. Absent → every
* slot uses the default derivation (unchanged behaviour).
*/
doseWindows: z
.array(doseWindowEntrySchema)
.max(8)
.optional()
.describe(
"Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.",
),
})
.refine((s) => !(s.rrule && s.rollingIntervalDays), {
message: "A schedule can be calendar-anchored (rrule) or rolling, not both",
path: ["rrule"],
})
.refine(
(s) =>
s.scheduleType !== "CYCLIC" ||
(s.cyclicOnWeeks !== undefined && s.cyclicOffWeeks !== undefined),
{
message: "cyclic schedules require both cyclicOnWeeks and cyclicOffWeeks",
path: ["cyclicOnWeeks"],
},
)
.refine(
(s) =>
s.scheduleType !== "PRN" ||
(s.rrule === undefined && s.rollingIntervalDays === undefined),
{
message:
"PRN schedules cannot carry a cadence (rrule or rollingIntervalDays)",
path: ["scheduleType"],
},
)
.refine(
(s) =>
s.rollingIntervalDays === undefined ||
s.rollingIntervalDays === null ||
!s.timesOfDay ||
s.timesOfDay.length <= 1,
{
message: "rolling-cadence schedules accept at most one time of day",
path: ["timesOfDay"],
},
)
.refine(
(s) => {
// Every per-dose window must name a real dose time. The effective dose
// times are `timesOfDay` when set, else the single legacy `windowStart`
// (mirrors the engine's `effectiveTimesOfDay`).
if (!s.doseWindows || s.doseWindows.length === 0) return true;
const times = new Set(
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
);
return s.doseWindows.every((w) => times.has(w.timeOfDay));
},
{
message:
"Each doseWindows.timeOfDay must match one of the schedule's timesOfDay",
path: ["doseWindows"],
},
)
.refine(
(s) => {
// A dose time may carry at most one explicit window.
if (!s.doseWindows || s.doseWindows.length === 0) return true;
const seen = new Set<string>();
for (const w of s.doseWindows) {
if (seen.has(w.timeOfDay)) return false;
seen.add(w.timeOfDay);
}
return true;
},
{
message: "doseWindows must not repeat a timeOfDay",
path: ["doseWindows"],
},
)
.meta({
id: "MedicationScheduleInput",
description:
"Single schedule entry on a medication. v1.5 introduces `timesOfDay`, `rrule`, `rollingIntervalDays`, and `reminderGraceMinutes` as first-class fields; `windowStart`, `windowEnd`, `daysOfWeek`, and `intervalWeeks` are preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint.",
});