mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user