feat(medications): as-needed medications log without a schedule

A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:56:03 +02:00
1 parent 550f314a54
commit 3943dcfb72
43 files changed
+1273 -70

No files matched your search

@@ -295,3 +295,75 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
expect(html).not.toContain("can still be taken");
});
});
/**
* v1.16.11 (#316) — as-needed (PRN) card presentation: a calm
* "As needed" marker where next-due normally sits, no due pill, no
* overdue escalation, and NO compliance block (neither bars nor an
* eternal skeleton — the batched compliance read excludes PRN rows).
* The last-intake line stays: the card is last-taken oriented.
*/
describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
function makeAsNeededMed(overrides: Record<string, unknown> = {}) {
const lastTaken = new Date();
lastTaken.setHours(lastTaken.getHours() - 3);
return {
id: "med-prn-1",
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: lastTaken.toISOString(),
todayEventCount: 1,
nextDueAt: null,
nextDueOverdue: false,
asNeeded: true,
schedules: [],
...overrides,
};
}
it("renders the calm marker, the last-intake line, and no compliance block", () => {
const med = makeAsNeededMed();
const client = makeClient();
// Deliberately NOT seeding the compliance cache: the batched read
// excludes as-needed medications, so the card must not fall back to
// the loading skeleton.
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).toContain('data-slot="medication-as-needed-marker"');
expect(html).toContain("As needed");
expect(html).toContain("Last intake:");
// No due/overdue presentation, ever.
expect(html).not.toContain("Overdue");
expect(html).not.toContain("Take now");
// No compliance bars and no skeleton placeholder for them.
expect(html).not.toContain("Adherence (");
});
it("keeps the quick-log action row (intakes still record)", () => {
const med = makeAsNeededMed();
const client = makeClient();
const html = render(
<MedicationCard
medication={med}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
// The shared intake actions render (take/skip) — an as-needed dose
// is loggable from the card exactly like a scheduled one.
expect(html).toContain("Taken");
});
});
@@ -261,6 +261,43 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain('aria-label="Take – Mounjaro"');
expect(html).toContain("4 doses");
});
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
const client = makeClient();
// Deliberately NOT seeding compliance for the PRN row — the batched
// payload excludes as-needed medications, and the column must show
// the en-dash instead of an eternal skeleton.
const lastTaken = new Date();
lastTaken.setHours(lastTaken.getHours() - 3);
const html = render(
<MedicationTable
activeMedications={[
med({
id: "prn1",
name: "Ibuprofen",
asNeeded: true,
schedules: [],
nextDueAt: null,
lastTakenAt: lastTaken.toISOString(),
stockDosesRemaining: 10,
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).toContain('data-slot="medication-table-as-needed-marker"');
expect(html).toContain("As needed");
// No due/overdue presentation in the status or next-dose cells.
expect(html).not.toContain("Overdue");
// Compliance column shows the en-dash, not bars or a skeleton.
expect(html).not.toContain("Adherence (");
// The quick actions stay — an as-needed dose is loggable inline.
expect(html).toContain('aria-label="Take – Ibuprofen"');
// Stock column still renders (inventory tracking works for PRN).
expect(html).toContain("10 doses");
});
});
describe("<MedicationTable> — manual order + aria-sort", () => {
@@ -195,6 +195,26 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
expect(due).toEqual([]);
});
it("excludes an as-needed medication (no schedules, never due) — v1.16.11 #316", () => {
// An as-needed (PRN) medication persists ZERO schedules and the
// server computes no next-due, so it can NEVER enter the due set —
// even with a recent ad-hoc intake on record.
const due = deriveDueMedications(
[
makeMed({
id: "prn",
schedules: [],
nextDueAt: null,
lastTakenAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
todayEventCount: 1,
}),
makeMed({ id: "scheduled" }),
],
opts,
);
expect(due.map((m) => m.id)).toEqual(["scheduled"]);
});
it("excludes a dose already covered by an actioned intake in its band", () => {
// Taken 30 minutes ago — inside the open 12:00 band; the in-window
// suppression (same as the card pill) keeps it out of the due set.
@@ -111,6 +111,15 @@ export interface MedicationCardBodyProps {
/** The open-cycle descriptor, or null when the display block is absent. */
currentCycle: CurrentCycle | null;
/**
* v1.16.11 (#316) — as-needed (PRN) medication. True suppresses the
* compliance block entirely (no bars, no skeleton, no error fallback —
* the batched compliance read excludes as-needed medications, so a
* skeleton would spin forever) and the open-cycle slot. The next/last
* lines carry the "Bei Bedarf" + last-taken presentation instead.
*/
asNeeded?: boolean;
/** "take" | "skip" while the matching request is in flight, else null. */
intakeLoading: string | null;
/** Record the displayed dose (the card binds the slot instant). */
@@ -137,6 +146,7 @@ export function MedicationCardBody({
complianceError = false,
onRetryCompliance,
currentCycle,
asNeeded = false,
intakeLoading,
onRecordIntake,
children,
@@ -214,6 +224,7 @@ export function MedicationCardBody({
error fallback (no bars, one notice line + retry) instead of
sitting on the skeleton forever. */}
{active &&
!asNeeded &&
(compliance ? (
<MedicationComplianceBars
rate7={compliance.rate7}
@@ -232,7 +243,7 @@ export function MedicationCardBody({
one xs text line on every active card: the descriptor rides the
compliance query, and a line appearing after the fetch resolved
used to grow the card and shift the grid row (CLS). */}
{active && (
{active && !asNeeded && (
<div className="min-h-4">
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
</div>
@@ -136,6 +136,8 @@ export interface MedicationDetailSnapshot {
startsOn?: string | null;
endsOn?: string | null;
oneShot?: boolean;
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
asNeeded?: boolean;
atcCode?: string | null;
rxNormCode?: string | null;
/**
@@ -189,6 +191,7 @@ function snapshotToWizardPayload(
startsOn: med.startsOn ? new Date(med.startsOn) : null,
endsOn: med.endsOn ? new Date(med.endsOn) : null,
oneShot: med.oneShot ?? false,
asNeeded: med.asNeeded ?? false,
schedules: med.schedules.map((s) => ({
id: s.id,
windowStart: s.windowStart,
@@ -226,6 +229,7 @@ export function MedicationDetailTabs({
const id = medication.id;
const oneShot = medication.oneShot === true;
const asNeeded = medication.asNeeded === true;
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
const isInjectable = medication.deliveryForm === "INJECTION";
@@ -238,9 +242,18 @@ export function MedicationDetailTabs({
// whatever `?tab=` carries — Injektion is absent for non-injectable
// medications, so a stale `?tab=injektion` deep-link falls back to the
// landing tab instead of rendering an empty surface.
// v1.16.11 — an as-needed medication has no schedule and never
// reminds, so the Zeitplan tab (times editor + reminder card +
// plan-history) does not apply and is dropped from the registry; a
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
const availableTabs = useMemo<TabSlug[]>(
() => TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
[isInjectable],
() =>
TAB_SLUGS.filter(
(slug) =>
(slug !== "injektion" || isInjectable) &&
(slug !== "zeitplan" || !asNeeded),
),
[isInjectable, asNeeded],
);
const requestedRaw = searchParams?.get("tab");
@@ -296,7 +309,9 @@ export function MedicationDetailTabs({
return null;
}
},
enabled: activeTab === "uebersicht",
// v1.16.11 — no compliance read for an as-needed medication (the
// section is hidden; there is no rate to fetch).
enabled: activeTab === "uebersicht" && !asNeeded,
staleTime: 30_000,
});
@@ -368,6 +383,7 @@ export function MedicationDetailTabs({
endsOn={medication.endsOn}
payload={payload}
oneShot={oneShot}
asNeeded={asNeeded}
startsOn={medication.startsOn}
/>
@@ -407,25 +423,35 @@ export function MedicationDetailTabs({
value={
!medication.active
? t("medications.detail.uebersicht.pausedHint")
: medication.nextDueAt
? fmt.dateTime(medication.nextDueAt)
: t("medications.detail.uebersicht.nextDoseNone")
: asNeeded
? t("medications.detail.uebersicht.nextDoseAsNeeded")
: medication.nextDueAt
? fmt.dateTime(medication.nextDueAt)
: t("medications.detail.uebersicht.nextDoseNone")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
jumpLabel={
asNeeded
? t("medications.detail.uebersicht.jumpToVerlauf")
: t("medications.detail.uebersicht.jumpToZeitplan")
}
onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
dataSlot="uebersicht-next-dose"
/>
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
medication.notificationsEnabled
? t("medications.detail.uebersicht.reminderOn")
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-reminder"
/>
{/* v1.16.11 — as-needed never reminds; the row would only
mislead (the cron skips schedule-less medications). */}
{!asNeeded && (
<StatusRow
label={t("medications.detail.uebersicht.reminderLabel")}
value={
medication.notificationsEnabled
? t("medications.detail.uebersicht.reminderOn")
: t("medications.detail.uebersicht.reminderOff")
}
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
onJump={() => onTabChange("zeitplan")}
dataSlot="uebersicht-reminder"
/>
)}
{inventoryItems.length > 0 && (
<StatusRow
label={t("medications.detail.uebersicht.supplyLabel")}
@@ -471,6 +497,7 @@ export function MedicationDetailTabs({
</ul>
</MedicationDetailSection>
{!asNeeded && (
<MedicationDetailSection
titleId="medication-uebersicht-compliance-heading"
title={t("medications.detail.uebersicht.complianceTitle")}
@@ -490,6 +517,7 @@ export function MedicationDetailTabs({
</p>
)}
</MedicationDetailSection>
)}
</TabsContent>
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
+19 -2
View File
@@ -85,6 +85,13 @@ interface Medication {
* still takeable" instead of the regular upcoming-intake phrasing.
*/
nextDueOverdue?: boolean;
/**
* v1.16.11 (#316) — as-needed (PRN) medication: no schedules, never
* due, never reminded, no compliance. The card renders a calm
* "Bei Bedarf" marker where next-due normally sits and replaces the
* compliance bars with the last-taken oriented presentation.
*/
asNeeded?: boolean;
schedules: Schedule[];
}
@@ -300,6 +307,15 @@ export function MedicationCard({
/>
);
// v1.16.11 — as-needed: a calm marker where next-due normally sits.
// No due pill, no overdue escalation, ever (structurally there is no
// schedule, so the window status is already null).
const asNeededLine = medication.asNeeded ? (
<span className="text-muted-foreground" data-slot="medication-as-needed-marker">
{t("medications.asNeededMarker")}
</span>
) : null;
// The upcoming-intake line value — a day label + window range + optional
// dose accent. The card owns this VALUE content (a daily med reads as a
// clock-time window); the structure / labels live in the shared body.
@@ -396,13 +412,14 @@ export function MedicationCard({
}
: null
}
doseStatus={doseStatus}
nextLine={nextLine}
doseStatus={medication.asNeeded ? "upcoming" : doseStatus}
nextLine={asNeededLine ?? nextLine}
lastLine={
medication.lastTakenAt
? formatLastTakenAt(medication.lastTakenAt)
: null
}
asNeeded={medication.asNeeded === true}
compliance={
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
}
@@ -31,6 +31,8 @@ export interface MedicationDetailSummaryProps {
/** Wizard payload used to derive the plain-language cadence line. */
payload: MedicationPayload;
oneShot: boolean;
/** v1.16.11 — as-needed (PRN): the cadence line reads "Bei Bedarf". */
asNeeded?: boolean;
startsOn?: string | null;
}
@@ -53,6 +55,7 @@ export function MedicationDetailSummary({
endsOn,
payload,
oneShot,
asNeeded = false,
startsOn,
}: MedicationDetailSummaryProps) {
const { t } = useTranslations();
@@ -83,7 +86,9 @@ export function MedicationDetailSummary({
date: formatters.dateTime(startsOn),
})
: t("medications.detail.cadence.oneShotPending")
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
: asNeeded
? t("medications.detail.cadence.asNeeded")
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
return (
<div className="space-y-1.5" data-slot="medication-detail-summary">
@@ -87,6 +87,11 @@ export interface TableMedication {
todayEventCount?: number;
nextDueAt?: string | null;
nextDueOverdue?: boolean;
/**
* v1.16.11 (#316) — as-needed (PRN): the next-dose cell shows a calm
* "Bei Bedarf" marker, the compliance column shows "–".
*/
asNeeded?: boolean;
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
stockDosesRemaining?: number | null;
schedules: TableSchedule[];
@@ -463,7 +468,18 @@ function MedicationTableRowItem({
let nextCell: React.ReactNode = (
<span className="text-muted-foreground">–</span>
);
if (medication.active) {
if (medication.asNeeded) {
// v1.16.11 — a calm marker where next-due normally sits; no due
// pill, no overdue escalation, ever.
nextCell = (
<span
className="text-muted-foreground"
data-slot="medication-table-as-needed-marker"
>
{t("medications.asNeededMarker")}
</span>
);
} else if (medication.active) {
if (medication.nextDueOverdue && nextAt) {
nextCell = (
<span className="font-medium text-amber-600 dark:text-amber-400">
@@ -609,7 +625,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell>{statusCell}</TableCell>
<TableCell className="text-sm">{nextCell}</TableCell>
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell>{medication.active && !medication.asNeeded ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active ? (
@@ -1036,3 +1036,93 @@ describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => {
]);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.16.11 (#316) — as-needed (PRN) mode
// ────────────────────────────────────────────────────────────────────
describe("as-needed mode (v1.16.11, #316)", () => {
function asNeededPayload(): WizardPayload {
return {
...emptyWizardPayload(),
mode: "asNeeded",
treatmentRow: "painRelief",
name: "Ibuprofen",
doseAmount: "400",
doseUnit: "mg",
unitsPerDose: "2",
};
}
it("progressIndices walks the one-shot-shaped path (no cadence detail, no times)", () => {
expect(progressIndices("asNeeded", "daily")).toEqual([1, 2, 3, 4, 8]);
});
it("buildCreateBody emits asNeeded:true with an EMPTY schedules array", () => {
const body = buildCreateBody(asNeededPayload());
expect(body.asNeeded).toBe(true);
expect(body.oneShot).toBe(false);
expect(body.schedules).toEqual([]);
// Dose fields stay first-class — they feed inventory consumption.
expect(body.dose).toBe("400 mg");
expect(body.unitsPerDose).toBe(2);
expect(body.category).toBe("PAIN_RELIEF");
});
it("a scheduled payload emits asNeeded:false (no accidental flag bleed)", () => {
const body = buildCreateBody(withCadence("daily"));
expect(body.asNeeded).toBe(false);
expect(body.schedules.length).toBeGreaterThan(0);
});
it("hydrateWizardPayload round-trips an as-needed snapshot back onto the mode", () => {
const hydrated = hydrateWizardPayload({
id: "m1",
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: true,
schedules: [],
});
expect(hydrated.mode).toBe("asNeeded");
// Step 5 pre-selects "Bei Bedarf" via the mode; the placeholder
// draft carries it too so the flat mirror stays coherent.
expect(hydrated.schedules).toHaveLength(1);
expect(hydrated.schedules[0].mode).toBe("asNeeded");
// Full round-trip: hydrate → encode emits the same wire shape.
const body = buildCreateBody(hydrated, "edit");
expect(body.asNeeded).toBe(true);
expect(body.schedules).toEqual([]);
// Edit bodies never round-trip the reminders default (invariant 16).
expect(body.notificationsEnabled).toBeUndefined();
});
it("summariseCadence reads the asNeeded key with no times phrase", () => {
const t = makeStubT();
const line = summariseCadence(asNeededPayload(), t);
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
expect(line).not.toContain("medications.wizard.summary.times");
});
it("summariseScheduleDraft reads the asNeeded key with no times phrase", () => {
const t = makeStubT();
const line = summariseScheduleDraft(
{ ...emptyWizardPayload().schedules[0], mode: "asNeeded" },
t,
);
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
expect(line).not.toContain("medications.wizard.summary.times");
});
it("validateStep gates: Step 5 passes once the mode is picked; review mirrors Step 4", () => {
const p = asNeededPayload();
expect(validateStep(p, 5)).toBe(true);
expect(validateStep(p, 8)).toBe(true);
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
});
});
@@ -8,9 +8,10 @@ import type { WizardPayload } from "../wizard-payload";
import type { StepProps } from "./Step1Name";
/**
* The six cadence rows surfaced in Step 5. Each maps to a (mode,
* The seven cadence rows surfaced in Step 5. Each maps to a (mode,
* cadenceKind) decision. "Einmalig" flips the wizard onto the
* one-shot path; every other row stays recurring.
* one-shot path; "Bei Bedarf" (v1.16.11, #316) onto the as-needed
* path (no schedule at all); every other row stays recurring.
*/
type Step5Row =
| "daily"
@@ -18,7 +19,8 @@ type Step5Row =
| "everyNWeeks"
| "monthly"
| "rolling"
| "oneShot";
| "oneShot"
| "asNeeded";
const STEP5_ROWS: readonly Step5Row[] = [
"daily",
@@ -27,10 +29,12 @@ const STEP5_ROWS: readonly Step5Row[] = [
"monthly",
"rolling",
"oneShot",
"asNeeded",
];
function rowFromPayload(payload: WizardPayload): Step5Row | null {
if (payload.mode === "oneShot") return "oneShot";
if (payload.mode === "asNeeded") return "asNeeded";
if (payload.mode === "recurring") {
switch (payload.cadence.kind) {
case "daily":
@@ -108,6 +112,13 @@ function applyRowPick(
});
return;
}
if (row === "asNeeded") {
// v1.16.11 — as-needed clears schedule configuration: the wizard
// emits an empty `schedules` array on save (`buildCreateBody`), so
// the cadence value left on the draft is inert.
applyPartial({ mode: "asNeeded" });
return;
}
const cadenceKind: CadenceKind = row;
applyPartial({
mode: "recurring",
@@ -145,7 +145,11 @@ export function Step8Summary({
</p>
)}
{payload.mode !== "oneShot" && (
{/* v1.16.11 — an as-needed medication carries no parallel
schedules; the single "Bei Bedarf" card stays as the route
back into Step 5 (mirroring the one-shot edit path), but the
add affordance is hidden alongside one-shot. */}
{payload.mode !== "oneShot" && payload.mode !== "asNeeded" && (
<button
type="button"
onClick={onAddSchedule}
@@ -163,6 +167,9 @@ export function Step8Summary({
)}
</div>
{/* v1.16.11 — an as-needed medication never reminds (no slots),
so the reminders toggle is hidden alongside the schedules. */}
{payload.mode !== "asNeeded" && (
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<Label
@@ -185,8 +192,9 @@ export function Step8Summary({
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
/>
</div>
)}
{showNoChannelHint && (
{payload.mode !== "asNeeded" && showNoChannelHint && (
<p
className="text-muted-foreground text-xs"
data-slot="wizard-no-channel-hint"
@@ -176,7 +176,7 @@ export function rowFromTreatment(
*/
export interface ScheduleDraft {
id?: string;
mode: "oneShot" | "recurring" | null;
mode: "oneShot" | "recurring" | "asNeeded" | null;
cadence: CadenceValue;
subControls: CadenceSubControls;
timesOfDay: string[];
@@ -237,7 +237,7 @@ export interface WizardPayload {
*/
allowedInjectionSites: InjectionSiteKey[];
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
mode: "oneShot" | "recurring" | null;
mode: "oneShot" | "recurring" | "asNeeded" | null;
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
cadence: CadenceValue;
/** Mirrors `schedules[activeScheduleIndex].subControls`. */
@@ -402,6 +402,14 @@ export function removeSchedule(
// ────────────────────────────────────────────────────────────────────
const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8];
/**
* v1.16.11 — as-needed (PRN) walks the one-shot shape: picking
* "Bei Bedarf" in Step 5 skips the sub-cadence + times steps entirely
* (an as-needed medication carries no schedule), landing on the
* summary. The dose step stays — dose text + unitsPerDose still feed
* inventory consumption.
*/
const AS_NEEDED_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];
@@ -418,6 +426,7 @@ export function progressIndices(
cadenceKind: CadenceKind,
): readonly number[] {
if (mode === "oneShot") return ONE_SHOT_PATH;
if (mode === "asNeeded") return AS_NEEDED_PATH;
if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH;
return RECURRING_PATH;
}
@@ -552,6 +561,11 @@ export interface CreateMedicationBody {
startsOn?: string;
endsOn?: string;
oneShot: boolean;
/**
* v1.16.11 (#316) — as-needed (PRN) medication. True emits an EMPTY
* `schedules` array (the route 422s on any entry alongside the flag).
*/
asNeeded: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -675,10 +689,15 @@ export function buildCreateBody(
// 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.
// v1.16.11 — an as-needed medication carries NO schedule at all; the
// wizard clears the list client-side (the server 422s otherwise).
const isOneShot = committed.mode === "oneShot";
const draftsToEmit = isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const isAsNeeded = committed.mode === "asNeeded";
const draftsToEmit = isAsNeeded
? []
: isOneShot
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
: committed.schedules;
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
@@ -716,6 +735,7 @@ export function buildCreateBody(
notificationsEnabled: committed.notificationsEnabled,
}),
oneShot: isOneShot,
asNeeded: isAsNeeded,
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
};
if (committed.startsOn) {
@@ -739,6 +759,7 @@ function summaryKeyForDraft(
sub: CadenceSubControls,
): string {
if (mode === "oneShot") return "oneShot";
if (mode === "asNeeded") return "asNeeded";
switch (cadence.kind) {
case "daily":
return "daily";
@@ -856,7 +877,9 @@ export function summariseCadence(
): string {
const cadenceLine = cadenceLineFor(payload, t);
const timesPhrase =
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
payload.mode !== "oneShot" &&
payload.mode !== "asNeeded" &&
payload.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(payload.timesOfDay).join(", "),
})
@@ -890,7 +913,9 @@ export function summariseScheduleDraft(
): string {
const cadenceLine = cadenceLineFor(draft, t);
const timesPhrase =
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
draft.mode !== "oneShot" &&
draft.mode !== "asNeeded" &&
draft.timesOfDay.length > 0
? t("medications.wizard.summary.times", {
times: sortTimes(draft.timesOfDay).join(", "),
})
@@ -928,6 +953,8 @@ export interface MedicationPayload {
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
/** v1.16.11 — as-needed (PRN) flag; hydrates the "Bei Bedarf" mode. */
asNeeded?: boolean;
schedules: Array<{
id?: string;
windowStart: string;
@@ -1012,8 +1039,13 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
const base = emptyWizardPayload();
const parsedDose = parseDoseExpression(initial.dose ?? "");
const drafts: ScheduleDraft[] =
initial.schedules.length > 0
// v1.16.11 — an as-needed medication persists ZERO schedules; hydrate
// one placeholder draft carrying the asNeeded mode so the flat mirror
// and Step-5 selection round-trip (the encode path emits no schedule).
const asNeeded = initial.asNeeded === true;
const drafts: ScheduleDraft[] = asNeeded
? [{ ...emptyScheduleDraft(), mode: "asNeeded" }]
: initial.schedules.length > 0
? initial.schedules.map((schedule) =>
hydrateScheduleDraft(schedule, initial.oneShot),
)