mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(medications): localise the dose unit and lift it onto its own line
A medication's dose is stored as free text the wizard composes from an
amount and a unit token ("1 pieces", "2 tablets"), and the card rendered
that token verbatim glued onto the name — so the unit showed in English
on every locale and the title read "Test ABC 1 pieces".
Translate the dose at display time: a formatDose helper splits the stored
string and runs a known unit token through the existing unit messages, so
"1 pieces" reads "1 Stück" / "1 piece(s)" per locale while a custom
free-text dose passes through untouched. The card header now carries the
name alone on its title line with the dose on a muted line beneath it,
above the category badge; the detail view, table and intake dialogs
localise the same way. Vorsorge and lab rows pass an empty dose so they
render no extra line.
This commit is contained in:
1 parent
a31711699c
commit
6fe552ffdf
14 files changed
+180
-43
No files matched your search
@@ -283,7 +283,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
|||||||
expect(html).not.toContain("Dose history");
|
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();
|
const client = makeClient();
|
||||||
seedCompliance(client, med7p5.id);
|
seedCompliance(client, med7p5.id);
|
||||||
seedGlp1Details(client, med7p5.id, {});
|
seedGlp1Details(client, med7p5.id, {});
|
||||||
@@ -299,10 +299,10 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
|||||||
|
|
||||||
expect(html).toContain("Mounjaro");
|
expect(html).toContain("Mounjaro");
|
||||||
expect(html).toContain("7.5 mg");
|
expect(html).toContain("7.5 mg");
|
||||||
// v1.4.28 FB-G1 — the GLP-1 row drops the middle-dot separator and
|
// FB-G1 — the GLP-1 row drops the middle-dot separator; the dose now
|
||||||
// surfaces `{name} {dose}` together on line 1 via the shared
|
// rides its OWN muted line under the name (the shared
|
||||||
// `<MedicationCardHeader>` primitive. The dot separator is gone.
|
// `<MedicationCardHeader>` primitive), localised via `formatDose`.
|
||||||
expect(html).toContain("Mounjaro 7.5 mg");
|
expect(html).toContain('data-slot="medication-card-header-dose"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows last + next injection labels with localised weekday", () => {
|
it("shows last + next injection labels with localised weekday", () => {
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import { MedicationCardHeader } from "@/components/medications/medication-card-h
|
|||||||
* v1.4.28 — narrow-viewport contract for the shared medication-list
|
* v1.4.28 — narrow-viewport contract for the shared medication-list
|
||||||
* row primitive (D-H6).
|
* row primitive (D-H6).
|
||||||
*
|
*
|
||||||
* FB-G1 calls for a two-line shape: `{name} {dose}` on line 1 and the
|
* FB-G1 calls for a stacked shape: `{name}` on line 1, the localised
|
||||||
* category badge on line 2. State badges (without-notification, paused,
|
* `{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
|
* 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
|
* viewport that pushed the row to three lines for ~20 % of configured
|
||||||
* drugs. State badges now ride their own row below the category badge
|
* drugs. State badges now ride their own row below the category badge
|
||||||
@@ -27,7 +28,7 @@ function render(node: React.ReactNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("<MedicationCardHeader>", () => {
|
describe("<MedicationCardHeader>", () => {
|
||||||
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(
|
const html = render(
|
||||||
<MedicationCardHeader
|
<MedicationCardHeader
|
||||||
name="Ramipril"
|
name="Ramipril"
|
||||||
@@ -35,8 +36,29 @@ describe("<MedicationCardHeader>", () => {
|
|||||||
categoryLabel="Blood Pressure"
|
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");
|
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(
|
||||||
|
<MedicationCardHeader
|
||||||
|
name="Vorsorge"
|
||||||
|
dose=""
|
||||||
|
categoryLabel="Screening"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain('data-slot="medication-card-header-dose"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not paint the state-badges row when no badges are supplied", () => {
|
it("does not paint the state-badges row when no badges are supplied", () => {
|
||||||
@@ -100,8 +122,9 @@ describe("<MedicationCardHeader>", () => {
|
|||||||
expect(html).toContain('data-slot="medication-card-header-link"');
|
expect(html).toContain('data-slot="medication-card-header-link"');
|
||||||
expect(html).toContain('href="/medications/med-1"');
|
expect(html).toContain('href="/medications/med-1"');
|
||||||
expect(html).toContain('aria-label="Open medication detail page"');
|
expect(html).toContain('aria-label="Open medication detail page"');
|
||||||
// The title still rides inside the link region.
|
// The name + dose still ride inside the link region.
|
||||||
expect(html).toContain("Ramipril 5 mg");
|
expect(html).toContain("Ramipril");
|
||||||
|
expect(html).toContain("5 mg");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a non-navigating div when href is omitted", () => {
|
it("falls back to a non-navigating div when href is omitted", () => {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { queryKeys } from "@/lib/query-keys";
|
|||||||
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
import { formatUnitCount } from "@/components/medications/units-per-dose";
|
||||||
import { apiGet } from "@/lib/api/api-fetch";
|
import { apiGet } from "@/lib/api/api-fetch";
|
||||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
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 { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||||
|
|
||||||
@@ -392,7 +393,7 @@ export function MedicationDetailTabs({
|
|||||||
page" and misled). `?edit=1` still opens the wizard directly. */}
|
page" and misled). `?edit=1` still opens the wizard directly. */}
|
||||||
<MedicationDetailSummary
|
<MedicationDetailSummary
|
||||||
name={medication.name}
|
name={medication.name}
|
||||||
dose={medication.dose}
|
dose={formatDose(medication.dose, t)}
|
||||||
active={medication.active}
|
active={medication.active}
|
||||||
endsOn={medication.endsOn}
|
endsOn={medication.endsOn}
|
||||||
payload={payload}
|
payload={payload}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||||
import { formatDateTime, formatTime } from "@/lib/format";
|
import { formatDateTime, formatTime } from "@/lib/format";
|
||||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
import { type InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||||
@@ -483,7 +484,7 @@ export function Glp1MedicationCard({
|
|||||||
return (
|
return (
|
||||||
<MedicationCardBody
|
<MedicationCardBody
|
||||||
name={medication.name}
|
name={medication.name}
|
||||||
dose={medication.dose}
|
dose={formatDose(medication.dose, t)}
|
||||||
categoryLabel={categoryLabel}
|
categoryLabel={categoryLabel}
|
||||||
active={medication.active}
|
active={medication.active}
|
||||||
href={`/medications/${medication.id}`}
|
href={`/medications/${medication.id}`}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { DateTimeField } from "@/components/ui/date-time-field";
|
|||||||
import { NativeSelect } from "@/components/ui/native-select";
|
import { NativeSelect } from "@/components/ui/native-select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import { runLogIntake } from "@/components/medications/use-medication-intake";
|
import { runLogIntake } from "@/components/medications/use-medication-intake";
|
||||||
|
|
||||||
interface LogIntakeSchedule {
|
interface LogIntakeSchedule {
|
||||||
@@ -243,7 +244,7 @@ export function LogIntakeDialog({
|
|||||||
>
|
>
|
||||||
{medications.map((m) => (
|
{medications.map((m) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{m.dose ? `${m.name} — ${m.dose}` : m.name}
|
{m.dose ? `${m.name} — ${formatDose(m.dose, t)}` : m.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</NativeSelect>
|
</NativeSelect>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import { CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
* route their title + dose + drug class through this primitive so the
|
* route their title + dose + drug class through this primitive so the
|
||||||
* surface stays one consistent shape:
|
* surface stays one consistent shape:
|
||||||
*
|
*
|
||||||
* Line 1: `{name} {dose}` — bold, `text-lg`
|
* Line 1: `{name}` — bold, `text-lg`
|
||||||
* Line 2: `{categoryLabel}` outline badge + optional state badges
|
* 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 trailing `actions` slot carries the overflow kebab on the right of
|
||||||
* the row. State badges (without-notification, paused-since, inactive)
|
* the row. State badges (without-notification, paused-since, inactive)
|
||||||
@@ -78,11 +79,22 @@ export function MedicationCardHeader({
|
|||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<CardTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg">
|
<CardTitle className="flex flex-wrap items-center gap-x-2 gap-y-1 text-lg">
|
||||||
<span>
|
<span>{name}</span>
|
||||||
{name} {dose}
|
|
||||||
</span>
|
|
||||||
{nameChip}
|
{nameChip}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{/* 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 ? (
|
||||||
|
<p
|
||||||
|
data-slot="medication-card-header-dose"
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
{dose}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{/* D-H6 — state badges (without-notification, paused-since,
|
{/* D-H6 — state badges (without-notification, paused-since,
|
||||||
inactive) used to share line 2 with the category badge
|
inactive) used to share line 2 with the category badge
|
||||||
via `flex flex-wrap`. At 320 px any state badge pushed
|
via `flex flex-wrap`. At 320 px any state badge pushed
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useWeekdayLabel } from "@/components/medications/card-parts/medication-
|
|||||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||||
import { formatDateTime, formatTime } from "@/lib/format";
|
import { formatDateTime, formatTime } from "@/lib/format";
|
||||||
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import {
|
import {
|
||||||
reduceCurrentWindowStatus,
|
reduceCurrentWindowStatus,
|
||||||
toZonedDate,
|
toZonedDate,
|
||||||
@@ -462,7 +463,7 @@ export function MedicationCard({
|
|||||||
return (
|
return (
|
||||||
<MedicationCardBody
|
<MedicationCardBody
|
||||||
name={medication.name}
|
name={medication.name}
|
||||||
dose={medication.dose}
|
dose={formatDose(medication.dose, t)}
|
||||||
categoryLabel={categoryLabel}
|
categoryLabel={categoryLabel}
|
||||||
active={medication.active}
|
active={medication.active}
|
||||||
href={`/medications/${medication.id}`}
|
href={`/medications/${medication.id}`}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
|
import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-layout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +322,7 @@ function SortableMedicationRow({
|
|||||||
labels,
|
labels,
|
||||||
onMove,
|
onMove,
|
||||||
}: SortableMedicationRowProps) {
|
}: SortableMedicationRowProps) {
|
||||||
|
const { t } = useTranslations();
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -363,7 +365,7 @@ function SortableMedicationRow({
|
|||||||
{medication.name}
|
{medication.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground block truncate text-xs">
|
<span className="text-muted-foreground block truncate text-xs">
|
||||||
{medication.dose}
|
{formatDose(medication.dose, t)}
|
||||||
{!medication.active && <> · {labels.inactive}</>}
|
{!medication.active && <> · {labels.inactive}</>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { formatTime } from "@/lib/format";
|
import { formatTime } from "@/lib/format";
|
||||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import { queryKeys } from "@/lib/query-keys";
|
import { queryKeys } from "@/lib/query-keys";
|
||||||
import { apiGet } from "@/lib/api/api-fetch";
|
import { apiGet } from "@/lib/api/api-fetch";
|
||||||
import {
|
import {
|
||||||
@@ -649,7 +650,7 @@ function MedicationTableRowItem({
|
|||||||
{medication.name}
|
{medication.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground block text-xs">
|
<span className="text-muted-foreground block text-xs">
|
||||||
{medication.dose}
|
{formatDose(medication.dose, t)}
|
||||||
{!medication.active && <> · {t("common.inactive")}</>}
|
{!medication.active && <> · {t("common.inactive")}</>}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
import { formatDose } from "@/lib/medications/format-dose";
|
||||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||||
import {
|
import {
|
||||||
runTakeAllDue,
|
runTakeAllDue,
|
||||||
@@ -88,7 +89,10 @@ export function TakeAllDueDialog({
|
|||||||
<span className="min-w-0 truncate">
|
<span className="min-w-0 truncate">
|
||||||
<span className="font-medium">{med.name}</span>
|
<span className="font-medium">{med.name}</span>
|
||||||
{med.dose ? (
|
{med.dose ? (
|
||||||
<span className="text-muted-foreground"> — {med.dose}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
— {formatDose(med.dose, t)}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{med.window ? (
|
{med.window ? (
|
||||||
|
|||||||
@@ -22,30 +22,11 @@ import {
|
|||||||
} from "@/lib/medications/injection-sites";
|
} from "@/lib/medications/injection-sites";
|
||||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||||
import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose";
|
import { unitsPerDoseOptionsFor } from "@/components/medications/units-per-dose";
|
||||||
|
import { DOSE_UNIT_KEYS } from "@/lib/medications/dose-units";
|
||||||
import { useTranslations } from "@/lib/i18n/context";
|
import { useTranslations } from "@/lib/i18n/context";
|
||||||
|
|
||||||
import type { StepProps } from "./step1-name";
|
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[] = [
|
const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
|
||||||
"ORAL",
|
"ORAL",
|
||||||
"INJECTION",
|
"INJECTION",
|
||||||
|
|||||||
@@ -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.<key>`.
|
||||||
|
* 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Canonical dose-unit key list — the single source of truth shared by the
|
||||||
|
* wizard's dose step (the unit `<Select>`) and the display-time
|
||||||
|
* {@link formatDose} helper. Keep both in lock-step by importing from here;
|
||||||
|
* never re-declare the array.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Each key resolves to a localised label at
|
||||||
|
* `medications.wizard.steps.step3.unit.<key>` in every locale bundle.
|
||||||
|
*/
|
||||||
|
export const DOSE_UNIT_KEYS = [
|
||||||
|
"mg",
|
||||||
|
"ml",
|
||||||
|
"iu",
|
||||||
|
"mcg",
|
||||||
|
"g",
|
||||||
|
"tablets",
|
||||||
|
"capsules",
|
||||||
|
"drops",
|
||||||
|
"puffs",
|
||||||
|
"sprays",
|
||||||
|
"pieces",
|
||||||
|
"other",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DoseUnitKey = (typeof DOSE_UNIT_KEYS)[number];
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { DOSE_UNIT_KEYS } from "./dose-units";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display-time dose formatter.
|
||||||
|
*
|
||||||
|
* The wizard composes `Medication.dose` as a free-text string
|
||||||
|
* `"{amount} {unitKey}"` storing the RAW unit key (e.g. "1 pieces",
|
||||||
|
* "5 mg"). Rendered verbatim that leaks the untranslated English key onto
|
||||||
|
* the card — "Test ABC 1 pieces" even on a German UI. This helper splits
|
||||||
|
* the amount from the unit and, when the unit is one of the canonical
|
||||||
|
* {@link DOSE_UNIT_KEYS}, swaps it for the localised label
|
||||||
|
* (`medications.wizard.steps.step3.unit.<key>`). A custom free-text dose
|
||||||
|
* the wizard never produced (e.g. "1 puff morning") passes through
|
||||||
|
* unchanged.
|
||||||
|
*
|
||||||
|
* Pure + SSR-safe: takes the `t` accessor as a parameter, no React hook.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mirrors `parseDoseExpression` in wizard-payload.ts — split on a leading
|
||||||
|
// number ("5 mg" → {amount:"5", unit:"mg"}); no number → {amount:"", unit:raw}.
|
||||||
|
const DOSE_EXPR_RE = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
|
||||||
|
|
||||||
|
const KNOWN_UNITS = new Set<string>(DOSE_UNIT_KEYS);
|
||||||
|
|
||||||
|
export function formatDose(
|
||||||
|
raw: string | null | undefined,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
|
||||||
|
const match = DOSE_EXPR_RE.exec(trimmed);
|
||||||
|
const amount = match ? (match[1] ?? "") : "";
|
||||||
|
const unit = match ? (match[2] ?? "").trim() : trimmed;
|
||||||
|
|
||||||
|
const key = unit.toLowerCase();
|
||||||
|
if (KNOWN_UNITS.has(key)) {
|
||||||
|
const label = t(`medications.wizard.steps.step3.unit.${key}`);
|
||||||
|
return amount ? `${amount} ${label}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom free-text dose (the wizard never minted this unit) — leave it be.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user