mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(medications): show the GLP-1 dose-strength curve instead of the empty therapy course
The GLP-1 detail disclosure carried a titration block that only ever rendered the standard EMA *reference* ladder — a static schedule with no values from the user's own therapy, blank whenever the medication's brand fell outside the catalog. Replace it with a dose-strength curve over the user's recorded dose-change history. The MedicationDoseChange stream is the history of record for titration (2.5 -> 5 -> 7.5 mg escalation steps). The new curve plots that series as a step line, carrying the latest dose forward to now so the current strength anchors the right edge. With a single dose change there is no escalation to draw, so the surface states the current dose plainly with a "no titration history yet" caption rather than a flat single-point chart; with none it shows the empty state. The curve plots only logged facts, so unlike the estimated-PK drug-level chart it needs no Research-Mode gate. Drop the now-unused reference-ladder section, its read route, the ladder helpers, the titration query key, and the orphaned i18n block.
This commit is contained in:
1 parent
c1f30466e7
commit
3f9fd9095f
18 files changed
+528
-1361
No files matched your search
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dosisstärke im Zeitverlauf",
|
||||
"axisLabel": "Erfasste Dosis ({unit})",
|
||||
"tooltipLabel": "Dosis",
|
||||
"currentDose": "Aktuelle Dosis: {dose} {unit}",
|
||||
"noCurrentDose": "Noch keine Dosis erfasst.",
|
||||
"empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
|
||||
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
|
||||
|
||||
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dose strength over time",
|
||||
"axisLabel": "Recorded dose ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Current dose: {dose} {unit}",
|
||||
"noCurrentDose": "No dose recorded yet.",
|
||||
"empty": "No titration history yet. Log a dose change to see the strength curve."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Body outline",
|
||||
"injectionSitePickerAriaLabel": "Injection site picker",
|
||||
|
||||
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dosis a lo largo del tiempo",
|
||||
"axisLabel": "Dosis registrada ({unit})",
|
||||
"tooltipLabel": "Dosis",
|
||||
"currentDose": "Dosis actual: {dose} {unit}",
|
||||
"noCurrentDose": "Aún no hay dosis registrada.",
|
||||
"empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
|
||||
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
|
||||
|
||||
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dose au fil du temps",
|
||||
"axisLabel": "Dose enregistrée ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Dose actuelle : {dose} {unit}",
|
||||
"noCurrentDose": "Aucune dose enregistrée pour l'instant.",
|
||||
"empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
|
||||
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
|
||||
|
||||
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dose nel tempo",
|
||||
"axisLabel": "Dose registrata ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Dose attuale: {dose} {unit}",
|
||||
"noCurrentDose": "Nessuna dose ancora registrata.",
|
||||
"empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
|
||||
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
|
||||
|
||||
+7
-17
@@ -980,23 +980,13 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"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}"
|
||||
"doseStrength": {
|
||||
"title": "Dawka w czasie",
|
||||
"axisLabel": "Zapisana dawka ({unit})",
|
||||
"tooltipLabel": "Dawka",
|
||||
"currentDose": "Aktualna dawka: {dose} {unit}",
|
||||
"noCurrentDose": "Nie zapisano jeszcze żadnej dawki.",
|
||||
"empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
|
||||
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — titration-ladder route tests.
|
||||
*
|
||||
* Mirrors the W19e cadence-route test fixture: external dependencies
|
||||
* are mocked at module boundaries (prisma, session, logging
|
||||
* transport) so each test pins one contract case. The pure ladder
|
||||
* helpers are covered separately in
|
||||
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeMed(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
dose: string;
|
||||
treatmentClass: "GLP1" | "GENERIC";
|
||||
userId: string;
|
||||
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: "med-1",
|
||||
name: overrides.name ?? "Mounjaro",
|
||||
dose: overrides.dose ?? "5 mg",
|
||||
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
|
||||
userId: overrides.userId ?? "user-1",
|
||||
createdAt: new Date(),
|
||||
doseChanges: overrides.doseChanges ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/titration", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ userId: "OTHER" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not GLP-1", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ treatmentClass: "GENERIC" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ name: "MysteryDrug" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.drugId).toBe("tirzepatide");
|
||||
expect(json.data.ladder.length).toBe(6);
|
||||
expect(json.data.currentStep.doseMg).toBe(5);
|
||||
expect(json.data.currentStepIndex).toBe(1);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(2);
|
||||
expect(json.data.nextStep.doseMg).toBe(7.5);
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
|
||||
doseChanges: [],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep).toBeNull();
|
||||
expect(json.data.currentStepIndex).toBeNull();
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null next-step at the ladder ceiling", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "15 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 15,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(15);
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "2.5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
// Six weeks ago, past tirzepatide's 4-week interval.
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(2.5);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(6);
|
||||
expect(json.data.escalationDue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
|
||||
*
|
||||
* GET /api/medications/[id]/titration
|
||||
* - returns the EMA-reference ladder for the medication's drug,
|
||||
* the user's current step (matched within ±10 % tolerance to
|
||||
* their latest dose), how long they've been on that step, the
|
||||
* immediate-next step (if any), and the observational
|
||||
* escalation-due flag.
|
||||
*
|
||||
* Pure computation — no writes. Delegates math to the
|
||||
* `src/lib/medications/titration/ladder` module so the route, the
|
||||
* Coach snapshot (future wave), and the detail-page section all read
|
||||
* the same numbers.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); medication is verified to
|
||||
* belong to the caller before any read. Defence-in-depth: returns
|
||||
* 404 for non-GLP-1 medications (the UI already gates on
|
||||
* `treatmentClass === "GLP1"`).
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
findDrugByBrand,
|
||||
findDrugIdByBrand,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
escalationDue,
|
||||
findCurrentStep,
|
||||
ladderFromRecord,
|
||||
nextStep,
|
||||
weeksOnCurrentStep,
|
||||
} from "@/lib/medications/titration/ladder";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
|
||||
// the 404 leak shape stays consistent across every medication
|
||||
// sub-route.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
if (med.treatmentClass !== "GLP1") {
|
||||
// Defence-in-depth: the UI already gates the section on this
|
||||
// class, but the route refuses to surface the ladder for a
|
||||
// non-GLP-1 row so we never leak a misleading "you are here"
|
||||
// on a generic medication.
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the GLP-1 drug record from the medication name. Same
|
||||
// path the DrugLevelChart uses; null when the medication name
|
||||
// doesn't map to a catalog brand (e.g. user typed a generic
|
||||
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
|
||||
// through the shared helpers in glp1-knowledge.
|
||||
const record = findDrugByBrand(med.name);
|
||||
const drugId = findDrugIdByBrand(med.name);
|
||||
if (!record || !drugId) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const ladder = ladderFromRecord(record);
|
||||
|
||||
// Resolve the user's most-recent dose. Prefer the
|
||||
// MedicationDoseChange stream (the history of record); fall back
|
||||
// to the legacy free-text `medication.dose` if the stream is empty.
|
||||
const latestChange =
|
||||
med.doseChanges.length > 0
|
||||
? med.doseChanges[med.doseChanges.length - 1]
|
||||
: null;
|
||||
const latestDoseMg = latestChange
|
||||
? latestChange.doseValue
|
||||
: parseDoseMgOrNull(med.dose);
|
||||
|
||||
const currentStep = findCurrentStep(drugId, latestDoseMg);
|
||||
const next = nextStep(drugId, currentStep);
|
||||
const asOf = new Date();
|
||||
const weeks = weeksOnCurrentStep(
|
||||
drugId,
|
||||
currentStep,
|
||||
med.doseChanges.map((dc) => ({
|
||||
effectiveFrom: dc.effectiveFrom,
|
||||
doseValue: dc.doseValue,
|
||||
})),
|
||||
asOf,
|
||||
);
|
||||
const due = escalationDue(drugId, currentStep, weeks);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.titration",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
drug_id: drugId,
|
||||
step_index: currentStep?.stepIndex ?? null,
|
||||
weeks_on_step: weeks,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
drugId,
|
||||
drugInn: record.inn,
|
||||
ladder,
|
||||
currentStep,
|
||||
currentStepIndex: currentStep?.stepIndex ?? null,
|
||||
weeksOnCurrentStep: weeks,
|
||||
nextStep: next,
|
||||
escalationDue: due,
|
||||
sourceEMA: record.sourceEMA,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
|
||||
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -129,10 +129,12 @@ export default function IntakeHistoryPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* O-2 — estimated active-ingredient curve + titration ladder as a
|
||||
default-CLOSED disclosure for GLP-1 medications. Genuine
|
||||
context, but opt-in so the intake table stays the dominant
|
||||
surface. The chart itself gates further on Research Mode. */}
|
||||
{/* O-2 — dose-strength (titration) curve + estimated
|
||||
active-ingredient curve as a default-CLOSED disclosure for
|
||||
GLP-1 medications. Genuine context, but opt-in so the intake
|
||||
table stays the dominant surface. The dose-strength curve plots
|
||||
the user's own logged dose-change history; the drug-level chart
|
||||
gates further on Research Mode. */}
|
||||
{medication && isGlp1 && (
|
||||
<details
|
||||
className="border-border/60 rounded-md border"
|
||||
@@ -142,6 +144,7 @@ export default function IntakeHistoryPage({
|
||||
{t("medications.detail.history.drugLevelDisclosure")}
|
||||
</summary>
|
||||
<div className="border-border/60 space-y-6 border-t px-3 py-3">
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
@@ -149,7 +152,6 @@ export default function IntakeHistoryPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
|
||||
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
@@ -235,10 +235,11 @@ export default function MedicationDetailPage({
|
||||
(genuine history context). */}
|
||||
{isGlp1 && <SideEffectsSection medicationId={id} />}
|
||||
|
||||
{/* GLP-1 drug-level curve + titration ladder as a default-CLOSED
|
||||
disclosure — genuine history context, opt-in so the intake
|
||||
table stays the dominant surface. The chart gates further on
|
||||
Research Mode. */}
|
||||
{/* GLP-1 dose-strength (titration) curve + estimated drug-level
|
||||
chart as a default-CLOSED disclosure — genuine history context,
|
||||
opt-in so the intake table stays the dominant surface. The
|
||||
dose-strength curve plots the user's own logged dose-change
|
||||
history; the drug-level chart gates further on Research Mode. */}
|
||||
{isGlp1 && (
|
||||
<details
|
||||
className="border-border/60 rounded-md border"
|
||||
@@ -248,6 +249,7 @@ export default function MedicationDetailPage({
|
||||
{t("medications.detail.history.drugLevelDisclosure")}
|
||||
</summary>
|
||||
<div className="border-border/60 space-y-6 border-t px-3 py-3">
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
@@ -255,7 +257,6 @@ export default function MedicationDetailPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder section.
|
||||
*
|
||||
* Sits at the bottom of the medication detail page below the W19e
|
||||
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
|
||||
* Same chrome as the W19d / W19e sections so the panels read as one
|
||||
* visual group — chrome is now composed via the shared
|
||||
* `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper;
|
||||
* the i18n `t()` function already accepts the `params` bag with
|
||||
* `{key}` placeholder substitution. One source of truth for the
|
||||
* template-fill semantics.
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
|
||||
export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.medicationTitration(medicationId),
|
||||
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 t("medications.titration.escalationDueHint", {
|
||||
weeks: data.weeksOnCurrentStep,
|
||||
typical: data.currentStep.typicalWeeks,
|
||||
});
|
||||
}
|
||||
return t("medications.titration.currentStepDwell", {
|
||||
weeks: data.weeksOnCurrentStep,
|
||||
});
|
||||
}, [data, t]);
|
||||
|
||||
const nextStepCaption = useMemo(() => {
|
||||
if (!data) return null;
|
||||
if (!data.nextStep) return null;
|
||||
return t("medications.titration.nextStepCaption", {
|
||||
dose: data.nextStep.doseMg,
|
||||
weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks,
|
||||
});
|
||||
}, [data, t]);
|
||||
|
||||
const headerExtras = data ? (
|
||||
<span
|
||||
className="text-muted-foreground text-xs uppercase tracking-wide"
|
||||
data-slot="titration-drug-inn"
|
||||
>
|
||||
{data.drugInn}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="titration-heading"
|
||||
title={t("medications.titration.section")}
|
||||
headerExtras={headerExtras}
|
||||
dataSlot="titration-section"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<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-xs uppercase tracking-wide">
|
||||
{t("medications.titration.drugLadderHeader")}
|
||||
</p>
|
||||
|
||||
{hasLadder ? (
|
||||
<ol
|
||||
// v1.4.27 MB7 / CF-48 — keep the vertical (`flex-col`)
|
||||
// layout until `md:` instead of breaking to horizontal
|
||||
// at `sm:`. On Pixel 5 (375 px) the horizontal ladder
|
||||
// had 4-5 visible steps but each step's "Standard 0.5
|
||||
// mg" + "typical 4 weeks" label combo wrapped to 3
|
||||
// lines per step, which read as a denser block than
|
||||
// the vertical layout below. From `md:` upwards the
|
||||
// wider container can absorb the horizontal stride.
|
||||
className="flex flex-col gap-2 md:flex-row md:flex-wrap md: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 md: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-xs uppercase tracking-wide">
|
||||
{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-xs">
|
||||
{t("medications.titration.typicalWeeksOnStep", {
|
||||
weeks: step.typicalWeeks,
|
||||
})}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-primary mt-1 text-xs 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-xs">
|
||||
{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-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("medications.titration.emaSourceCta")}
|
||||
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v1.8.6 W4c — dose-strength (titration) curve tests.
|
||||
*
|
||||
* Pins the three honest data states and the pure point-builder:
|
||||
* - 0 dose-change rows → empty state, current-dose absent.
|
||||
* - exactly 1 row → empty state, current dose surfaced (no flat
|
||||
* single-point chart).
|
||||
* - 2+ rows → the step-curve SVG body renders.
|
||||
*
|
||||
* Recharts in `renderToStaticMarkup` paints the static frame only, so we
|
||||
* assert on the `data-slot` seams rather than measured geometry — the
|
||||
* same approach the sibling DrugLevelChart test uses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return { data: queryResults[key], isLoading: false };
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DoseStrengthCurve,
|
||||
buildCurvePoints,
|
||||
} from "../dose-strength-curve";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const KEY = "medications/med-1/glp1-details";
|
||||
const ASOF = new Date("2026-06-01T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("buildCurvePoints", () => {
|
||||
it("returns an empty list for no dose changes", () => {
|
||||
expect(buildCurvePoints([], ASOF)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the single row unchanged (no carry-forward) for one change", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
expect(pts).toHaveLength(1);
|
||||
expect(pts[0].dose).toBe(2.5);
|
||||
});
|
||||
|
||||
it("sorts by effectiveFrom and carries the latest dose forward to asOf", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "b",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
// two source rows + the carried-forward "now" anchor
|
||||
expect(pts).toHaveLength(3);
|
||||
expect(pts.map((p) => p.dose)).toEqual([2.5, 5, 5]);
|
||||
expect(pts[2].t).toBe(ASOF.getTime());
|
||||
});
|
||||
|
||||
it("drops rows with unparseable dates or non-finite doses", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "bad-date",
|
||||
effectiveFrom: "not-a-date",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "ok-2",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 7.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
expect(pts.map((p) => p.dose)).toEqual([2.5, 7.5, 7.5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DoseStrengthCurve>", () => {
|
||||
it("renders the empty state with no current dose for zero rows", () => {
|
||||
setQueryResult(KEY, { doseChanges: [] });
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-empty"');
|
||||
expect(html).toContain("No dose recorded yet.");
|
||||
expect(html).toContain("No titration history yet.");
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
|
||||
});
|
||||
|
||||
it("surfaces the current dose but no curve for a single dose change", () => {
|
||||
setQueryResult(KEY, {
|
||||
doseChanges: [
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-empty"');
|
||||
expect(html).toContain("Current dose: 5 mg");
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
|
||||
});
|
||||
|
||||
it("renders the step curve for two or more dose changes", () => {
|
||||
setQueryResult(KEY, {
|
||||
doseChanges: [
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-area"');
|
||||
expect(html).toContain('data-slot="dose-strength-curve-caption"');
|
||||
// Mounted inside the shared section chrome.
|
||||
expect(html).toContain('aria-labelledby="dose-strength-curve-title"');
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-empty"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.8.6 W4c — GLP-1 dose-strength (titration) curve.
|
||||
*
|
||||
* Replaces the EMA *reference*-ladder block that showed no values when
|
||||
* the medication's brand fell outside the catalog. This surface plots
|
||||
* the user's OWN dose-strength history — the `MedicationDoseChange`
|
||||
* stream (`effectiveFrom` + `doseValue` + `doseUnit`) returned by
|
||||
* `GET /api/medications/[id]/glp1` — as a step curve over time, so a
|
||||
* 2.5 → 5 → 7.5 → 10 mg escalation reads at a glance.
|
||||
*
|
||||
* Honest data contract:
|
||||
* - 2+ dose-change rows → step curve, the latest dose carried forward
|
||||
* to "now" so the current strength stays visible at the right edge.
|
||||
* - exactly 1 row → no escalation to draw; surface the single current
|
||||
* dose plainly with a "no titration history yet" caption rather than
|
||||
* a flat single-point chart.
|
||||
* - 0 rows → empty state with the same caption. The
|
||||
* `MedicationDoseChange` table is the history of record; when it is
|
||||
* empty there is genuinely no time series to plot.
|
||||
*
|
||||
* No Research-Mode gate — unlike the estimated-PK `DrugLevelChart`, this
|
||||
* curve plots only logged facts (the user's recorded dose strengths), so
|
||||
* it carries no pharmacokinetic estimate and needs no disclaimer gate.
|
||||
*
|
||||
* Dracula purple tokens + the shared `<MedicationDetailSection>` chrome
|
||||
* so the GLP-1 disclosure reads as one visual group with the drug-level
|
||||
* chart that sits beside it.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
const CURVE_COLOR = "var(--dracula-purple)";
|
||||
|
||||
interface DoseChange {
|
||||
id: string;
|
||||
effectiveFrom: string;
|
||||
doseValue: number;
|
||||
doseUnit: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
interface Glp1DetailsResponse {
|
||||
doseChanges: DoseChange[];
|
||||
}
|
||||
|
||||
export interface DoseStrengthCurveProps {
|
||||
medicationId: string;
|
||||
/** Override "now" for deterministic snapshot tests. */
|
||||
asOf?: Date;
|
||||
}
|
||||
|
||||
interface CurvePoint {
|
||||
t: number;
|
||||
dose: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the step-curve points from the dose-change stream. The series is
|
||||
* sorted by `effectiveFrom`, finite-guarded, and the latest dose is
|
||||
* carried forward to `asOf` so the current strength anchors the right
|
||||
* edge. Exported for the unit test.
|
||||
*/
|
||||
export function buildCurvePoints(
|
||||
doseChanges: readonly DoseChange[],
|
||||
asOf: Date,
|
||||
): CurvePoint[] {
|
||||
const sorted = doseChanges
|
||||
.map((dc) => ({
|
||||
t: Date.parse(dc.effectiveFrom),
|
||||
dose: dc.doseValue,
|
||||
unit: dc.doseUnit,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.dose))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
|
||||
if (sorted.length < 2) return sorted;
|
||||
|
||||
// Carry the latest dose forward to "now" so the most-recent strength
|
||||
// reads as a flat segment up to the right edge rather than ending at
|
||||
// the last change date.
|
||||
const last = sorted[sorted.length - 1];
|
||||
const nowMs = asOf.getTime();
|
||||
if (nowMs > last.t) {
|
||||
sorted.push({ t: nowMs, dose: last.dose, unit: last.unit });
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function DoseStrengthCurve({
|
||||
medicationId,
|
||||
asOf,
|
||||
}: DoseStrengthCurveProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: details, isLoading } = useQuery<Glp1DetailsResponse | null>({
|
||||
queryKey: queryKeys.medicationGlp1Details(medicationId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/glp1`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as Glp1DetailsResponse;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const now = useMemo(() => asOf ?? new Date(), [asOf]);
|
||||
const doseChanges = useMemo(
|
||||
() => details?.doseChanges ?? [],
|
||||
[details],
|
||||
);
|
||||
const points = useMemo(
|
||||
() => buildCurvePoints(doseChanges, now),
|
||||
[doseChanges, now],
|
||||
);
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// The latest logged dose — surfaced in the single-row / empty states so
|
||||
// the user always sees their current strength even without a curve.
|
||||
const latest = useMemo(() => {
|
||||
if (doseChanges.length === 0) return null;
|
||||
const sorted = [...doseChanges].sort(
|
||||
(a, b) => Date.parse(a.effectiveFrom) - Date.parse(b.effectiveFrom),
|
||||
);
|
||||
return sorted[sorted.length - 1];
|
||||
}, [doseChanges]);
|
||||
|
||||
const hasCurve = points.length >= 2;
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
points.map((p) => ({
|
||||
t: p.t,
|
||||
dose: p.dose,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
// Four evenly-spaced date ticks across the plotted span so the strip
|
||||
// reads start → … → now without crowding.
|
||||
const ticks = useMemo(() => {
|
||||
if (chartData.length < 2) return [];
|
||||
const first = chartData[0].t;
|
||||
const last = chartData[chartData.length - 1].t;
|
||||
const span = last - first;
|
||||
if (span <= 0) return [first];
|
||||
return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f));
|
||||
}, [chartData]);
|
||||
|
||||
const unit = latest?.doseUnit ?? "mg";
|
||||
|
||||
const body = isLoading ? (
|
||||
<div
|
||||
className="flex h-[200px] min-h-[200px] items-center justify-center"
|
||||
data-slot="dose-strength-curve-loading"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !hasCurve ? (
|
||||
<div
|
||||
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
|
||||
data-slot="dose-strength-curve-empty"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{latest ? (
|
||||
<p>
|
||||
{t("medications.doseStrength.currentDose", {
|
||||
dose: fmt.number(latest.doseValue),
|
||||
unit: latest.doseUnit,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t("medications.doseStrength.noCurrentDose")}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs italic">{t("medications.doseStrength.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="touch-pan-y"
|
||||
style={{ height: "200px" }}
|
||||
data-slot="dose-strength-curve-area"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
ticks={ticks}
|
||||
tickFormatter={(v) => fmt.dateShort(new Date(v as number))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
width={34}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => fmt.number(v as number)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
labelFormatter={(v) => fmt.dateShort(new Date(v as number))}
|
||||
formatter={(value) => [
|
||||
`${fmt.number(value as number)} ${unit}`,
|
||||
t("medications.doseStrength.tooltipLabel"),
|
||||
]}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="dose"
|
||||
stroke={CURVE_COLOR}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="dose-strength-curve-title"
|
||||
title={t("medications.doseStrength.title")}
|
||||
dataSlot="dose-strength-curve"
|
||||
>
|
||||
{hasCurve && (
|
||||
<p
|
||||
className="text-muted-foreground mb-2 text-xs"
|
||||
data-slot="dose-strength-curve-caption"
|
||||
>
|
||||
{t("medications.doseStrength.axisLabel", { unit })}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
@@ -131,11 +131,6 @@ describe("queryKeys factory", () => {
|
||||
"med1",
|
||||
"compliance",
|
||||
]);
|
||||
expect(queryKeys.medicationTitration("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"titration",
|
||||
]);
|
||||
expect(queryKeys.medicationCadence("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — pure titration-ladder helper tests.
|
||||
*
|
||||
* Pins the contract the API route and the detail-page section read:
|
||||
* - every EMA-approved drug exposes a non-empty, strictly ascending
|
||||
* ladder;
|
||||
* - `findCurrentStep` snaps within a ±10 % tolerance and returns
|
||||
* null outside that window;
|
||||
* - `nextStep` returns null at the ladder ceiling;
|
||||
* - `weeksOnCurrentStep` is 0 with no matching dose-change rows;
|
||||
* - `escalationDue` toggles at the EMA-reference dwell-time
|
||||
* boundary (and never fires past the ceiling).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
escalationDue,
|
||||
findCurrentStep,
|
||||
getLadder,
|
||||
ladderFromRecord,
|
||||
nextStep,
|
||||
weeksOnCurrentStep,
|
||||
type DoseChangeLike,
|
||||
} from "../ladder";
|
||||
import {
|
||||
GLP1_DRUG_IDS,
|
||||
GLP1_DRUGS,
|
||||
type Glp1DrugId,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
|
||||
function d(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
describe("getLadder / ladderFromRecord", () => {
|
||||
it("returns a non-empty ladder for every EMA-approved drug", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const ladder = getLadder(id);
|
||||
expect(ladder.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces strictly ascending doses with sequential indices", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const ladder = getLadder(id);
|
||||
for (let i = 0; i < ladder.length; i++) {
|
||||
expect(ladder[i].stepIndex).toBe(i);
|
||||
if (i > 0) {
|
||||
expect(ladder[i].doseMg).toBeGreaterThan(ladder[i - 1].doseMg);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the EMA-reference dwell time on every step", () => {
|
||||
for (const id of GLP1_DRUG_IDS) {
|
||||
const expected = GLP1_DRUGS[id].titrationIntervalWeeks;
|
||||
const ladder = getLadder(id);
|
||||
for (const step of ladder) {
|
||||
expect(step.typicalWeeks).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the record-direct overload", () => {
|
||||
const ladderById = getLadder("tirzepatide");
|
||||
const ladderByRecord = ladderFromRecord(GLP1_DRUGS.tirzepatide);
|
||||
expect(ladderByRecord).toEqual(ladderById);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCurrentStep", () => {
|
||||
it("returns null when the latest dose is null or non-finite", () => {
|
||||
expect(findCurrentStep("tirzepatide", null)).toBeNull();
|
||||
expect(findCurrentStep("tirzepatide", Number.NaN)).toBeNull();
|
||||
expect(findCurrentStep("tirzepatide", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("matches an exact dose on the ladder", () => {
|
||||
// Tirzepatide ladder: 2.5, 5, 7.5, 10, 12.5, 15
|
||||
expect(findCurrentStep("tirzepatide", 5)?.doseMg).toBe(5);
|
||||
expect(findCurrentStep("tirzepatide", 15)?.stepIndex).toBe(5);
|
||||
});
|
||||
|
||||
it("snaps within the ±10 % tolerance window", () => {
|
||||
// 5 mg ± 10 % = [4.5, 5.5]; 4.7 should round to step 5 mg.
|
||||
expect(findCurrentStep("tirzepatide", 4.7)?.doseMg).toBe(5);
|
||||
expect(findCurrentStep("tirzepatide", 5.4)?.doseMg).toBe(5);
|
||||
});
|
||||
|
||||
it("returns null outside the tolerance window", () => {
|
||||
// 6 mg is > 5 mg + 10 % AND < 7.5 mg − 10 % → unmatched.
|
||||
expect(findCurrentStep("tirzepatide", 6.0)).toBeNull();
|
||||
// 3 mg also outside any step (2.5 + 10 % = 2.75; 5 − 10 % = 4.5).
|
||||
expect(findCurrentStep("tirzepatide", 3.5)).toBeNull();
|
||||
});
|
||||
|
||||
it("works for semaglutide (smallest dose step on the catalog)", () => {
|
||||
expect(findCurrentStep("semaglutide", 0.25)?.doseMg).toBe(0.25);
|
||||
expect(findCurrentStep("semaglutide", 1.0)?.stepIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextStep", () => {
|
||||
it("returns the immediate-next step from the bottom of the ladder", () => {
|
||||
const current = findCurrentStep("tirzepatide", 2.5);
|
||||
expect(nextStep("tirzepatide", current)?.doseMg).toBe(5);
|
||||
});
|
||||
|
||||
it("returns null at the ladder ceiling", () => {
|
||||
const top = findCurrentStep("tirzepatide", 15);
|
||||
expect(top?.doseMg).toBe(15);
|
||||
expect(nextStep("tirzepatide", top)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when given a null current step", () => {
|
||||
expect(nextStep("tirzepatide", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("weeksOnCurrentStep", () => {
|
||||
const ASOF = d("2026-05-14T00:00:00.000Z");
|
||||
|
||||
it("returns 0 with no dose-change history", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
expect(weeksOnCurrentStep("tirzepatide", current, [], ASOF)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when current step is null", () => {
|
||||
expect(weeksOnCurrentStep("tirzepatide", null, [], ASOF)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the elapsed whole-weeks since the latest matching change", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
const events: DoseChangeLike[] = [
|
||||
// Switched onto 5 mg six weeks ago.
|
||||
{ effectiveFrom: d("2026-04-02T00:00:00.000Z"), doseValue: 5 },
|
||||
];
|
||||
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(6);
|
||||
});
|
||||
|
||||
it("ignores dose-change rows for other steps", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
const events: DoseChangeLike[] = [
|
||||
// A 2.5 mg row from 12 weeks ago should not count.
|
||||
{ effectiveFrom: d("2026-02-19T00:00:00.000Z"), doseValue: 2.5 },
|
||||
// A 5 mg row from 2 weeks ago — this is the latest matching row.
|
||||
{ effectiveFrom: d("2026-04-30T00:00:00.000Z"), doseValue: 5 },
|
||||
];
|
||||
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(2);
|
||||
});
|
||||
|
||||
it("returns 0 when the only matching change is in the future", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
const events: DoseChangeLike[] = [
|
||||
{ effectiveFrom: d("2027-01-01T00:00:00.000Z"), doseValue: 5 },
|
||||
];
|
||||
expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escalationDue", () => {
|
||||
const TIRZEPATIDE_DWELL = GLP1_DRUGS.tirzepatide.titrationIntervalWeeks;
|
||||
|
||||
it("returns false when current step is null", () => {
|
||||
expect(escalationDue("tirzepatide", null, 99)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false below the dwell-time boundary", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
expect(
|
||||
escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL - 1),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("toggles true at the dwell-time boundary", () => {
|
||||
const current = findCurrentStep("tirzepatide", 5);
|
||||
expect(escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("never fires past the ladder ceiling", () => {
|
||||
const top = findCurrentStep("tirzepatide", 15);
|
||||
expect(escalationDue("tirzepatide", top, 999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog coverage", () => {
|
||||
it("exposes a ladder for every drug id in the catalog", () => {
|
||||
const ids: Glp1DrugId[] = [...GLP1_DRUG_IDS];
|
||||
expect(ids.length).toBe(5);
|
||||
for (const id of ids) {
|
||||
const ladder = getLadder(id);
|
||||
expect(ladder.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — pure titration-ladder helpers for the GLP-1 detail page.
|
||||
*
|
||||
* The W19a knowledge layer (`glp1-knowledge.ts`) already carries the
|
||||
* EMA-approved escalation schedules under `titrationStepsMg` +
|
||||
* `titrationIntervalWeeks`. This module turns that static reference
|
||||
* into a small contract the API route and the detail-page section
|
||||
* can both read:
|
||||
*
|
||||
* - `getLadder(drugId)` — normalises the catalog row into an
|
||||
* ordered `TitrationStep[]` with
|
||||
* `stepIndex` and `typicalWeeks`.
|
||||
* - `findCurrentStep(...)` — matches the user's latest dose to
|
||||
* the closest ladder step within a
|
||||
* ±10 % tolerance window; returns
|
||||
* null when the dose is outside any
|
||||
* ladder bucket (e.g. user is on a
|
||||
* non-standard dose).
|
||||
* - `nextStep(...)` — immediate-next step, null at the
|
||||
* ladder ceiling.
|
||||
* - `weeksOnCurrentStep(...)` — how long the user has actually been
|
||||
* on the matched step, derived from
|
||||
* the `MedicationDoseChange` stream.
|
||||
* - `escalationDue(...)` — true when (a) there's a next step
|
||||
* and (b) the user has been on the
|
||||
* current step at least
|
||||
* `titrationIntervalWeeks`. Display
|
||||
* copy is strictly observational
|
||||
* ("ladder typically steps up around
|
||||
* N weeks") — never prescriptive
|
||||
* (parallel to the W19c-Safety
|
||||
* GROUND RULE: MDR boundary).
|
||||
*
|
||||
* No DB access; every function takes pre-fetched rows. Same shape as
|
||||
* the W19d / W19e pure modules so the route owns prisma and this
|
||||
* module owns the math.
|
||||
*/
|
||||
|
||||
import {
|
||||
GLP1_DRUGS,
|
||||
type Glp1DrugId,
|
||||
type Glp1DrugRecord,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
|
||||
/**
|
||||
* Normalised ladder step — easier to consume in the UI than the bare
|
||||
* `number[]` on the catalog record because each step carries its own
|
||||
* position and the typical-weeks figure attached to the drug.
|
||||
*/
|
||||
export interface TitrationStep {
|
||||
/** 0-based position in the ladder. */
|
||||
stepIndex: number;
|
||||
/** Step dose in mg (always > 0, strictly ascending across the ladder). */
|
||||
doseMg: number;
|
||||
/** EMA-reference minimum weeks before stepping up from this dose. */
|
||||
typicalWeeks: number;
|
||||
}
|
||||
|
||||
/** Dose-change row this module needs — a tiny subset of the model. */
|
||||
export interface DoseChangeLike {
|
||||
/** When the user transitioned onto a new dose. */
|
||||
effectiveFrom: Date;
|
||||
/** Numeric dose, paired with `doseUnit` for display elsewhere. */
|
||||
doseValue: number;
|
||||
}
|
||||
|
||||
/** ±10 % match radius for snapping a logged dose to a ladder step. */
|
||||
const STEP_MATCH_TOLERANCE = 0.1;
|
||||
|
||||
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Build the ordered ladder for a given drug-id. Returns an empty
|
||||
* array if the id is not in the catalog (defensive — the catalog is
|
||||
* static so this branch is unreachable in practice).
|
||||
*/
|
||||
export function getLadder(drugId: Glp1DrugId): TitrationStep[] {
|
||||
const record = GLP1_DRUGS[drugId];
|
||||
if (!record) return [];
|
||||
return ladderFromRecord(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload that takes the record directly (the chart and
|
||||
* the API route already resolve the record once via `findDrugByBrand`
|
||||
* — passing it through avoids a second lookup).
|
||||
*/
|
||||
export function ladderFromRecord(record: Glp1DrugRecord): TitrationStep[] {
|
||||
return record.titrationStepsMg.map((doseMg, stepIndex) => ({
|
||||
stepIndex,
|
||||
doseMg,
|
||||
typicalWeeks: record.titrationIntervalWeeks,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the user's most recent dose to the closest step on the
|
||||
* ladder, within ±10 % tolerance.
|
||||
*
|
||||
* Returns null when the latest dose is outside any step's tolerance
|
||||
* window — this is the "user is on a non-standard dose" path the UI
|
||||
* surfaces with an explanatory caption instead of a misleading
|
||||
* highlight.
|
||||
*/
|
||||
export function findCurrentStep(
|
||||
drugId: Glp1DrugId,
|
||||
latestDoseMg: number | null,
|
||||
): TitrationStep | null {
|
||||
if (latestDoseMg === null || !Number.isFinite(latestDoseMg)) return null;
|
||||
if (latestDoseMg <= 0) return null;
|
||||
const ladder = getLadder(drugId);
|
||||
if (ladder.length === 0) return null;
|
||||
|
||||
let best: { step: TitrationStep; diff: number } | null = null;
|
||||
for (const step of ladder) {
|
||||
const diff = Math.abs(step.doseMg - latestDoseMg);
|
||||
const tolerance = step.doseMg * STEP_MATCH_TOLERANCE;
|
||||
if (diff > tolerance) continue;
|
||||
if (!best || diff < best.diff) {
|
||||
best = { step, diff };
|
||||
}
|
||||
}
|
||||
return best?.step ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the immediate-next step above `currentStep`, or null when
|
||||
* the user is already on the ceiling. Identity-by-stepIndex so callers
|
||||
* that round-tripped the step through JSON still match.
|
||||
*/
|
||||
export function nextStep(
|
||||
drugId: Glp1DrugId,
|
||||
currentStep: TitrationStep | null,
|
||||
): TitrationStep | null {
|
||||
if (!currentStep) return null;
|
||||
const ladder = getLadder(drugId);
|
||||
const idx = ladder.findIndex((s) => s.stepIndex === currentStep.stepIndex);
|
||||
if (idx < 0) return null;
|
||||
if (idx + 1 >= ladder.length) return null;
|
||||
return ladder[idx + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how long the user has been on the matched step, in whole
|
||||
* weeks rounded down. Reads the `MedicationDoseChange` stream and
|
||||
* scans backwards from `asOf` for the most recent transition onto
|
||||
* (or past) the matched dose; the elapsed time since that transition
|
||||
* is the answer.
|
||||
*
|
||||
* Returns 0 when:
|
||||
* - no dose-change rows match the step at all (e.g. brand-new med
|
||||
* where the user hasn't logged a titration row yet);
|
||||
* - the latest matching change is in the future;
|
||||
* - `currentStep` is null.
|
||||
*
|
||||
* Rationale: matching by step (not by exact mg) is what makes this
|
||||
* resilient to a user logging "0.5 mg" and the ladder declaring
|
||||
* "0.5 mg" — we already snap to a step in `findCurrentStep`, so we
|
||||
* snap dose-change rows the same way here.
|
||||
*/
|
||||
export function weeksOnCurrentStep(
|
||||
drugId: Glp1DrugId,
|
||||
currentStep: TitrationStep | null,
|
||||
doseChanges: DoseChangeLike[],
|
||||
asOf: Date,
|
||||
): number {
|
||||
if (!currentStep) return 0;
|
||||
const tolerance = currentStep.doseMg * STEP_MATCH_TOLERANCE;
|
||||
// Sort defensively — the API route orders by effectiveFrom asc but
|
||||
// we don't trust caller ordering.
|
||||
const matches = doseChanges
|
||||
.filter((dc) => {
|
||||
const diff = Math.abs(dc.doseValue - currentStep.doseMg);
|
||||
return diff <= tolerance;
|
||||
})
|
||||
.sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime());
|
||||
if (matches.length === 0) return 0;
|
||||
const latest = matches[0];
|
||||
if (latest.effectiveFrom.getTime() > asOf.getTime()) return 0;
|
||||
const elapsedMs = asOf.getTime() - latest.effectiveFrom.getTime();
|
||||
return Math.max(0, Math.floor(elapsedMs / MS_PER_WEEK));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display flag — true when the EMA-reference minimum dwell time has
|
||||
* elapsed AND a next ladder step exists.
|
||||
*
|
||||
* Parameters:
|
||||
* - `drugIdForCeiling`: identifies which ladder to walk so the
|
||||
* "next step exists" guard knows when the user has hit the
|
||||
* ceiling. The dwell-time check itself reads `typicalWeeks`
|
||||
* straight off `currentStep`; the drug id is only needed to
|
||||
* answer "is there a step above this one?".
|
||||
* - `currentStep`: the step the user is sitting on today. Null
|
||||
* when titration is paused or the user is at the bottom of the
|
||||
* ladder with no recorded dose history.
|
||||
* - `weeksOnStep`: how many complete weeks have elapsed since the
|
||||
* user landed on `currentStep`. Computed by `weeksOnCurrentStep`.
|
||||
*
|
||||
* IMPORTANT: this is a *reference* signal, not advice. The UI copy
|
||||
* tied to this boolean is strictly observational ("ladder typically
|
||||
* steps up around N weeks") — never prescriptive. MDR boundary, see
|
||||
* the W19c safety ground-rules.
|
||||
*/
|
||||
export function escalationDue(
|
||||
drugIdForCeiling: Glp1DrugId,
|
||||
currentStep: TitrationStep | null,
|
||||
weeksOnStep: number,
|
||||
): boolean {
|
||||
if (!currentStep) return false;
|
||||
if (!nextStep(drugIdForCeiling, currentStep)) return false;
|
||||
return weeksOnStep >= currentStep.typicalWeeks;
|
||||
}
|
||||
@@ -116,8 +116,6 @@ export const queryKeys = {
|
||||
*/
|
||||
medicationCompliance: (medicationId: string) =>
|
||||
["medications", medicationId, "compliance"] as const,
|
||||
medicationTitration: (medicationId: string) =>
|
||||
["medications", medicationId, "titration"] as const,
|
||||
medicationCadence: (medicationId: string) =>
|
||||
["medications", medicationId, "cadence"] as const,
|
||||
medicationGlp1Details: (medicationId: string) =>
|
||||
|
||||
Reference in new issue
Block a user