feat(medications): thread the dose window through the wizard

Wire the per-dose window into the creation/edit wizard so a new
medication and a structural edit set windows with the same semantics the
schedule tab uses. Step 7 renders the shared <DoseWindowEditor> below the
times picker; the payload carries doseWindows on each schedule draft, the
create body emits only the explicit ranges that still name a live dose
time and differ from the default band, and edit-hydrate round-trips the
persisted windows back onto the active draft.

The step root data attrs stay stable for e2e.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 19:38:58 +02:00
1 parent 63baf47c96
commit e849b9cfb9
3 files changed
+163

No files matched your search

@@ -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" },
]);
});
});
@@ -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. */}
<DoseWindowEditor
timesOfDay={payload.timesOfDay}
value={payload.doseWindows}
onChange={(doseWindows) => applyPartial({ doseWindows })}
scale={scaleForCadence(payload)}
/>
</div>
);
}
@@ -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,