diff --git a/src/components/medications/__tests__/medication-card-glp1.test.tsx b/src/components/medications/__tests__/medication-card-glp1.test.tsx index 4ddaa4688..2ee9567b7 100644 --- a/src/components/medications/__tests__/medication-card-glp1.test.tsx +++ b/src/components/medications/__tests__/medication-card-glp1.test.tsx @@ -283,7 +283,7 @@ describe(" — GLP-1 variant rendering", () => { expect(html).not.toContain("Dose history"); }); - it("shows drug name + current dose on line 1 ('Mounjaro 7.5 mg')", () => { + it("shows drug name on line 1 and the dose on its own line below", () => { const client = makeClient(); seedCompliance(client, med7p5.id); seedGlp1Details(client, med7p5.id, {}); @@ -299,10 +299,10 @@ describe(" — GLP-1 variant rendering", () => { expect(html).toContain("Mounjaro"); expect(html).toContain("7.5 mg"); - // v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and - // surfaces `{name} {dose}` together on line 1 via the shared - // `` primitive. The dot separator is gone. - expect(html).toContain("Mounjaro 7.5 mg"); + // FB-G1 — the GLP-1 row drops the middle-dot separator; the dose now + // rides its OWN muted line under the name (the shared + // `` primitive), localised via `formatDose`. + expect(html).toContain('data-slot="medication-card-header-dose"'); }); it("shows last + next injection labels with localised weekday", () => { diff --git a/src/components/medications/__tests__/medication-card-header.test.tsx b/src/components/medications/__tests__/medication-card-header.test.tsx index f38b5ff0d..0cb7a17d5 100644 --- a/src/components/medications/__tests__/medication-card-header.test.tsx +++ b/src/components/medications/__tests__/medication-card-header.test.tsx @@ -9,8 +9,9 @@ import { MedicationCardHeader } from "@/components/medications/medication-card-h * v1.4.28 — narrow-viewport contract for the shared medication-list * row primitive (D-H6). * - * FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the - * category badge on line 2. State badges (without-notification, paused, + * FB-G1 calls for a stacked shape: `{name}` on line 1, the localised + * `{dose}` on its own muted line below it (omitted when empty), and the + * category badge under that. State badges (without-notification, paused, * inactive) used to share line 2 via `flex flex-wrap`; on a 320 px * viewport that pushed the row to three lines for ~20 % of configured * drugs. State badges now ride their own row below the category badge @@ -27,7 +28,7 @@ function render(node: React.ReactNode) { } describe("", () => { - it("paints `{name} {dose}` on the title row", () => { + it("paints the name on the title row and the dose on its own muted line", () => { const html = render( ", () => { categoryLabel="Blood Pressure" />, ); - expect(html).toContain("Ramipril 5 mg"); + // Name + dose are now separate elements — the dose rides its own + // `data-slot` line directly under the title, not glued to the name. + expect(html).toContain("Ramipril"); + expect(html).toContain('data-slot="medication-card-header-dose"'); + expect(html).toContain("5 mg"); expect(html).toContain("Blood Pressure"); + // The dose line paints between the name and the category badge. + const nameIdx = html.indexOf("Ramipril"); + const doseIdx = html.indexOf('data-slot="medication-card-header-dose"'); + const categoryIdx = html.indexOf("Blood Pressure"); + expect(nameIdx).toBeLessThan(doseIdx); + expect(doseIdx).toBeLessThan(categoryIdx); + }); + + it("omits the dose line entirely when the dose is empty", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="medication-card-header-dose"'); }); it("does not paint the state-badges row when no badges are supplied", () => { @@ -100,8 +122,9 @@ describe("", () => { expect(html).toContain('data-slot="medication-card-header-link"'); expect(html).toContain('href="/medications/med-1"'); expect(html).toContain('aria-label="Open medication detail page"'); - // The title still rides inside the link region. - expect(html).toContain("Ramipril 5 mg"); + // The name + dose still ride inside the link region. + expect(html).toContain("Ramipril"); + expect(html).toContain("5 mg"); }); it("falls back to a non-navigating div when href is omitted", () => { diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 8486e0324..83a7240ff 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -80,6 +80,7 @@ import { queryKeys } from "@/lib/query-keys"; import { formatUnitCount } from "@/components/medications/units-per-dose"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { formatDose } from "@/lib/medications/format-dose"; import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload"; import type { ComplianceDisplay } from "@/lib/analytics/compliance"; @@ -392,7 +393,7 @@ export function MedicationDetailTabs({ page" and misled). `?edit=1` still opens the wizard directly. */} {medications.map((m) => ( ))} diff --git a/src/components/medications/medication-card-header.tsx b/src/components/medications/medication-card-header.tsx index f3713df2a..b0959696b 100644 --- a/src/components/medications/medication-card-header.tsx +++ b/src/components/medications/medication-card-header.tsx @@ -14,8 +14,9 @@ import { CardHeader, CardTitle } from "@/components/ui/card"; * route their title + dose + drug class through this primitive so the * surface stays one consistent shape: * - * Line 1: `{name} {dose}` — bold, `text-lg` - * Line 2: `{categoryLabel}` outline badge + optional state badges + * Line 1: `{name}` — bold, `text-lg` + * Line 2: `{dose}` — muted, rendered only when non-empty + * Line 3: `{categoryLabel}` outline badge + optional state badges * * The trailing `actions` slot carries the overflow kebab on the right of * the row. State badges (without-notification, paused-since, inactive) @@ -78,11 +79,22 @@ export function MedicationCardHeader({ const body = ( <> - - {name} {dose} - + {name} {nameChip} + {/* The dose sits on its OWN muted line directly under the name, above + the category badge. Rendered only when a non-empty dose string is + supplied — Vorsorge / lab-list rows pass `dose=""` and get no empty + line. The unit is already localised by `formatDose` at the call + site (the raw `Medication.dose` carries the English unit key). */} + {dose ? ( +

+ {dose} +

+ ) : null} {/* D-H6 — state badges (without-notification, paused-since, inactive) used to share line 2 with the category badge via `flex flex-wrap`. At 320 px any state badge pushed diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3f5aff828..35aff5182 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -11,6 +11,7 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication- import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; +import { formatDose } from "@/lib/medications/format-dose"; import { reduceCurrentWindowStatus, toZonedDate, @@ -462,7 +463,7 @@ export function MedicationCard({ return ( - {medication.dose} + {formatDose(medication.dose, t)} {!medication.active && <> · {labels.inactive}} diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx index dd97b5c5e..1ae984b71 100644 --- a/src/components/medications/medication-table.tsx +++ b/src/components/medications/medication-table.tsx @@ -35,6 +35,7 @@ import { import { formatTime } from "@/lib/format"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { formatDose } from "@/lib/medications/format-dose"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { @@ -649,7 +650,7 @@ function MedicationTableRowItem({ {medication.name} - {medication.dose} + {formatDose(medication.dose, t)} {!medication.active && <> · {t("common.inactive")}} diff --git a/src/components/medications/take-all-due-dialog.tsx b/src/components/medications/take-all-due-dialog.tsx index 78b5558ae..7a2913d31 100644 --- a/src/components/medications/take-all-due-dialog.tsx +++ b/src/components/medications/take-all-due-dialog.tsx @@ -28,6 +28,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useTranslations } from "@/lib/i18n/context"; +import { formatDose } from "@/lib/medications/format-dose"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { runTakeAllDue, @@ -88,7 +89,10 @@ export function TakeAllDueDialog({ {med.name} {med.dose ? ( - — {med.dose} + + {" "} + — {formatDose(med.dose, t)} + ) : null} {med.window ? ( diff --git a/src/components/medications/wizard/steps/step3-dose.tsx b/src/components/medications/wizard/steps/step3-dose.tsx index 85bc82012..4c4ed7683 100644 --- a/src/components/medications/wizard/steps/step3-dose.tsx +++ b/src/components/medications/wizard/steps/step3-dose.tsx @@ -22,30 +22,11 @@ import { } from "@/lib/medications/injection-sites"; import type { MedicationDeliveryForm } from "@/lib/validations/medication"; import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose"; +import { DOSE_UNIT_KEYS } from "@/lib/medications/dose-units"; import { useTranslations } from "@/lib/i18n/context"; import type { StepProps } from "./step1-name"; -/** - * Dose unit choices, ordered so mg / ml / IE / µg lead and the - * device-form units (Tablette, Hub, Sprühstoß, …) follow. Mirrors the - * v1.5.3 list with the maintainer-requested order tightening. - */ -const DOSE_UNIT_KEYS = [ - "mg", - "ml", - "iu", - "mcg", - "g", - "tablets", - "capsules", - "drops", - "puffs", - "sprays", - "pieces", - "other", -] as const; - const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [ "ORAL", "INJECTION", diff --git a/src/lib/medications/__tests__/format-dose.test.ts b/src/lib/medications/__tests__/format-dose.test.ts new file mode 100644 index 000000000..7b2c7b868 --- /dev/null +++ b/src/lib/medications/__tests__/format-dose.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; + +import { formatDose } from "@/lib/medications/format-dose"; + +/** + * The unit keys translate via `medications.wizard.steps.step3.unit.`. + * The stub mirrors a German bundle for the keys under test and echoes the + * key for anything else so a wrong lookup is visible. + */ +const t = (key: string): string => + ({ + "medications.wizard.steps.step3.unit.pieces": "Stück", + "medications.wizard.steps.step3.unit.mg": "mg", + "medications.wizard.steps.step3.unit.tablets": "Tablette(n)", + })[key] ?? key; + +describe("formatDose", () => { + it("translates a raw unit key glued to an amount", () => { + expect(formatDose("1 pieces", t)).toBe("1 Stück"); + }); + + it("keeps a numeric amount and translates the unit", () => { + expect(formatDose("5 mg", t)).toBe("5 mg"); + expect(formatDose("2 tablets", t)).toBe("2 Tablette(n)"); + }); + + it("passes a custom free-text dose through unchanged", () => { + expect(formatDose("1 puff morning", t)).toBe("1 puff morning"); + }); + + it("returns an empty string for empty / null / undefined input", () => { + expect(formatDose("", t)).toBe(""); + expect(formatDose(null, t)).toBe(""); + expect(formatDose(undefined, t)).toBe(""); + }); +}); diff --git a/src/lib/medications/dose-units.ts b/src/lib/medications/dose-units.ts new file mode 100644 index 000000000..00a733524 --- /dev/null +++ b/src/lib/medications/dose-units.ts @@ -0,0 +1,29 @@ +/** + * Canonical dose-unit key list — the single source of truth shared by the + * wizard's dose step (the unit `