fix(medications): keep a medication's category when it is edited on the web

The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.

An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:01:00 +02:00
1 parent a224875ed9
commit e94e9b94a8
10 files changed
+168 -6

No files matched your search

+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Nahrungsergänzung",
"antibiotic": "Antibiotikum",
"mentalHealth": "Psychische Gesundheit",
"thyroid": "Schilddrüse",
"digestive": "Magen/Darm",
"skin": "Hautpflege",
"sleepAid": "Schlafmittel",
"other": "Sonstiges"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Supplement",
"antibiotic": "Antibiotic",
"mentalHealth": "Mental health",
"thyroid": "Thyroid",
"digestive": "Gastrointestinal",
"skin": "Skincare",
"sleepAid": "Sleep aid",
"other": "Other"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Suplemento",
"antibiotic": "Antibiótico",
"mentalHealth": "Salud mental",
"thyroid": "Tiroides",
"digestive": "Digestivo",
"skin": "Cuidado de la piel",
"sleepAid": "Para dormir",
"other": "Otro"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Complément",
"antibiotic": "Antibiotique",
"mentalHealth": "Santé mentale",
"thyroid": "Thyroïde",
"digestive": "Digestif",
"skin": "Soin de la peau",
"sleepAid": "Aide au sommeil",
"other": "Autre"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Integratore",
"antibiotic": "Antibiotico",
"mentalHealth": "Salute mentale",
"thyroid": "Tiroide",
"digestive": "Digestivo",
"skin": "Cura della pelle",
"sleepAid": "Per dormire",
"other": "Altro"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "보충제",
"antibiotic": "항생제",
"mentalHealth": "정신 건강",
"thyroid": "갑상선",
"digestive": "소화기",
"skin": "피부",
"sleepAid": "수면 보조",
"other": "기타"
},
"cadence": {
+4
View File
@@ -1569,6 +1569,10 @@
"supplement": "Suplement",
"antibiotic": "Antybiotyk",
"mentalHealth": "Zdrowie psychiczne",
"thyroid": "Tarczyca",
"digestive": "Trawienne",
"skin": "Pielęgnacja skóry",
"sleepAid": "Na sen",
"other": "Inne"
},
"cadence": {
@@ -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,
@@ -335,6 +336,10 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
for (const row of rows) {
@@ -356,6 +361,10 @@ 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'", () => {
@@ -1249,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,9 +3,12 @@
import {
Activity,
Apple,
BedDouble,
Brain,
Droplet,
Flame,
Gauge,
Hand,
Leaf,
type LucideIcon,
type LucideProps,
@@ -13,6 +16,7 @@ import {
Stethoscope,
Syringe,
Tag,
Utensils,
Wind,
} from "lucide-react";
import type { ComponentType } from "react";
@@ -37,6 +41,10 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
supplement: Leaf,
antibiotic: ShieldCheck,
mentalHealth: Brain,
thyroid: Gauge,
digestive: Utensils,
skin: Hand,
sleepAid: BedDouble,
other: Tag as unknown as LucideIcon,
};
@@ -87,13 +87,20 @@ export type WizardTreatmentRow =
| "supplement"
| "antibiotic"
| "mentalHealth"
| "thyroid"
| "digestive"
| "skin"
| "sleepAid"
| "other";
/**
* 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 (v1.39.4) ·
* Sonstiges.
* 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",
@@ -106,6 +113,10 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
"supplement",
"antibiotic",
"mentalHealth",
"thyroid",
"digestive",
"skin",
"sleepAid",
"other",
];
@@ -131,6 +142,10 @@ export const WIZARD_TREATMENT_MAPPING: Record<
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" },
};
@@ -164,6 +179,14 @@ export function rowFromTreatment(
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";
}
@@ -226,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
@@ -597,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. */
@@ -808,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 && {
@@ -1194,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