mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
b4ed2eda9e
commit
86dbff0e6e
9 files changed
+594
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user