Files
HealthLog/src/components/medications/wizard/wizard-payload.ts
T
Marc-André Bombeck e849b9cfb9 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.
2026-06-08 19:38:58 +02:00

1117 lines
41 KiB
TypeScript

/**
* v1.5.4 — Medication wizard payload + pure helpers.
*
* The wizard is a popup that drives the same eight-step decision in
* a "one question per breath" cadence. The pure helpers here
* (state machine, validation gate, request body builder, summary
* formatter) carry the test surface; the dialog and step components
* compose them.
*
* Step encoding:
* 1. Name (medication-global)
* 2. Treatment class / clinical category (medication-global)
* 3. Dose (medication-global)
* 4. Course window (medication-global)
* 5. Cadence (per-schedule)
* 6. Sub-cadence detail (per-schedule, conditional)
* 7. Times of day (per-schedule)
* 8. Schedule list + reminders + summary (medication-global)
*
* The visible step counter follows the path the user actually walks
* for the ACTIVE schedule:
*
* ONE_SHOT_PATH = [1, 2, 3, 4, 8] — 5 steps
* DAILY_PATH = [1, 2, 3, 4, 5, 7, 8] — 7 steps
* RECURRING_PATH = [1, 2, 3, 4, 5, 6, 7, 8] — 8 steps
*
* The mode is set in Step 5: "Einmalig" picks `oneShot`; every other
* radio picks `recurring`. The cadence kind drives the path between
* daily (skips Step 6) and the calendar-anchored / rolling shapes.
*
* Compose-mode: a medication can hold N parallel schedules. Steps 5-7
* mutate the schedule at `activeScheduleIndex`; Step 8 surfaces the
* full list with add / edit / remove actions. The flat
* `(mode, cadence, subControls, timesOfDay)` fields on the payload
* mirror the active draft so the step components stay
* single-schedule-aware. The list-mutating helpers below keep that
* mirror in sync.
*
* Each row in Step 2 maps to a `(treatmentClass, category)` pair that
* the request body carries. GLP-1-Injektion is the only row that picks
* `treatmentClass = "GLP1"`; every other row inherits `GENERIC` and
* differs only on the clinical-category bucket the analytics layer
* reads. The mapping table is the single source of truth.
*/
import { encodeCadence } from "@/components/medications/scheduling/CadencePicker";
import {
dateToIsoString,
isoStringToDate,
} from "@/components/medications/scheduling/CourseWindowRow";
import {
type CadenceKind,
type CadenceSubControls,
type CadenceValue,
DEFAULT_SUB_CONTROLS,
} from "@/components/medications/scheduling/types";
import {
inferCadenceFromLegacy,
legacyPairFromCadence,
} from "@/components/medications/scheduling/legacy-bridge";
import {
type MedicationTreatmentClass,
type MedicationCategoryValue,
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
// ────────────────────────────────────────────────────────────────────
/**
* Step 2 row identifiers. Distinct from `MedicationCategoryValue` so a
* single picker row can map onto the orthogonal `(treatmentClass,
* category)` pair the schema persists.
*/
export type WizardTreatmentRow =
| "bloodPressure"
| "diabetes"
| "hormone"
| "glp1"
| "painRelief"
| "allergy"
| "vitamin"
| "supplement"
| "antibiotic"
| "other";
/**
* The ten taxonomy rows in their visible order. Marc-confirmed (D-1
* §3 Step 2): Blutdruck · Diabetes · Hormone · GLP-1-Injektion ·
* Schmerz · Allergie · Vitamine · Nahrungsergänzung · Antibiotikum ·
* Sonstiges.
*/
export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"bloodPressure",
"diabetes",
"hormone",
"glp1",
"painRelief",
"allergy",
"vitamin",
"supplement",
"antibiotic",
"other",
];
/**
* Mapping table: Step 2 row → `(treatmentClass, category)` pair the
* request body carries. GLP-1 is the only row that bends the Prisma
* treatment-class enum; every other row inherits `GENERIC`.
*/
export const WIZARD_TREATMENT_MAPPING: Record<
WizardTreatmentRow,
{ treatmentClass: MedicationTreatmentClass; category: MedicationCategoryValue }
> = {
bloodPressure: { treatmentClass: "GENERIC", category: "BLOOD_PRESSURE" },
diabetes: { treatmentClass: "GENERIC", category: "DIABETES" },
hormone: { treatmentClass: "GENERIC", category: "HORMONE" },
glp1: { treatmentClass: "GLP1", category: "OTHER" },
painRelief: { treatmentClass: "GENERIC", category: "PAIN_RELIEF" },
allergy: { treatmentClass: "GENERIC", category: "ALLERGY" },
vitamin: { treatmentClass: "GENERIC", category: "VITAMIN" },
supplement: { treatmentClass: "GENERIC", category: "SUPPLEMENT" },
antibiotic: { treatmentClass: "GENERIC", category: "ANTIBIOTIC" },
other: { treatmentClass: "GENERIC", category: "OTHER" },
};
/**
* Reverse-map a `(treatmentClass, category)` pair onto the Step 2 row
* the wizard pre-selects on edit. GLP-1 takes priority (a GLP-1
* medication is a GLP-1 row regardless of the side-table value).
* Unknown categories fall back to "other".
*/
export function rowFromTreatment(
treatmentClass: string | undefined,
category: string | undefined,
): WizardTreatmentRow {
if (treatmentClass === "GLP1") return "glp1";
switch (category) {
case "BLOOD_PRESSURE":
return "bloodPressure";
case "DIABETES":
return "diabetes";
case "HORMONE":
return "hormone";
case "PAIN_RELIEF":
return "painRelief";
case "ALLERGY":
return "allergy";
case "VITAMIN":
return "vitamin";
case "SUPPLEMENT":
return "supplement";
case "ANTIBIOTIC":
return "antibiotic";
default:
return "other";
}
}
// ────────────────────────────────────────────────────────────────────
// Wizard payload + per-schedule draft
// ────────────────────────────────────────────────────────────────────
/**
* One schedule under construction inside the wizard. A medication can
* hold N parallel drafts (e.g. short-acting insulin to meals + a
* long-acting evening dose on the same medication). The `id` is
* present on edit hydrate so the round-trip preserves identity for
* the API layer; create drafts leave it undefined.
*/
export interface ScheduleDraft {
id?: string;
mode: "oneShot" | "recurring" | null;
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[];
}
/**
* The wizard's working state. The cadence + sub-controls mirror the
* picker primitives; the treatment row collapses Step 2's
* `(treatmentClass, category)` decision into a single value the
* mapping table re-expands at submit time.
*
* Steps 1-4 + 8 are medication-global. Steps 5-7 edit the schedule
* at `activeScheduleIndex`. The flat `(mode, cadence, subControls,
* timesOfDay)` fields mirror the active draft so the step components
* stay single-schedule-aware; the list-mutating helpers below keep
* the mirror in sync.
*/
export interface WizardPayload {
name: string;
doseAmount: string;
doseUnit: string;
treatmentRow: WizardTreatmentRow | null;
/**
* v1.6.0 — route of administration. Drives the injection-site
* rotation preview (shown when `INJECTION`) and, paired with
* `mode === "oneShot"`, the one-time-injection shape. Defaults to
* `ORAL`; the GLP-1 treatment row nudges it to `INJECTION` on
* selection so the common case needs no extra tap.
*/
deliveryForm: MedicationDeliveryForm;
/**
* v1.6.0 — doses per pen / vial for inventory math. Empty string =
* inventory tracking off (maps to `undefined` / NULL on the wire).
*/
dosesPerUnit: string;
/**
* v1.8.5 — per-medication injection-site tracking opt-in. Only
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
* false (opt-in). Toggling it off clears `allowedInjectionSites`.
*/
trackInjectionSites: boolean;
/**
* v1.8.5 — per-medication allowed / preferred injection sites. Empty =
* no restriction (every site offered). Only meaningful when
* `trackInjectionSites` is true.
*/
allowedInjectionSites: InjectionSiteKey[];
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
mode: "oneShot" | "recurring" | null;
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
cadence: CadenceValue;
/** Mirrors `schedules[activeScheduleIndex].subControls`. */
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;
/** Every parallel schedule under construction. Always >= 1 entry. */
schedules: ScheduleDraft[];
/** Which schedule Steps 5-7 currently edit. */
activeScheduleIndex: number;
}
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
function todayUtc(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0),
);
}
/** Default draft for a fresh schedule slot. */
export function emptyScheduleDraft(): ScheduleDraft {
return {
mode: null,
cadence: encodeCadence("daily", DEFAULT_SUB_CONTROLS),
subControls: { ...DEFAULT_SUB_CONTROLS },
timesOfDay: ["08:00"],
};
}
/** Sensible defaults for a fresh wizard. */
export function emptyWizardPayload(): WizardPayload {
const draft = emptyScheduleDraft();
return {
name: "",
doseAmount: "",
doseUnit: "mg",
treatmentRow: null,
deliveryForm: "ORAL",
dosesPerUnit: "",
trackInjectionSites: false,
allowedInjectionSites: [],
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
doseWindows: draft.doseWindows ?? [],
startsOn: todayUtc(),
endsOn: null,
notificationsEnabled: true,
schedules: [draft],
activeScheduleIndex: 0,
};
}
// ────────────────────────────────────────────────────────────────────
// Active-draft mirror helpers
// ────────────────────────────────────────────────────────────────────
/**
* Project the flat per-schedule fields onto the schedule slot at
* `activeScheduleIndex`. Pure — returns a new payload with the
* updated schedules array.
*/
export function commitActiveDraft(payload: WizardPayload): WizardPayload {
const idx = payload.activeScheduleIndex;
if (idx < 0 || idx >= payload.schedules.length) return payload;
const current = payload.schedules[idx];
const next: ScheduleDraft = {
...current,
mode: payload.mode,
cadence: payload.cadence,
subControls: payload.subControls,
timesOfDay: payload.timesOfDay,
doseWindows: payload.doseWindows,
};
const schedules = payload.schedules.slice();
schedules[idx] = next;
return { ...payload, schedules };
}
/**
* Pull the schedule slot at `index` onto the flat mirror fields and
* set `activeScheduleIndex`. Used when Step 8 routes the user into a
* Bearbeiten action.
*/
export function setActiveSchedule(
payload: WizardPayload,
index: number,
): WizardPayload {
if (index < 0 || index >= payload.schedules.length) return payload;
const draft = payload.schedules[index];
return {
...payload,
activeScheduleIndex: index,
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
doseWindows: draft.doseWindows ?? [],
};
}
/**
* Append a fresh empty schedule to the list, commit the currently
* active draft so its in-flight edits are not lost, and land the
* active pointer on the new slot.
*/
export function addSchedule(payload: WizardPayload): WizardPayload {
const committed = commitActiveDraft(payload);
const draft = emptyScheduleDraft();
const schedules = [...committed.schedules, draft];
return {
...committed,
schedules,
activeScheduleIndex: schedules.length - 1,
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
};
}
/**
* Remove the schedule at `index`. Refuses when only one schedule
* remains — a medication must carry at least one. The active pointer
* clamps into the new list bounds and the flat mirror re-syncs.
*/
export function removeSchedule(
payload: WizardPayload,
index: number,
): WizardPayload {
if (payload.schedules.length <= 1) return payload;
if (index < 0 || index >= payload.schedules.length) return payload;
const schedules = payload.schedules.filter((_, i) => i !== index);
const nextIndex = Math.min(payload.activeScheduleIndex, schedules.length - 1);
const draft = schedules[nextIndex];
return {
...payload,
schedules,
activeScheduleIndex: nextIndex,
mode: draft.mode,
cadence: draft.cadence,
subControls: draft.subControls,
timesOfDay: draft.timesOfDay,
};
}
// ────────────────────────────────────────────────────────────────────
// Path table — visible step counter
// ────────────────────────────────────────────────────────────────────
const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8];
const DAILY_PATH: readonly number[] = [1, 2, 3, 4, 5, 7, 8];
const RECURRING_PATH: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8];
/**
* The ordered list of raw step numbers for the active mode + cadence.
* Mirrors v1.5.3's `progressIndices` shape so the visible counter
* tracks the path the user actually walks.
*
* The mode is set in Step 5. Before that, the wizard renders the
* recurring path so the displayed counter stays monotone.
*/
export function progressIndices(
mode: WizardPayload["mode"],
cadenceKind: CadenceKind,
): readonly number[] {
if (mode === "oneShot") return ONE_SHOT_PATH;
if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH;
return RECURRING_PATH;
}
// ────────────────────────────────────────────────────────────────────
// validateStep — pure per-step gate
// ────────────────────────────────────────────────────────────────────
/**
* Returns true when the step's required inputs are populated.
*
* Matrix:
* - Step 1: name populated.
* - Step 2: treatment row picked.
* - Step 3: doseAmount populated.
* - Step 4: startsOn present + (endsOn null OR endsOn >= startsOn).
* - Step 5: mode picked.
* - Step 6: cadence-specific (weekdays >= 1; interval >= 1; monthly
* day 1..31; rolling 1..365).
* - Step 7: >= 1 valid HH:mm time.
* - Step 8: mirrors Step 4 (the reminders toggle has no failure mode).
*/
export function validateStep(payload: WizardPayload, step: number): boolean {
switch (step) {
case 1:
return payload.name.trim().length > 0;
case 2:
return payload.treatmentRow !== null;
case 3:
return payload.doseAmount.trim().length > 0;
case 4:
if (payload.startsOn === null) return false;
if (payload.endsOn === null) return true;
return payload.endsOn.getTime() >= payload.startsOn.getTime();
case 5:
return payload.mode !== null;
case 6: {
if (payload.mode !== "recurring") return true;
switch (payload.cadence.kind) {
case "weekdays":
return payload.subControls.weekdays.length >= 1;
case "everyNWeeks":
return (
payload.subControls.weekdays.length >= 1 &&
payload.subControls.intervalWeeks >= 1
);
case "monthly":
return (
payload.subControls.dayOfMonth >= 1 &&
payload.subControls.dayOfMonth <= 31
);
case "rolling":
return (
payload.subControls.rollingDays >= 1 &&
payload.subControls.rollingDays <= 365
);
default:
return true;
}
}
case 7: {
const times = payload.timesOfDay.filter((t) => TIME_RE.test(t));
return times.length >= 1;
}
case 8:
if (payload.startsOn === null) return false;
if (payload.endsOn === null) return true;
return payload.endsOn.getTime() >= payload.startsOn.getTime();
default:
return false;
}
}
// ────────────────────────────────────────────────────────────────────
// firstInvalidIndex — multi-step forward lookahead
// ────────────────────────────────────────────────────────────────────
/**
* Walks `stepList` from `fromIndex` (inclusive) and returns the index of
* the first slot whose `validateStep` gate fails, or `stepList.length`
* when every slot from `fromIndex` onward validates.
*
* This generalises the single-step `canContinue` gate
* (`validateStep(payload, step)`) into the forward-jump lookahead the
* dot stepper uses: a forward jump to path-index `j` is allowed iff
* `firstInvalidIndex(payload, stepList, i) >= j` — i.e. every gate
* between the current slot `i` and the target `j` passes. The dot
* stepper reuses the same value as its `reachableUntil` ceiling so the
* gate and the visual reachability never drift.
*
* Pure: reuses `validateStep` verbatim, no new validation logic.
*/
export function firstInvalidIndex(
payload: WizardPayload,
stepList: readonly number[],
fromIndex: number,
): number {
for (let k = Math.max(0, fromIndex); k < stepList.length; k++) {
if (!validateStep(payload, stepList[k])) return k;
}
return stepList.length;
}
// ────────────────────────────────────────────────────────────────────
// buildCreateBody — POST /api/medications request body
// ────────────────────────────────────────────────────────────────────
export interface CreateMedicationBody {
name: string;
dose: string;
category: MedicationCategoryValue;
treatmentClass: MedicationTreatmentClass;
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: MedicationDeliveryForm;
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
dosesPerUnit?: number;
/** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */
allowedInjectionSites?: InjectionSiteKey[];
/**
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
* default for `notificationsEnabled` but never overwrites it on
* edit. The detail page's notifications switch is the single source
* of truth post-create; the wizard PUT body omits the field so a
* user-toggled value never round-trips back to the wizard default.
* `buildCreateBody(payload, "edit")` returns `undefined` here.
*/
notificationsEnabled?: boolean;
startsOn?: string;
endsOn?: string;
oneShot: boolean;
schedules: Array<{
id?: string;
windowStart: string;
windowEnd: string;
timesOfDay: string[];
rrule?: string;
rollingIntervalDays?: number;
daysOfWeek?: number[];
intervalWeeks?: number;
/** v1.15.18 — per-dose explicit on-time windows. Omitted when none. */
doseWindows?: DoseWindowEntry[];
}>;
}
function composeDose(amount: string, unit: string): string {
const a = amount.trim();
const u = unit.trim();
if (!a) return u;
if (!u) return a;
return `${a} ${u}`;
}
function sortTimes(times: string[]): string[] {
return [...times]
.filter((t) => TIME_RE.test(t))
.sort((a, b) => a.localeCompare(b));
}
function deriveWindow(sortedTimes: string[]): [string, string] {
if (sortedTimes.length === 0) return ["08:00", "09:00"];
const start = sortedTimes[0];
if (sortedTimes.length === 1) return [start, addOneHour(start)];
return [start, sortedTimes[sortedTimes.length - 1]];
}
function addOneHour(hhmm: string): string {
const m = TIME_RE.exec(hhmm);
if (!m) return hhmm;
const h = Number(m[1]);
const mm = m[2];
const next = (h + 1) % 24;
return `${String(next).padStart(2, "0")}:${mm}`;
}
/**
* Encode a single `ScheduleDraft` into the request-body schedule
* entry. Mirrors the legacy bridge so each schedule dual-writes
* `(daysOfWeek, intervalWeeks, rrule, rollingIntervalDays,
* timesOfDay)` correctly. The `oneShotMedication` flag suppresses
* recurrence on every schedule — the route enforces the
* one-schedule-per-one-shot invariant on top.
*/
export function encodeScheduleDraft(
draft: ScheduleDraft,
oneShotMedication: boolean,
): CreateMedicationBody["schedules"][number] {
const isOneShot = oneShotMedication || draft.mode === "oneShot";
const times = sortTimes(
isOneShot ? draft.timesOfDay.slice(0, 1) : draft.timesOfDay,
);
const [windowStart, windowEnd] = deriveWindow(times);
const legacyPair = legacyPairFromCadence(draft.cadence, draft.subControls);
const out: CreateMedicationBody["schedules"][number] = {
windowStart,
windowEnd,
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;
} else if (draft.cadence.rollingIntervalDays !== null) {
out.rollingIntervalDays = draft.cadence.rollingIntervalDays;
}
if (legacyPair.daysOfWeek.length > 0) {
out.daysOfWeek = legacyPair.daysOfWeek;
}
out.intervalWeeks = legacyPair.intervalWeeks;
}
return out;
}
/**
* Map a wizard payload onto the `POST /api/medications` body. The
* route still requires the legacy `windowStart` / `windowEnd` pair for
* the v1.5.x dual-write window; we derive them from the times.
*
* The treatment row drives both `treatmentClass` and `category` through
* the mapping table. The route writes `category` to the side-table.
*
* Compose-mode: every entry in `payload.schedules` lands in the body's
* `schedules` array. The active draft is committed first so any
* in-flight edits flow into the encoded entry.
*
* For one-shot medications the wizard collapses to a single schedule;
* recurrence fields are suppressed across the board and the route
* normalises `endsOn` to equal `startsOn`.
*/
export function buildCreateBody(
payload: WizardPayload,
forMode: "create" | "edit" = "create",
): CreateMedicationBody {
const committed = commitActiveDraft(payload);
const dose = composeDose(committed.doseAmount, committed.doseUnit);
const mapping =
committed.treatmentRow !== null
? WIZARD_TREATMENT_MAPPING[committed.treatmentRow]
: WIZARD_TREATMENT_MAPPING.other;
// A one-shot medication carries exactly one schedule with no
// recurrence. We honour the active draft (it carries the times the
// user picked); the route enforces the one-schedule invariant.
const isOneShot = committed.mode === "oneShot";
const draftsToEmit = isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
category: mapping.category,
treatmentClass: mapping.treatmentClass,
deliveryForm: committed.deliveryForm,
...(Number.isFinite(parsedDosesPerUnit) &&
parsedDosesPerUnit >= 1 && {
dosesPerUnit: parsedDosesPerUnit,
}),
// v1.8.5 — injection-site tracking is only meaningful for an
// INJECTION delivery form. Always send the boolean for an injection
// (so edits can deactivate it); send the allowed list only when
// tracking is on. Non-injections omit both (server keeps defaults).
...(committed.deliveryForm === "INJECTION" && {
trackInjectionSites: committed.trackInjectionSites,
...(committed.trackInjectionSites && {
allowedInjectionSites: committed.allowedInjectionSites,
}),
}),
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
// create. Edits never round-trip the wizard's default because the
// detail page's notifications switch is the single source of
// truth post-create.
...(forMode === "create" && {
notificationsEnabled: committed.notificationsEnabled,
}),
oneShot: isOneShot,
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
};
if (committed.startsOn) {
body.startsOn = dateToIsoString(committed.startsOn);
}
if (isOneShot && committed.startsOn) {
body.endsOn = dateToIsoString(committed.startsOn);
} else if (committed.endsOn) {
body.endsOn = dateToIsoString(committed.endsOn);
}
return body;
}
// ────────────────────────────────────────────────────────────────────
// summariseCadence — plain-language Step 8 line
// ────────────────────────────────────────────────────────────────────
function summaryKeyForDraft(
mode: WizardPayload["mode"],
cadence: CadenceValue,
sub: CadenceSubControls,
): string {
if (mode === "oneShot") return "oneShot";
switch (cadence.kind) {
case "daily":
return "daily";
case "weekdays":
return "weekdays";
case "everyNWeeks":
return sub.intervalWeeks === 2 ? "biweekly" : "everyNWeeks";
case "monthly":
return "monthly";
case "everyNMonths":
return sub.intervalMonths === 3 ? "quarterly" : "everyNMonths";
case "yearly":
return "yearly";
case "rolling":
return "rolling";
case "oneShot":
return "oneShot";
}
}
function weekdaysDetail(
tokens: readonly string[],
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (tokens.length === 0) return "";
const names = tokens
.map((tok) =>
t(`medications.scheduling.cadence.weekdays.long.${tok.toLowerCase()}`),
)
.filter(Boolean);
if (names.length === 0) return "";
return t("medications.wizard.summary.weekdaysDetail", {
days: names.join(", "),
});
}
/**
* The cadence-bearing slice both {@link WizardPayload} and
* {@link ScheduleDraft} share. Summary helpers only ever read these three
* fields, so the cadence-line builder below stays draft/payload-agnostic.
*/
type CadenceShape = Pick<ScheduleDraft, "mode" | "cadence" | "subControls">;
/**
* The interpolation count the cadence summary keys read. `everyNWeeks`
* surfaces the week interval; `rolling` the day interval; everything else
* leaves it at zero. Extracted so the medication-wide and per-schedule
* summaries can't drift apart.
*/
function cadenceInterpolationN(shape: CadenceShape): number {
if (shape.cadence.kind === "everyNWeeks") return shape.subControls.intervalWeeks;
if (shape.cadence.kind === "rolling")
return shape.cadence.rollingIntervalDays ?? 0;
return 0;
}
/**
* The trailing detail clause appended to a cadence phrase (the weekday
* list or the day-of-month). Empty for one-shot doses and for cadences
* that carry no extra detail.
*/
function cadenceDetailPhrase(
shape: CadenceShape,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (shape.mode === "oneShot") return "";
switch (shape.cadence.kind) {
case "weekdays":
return weekdaysDetail(shape.subControls.weekdays, t);
case "everyNWeeks":
return weekdaysDetail(shape.subControls.weekdays, t);
case "monthly":
return t("medications.wizard.summary.dayOfMonthDetail", {
day: shape.subControls.dayOfMonth,
});
default:
return "";
}
}
/**
* The full cadence line — phrase (`every N weeks`, `daily`, …) plus its
* detail clause. Both {@link summariseCadence} and
* {@link summariseScheduleDraft} build their first segment from this so
* the two summaries render byte-identical cadence text.
*/
function cadenceLineFor(
shape: CadenceShape,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceKey = summaryKeyForDraft(
shape.mode,
shape.cadence,
shape.subControls,
);
const cadencePhrase = t(`medications.wizard.summary.cadence.${cadenceKey}`, {
n: cadenceInterpolationN(shape),
});
return cadencePhrase + cadenceDetailPhrase(shape, t);
}
/**
* Plain-language summary for Step 8. Mirrors v1.5.3's helper shape so
* the test surface stays familiar. The `t` callback is passed in so
* the helper stays pure (no React hook coupling).
*/
export function summariseCadence(
payload: WizardPayload,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(payload.timesOfDay).join(", "),
})
: "";
const startPhrase = payload.startsOn
? t("medications.wizard.summary.startsOn", {
date: dateToIsoString(payload.startsOn),
})
: "";
const endPhrase =
payload.mode === "oneShot"
? ""
: payload.endsOn
? t("medications.wizard.summary.endsOn", {
date: dateToIsoString(payload.endsOn),
})
: t("medications.wizard.summary.noEndDate");
return [cadenceLine, timesPhrase, startPhrase, endPhrase]
.filter(Boolean)
.join(" · ");
}
/**
* Per-schedule summary the Step 8 list cards render. Skips the
* course-window phrases (those are medication-global and only need to
* render once on the summary block at the top of Step 8).
*/
export function summariseScheduleDraft(
draft: ScheduleDraft,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const cadenceLine = cadenceLineFor(draft, t);
const timesPhrase =
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(draft.timesOfDay).join(", "),
})
: "";
return [cadenceLine, timesPhrase].filter(Boolean).join(" · ");
}
// ────────────────────────────────────────────────────────────────────
// Hydration — edit-path payload from an existing medication
// ────────────────────────────────────────────────────────────────────
/**
* Snapshot of a medication's persisted state, as the list page passes
* it to `<MedicationWizardDialog mode="edit">`. Schedules carry both
* the v1.5 cadence fields and the legacy `(daysOfWeek, intervalWeeks)`
* pair so the bridge can hydrate older medications cleanly.
*/
export interface MedicationPayload {
id: string;
name: string;
dose: string;
category: string;
treatmentClass?: string;
/** v1.6.0 — route of administration. Defaults to ORAL when absent. */
deliveryForm?: string;
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
dosesPerUnit?: number | null;
/** v1.8.5 — injection-site tracking opt-in. */
trackInjectionSites?: boolean;
/** v1.8.5 — per-medication allowed / preferred injection sites. */
allowedInjectionSites?: string[];
notificationsEnabled: boolean;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
schedules: Array<{
id?: string;
windowStart: string;
windowEnd: string;
label?: string | null;
dose?: string | null;
daysOfWeek?: number[];
intervalWeeks?: number;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
/** v1.15.18 — per-dose explicit on-time windows (edit-hydrate). */
doseWindows?: DoseWindowEntry[] | null;
}>;
}
/** Coerce a persisted `deliveryForm` string onto the closed enum. */
function normaliseDeliveryForm(value: string | undefined): MedicationDeliveryForm {
return value === "INJECTION" || value === "OTHER" ? value : "ORAL";
}
const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
/** Try to split "5 mg" into ["5", "mg"]; falls back to ["", input]. */
function parseDoseExpression(input: string): { amount: string; unit: string } {
const match = DOSE_EXPR_RE.exec(input.trim());
if (!match) return { amount: "", unit: input.trim() };
const amount = match[1] ?? "";
const unit = (match[2] ?? "").trim();
return { amount, unit };
}
interface MedicationScheduleSnapshot {
id?: string;
windowStart: string;
windowEnd: string;
daysOfWeek?: number[];
intervalWeeks?: number;
timesOfDay?: string[];
rrule?: string | null;
rollingIntervalDays?: number | null;
doseWindows?: DoseWindowEntry[] | null;
}
function hydrateScheduleDraft(
schedule: MedicationScheduleSnapshot,
oneShot: boolean,
): ScheduleDraft {
const cadence = deriveCadenceFromSchedule(schedule, oneShot);
const times =
schedule.timesOfDay && schedule.timesOfDay.length > 0
? [...schedule.timesOfDay]
: schedule.windowStart
? [schedule.windowStart]
: ["08:00"];
const draft: ScheduleDraft = {
mode: oneShot ? "oneShot" : "recurring",
cadence: cadence.value,
subControls: cadence.subControls,
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;
}
/**
* Hydrate a `WizardPayload` from a medication snapshot. Every schedule
* on the snapshot lands as its own `ScheduleDraft` so compose-mode
* surfaces the full list on the edit path. The flat mirror points at
* the first draft so the step components stay coherent.
*
* The legacy bridge (`inferCadenceFromLegacy`) infers a cadence from
* the `(daysOfWeek, intervalWeeks)` pair when the v1.5 `rrule` /
* `rollingIntervalDays` fields are absent.
*/
export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload {
const base = emptyWizardPayload();
const parsedDose = parseDoseExpression(initial.dose ?? "");
const drafts: ScheduleDraft[] =
initial.schedules.length > 0
? initial.schedules.map((schedule) =>
hydrateScheduleDraft(schedule, initial.oneShot),
)
: [emptyScheduleDraft()];
const first = drafts[0];
return {
...base,
name: initial.name ?? "",
doseAmount: parsedDose.amount,
doseUnit: parsedDose.unit || base.doseUnit,
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
dosesPerUnit:
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
? String(initial.dosesPerUnit)
: "",
trackInjectionSites: initial.trackInjectionSites ?? false,
allowedInjectionSites: (initial.allowedInjectionSites ??
[]) as InjectionSiteKey[],
mode: first.mode,
cadence: first.cadence,
subControls: first.subControls,
timesOfDay: first.timesOfDay,
doseWindows: first.doseWindows ?? [],
startsOn: initial.startsOn ?? base.startsOn,
endsOn: initial.endsOn,
notificationsEnabled: initial.notificationsEnabled ?? true,
schedules: drafts,
activeScheduleIndex: 0,
};
}
/**
* Decision the dialog uses to pick the landing step on edit-hydrate:
* Step 1 for a single-schedule medication (a straight top-down walk),
* Step 8 (the summary list) for a multi-schedule one, where the muscle
* memory is "see every parallel schedule first".
*/
export function landingStepForEdit(payload: WizardPayload): number {
return payload.schedules.length > 1 ? 8 : 1;
}
function deriveCadenceFromSchedule(
schedule: MedicationPayload["schedules"][number],
oneShot: boolean,
): { value: CadenceValue; subControls: CadenceSubControls } {
if (oneShot) {
const sub = { ...DEFAULT_SUB_CONTROLS };
return { value: encodeCadence("oneShot", sub), subControls: sub };
}
if (typeof schedule.rollingIntervalDays === "number") {
const sub: CadenceSubControls = {
...DEFAULT_SUB_CONTROLS,
rollingDays: schedule.rollingIntervalDays,
};
return { value: encodeCadence("rolling", sub), subControls: sub };
}
if (typeof schedule.rrule === "string" && schedule.rrule.length > 0) {
const sub = subControlsFromRrule(schedule.rrule);
const kind = kindFromRrule(schedule.rrule);
return { value: encodeCadence(kind, sub), subControls: sub };
}
// Legacy fallback — infer from daysOfWeek + intervalWeeks.
return inferCadenceFromLegacy({
daysOfWeek: schedule.daysOfWeek ?? [],
intervalWeeks: schedule.intervalWeeks ?? 1,
});
}
function kindFromRrule(rrule: string): CadenceKind {
// Lightweight inspection — the picker re-encodes the RRULE on every
// change so the wizard never relies on a perfect parse here.
if (rrule.includes("FREQ=DAILY")) return "daily";
if (rrule.includes("FREQ=WEEKLY")) {
return /INTERVAL=([2-9]|[1-9]\d)/.test(rrule) ? "everyNWeeks" : "weekdays";
}
if (rrule.includes("FREQ=MONTHLY")) {
return /INTERVAL=([2-9]|[1-9]\d)/.test(rrule) ? "everyNMonths" : "monthly";
}
if (rrule.includes("FREQ=YEARLY")) return "yearly";
return "daily";
}
function subControlsFromRrule(rrule: string): CadenceSubControls {
const sub: CadenceSubControls = { ...DEFAULT_SUB_CONTROLS };
const byday = /BYDAY=([A-Z,]+)/.exec(rrule);
if (byday) {
const tokens = byday[1].split(",").filter((t) =>
["MO", "TU", "WE", "TH", "FR", "SA", "SU"].includes(t),
);
if (tokens.length > 0) {
sub.weekdays = tokens as CadenceSubControls["weekdays"];
}
}
const interval = /INTERVAL=(\d{1,3})/.exec(rrule);
if (interval) {
const n = Number(interval[1]);
if (rrule.includes("FREQ=WEEKLY")) sub.intervalWeeks = n;
else if (rrule.includes("FREQ=MONTHLY")) sub.intervalMonths = n;
}
const bymonthday = /BYMONTHDAY=(-?\d{1,2})/.exec(rrule);
if (bymonthday) {
const n = Number(bymonthday[1]);
if (n >= 1 && n <= 31) sub.dayOfMonth = n;
}
const bymonth = /BYMONTH=(\d{1,2})/.exec(rrule);
if (bymonth && bymonthday) {
const month = Number(bymonth[1]);
const day = Number(bymonthday[1]);
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
sub.yearlyDate = `${new Date().getUTCFullYear()}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
}
return sub;
}
// ────────────────────────────────────────────────────────────────────
// Re-exports — convenience for the dialog and tests
// ────────────────────────────────────────────────────────────────────
export { dateToIsoString, isoStringToDate };