From e94e9b94a84c79a1d537cd26a3d22926723fa7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 14:01:00 +0200 Subject: [PATCH] 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. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/ko.json | 4 + messages/pl.json | 4 + .../wizard/__tests__/wizard-payload.test.ts | 81 +++++++++++++++++++ .../medications/wizard/steps/step2-class.tsx | 8 ++ .../medications/wizard/wizard-payload.ts | 57 +++++++++++-- 10 files changed, 168 insertions(+), 6 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2e1336d21..bd98e8a24 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 73afdad3a..c8828b1b8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1569,6 +1569,10 @@ "supplement": "Supplement", "antibiotic": "Antibiotic", "mentalHealth": "Mental health", + "thyroid": "Thyroid", + "digestive": "Gastrointestinal", + "skin": "Skincare", + "sleepAid": "Sleep aid", "other": "Other" }, "cadence": { diff --git a/messages/es.json b/messages/es.json index 40b1d7b68..1056aee02 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 7b02455d8..726ae3522 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 073f20c90..8b8be53b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 4f08433c1..538e4b29a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1569,6 +1569,10 @@ "supplement": "보충제", "antibiotic": "항생제", "mentalHealth": "정신 건강", + "thyroid": "갑상선", + "digestive": "소화기", + "skin": "피부", + "sleepAid": "수면 보조", "other": "기타" }, "cadence": { diff --git a/messages/pl.json b/messages/pl.json index 568350511..6eb2c28fa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index 34c956e6b..9de116879 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -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"); + }); +}); diff --git a/src/components/medications/wizard/steps/step2-class.tsx b/src/components/medications/wizard/steps/step2-class.tsx index 10cdea478..1337cbbbf 100644 --- a/src/components/medications/wizard/steps/step2-class.tsx +++ b/src/components/medications/wizard/steps/step2-class.tsx @@ -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> = { supplement: Leaf, antibiotic: ShieldCheck, mentalHealth: Brain, + thyroid: Gauge, + digestive: Utensils, + skin: Hand, + sleepAid: BedDouble, other: Tag as unknown as LucideIcon, }; diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 506c32d9a..498e7c685 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -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