mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(medications): validate per-dose dose windows on schedule writes
Accept an optional doseWindows array on the schedule create/update routes: each entry pins an explicit on-time range [start,end] to one of the schedule's times-of-day, defaulting to the symmetric grace band when absent. Reject malformed ranges (bad HH:mm, a timeOfDay outside the schedule, an inverted span) with a 422 so the persisted window can never feed the band minter a degenerate range. Regenerates the OpenAPI contract for the additive field (iOS-safe).
This commit is contained in:
1 parent
6526780f3b
commit
e523a3ae00
5 files changed
+248
No files matched your search
@@ -4307,6 +4307,15 @@ components:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 52
|
||||
doseWindows:
|
||||
description: 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.
|
||||
maxItems: 8
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DoseWindowEntry"
|
||||
required:
|
||||
- windowStart
|
||||
- windowEnd
|
||||
@@ -4315,6 +4324,28 @@ components:
|
||||
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.
|
||||
DoseWindowEntry:
|
||||
type: object
|
||||
properties:
|
||||
timeOfDay:
|
||||
type: string
|
||||
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
|
||||
description: The dose time this window applies to (matches a `timesOfDay` entry).
|
||||
start:
|
||||
type: string
|
||||
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
|
||||
description: On-time band lower bound (HH:mm, user local).
|
||||
end:
|
||||
type: string
|
||||
pattern: ^([01]\d|2[0-3]):([0-5]\d)$
|
||||
description: On-time band upper bound (HH:mm, user local). Must be >= `start`.
|
||||
required:
|
||||
- timeOfDay
|
||||
- start
|
||||
- end
|
||||
description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]`
|
||||
(HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then
|
||||
ad-hoc.
|
||||
UpdateMedicationRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -318,6 +318,10 @@ export const PUT = apiHandler(
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
|
||||
// replace re-creates the rows, so the column is re-written each
|
||||
// time; absent leaves it NULL (default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -326,6 +326,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(s.cyclicOffWeeks !== undefined && {
|
||||
cyclicOffWeeks: s.cyclicOffWeeks,
|
||||
}),
|
||||
// v1.15.18 — per-dose configurable on-time windows. Stored as the
|
||||
// validated `{ timeOfDay, start, end }[]` JSON; absent leaves the
|
||||
// column NULL (every slot on the default ±1h derivation).
|
||||
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time window (`doseWindows`) validation.
|
||||
*
|
||||
* The schedule schema accepts an optional `[{ timeOfDay, start, end }]` array.
|
||||
* Each entry must be well-formed HH:mm with `start <= end`, name a real dose
|
||||
* time of the schedule, and no `timeOfDay` may repeat. Absent leaves the
|
||||
* default ±1h behaviour unchanged.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { doseWindowEntrySchema, scheduleSchema } from "../medication";
|
||||
|
||||
function baseSchedule(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
windowStart: "07:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("doseWindowEntrySchema", () => {
|
||||
it("accepts a well-formed entry", () => {
|
||||
expect(
|
||||
doseWindowEntrySchema.safeParse({
|
||||
timeOfDay: "07:00",
|
||||
start: "07:00",
|
||||
end: "09:00",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects start > end", () => {
|
||||
expect(
|
||||
doseWindowEntrySchema.safeParse({
|
||||
timeOfDay: "07:00",
|
||||
start: "12:00",
|
||||
end: "07:00",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed HH:mm", () => {
|
||||
expect(
|
||||
doseWindowEntrySchema.safeParse({
|
||||
timeOfDay: "7:00",
|
||||
start: "07:00",
|
||||
end: "09:00",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
doseWindowEntrySchema.safeParse({
|
||||
timeOfDay: "07:00",
|
||||
start: "07:00",
|
||||
end: "25:00",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a zero-width window (start === end)", () => {
|
||||
expect(
|
||||
doseWindowEntrySchema.safeParse({
|
||||
timeOfDay: "07:00",
|
||||
start: "08:00",
|
||||
end: "08:00",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleSchema doseWindows", () => {
|
||||
it("is optional — a schedule without it validates", () => {
|
||||
expect(scheduleSchema.safeParse(baseSchedule()).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a window for a real dose time", () => {
|
||||
const r = scheduleSchema.safeParse(
|
||||
baseSchedule({
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "07:00", end: "09:00" }],
|
||||
}),
|
||||
);
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a window whose timeOfDay is not a scheduled dose time", () => {
|
||||
const r = scheduleSchema.safeParse(
|
||||
baseSchedule({
|
||||
doseWindows: [{ timeOfDay: "13:00", start: "13:00", end: "14:00" }],
|
||||
}),
|
||||
);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a duplicate timeOfDay", () => {
|
||||
const r = scheduleSchema.safeParse(
|
||||
baseSchedule({
|
||||
doseWindows: [
|
||||
{ timeOfDay: "07:00", start: "07:00", end: "08:00" },
|
||||
{ timeOfDay: "07:00", start: "08:00", end: "09:00" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a per-entry start > end", () => {
|
||||
const r = scheduleSchema.safeParse(
|
||||
baseSchedule({
|
||||
doseWindows: [{ timeOfDay: "07:00", start: "12:00", end: "07:00" }],
|
||||
}),
|
||||
);
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("matches a legacy single-time schedule against windowStart", () => {
|
||||
const r = scheduleSchema.safeParse({
|
||||
windowStart: "08:00",
|
||||
windowEnd: "10:00",
|
||||
// no timesOfDay → effective dose time is windowStart
|
||||
doseWindows: [{ timeOfDay: "08:00", start: "08:00", end: "11:00" }],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,43 @@ export type MedicationDeliveryForm =
|
||||
const RRULE_PROPS =
|
||||
/^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/;
|
||||
|
||||
/**
|
||||
* v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose
|
||||
* the window applies to; `start`/`end` are the HH:mm on-time bounds in the
|
||||
* user's wall clock. `start <= end` within the day (an overnight window is not
|
||||
* a configurable on-time band — the late tail owns the cross-midnight tail).
|
||||
*/
|
||||
export const doseWindowEntrySchema = z
|
||||
.object({
|
||||
timeOfDay: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe("The dose time this window applies to (matches a `timesOfDay` entry)."),
|
||||
start: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe("On-time band lower bound (HH:mm, user local)."),
|
||||
end: z
|
||||
.string()
|
||||
.regex(timeRegex, "Format: HH:mm")
|
||||
.describe("On-time band upper bound (HH:mm, user local). Must be >= `start`."),
|
||||
})
|
||||
.refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), {
|
||||
message: "Window start must be on or before end (same day)",
|
||||
path: ["end"],
|
||||
})
|
||||
.meta({
|
||||
id: "DoseWindowEntry",
|
||||
description:
|
||||
"One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.",
|
||||
});
|
||||
|
||||
/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */
|
||||
function hhmmToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(":");
|
||||
return Number(h) * 60 + Number(m);
|
||||
}
|
||||
|
||||
export const scheduleSchema = z
|
||||
.object({
|
||||
windowStart: z
|
||||
@@ -251,6 +288,21 @@ export const scheduleSchema = z
|
||||
.describe(
|
||||
"Cyclic \"off\" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.",
|
||||
),
|
||||
/**
|
||||
* v1.15.18 — per-dose configurable on-time intake window (Marc'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),
|
||||
@@ -290,6 +342,38 @@ export const scheduleSchema = z
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user