diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 4dcef09f1..5d41d10cc 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -895,3 +895,100 @@ describe("compose-mode — multi-schedule encoder + hydrator", () => { ]); }); }); + +describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => { + it("emits only explicit, live-time windows in the create body", () => { + const p: WizardPayload = { + ...withCadence("daily"), + timesOfDay: ["08:00", "20:00"], + doseWindows: [ + // explicit widened band → kept + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + // point-equivalent (±1h) → dropped + { timeOfDay: "20:00", start: "19:00", end: "21:00" }, + ], + }; + const body = buildCreateBody(p); + expect(body.schedules[0].doseWindows).toEqual([ + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + ]); + }); + + it("omits doseWindows when every dose uses the default band", () => { + const p: WizardPayload = { + ...withCadence("daily"), + timesOfDay: ["08:00"], + doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "09:00" }], + }; + expect(buildCreateBody(p).schedules[0].doseWindows).toBeUndefined(); + }); + + it("drops a window whose timeOfDay is no longer a live dose time", () => { + const p: WizardPayload = { + ...withCadence("daily"), + timesOfDay: ["08:00"], + doseWindows: [{ timeOfDay: "20:00", start: "18:00", end: "23:00" }], + }; + expect(buildCreateBody(p).schedules[0].doseWindows).toBeUndefined(); + }); + + it("hydrates persisted windows back onto the active draft mirror", () => { + const initial: MedicationPayload = { + id: "med1", + name: "Foo", + dose: "5 mg", + category: "OTHER", + treatmentClass: "GENERIC", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sched1", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "12:00" }], + }, + ], + }; + const payload = hydrateWizardPayload(initial); + expect(payload.doseWindows).toEqual([ + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + ]); + expect(payload.schedules[0].doseWindows).toEqual([ + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + ]); + }); + + it("survives the edit → re-encode round-trip", () => { + const initial: MedicationPayload = { + id: "med1", + name: "Foo", + dose: "5 mg", + category: "OTHER", + treatmentClass: "GENERIC", + notificationsEnabled: true, + startsOn: null, + endsOn: null, + oneShot: false, + schedules: [ + { + id: "sched1", + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + doseWindows: [{ timeOfDay: "08:00", start: "07:00", end: "12:00" }], + }, + ], + }; + const payload = hydrateWizardPayload(initial); + const body = buildCreateBody(payload, "edit"); + expect(body.schedules[0].doseWindows).toEqual([ + { timeOfDay: "08:00", start: "07:00", end: "12:00" }, + ]); + }); +}); diff --git a/src/components/medications/wizard/steps/Step7Times.tsx b/src/components/medications/wizard/steps/Step7Times.tsx index 507ce5fa5..3af88c51d 100644 --- a/src/components/medications/wizard/steps/Step7Times.tsx +++ b/src/components/medications/wizard/steps/Step7Times.tsx @@ -3,6 +3,8 @@ import { Moon, Sun, Sunrise, Sunset } from "lucide-react"; import { TimesOfDayChips } from "@/components/medications/scheduling/TimesOfDayChips"; +import { DoseWindowEditor } from "@/components/medications/scheduling/DoseWindowEditor"; +import type { DoseWindowScale } from "@/components/medications/scheduling/dose-window"; import { useTranslations } from "@/lib/i18n/context"; import type { StepProps } from "./Step1Name"; @@ -21,6 +23,20 @@ const PRESETS = [ { time: "22:00", icon: Moon, key: "night" }, ] as const; +/** + * Window scale for the late-tail hint — a weekly / monthly RRULE or a + * ≥2-day rolling cadence is day-scale (the 4-day rule); everything else + * is intraday (the ±1h / +3h minute bands). + */ +function scaleForCadence(payload: StepProps["payload"]): DoseWindowScale { + const rolling = payload.cadence.rollingIntervalDays; + if (typeof rolling === "number" && rolling >= 2) return "dayScale"; + if (/FREQ=(WEEKLY|MONTHLY|YEARLY)/.test(payload.cadence.rrule ?? "")) { + return "dayScale"; + } + return "intraday"; +} + export function Step7Times({ payload, applyPartial }: StepProps) { const { t } = useTranslations(); function togglePreset(time: string) { @@ -82,6 +98,15 @@ export function Step7Times({ payload, applyPartial }: StepProps) { // each suggestion isn't shown twice. showPresets={false} /> + + {/* v1.15.18 — the SAME per-dose window editor the Zeitplan tab uses, + so a new med sets windows with identical semantics to an edit. */} + applyPartial({ doseWindows })} + scale={scaleForCadence(payload)} + /> ); } diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 21e7f27ce..7a7cffa44 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -64,6 +64,10 @@ import { type MedicationDeliveryForm, } from "@/lib/validations/medication"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { + isExplicitRange, + type DoseWindowEntry, +} from "@/components/medications/scheduling/dose-window"; // ──────────────────────────────────────────────────────────────────── // Step 2 — treatment-class taxonomy @@ -176,6 +180,13 @@ export interface ScheduleDraft { cadence: CadenceValue; subControls: CadenceSubControls; timesOfDay: string[]; + /** + * v1.15.18 — per-dose explicit on-time windows. One entry per dose + * time the user widened beyond the symmetric ±1h default; a time with + * no entry keeps the default band. Empty when every dose uses the + * default (the wire then omits / sends `[]`, leaving the column NULL). + */ + doseWindows?: DoseWindowEntry[]; } /** @@ -228,6 +239,12 @@ export interface WizardPayload { subControls: CadenceSubControls; /** Mirrors `schedules[activeScheduleIndex].timesOfDay`. */ timesOfDay: string[]; + /** + * v1.15.18 — mirrors `schedules[activeScheduleIndex].doseWindows`. The + * per-dose window editor in Step 7 reads + writes this flat slot; the + * active-draft helpers keep it in sync with the schedule list. + */ + doseWindows: DoseWindowEntry[]; startsOn: Date | null; endsOn: Date | null; notificationsEnabled: boolean; @@ -272,6 +289,7 @@ export function emptyWizardPayload(): WizardPayload { cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + doseWindows: draft.doseWindows ?? [], startsOn: todayUtc(), endsOn: null, notificationsEnabled: true, @@ -299,6 +317,7 @@ export function commitActiveDraft(payload: WizardPayload): WizardPayload { cadence: payload.cadence, subControls: payload.subControls, timesOfDay: payload.timesOfDay, + doseWindows: payload.doseWindows, }; const schedules = payload.schedules.slice(); schedules[idx] = next; @@ -323,6 +342,7 @@ export function setActiveSchedule( cadence: draft.cadence, subControls: draft.subControls, timesOfDay: draft.timesOfDay, + doseWindows: draft.doseWindows ?? [], }; } @@ -533,6 +553,8 @@ export interface CreateMedicationBody { rollingIntervalDays?: number; daysOfWeek?: number[]; intervalWeeks?: number; + /** v1.15.18 — per-dose explicit on-time windows. Omitted when none. */ + doseWindows?: DoseWindowEntry[]; }>; } @@ -591,6 +613,16 @@ export function encodeScheduleDraft( timesOfDay: times, }; if (draft.id) out.id = draft.id; + // v1.15.18 — emit only the explicit windows that still name a live dose + // time and actually differ from the default ±1h band (a point-equivalent + // window leaves the column on the default derivation). + if (draft.doseWindows && draft.doseWindows.length > 0) { + const liveTimes = new Set(times); + const windows = draft.doseWindows.filter( + (w) => liveTimes.has(w.timeOfDay) && isExplicitRange(w), + ); + if (windows.length > 0) out.doseWindows = windows; + } if (!isOneShot) { if (draft.cadence.rrule !== null) { out.rrule = draft.cadence.rrule; @@ -885,6 +917,8 @@ export interface MedicationPayload { timesOfDay?: string[]; rrule?: string | null; rollingIntervalDays?: number | null; + /** v1.15.18 — per-dose explicit on-time windows (edit-hydrate). */ + doseWindows?: DoseWindowEntry[] | null; }>; } @@ -913,6 +947,7 @@ interface MedicationScheduleSnapshot { timesOfDay?: string[]; rrule?: string | null; rollingIntervalDays?: number | null; + doseWindows?: DoseWindowEntry[] | null; } function hydrateScheduleDraft( @@ -933,6 +968,11 @@ function hydrateScheduleDraft( timesOfDay: times, }; if (schedule.id) draft.id = schedule.id; + // v1.15.18 — round-trip the persisted per-dose windows so an edit keeps + // the user's explicit ranges instead of resetting them to the default. + if (schedule.doseWindows && schedule.doseWindows.length > 0) { + draft.doseWindows = schedule.doseWindows; + } return draft; } @@ -976,6 +1016,7 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload cadence: first.cadence, subControls: first.subControls, timesOfDay: first.timesOfDay, + doseWindows: first.doseWindows ?? [], startsOn: initial.startsOn ?? base.startsOn, endsOn: initial.endsOn, notificationsEnabled: initial.notificationsEnabled ?? true,