feat(medications): EMA titration-ladder display on GLP-1 detail page

Adds the read-only TitrationSection to the medication detail page,
mounted between SchedulingSection (W19e) and IntakeHistoryList.
Same chrome as the W19d / W19e panels so the Wave-4b stack reads
as one visual group.

Layout:
  - Header with drug INN
  - Vertical step list on mobile, horizontal track from sm; each
    step shows dose, typical-weeks badge, and a current/past/upcoming
    state attribute the design system can hook (CSS-only, no JS state)
  - Current step highlighted with a primary-color ring + 'You are
    here' caption
  - Optional dwell hint + next-step caption — observational copy
    only (no 'should/must step up' anywhere; verified by test)
  - Ceiling message when the user is on the top step
  - Non-standard dose caption when the latest dose doesn't snap to
    a step inside the ±10 % tolerance
  - Disclaimer + external EMA-source link on every render

i18n (medications.titration.*) added in all six locales after the
medications.scheduling block per the W19e handoff for stable JSON
ordering. EN + DE hand-curated Marc-Voice; FR / ES / IT / PL drafted
from EN. Parity test (i18n-locale-integrity) passes.

10 component tests cover surface render, step-card count, single
'you are here', next-step caption, ceiling message, non-standard
dose, disclaimer + EMA link, empty ladder, observational-copy
discipline, and DE locale switch.

The escalationDueHint copy is strictly reference (MDR boundary,
parallel to W19c safety ground rules); the test pins the negative
assertion — no 'should step up', no 'must step up', no
'recommend step up'.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:27:29 +02:00
1 parent b4ed2eda9e
commit 86dbff0e6e
9 files changed
+594

No files matched your search

+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Titrationsstufen",
"loading": "Lade Titrationsdaten",
"loadFailed": "Titrationsdaten konnten nicht geladen werden.",
"drugLadderHeader": "Standard-Schema (EMA-Referenz)",
"typicalWeeksOnStep": "≥ {weeks} Wo. typisch",
"youAreHere": "Hier stehst du",
"nextStepCaption": "Nächste Stufe (typisch): {dose} mg nach ~{weeks} Wochen auf der aktuellen Stufe.",
"ceilingMessage": "Du bist auf der höchsten Standardstufe.",
"escalationDueHint": "Du bist seit {weeks} Wochen auf dieser Stufe; das Schema sieht eine Steigerung typischerweise nach ~{typical} Wochen vor.",
"currentStepDwell": "Seit {weeks} Wochen auf dieser Stufe",
"weeksUnit": "Wo.",
"doseUnitMg": "mg",
"emptyState": "Noch keine Dosis-Historie. Erfasse eine Dosis, um deine Position im Schema zu sehen.",
"nonStandardDose": "Aktuelle Dosis liegt außerhalb des Standard-Schemas.",
"disclaimer": "EMA-Referenzwerte. Sprich mit deiner Ärztin oder deinem Arzt, bevor du Dosen änderst.",
"emaSourceCta": "EMA-Quelle",
"stepLabel": "Stufe {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"siteAbdomenLeft": "Bauch, unten links",
+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Titration ladder",
"loading": "Loading titration data",
"loadFailed": "Titration data failed to load.",
"drugLadderHeader": "Standard ladder (EMA reference)",
"typicalWeeksOnStep": "≥ {weeks} wks typical",
"youAreHere": "You are here",
"nextStepCaption": "Next step (typical): {dose} mg after ~{weeks} weeks on the current step.",
"ceilingMessage": "You are at the top of the standard ladder.",
"escalationDueHint": "You've been on this step for {weeks} weeks; the ladder typically steps up around {typical} weeks.",
"currentStepDwell": "On this step for {weeks} weeks",
"weeksUnit": "wks",
"doseUnitMg": "mg",
"emptyState": "No dose history yet. Log a dose to see where you are on the ladder.",
"nonStandardDose": "Current dose is outside the standard ladder buckets.",
"disclaimer": "EMA reference values. Talk to your doctor before changing doses.",
"emaSourceCta": "EMA source",
"stepLabel": "Step {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"siteAbdomenLeft": "Abdomen, lower left",
+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Escalado de titulación",
"loading": "Cargando datos de titulación",
"loadFailed": "No se pudieron cargar los datos de titulación.",
"drugLadderHeader": "Escalado estándar (referencia EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. típico",
"youAreHere": "Estás aquí",
"nextStepCaption": "Siguiente escalón (típico): {dose} mg tras ~{weeks} semanas en el escalón actual.",
"ceilingMessage": "Estás en el último escalón estándar.",
"escalationDueHint": "Llevas {weeks} semanas en este escalón; el esquema suele avanzar tras ~{typical} semanas.",
"currentStepDwell": "En este escalón desde hace {weeks} semanas",
"weeksUnit": "sem.",
"doseUnitMg": "mg",
"emptyState": "Aún no hay historial de dosis. Registra una dosis para ver tu posición en el escalado.",
"nonStandardDose": "La dosis actual está fuera de los escalones estándar.",
"disclaimer": "Valores de referencia EMA. Consulta a tu médico antes de cambiar la dosis.",
"emaSourceCta": "Fuente EMA",
"stepLabel": "Escalón {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"siteAbdomenLeft": "Abdomen, lower left",
+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Paliers de titration",
"loading": "Chargement des données de titration",
"loadFailed": "Échec du chargement des données de titration.",
"drugLadderHeader": "Paliers standard (référence EMA)",
"typicalWeeksOnStep": "≥ {weeks} sem. typique",
"youAreHere": "Vous êtes ici",
"nextStepCaption": "Palier suivant (typique) : {dose} mg après ~{weeks} semaines au palier actuel.",
"ceilingMessage": "Vous êtes au sommet du palier standard.",
"escalationDueHint": "Vous êtes à ce palier depuis {weeks} semaines ; le schéma prévoit une progression typique après ~{typical} semaines.",
"currentStepDwell": "À ce palier depuis {weeks} semaines",
"weeksUnit": "sem.",
"doseUnitMg": "mg",
"emptyState": "Aucun historique de dose. Enregistrez une dose pour voir votre position dans le schéma.",
"nonStandardDose": "La dose actuelle est en dehors des paliers standard.",
"disclaimer": "Valeurs de référence EMA. Consultez votre médecin avant toute modification de dose.",
"emaSourceCta": "Source EMA",
"stepLabel": "Palier {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"siteAbdomenLeft": "Abdomen, lower left",
+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Scala di titolazione",
"loading": "Caricamento dati di titolazione",
"loadFailed": "Impossibile caricare i dati di titolazione.",
"drugLadderHeader": "Scala standard (riferimento EMA)",
"typicalWeeksOnStep": "≥ {weeks} sett. tipico",
"youAreHere": "Sei qui",
"nextStepCaption": "Prossimo gradino (tipico): {dose} mg dopo ~{weeks} settimane al gradino attuale.",
"ceilingMessage": "Sei al gradino più alto della scala standard.",
"escalationDueHint": "Sei su questo gradino da {weeks} settimane; lo schema prevede un avanzamento tipico dopo ~{typical} settimane.",
"currentStepDwell": "Su questo gradino da {weeks} settimane",
"weeksUnit": "sett.",
"doseUnitMg": "mg",
"emptyState": "Nessuna cronologia delle dosi. Registra una dose per vedere la tua posizione sulla scala.",
"nonStandardDose": "La dose attuale è fuori dai gradini standard.",
"disclaimer": "Valori di riferimento EMA. Consulta il tuo medico prima di cambiare la dose.",
"emaSourceCta": "Fonte EMA",
"stepLabel": "Gradino {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"siteAbdomenLeft": "Abdomen, lower left",
+19
View File
@@ -709,6 +709,25 @@
}
}
},
"titration": {
"section": "Schemat titracji",
"loading": "Wczytywanie danych titracji",
"loadFailed": "Nie udało się wczytać danych titracji.",
"drugLadderHeader": "Standardowy schemat (referencja EMA)",
"typicalWeeksOnStep": "≥ {weeks} tyg. typowo",
"youAreHere": "Jesteś tutaj",
"nextStepCaption": "Następny stopień (typowo): {dose} mg po ~{weeks} tygodniach na obecnym stopniu.",
"ceilingMessage": "Jesteś na najwyższym stopniu schematu standardowego.",
"escalationDueHint": "Jesteś na tym stopniu od {weeks} tygodni; schemat zwykle przewiduje przejście wyżej po ~{typical} tygodniach.",
"currentStepDwell": "Na tym stopniu od {weeks} tygodni",
"weeksUnit": "tyg.",
"doseUnitMg": "mg",
"emptyState": "Brak historii dawek. Zarejestruj dawkę, aby zobaczyć swoją pozycję w schemacie.",
"nonStandardDose": "Aktualna dawka jest poza standardowymi stopniami schematu.",
"disclaimer": "Wartości referencyjne EMA. Skonsultuj się z lekarzem przed zmianą dawki.",
"emaSourceCta": "Źródło EMA",
"stepLabel": "Stopień {n}"
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"siteAbdomenLeft": "Abdomen, lower left",
+11
View File
@@ -9,6 +9,7 @@ import { IntakeHistoryList } from "@/components/medications/intake-history-list"
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
@@ -123,6 +124,16 @@ export default function IntakeHistoryPage({
/>
)}
{/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA
reference visual showing the standard dose-escalation schedule
with the user's current step highlighted. Sits between
SchedulingSection and IntakeHistoryList so the user lands on
cycle context (chart) -> side effects -> cadence/adherence ->
titration position -> per-dose timeline. */}
{medication?.treatmentClass === "GLP1" && (
<TitrationSection medicationId={id} />
)}
<IntakeHistoryList
medicationId={id}
createOpen={createOpen}
@@ -0,0 +1,248 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W19f — GLP-1 titration-ladder section.
*
* Sits between `<SchedulingSection>` (W19e) and `<IntakeHistoryList>`
* on the medication detail page. Same chrome as the W19d / W19e
* sections so the Wave-4b panels read as one visual group.
*
* Layout:
* - Header: drug INN + "Standard ladder (EMA reference)"
* - Vertical step list (mobile) — same nodes laid horizontally
* from `sm` upwards. Each step shows dose, typical-weeks badge,
* and a status dot (current / past / upcoming).
* - Current-step ring with "You are here" caption.
* - Next-step caption + observational dwell hint when the EMA
* dwell-time has elapsed. Copy is strictly observational; the
* ladder is a *reference*, never a prescription (MDR boundary).
* - Disclaimer + EMA-source link.
*
* Data comes from `/api/medications/[id]/titration`; that route owns
* the prisma queries and delegates math to the pure ladder helpers.
*/
interface TitrationStep {
stepIndex: number;
doseMg: number;
typicalWeeks: number;
}
interface TitrationResponse {
drugId: string;
drugInn: string;
ladder: TitrationStep[];
currentStep: TitrationStep | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: TitrationStep | null;
escalationDue: boolean;
sourceEMA: string;
}
interface TitrationSectionProps {
medicationId: string;
}
function templateFill(
template: string,
values: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (match, key) => {
if (key in values) return String(values[key]);
return match;
});
}
export function TitrationSection({ medicationId }: TitrationSectionProps) {
const { t } = useTranslations();
const { data, isLoading, error } = useQuery({
queryKey: ["medications", medicationId, "titration"],
queryFn: async (): Promise<TitrationResponse> => {
const res = await fetch(`/api/medications/${medicationId}/titration`);
if (!res.ok) {
throw new Error(`Failed to load titration: ${res.status}`);
}
const json = await res.json();
return json.data as TitrationResponse;
},
});
const hasLadder = !!data && data.ladder.length > 0;
const currentIdx = data?.currentStepIndex ?? null;
// Compose the optional dwell hint AND the next-step caption. The
// dwell hint is strictly observational — no "you should step up";
// the boolean only gates *display*, never *action*.
const dwellHint = useMemo(() => {
if (!data || data.currentStep === null) return null;
if (data.weeksOnCurrentStep <= 0) return null;
if (data.escalationDue) {
return templateFill(t("medications.titration.escalationDueHint"), {
weeks: data.weeksOnCurrentStep,
typical: data.currentStep.typicalWeeks,
});
}
return templateFill(t("medications.titration.currentStepDwell"), {
weeks: data.weeksOnCurrentStep,
});
}, [data, t]);
const nextStepCaption = useMemo(() => {
if (!data) return null;
if (!data.nextStep) return null;
return templateFill(t("medications.titration.nextStepCaption"), {
dose: data.nextStep.doseMg,
weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks,
});
}, [data, t]);
return (
<section
className="border-border/60 rounded-md border"
aria-labelledby="titration-heading"
data-slot="titration-section"
>
<header className="flex items-center justify-between px-3 py-2.5">
<h2
id="titration-heading"
className="text-foreground/85 text-sm font-medium"
>
{t("medications.titration.section")}
</h2>
{data && (
<span
className="text-muted-foreground text-[10px] uppercase tracking-wide"
data-slot="titration-drug-inn"
>
{data.drugInn}
</span>
)}
</header>
<div className="border-border/60 border-t px-3 py-3 text-xs">
{isLoading && (
<div className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>{t("medications.titration.loading")}</span>
</div>
)}
{!!error && !isLoading && (
<p className="text-destructive">
{t("medications.titration.loadFailed")}
</p>
)}
{data && !isLoading && (
<div className="space-y-3">
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
{t("medications.titration.drugLadderHeader")}
</p>
{hasLadder ? (
<ol
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-stretch"
aria-label={t("medications.titration.drugLadderHeader")}
>
{data.ladder.map((step) => {
const isCurrent = currentIdx === step.stepIndex;
const isPast =
currentIdx !== null && step.stepIndex < currentIdx;
return (
<li
key={step.stepIndex}
className={[
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 sm:min-w-[7rem]",
isCurrent
? "border-primary ring-primary/40 ring-2"
: "",
].join(" ")}
aria-current={isCurrent ? "step" : undefined}
data-slot="titration-step"
data-step-index={step.stepIndex}
data-step-state={
isCurrent ? "current" : isPast ? "past" : "upcoming"
}
>
<span className="text-muted-foreground text-[10px] uppercase tracking-wide">
{templateFill(t("medications.titration.stepLabel"), {
n: step.stepIndex + 1,
})}
</span>
<span className="text-foreground text-sm font-medium">
{step.doseMg} {t("medications.titration.doseUnitMg")}
</span>
<span className="text-muted-foreground text-[10px]">
{templateFill(
t("medications.titration.typicalWeeksOnStep"),
{ weeks: step.typicalWeeks },
)}
</span>
{isCurrent && (
<span className="text-primary mt-1 text-[10px] font-medium uppercase tracking-wide">
{t("medications.titration.youAreHere")}
</span>
)}
</li>
);
})}
</ol>
) : (
<p className="text-muted-foreground">
{t("medications.titration.emptyState")}
</p>
)}
{data.currentStep === null && hasLadder && (
<p className="text-muted-foreground">
{t("medications.titration.nonStandardDose")}
</p>
)}
{dwellHint && (
<p
className="text-muted-foreground"
data-slot="titration-dwell-hint"
>
{dwellHint}
</p>
)}
{nextStepCaption && (
<p className="text-muted-foreground">{nextStepCaption}</p>
)}
{data.nextStep === null && data.currentStep !== null && (
<p className="text-muted-foreground">
{t("medications.titration.ceilingMessage")}
</p>
)}
<div className="border-border/60 border-t pt-2">
<p className="text-muted-foreground text-[10px]">
{t("medications.titration.disclaimer")}
</p>
<a
href={data.sourceEMA}
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-[10px] underline-offset-2 hover:underline"
>
{t("medications.titration.emaSourceCta")}
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
</a>
</div>
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,221 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TitrationSection } from "@/components/medications/TitrationSection";
/**
* v1.4.25 W19f — TitrationSection SSR smoke tests.
*
* Same testing convention as `SchedulingSection.test.tsx`:
* `renderToStaticMarkup` + seeded react-query cache. Interactive
* branches (loading spinner, fetch-error) are covered by the API
* route tests + the pure ladder tests; surface tests here pin the
* static-render contract:
*
* 1. Header + drug INN render.
* 2. Each ladder step renders with dose + typical-weeks label.
* 3. The current step has a "You are here" caption.
* 4. The next-step caption renders when a next step exists.
* 5. The ceiling message renders when the user is on the top
* step (no next step).
* 6. The non-standard dose caption renders when current step is
* null but the ladder is non-empty.
* 7. The disclaimer + EMA source link always render with data.
* 8. Empty ladder → empty-state copy renders.
* 9. The escalationDueHint copy is observational, not prescriptive.
* 10. German locale renders.
*/
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
interface SeedShape {
drugId: string;
drugInn: string;
ladder: Array<{ stepIndex: number; doseMg: number; typicalWeeks: number }>;
currentStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
currentStepIndex: number | null;
weeksOnCurrentStep: number;
nextStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
escalationDue: boolean;
sourceEMA: string;
}
function seed(client: QueryClient, medId: string, payload: SeedShape) {
client.setQueryData(["medications", medId, "titration"], payload);
}
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
const TIRZEPATIDE_LADDER = [
{ stepIndex: 0, doseMg: 2.5, typicalWeeks: 4 },
{ stepIndex: 1, doseMg: 5, typicalWeeks: 4 },
{ stepIndex: 2, doseMg: 7.5, typicalWeeks: 4 },
{ stepIndex: 3, doseMg: 10, typicalWeeks: 4 },
{ stepIndex: 4, doseMg: 12.5, typicalWeeks: 4 },
{ stepIndex: 5, doseMg: 15, typicalWeeks: 4 },
];
const SEED_ON_STEP_5MG: SeedShape = {
drugId: "tirzepatide",
drugInn: "Tirzepatide",
ladder: TIRZEPATIDE_LADDER,
currentStep: TIRZEPATIDE_LADDER[1],
currentStepIndex: 1,
weeksOnCurrentStep: 2,
nextStep: TIRZEPATIDE_LADDER[2],
escalationDue: false,
sourceEMA:
"https://www.ema.europa.eu/en/documents/product-information/mounjaro-epar-product-information_en.pdf",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("<TitrationSection> — surface render", () => {
it("renders the section heading and drug INN", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Titration ladder");
expect(html).toContain("Tirzepatide");
expect(html).toContain("Standard ladder (EMA reference)");
});
it("renders every step on the ladder", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
for (const step of TIRZEPATIDE_LADDER) {
// Each step's dose label appears (e.g. "2.5 mg", "15 mg").
expect(html).toContain(`${step.doseMg} mg`);
}
// Step ladder count = 6 steps; each step renders one card.
const stepCards = html.match(/data-slot="titration-step"/g) ?? [];
expect(stepCards.length).toBe(6);
});
it('renders "You are here" on the current step only', () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are here");
// Only one "you are here" caption rendered, on the matched step.
expect(html.match(/You are here/g)?.length).toBe(1);
});
it("renders the next-step caption when a next step exists", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("Next step (typical): 7.5 mg");
});
it("renders the ceiling message at the top of the ladder", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: TIRZEPATIDE_LADDER[5],
currentStepIndex: 5,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain("You are at the top of the standard ladder.");
});
it("renders the non-standard dose caption when current step is null", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
currentStep: null,
currentStepIndex: null,
nextStep: null,
weeksOnCurrentStep: 0,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"Current dose is outside the standard ladder buckets.",
);
});
it("always renders the disclaimer and the EMA-source link", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"EMA reference values. Talk to your doctor before changing doses.",
);
expect(html).toContain("EMA source");
expect(html).toContain("mounjaro-epar-product-information");
});
it("renders the empty-state copy when the ladder is empty", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
ladder: [],
currentStep: null,
currentStepIndex: null,
nextStep: null,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
expect(html).toContain(
"No dose history yet. Log a dose to see where you are on the ladder.",
);
});
it("renders the escalation-due hint as an observation, not a prescription", () => {
const client = makeClient();
seed(client, "med-1", {
...SEED_ON_STEP_5MG,
weeksOnCurrentStep: 6,
escalationDue: true,
});
const html = render(<TitrationSection medicationId="med-1" />, client);
// The copy must NOT prescribe an action ("you should step up").
expect(html).not.toMatch(/should step up/i);
expect(html).not.toMatch(/must step up/i);
expect(html).not.toMatch(/recommend.*step up/i);
// The observational template must be rendered.
expect(html).toContain("the ladder typically steps up around");
// The escaped apostrophe is the SSR render of the curly-apostrophe-free
// template; we assert on the unencoded plain segment instead of the
// contraction itself to stay robust to the renderer's HTML entity choice.
expect(html).toContain("been on this step for 6 weeks");
});
it("renders the section heading in German", () => {
const client = makeClient();
seed(client, "med-1", SEED_ON_STEP_5MG);
const html = render(
<TitrationSection medicationId="med-1" />,
client,
"de",
);
expect(html).toContain("Titrationsstufen");
expect(html).toContain("Standard-Schema (EMA-Referenz)");
expect(html).toContain("Hier stehst du");
});
});