Files
HealthLog/src/components/medications/wizard/wizard-payload.ts
T
Marc-André Bombeck 7d3557a3fd fix(medications): every taken dose moves the stock, in units that match the product
Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
2026-06-12 17:42:31 +02:00

1143 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. maintainer-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.16.10 — inventory units one dose consumes (2 × 2 mg tablets for
* a 4 mg dose). Empty string / "1" both map to the server default 1.
*/
unitsPerDose: 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: "",
unitsPerDose: "1",
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.16.10 — inventory units per dose. Omitted at the default 1. */
unitsPerDose?: 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 parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 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.16.10 — always sent when valid (an edit back to 1 must reach
// the server; the create default matches the schema default).
...(Number.isFinite(parsedUnitsPerDose) &&
parsedUnitsPerDose >= 1 && {
unitsPerDose: parsedUnitsPerDose,
}),
// 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,
// v1.16.4 — UI callers pass `useFormatters().date` so the course-window
// dates render in the active locale ("11.06.2026" / "Jun 11, 2026")
// instead of the raw ISO string. The ISO default keeps the helper pure
// for non-React callers.
formatDate: (d: Date) => string = dateToIsoString,
): 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: formatDate(payload.startsOn),
})
: "";
const endPhrase =
payload.mode === "oneShot"
? ""
: payload.endsOn
? t("medications.wizard.summary.endsOn", {
date: formatDate(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.16.10 — inventory units per dose (default 1). */
unitsPerDose?: 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)
: "",
unitsPerDose:
typeof initial.unitsPerDose === "number" && initial.unitsPerDose >= 1
? String(initial.unitsPerDose)
: "1",
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 };