Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:18:09 +02:00
commit 206da07e8c
42 files changed
+1075 -34

No files matched your search

@@ -367,3 +367,62 @@ describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
expect(html).toContain("Taken");
});
});
/**
* v1.39.4 (#1040) — a medication whose course has ended keeps its card
* (history, adherence) but offers no taken / skip and names the state.
*/
describe("<MedicationCard> — ended course (#1040)", () => {
function makeMed(overrides: Record<string, unknown> = {}) {
return {
id: "med-course-1",
name: "Amoxicillin",
dose: "500 mg",
category: "ANTIBIOTIC",
treatmentClass: undefined as string | undefined,
active: true,
notificationsEnabled: true,
pausedAt: null,
lastTakenAt: null,
todayEventCount: 0,
nextDueAt: null,
nextDueOverdue: false,
schedules: [{ id: "s1", ...pastWindow }],
...overrides,
};
}
it("drops the action row and shows the Ended badge once the course is over", () => {
const client = makeClient();
seedCompliance(client, "med-course-1");
const html = render(
<MedicationCard
medication={makeMed({ courseStatus: "ENDED", intakeActionable: false })}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).not.toContain('data-slot="medication-intake-take"');
expect(html).not.toContain('data-slot="medication-intake-skip"');
expect(html).toContain('data-slot="medication-course-ended-badge"');
});
it("keeps the action row on the last course day", () => {
const client = makeClient();
seedCompliance(client, "med-course-1");
const html = render(
<MedicationCard
medication={makeMed({
courseStatus: "CURRENT",
intakeActionable: true,
})}
onEdit={() => {}}
onOpenHistory={() => {}}
/>,
client,
);
expect(html).toContain('data-slot="medication-intake-take"');
expect(html).not.toContain('data-slot="medication-course-ended-badge"');
});
});
@@ -0,0 +1,71 @@
/**
* v1.39.4 (#1040) — the detail header's "Ended" status follows the
* server's `courseStatus`. It used to compare the end date's UTC midnight
* with the clock, which read the whole last course day as ended.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
const payload = {
id: "m1",
name: "Amoxicillin",
dose: "500 mg",
category: "ANTIBIOTIC",
notificationsEnabled: true,
startsOn: new Date("2026-09-20T00:00:00Z"),
endsOn: new Date("2026-09-27T00:00:00Z"),
oneShot: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
],
};
function render(courseStatus: "UPCOMING" | "CURRENT" | "ENDED") {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={new QueryClient()}>
<MedicationDetailSummary
name="Amoxicillin"
dose="500 mg"
active
courseStatus={courseStatus}
payload={payload}
oneShot={false}
startsOn="2026-09-20T00:00:00.000Z"
/>
</QueryClientProvider>
</I18nProvider>,
);
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
// The last course day, mid-morning in UTC.
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("<MedicationDetailSummary> — course status (#1040)", () => {
it("reads as active on the last course day", () => {
const html = render("CURRENT");
expect(html).toContain("bg-success");
expect(html).not.toContain("bg-muted-foreground");
});
it("reads as ended once the server says the course is over", () => {
const html = render("ENDED");
expect(html).toContain("bg-muted-foreground");
expect(html).not.toContain("bg-success");
});
});
@@ -411,6 +411,39 @@ describe("<MedicationTable> — structure + shared payloads", () => {
expect(html).toContain("4 doses");
});
it("offers no dose actions on an ended course and names it; the last course day keeps them (#1040)", () => {
const client = makeClient();
seedCompliance(client, "ended", 90, 88);
seedCompliance(client, "last", 90, 88);
const html = render(
<MedicationTable
activeMedications={[
med({
id: "ended",
name: "Amoxicillin",
courseStatus: "ENDED",
intakeActionable: false,
}),
med({
id: "last",
name: "Prednisolone",
courseStatus: "CURRENT",
intakeActionable: true,
}),
]}
inactiveMedications={[]}
/>,
client,
);
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
expect(html).not.toContain('aria-label="Skip – Amoxicillin"');
expect(html).toContain('data-slot="medication-table-course-ended-marker"');
expect(html).toContain(">Ended<");
expect(html).toContain('aria-label="Take – Prednisolone"');
expect(html).toContain('aria-label="Skip – Prednisolone"');
});
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
@@ -174,6 +174,23 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
expect(due.map((d) => d.id)).toEqual(["due"]);
});
it("excludes a medication whose course has ended, even with an escalated dose (#1040)", () => {
const due = deriveDueMedications(
[
makeMed({ id: "ended", intakeActionable: false }),
makeMed({ id: "on" }),
],
{
...opts,
doseStatusById: new Map([
["ended", "missed" as const],
["on", "upcoming" as const],
]),
},
);
expect(due.map((d) => d.id)).toEqual(["on"]);
});
it("excludes a medication whose dose band is not currently open", () => {
const due = deriveDueMedications(
[
@@ -141,6 +141,13 @@ export interface MedicationCardBodyProps {
*/
recordOnly?: boolean;
/**
* v1.39.4 (#1040) — the server's `intakeActionable`: false once the course
* has ended (or before it starts), so the card offers no take / skip and
* raises no overdue escalation for a dose that is no longer part of it.
*/
intakeActionable?: boolean;
/**
* v1.16.11 — projected supply runway in whole days, set ONLY while it
* sits below the user's low-stock threshold (the variants gate it).
@@ -191,6 +198,7 @@ export function MedicationCardBody({
currentCycle,
asNeeded = false,
recordOnly = false,
intakeActionable = true,
lowStockRunwayDays = null,
intakeLoading,
onRecordIntake,
@@ -203,10 +211,11 @@ export function MedicationCardBody({
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
// `overdue` tail reads the calmer "Überfällig". Both use the destructive
// tone so the urgency is unmistakable; suppressed on an inactive med.
const canAct = active && intakeActionable;
const overdueLabel =
active && doseStatus === "missed"
canAct && doseStatus === "missed"
? t("medications.veryOverdue")
: active && doseStatus === "overdue"
: canAct && doseStatus === "overdue"
? t("medications.overdue")
: null;
@@ -352,7 +361,7 @@ export function MedicationCardBody({
{/* Quick actions — bottom-pinned so the action rows align across a
grid row regardless of how much content sits above. */}
{active && !recordOnly && (
{canAct && !recordOnly && (
<div className="mt-auto pt-0">
<MedicationIntakeActions
intakeLoading={intakeLoading}
@@ -8,6 +8,8 @@ interface MedicationStateBadgesProps {
pausedAt: string | null;
/** v1.39.1 (#1033) — intake tracking off: kept as a record only. */
recordOnly?: boolean;
/** v1.39.4 (#1040) — the course's end date has passed. */
courseEnded?: boolean;
}
/**
@@ -21,6 +23,7 @@ export function MedicationStateBadges({
active,
pausedAt,
recordOnly = false,
courseEnded = false,
}: MedicationStateBadgesProps) {
const { t } = useTranslations();
@@ -35,7 +38,16 @@ export function MedicationStateBadges({
{t("medications.recordOnlyBadge")}
</Badge>
)}
{!notificationsEnabled && !recordOnly && (
{courseEnded && (
<Badge
variant="secondary"
className="text-xs"
data-slot="medication-course-ended-badge"
>
{t("medications.courseEndedBadge")}
</Badge>
)}
{!notificationsEnabled && !recordOnly && !courseEnded && (
<Badge variant="secondary" className="text-xs">
{t("medications.withoutNotification")}
</Badge>
@@ -170,6 +170,8 @@ export interface MedicationDetailSnapshot {
* `recordedSchedules`; the page shows (and edits) the stored rows.
*/
trackIntake?: boolean;
/** v1.39.4 (#1040) — where today sits in the course (server-resolved). */
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
recordedSchedules?: ScheduleSnapshot[];
schedules: ScheduleSnapshot[];
}
@@ -501,7 +503,7 @@ export function MedicationDetailTabs({
name={medication.name}
dose={formatDose(medication.dose, t)}
active={medication.active}
endsOn={medication.endsOn}
courseStatus={served.courseStatus}
payload={payload}
oneShot={oneShot}
asNeeded={asNeeded}
@@ -135,6 +135,13 @@ export interface Glp1Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
*/
intakeActionable?: boolean;
/** v1.39.4 (#1040) — where today sits in the course. */
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
schedules: ScheduleLite[];
}
@@ -446,6 +453,7 @@ export function Glp1MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
courseEnded={medication.courseStatus === "ENDED"}
/>
);
@@ -560,6 +568,7 @@ export function Glp1MedicationCard({
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
recordOnly={medication.trackIntake === false}
intakeActionable={medication.intakeActionable !== false}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -120,6 +120,13 @@ interface Medication {
* compliance bars and the take / skip actions and shows a badge instead.
*/
trackIntake?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
* when true (active, tracked, and the course includes today).
*/
intakeActionable?: boolean;
/** v1.39.4 (#1040) — where today sits in the course. */
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
schedules: Schedule[];
}
@@ -385,6 +392,7 @@ export function MedicationCard({
active={medication.active}
pausedAt={medication.pausedAt}
recordOnly={medication.trackIntake === false}
courseEnded={medication.courseStatus === "ENDED"}
/>
);
@@ -525,6 +533,7 @@ export function MedicationCard({
complianceNotApplicable ? null : (display?.currentCycle ?? null)
}
recordOnly={medication.trackIntake === false}
intakeActionable={medication.intakeActionable !== false}
lowStockRunwayDays={lowStockRunwayDays}
intakeLoading={intakeLoading}
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
@@ -32,7 +32,12 @@ export interface MedicationDetailSummaryProps {
name: string;
dose: string;
active: boolean;
endsOn?: string | null;
/**
* v1.39.4 (#1040) — the server's course position. "Ended" follows it, so
* the last course day reads as active on the user's clock instead of
* flipping at UTC midnight of the end date.
*/
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
/** Wizard payload used to derive the plain-language cadence line. */
payload: MedicationPayload;
oneShot: boolean;
@@ -45,13 +50,11 @@ export interface MedicationDetailSummaryProps {
type Status = "active" | "paused" | "ended";
function resolveStatus(active: boolean, endsOn?: string | null): Status {
if (endsOn) {
const end = new Date(endsOn);
if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) {
return "ended";
}
}
function resolveStatus(
active: boolean,
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED",
): Status {
if (courseStatus === "ENDED") return "ended";
return active ? "active" : "paused";
}
@@ -59,7 +62,7 @@ export function MedicationDetailSummary({
name,
dose,
active,
endsOn,
courseStatus,
payload,
oneShot,
asNeeded = false,
@@ -69,7 +72,7 @@ export function MedicationDetailSummary({
const { t, locale } = useTranslations();
const formatters = useFormatters();
const dateFormatPref = useDateFormatPreference();
const status = resolveStatus(active, endsOn);
const status = resolveStatus(active, courseStatus);
const statusLabel =
status === "active"
@@ -98,6 +98,12 @@ export interface TableMedication {
* cell names that, the compliance and action cells show "–".
*/
trackIntake?: boolean;
/**
* v1.39.4 (#1040) — server-resolved: the action cell offers taken / skip
* only when true; an ended course shows "Ended" where next-due sits.
*/
intakeActionable?: boolean;
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
stockDosesRemaining?: number | null;
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
@@ -478,10 +484,11 @@ function MedicationTableRowItem({
// column with no neighbouring last-intake context. Order here:
// last-dose context outranks the overdue escalation, which outranks
// the window pill.
const canAct = medication.active && medication.intakeActionable !== false;
const overdueLabel =
medication.active && doseStatus === "missed"
canAct && doseStatus === "missed"
? t("medications.veryOverdue")
: medication.active && doseStatus === "overdue"
: canAct && doseStatus === "overdue"
? t("medications.overdue")
: null;
@@ -528,6 +535,16 @@ function MedicationTableRowItem({
{t("medications.recordOnlyBadge")}
</span>
);
} else if (medication.courseStatus === "ENDED") {
// v1.39.4 (#1040) — the course is over: nothing is due any more.
nextCell = (
<span
className="text-muted-foreground"
data-slot="medication-table-course-ended-marker"
>
{t("medications.courseEndedBadge")}
</span>
);
} else if (medication.asNeeded) {
// v1.16.11 — a calm marker where next-due normally sits; no due
// pill, no overdue escalation, ever.
@@ -713,7 +730,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAddIntake && !recordOnly ? (
{canAct && canAddIntake && !recordOnly ? (
<div className="flex gap-1.5">
<Button
size="icon"
+9 -1
View File
@@ -85,6 +85,8 @@ export interface DueDerivationMedication {
asNeeded?: boolean;
/** v1.39.1 (#1033) — false: kept as a record, never due, never in the set. */
trackIntake?: boolean;
/** v1.39.4 (#1040) — false: no dose actions today (e.g. the course ended). */
intakeActionable?: boolean;
schedules: DueDerivationSchedule[];
}
@@ -155,7 +157,13 @@ export function deriveDueMedications(
// compliance row for one must not pull it into the set.
// A medication with intake tracking off is never due either; the list
// already serves it with no schedules, this makes the rule explicit.
if (!m.active || m.pausedAt || m.asNeeded || m.trackIntake === false) {
if (
!m.active ||
m.pausedAt ||
m.asNeeded ||
m.trackIntake === false ||
m.intakeActionable === false
) {
continue;
}
@@ -21,6 +21,7 @@ import {
type WizardTreatmentRow,
} from "@/components/medications/wizard/wizard-payload";
import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication";
import {
type CadenceKind,
DEFAULT_SUB_CONTROLS,
@@ -310,6 +311,13 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
});
});
it("maps Psychische Gesundheit → (GENERIC, MENTAL_HEALTH) — new in v1.39.4", () => {
expect(WIZARD_TREATMENT_MAPPING.mentalHealth).toEqual({
treatmentClass: "GENERIC",
category: "MENTAL_HEALTH",
});
});
it("maps GLP-1-Injektion → (GLP1, OTHER) — only row with GLP1", () => {
expect(WIZARD_TREATMENT_MAPPING.glp1).toEqual({
treatmentClass: "GLP1",
@@ -327,6 +335,11 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
"vitamin",
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
for (const row of rows) {
@@ -347,6 +360,11 @@ describe("rowFromTreatment — reverse mapping for edit-hydration", () => {
it("ANTIBIOTIC category maps to the antibiotic row", () => {
expect(rowFromTreatment("GENERIC", "ANTIBIOTIC")).toBe("antibiotic");
expect(rowFromTreatment("GENERIC", "MENTAL_HEALTH")).toBe("mentalHealth");
expect(rowFromTreatment("GENERIC", "THYROID")).toBe("thyroid");
expect(rowFromTreatment("GENERIC", "DIGESTIVE")).toBe("digestive");
expect(rowFromTreatment("GENERIC", "SKIN")).toBe("skin");
expect(rowFromTreatment("GENERIC", "SLEEP_AID")).toBe("sleepAid");
});
it("unknown category falls back to 'other'", () => {
@@ -1240,3 +1258,75 @@ describe("as-needed mode (v1.16.11, #316)", () => {
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
});
});
describe("category on edit (v1.39.4)", () => {
function hydrate(category: string, treatmentClass = "GENERIC") {
return hydrateWizardPayload({
id: "m1",
name: "Levothyroxine",
dose: "50 µg",
category,
treatmentClass,
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
rrule: "FREQ=DAILY",
},
],
});
}
it("gives every stored category a wizard row of its own", () => {
const covered = new Set(
Object.values(WIZARD_TREATMENT_MAPPING).map((m) => m.category),
);
for (const value of MEDICATION_CATEGORY_VALUES) {
expect(covered.has(value), value).toBe(true);
}
});
it.each(["THYROID", "SKIN", "SLEEP_AID", "MENTAL_HEALTH"])(
"an edit that leaves the row alone keeps %s",
(category) => {
const body = buildCreateBody(
{ ...hydrate(category), name: "Renamed" },
"edit",
);
expect("category" in body).toBe(false);
expect("treatmentClass" in body).toBe(false);
},
);
it("never overwrites a category the wizard has no row for", () => {
const payload = hydrate("SOMETHING_NEWER");
expect(payload.treatmentRow).toBe("other");
const body = buildCreateBody(payload, "edit");
expect("category" in body).toBe(false);
});
it("keeps a GLP-1 medication's clinical category on an untouched edit", () => {
const body = buildCreateBody(hydrate("BLOOD_PRESSURE", "GLP1"), "edit");
expect("category" in body).toBe(false);
expect("treatmentClass" in body).toBe(false);
});
it("sends the new category when the row changed", () => {
const body = buildCreateBody(
{ ...hydrate("THYROID"), treatmentRow: "sleepAid" },
"edit",
);
expect(body.category).toBe("SLEEP_AID");
expect(body.treatmentClass).toBe("GENERIC");
});
it("always sends the category on create", () => {
const body = buildCreateBody({ ...hydrate("SKIN") }, "create");
expect(body.category).toBe("SKIN");
});
});
@@ -3,8 +3,12 @@
import {
Activity,
Apple,
BedDouble,
Brain,
Droplet,
Flame,
Gauge,
Hand,
Leaf,
type LucideIcon,
type LucideProps,
@@ -12,6 +16,7 @@ import {
Stethoscope,
Syringe,
Tag,
Utensils,
Wind,
} from "lucide-react";
import type { ComponentType } from "react";
@@ -35,6 +40,11 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
vitamin: Apple,
supplement: Leaf,
antibiotic: ShieldCheck,
mentalHealth: Brain,
thyroid: Gauge,
digestive: Utensils,
skin: Hand,
sleepAid: BedDouble,
other: Tag as unknown as LucideIcon,
};
@@ -86,13 +86,21 @@ export type WizardTreatmentRow =
| "vitamin"
| "supplement"
| "antibiotic"
| "mentalHealth"
| "thyroid"
| "digestive"
| "skin"
| "sleepAid"
| "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.
* The taxonomy rows in their visible order (D-1 §3 Step 2): Blutdruck ·
* Diabetes · Hormone · GLP-1-Injektion · Schmerz · Allergie · Vitamine ·
* Nahrungsergänzung · Antibiotikum · Psychische Gesundheit · Schilddrüse ·
* Verdauung · Haut · Schlafmittel · Sonstiges. v1.39.4 gave every stored
* category a row; before, a thyroid, digestive, skin or sleep-aid
* medication hydrated as
* "Sonstiges" and an edit saved it back as OTHER.
*/
export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"bloodPressure",
@@ -104,6 +112,11 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"vitamin",
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
@@ -128,6 +141,11 @@ export const WIZARD_TREATMENT_MAPPING: Record<
vitamin: { treatmentClass: "GENERIC", category: "VITAMIN" },
supplement: { treatmentClass: "GENERIC", category: "SUPPLEMENT" },
antibiotic: { treatmentClass: "GENERIC", category: "ANTIBIOTIC" },
mentalHealth: { treatmentClass: "GENERIC", category: "MENTAL_HEALTH" },
thyroid: { treatmentClass: "GENERIC", category: "THYROID" },
digestive: { treatmentClass: "GENERIC", category: "DIGESTIVE" },
skin: { treatmentClass: "GENERIC", category: "SKIN" },
sleepAid: { treatmentClass: "GENERIC", category: "SLEEP_AID" },
other: { treatmentClass: "GENERIC", category: "OTHER" },
};
@@ -159,6 +177,16 @@ export function rowFromTreatment(
return "supplement";
case "ANTIBIOTIC":
return "antibiotic";
case "MENTAL_HEALTH":
return "mentalHealth";
case "THYROID":
return "thyroid";
case "DIGESTIVE":
return "digestive";
case "SKIN":
return "skin";
case "SLEEP_AID":
return "sleepAid";
default:
return "other";
}
@@ -221,6 +249,14 @@ export interface WizardPayload {
doseAmount: string;
doseUnit: string;
treatmentRow: WizardTreatmentRow | null;
/**
* v1.39.4 — the row the edit hydrated to. An edit sends `category` and
* `treatmentClass` only when `treatmentRow` differs from it, so a stored
* category the wizard has no row for (a value a newer server or another
* client wrote) is never overwritten by saving an unrelated change.
* Absent on create.
*/
initialTreatmentRow?: WizardTreatmentRow | null;
/**
* v1.6.0 — route of administration. Drives the injection-site
* rotation preview (shown when `INJECTION`) and, paired with
@@ -592,8 +628,9 @@ export function firstInvalidIndex(
export interface CreateMedicationBody {
name: string;
dose: string;
category: MedicationCategoryValue;
treatmentClass: MedicationTreatmentClass;
/** Omitted on an edit that left the category row untouched (v1.39.4). */
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. */
@@ -803,11 +840,20 @@ export function buildCreateBody(
// reads 1), "1 1/2" and "1½" are 1.5, and a value the server would
// refuse is null. Step 3 cannot be left while it is null.
const parsedUnitsPerDose = parseUnitsPerDoseInput(committed.unitsPerDose);
// v1.39.4 — an edit that did not touch the category row leaves the stored
// category (and treatment class) alone rather than re-deriving them from
// the row, which cannot represent every stored value.
const keepStoredCategory =
forMode === "edit" &&
committed.initialTreatmentRow !== undefined &&
committed.treatmentRow === committed.initialTreatmentRow;
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
category: mapping.category,
treatmentClass: mapping.treatmentClass,
...(!keepStoredCategory && {
category: mapping.category,
treatmentClass: mapping.treatmentClass,
}),
deliveryForm: committed.deliveryForm,
...(Number.isFinite(parsedDosesPerUnit) &&
parsedDosesPerUnit >= 1 && {
@@ -1189,6 +1235,10 @@ export function hydrateWizardPayload(
doseAmount: parsedDose.amount,
doseUnit: parsedDose.unit || base.doseUnit,
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
initialTreatmentRow: rowFromTreatment(
initial.treatmentClass,
initial.category,
),
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
dosesPerUnit:
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1