From 8617fa7129d725f24bee6136937c1cd14f0002ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 9 Jul 2026 13:02:02 +0200 Subject: [PATCH] feat(medications): Wirkung tab + Insights efficacy summary Add the per-medication "Wirkung" tab (hidden for one-shot and no-target meds) rendering the target series with a start marker, dose-change markers, shaded pause bands and a faint reference band, an adherence lane, a neutral before/after-start card with honest empty states, an optional level-shift note, and a retarget chooser. Weave a compact, read-only efficacy summary into the Insights medication surface that reads the same DTO and links through to the tab. All copy is descriptive; add a banned-verdict-verb guard test over the medications.efficacy namespace across all six locales. --- messages/de.json | 39 +- messages/en.json | 39 +- messages/es.json | 39 +- messages/fr.json | 39 +- messages/it.json | 39 +- messages/pl.json | 39 +- .../medications-efficacy-verb-guard.test.ts | 105 +++++ src/app/insights/medications/page.tsx | 8 + src/components/charts/chart-runtime.ts | 1 + .../insights/medication-efficacy-summary.tsx | 126 ++++++ .../detail/efficacy/efficacy-chart.tsx | 244 +++++++++++ .../detail/efficacy/efficacy-tab.tsx | 381 ++++++++++++++++++ .../detail/medication-detail-tabs.tsx | 33 +- src/lib/query-keys/medications.ts | 9 + 14 files changed, 1127 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/medications-efficacy-verb-guard.test.ts create mode 100644 src/components/insights/medication-efficacy-summary.tsx create mode 100644 src/components/medications/detail/efficacy/efficacy-chart.tsx create mode 100644 src/components/medications/detail/efficacy/efficacy-tab.tsx diff --git a/messages/de.json b/messages/de.json index 65d74b3dc..9d6e5c66c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1593,7 +1593,8 @@ "verlauf": "Verlauf", "injektion": "Injektion", "api": "API", - "erweitert": "Erweitert" + "erweitert": "Erweitert", + "wirkung": "Wirkung" }, "shell": { "loadFailed": "Dieses Medikament konnte nicht geladen werden.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.", "reorderSaved": "Reihenfolge gespeichert", "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden", - "customize": "Medikamente anpassen" + "customize": "Medikamente anpassen", + "efficacy": { + "title": "Wirkung", + "intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang – eine Assoziation, keine Ursache – und keine Empfehlung zu Behandlung oder Dosis.", + "notEligible": "Für dieses Medikament gibt es keinen zugehörigen Messwert.", + "startFallback": "Es ist kein Startdatum hinterlegt, daher dient der erste erfasste Wert als Bezugspunkt.", + "noStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", + "disclaimer": "Nur ein Zusammenhang: Die Zahlen zeigen, was sich im selben Zeitraum verändert hat, nicht dass das Medikament die Veränderung verursacht hat. Besprich Entscheidungen zu deiner Behandlung mit deiner Ärztin oder deinem Arzt.", + "primaryBadge": "Hauptziel", + "noSeries": "Für diesen Zielwert sind noch keine Messungen erfasst.", + "startMarker": "Beginn", + "adherenceLane": "Einnahmetreue", + "typicalRange": "Üblicher Bereich", + "levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.", + "retarget": { + "label": "Nicht der richtige Messwert?", + "placeholder": "Verfolgen anhand…", + "apply": "Übernehmen", + "reset": "Auf Standard zurücksetzen" + }, + "beforeAfter": { + "summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.", + "counts": "Basierend auf {beforeCount} Werten davor und {afterCount} seitdem.", + "needBefore": "Noch zu wenige Werte vor dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needAfter": "Noch zu wenige Werte seit dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", + "needData": "Noch zu wenige Werte – für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt." + }, + "insights": { + "title": "Medikamenten-Wirkung", + "subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.", + "link": "Wirkung öffnen", + "empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen." + } + } }, "charts": { "loadingLabel": "Diagramm wird geladen…", diff --git a/messages/en.json b/messages/en.json index c7fac3b9b..15bd8dfc7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1593,7 +1593,8 @@ "verlauf": "History", "injektion": "Injection", "api": "API", - "erweitert": "Advanced" + "erweitert": "Advanced", + "wirkung": "Effect" }, "shell": { "loadFailed": "This medication could not be loaded.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Set the order of your medications – it applies to both the card and the table view.", "reorderSaved": "Order saved", "reorderSaveFailed": "Order could not be saved", - "customize": "Customize medications" + "customize": "Customize medications", + "efficacy": { + "title": "Effect", + "intro": "How the measurements this medication is meant to influence have moved around the time you started it. It shows what lines up in time — an association, not a cause — and never advice about your treatment or dose.", + "notEligible": "There is no outcome to relate this medication to.", + "startFallback": "No start date is set, so the comparison uses your first recorded reading as the reference point.", + "noStart": "Set a start date for this medication to compare the readings before and after it began.", + "disclaimer": "Association only: these figures show what changed over the same period, not that the medication caused the change. Discuss any decision about your treatment with your doctor.", + "primaryBadge": "Main target", + "noSeries": "No readings recorded for this target yet.", + "startMarker": "Start", + "adherenceLane": "Adherence", + "typicalRange": "Typical range", + "levelShift": "A shift in the level was detected around {date}.", + "retarget": { + "label": "Not the right measurement?", + "placeholder": "Track against…", + "apply": "Apply", + "reset": "Reset to default" + }, + "beforeAfter": { + "summary": "Ø {before} before → Ø {after} since — a change of {delta}.", + "counts": "Based on {beforeCount} readings before and {afterCount} since.", + "needBefore": "Not enough readings before the start yet — about {weeks} weeks are needed before and after to compare.", + "needAfter": "Not enough readings since the start yet — about {weeks} weeks are needed before and after to compare.", + "needStart": "Set a start date to compare the readings before and after it began.", + "needData": "Not enough readings yet — about {weeks} weeks are needed before and after the start to compare." + }, + "insights": { + "title": "Medication effect", + "subtitle": "What lines up with the measurements your medications target — association only, never a verdict.", + "link": "Open the effect view", + "empty": "Not enough data yet to relate your medications to their target measurements." + } + } }, "charts": { "loadingLabel": "Loading chart…", diff --git a/messages/es.json b/messages/es.json index 17567f082..053ecf2bb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1593,7 +1593,8 @@ "verlauf": "Historial", "injektion": "Inyección", "api": "API", - "erweitert": "Avanzado" + "erweitert": "Avanzado", + "wirkung": "Efecto" }, "shell": { "loadFailed": "No se pudo cargar este medicamento.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.", "reorderSaved": "Orden guardado", "reorderSaveFailed": "No se pudo guardar el orden", - "customize": "Personalizar medicamentos" + "customize": "Personalizar medicamentos", + "efficacy": { + "title": "Efecto", + "intro": "Cómo han evolucionado las mediciones sobre las que actúa este medicamento en torno al momento en que empezaste a tomarlo. Muestra lo que coincide en el tiempo: una asociación, no una causa, y nunca un consejo sobre tu tratamiento o dosis.", + "notEligible": "No hay ninguna medición que relacionar con este medicamento.", + "startFallback": "No hay fecha de inicio, así que la comparación usa tu primera lectura registrada como referencia.", + "noStart": "Añade una fecha de inicio para comparar las lecturas antes y después de empezar.", + "disclaimer": "Solo asociación: estas cifras muestran lo que cambió en el mismo periodo, no que el medicamento causara el cambio. Consulta cualquier decisión sobre tu tratamiento con tu médico.", + "primaryBadge": "Objetivo principal", + "noSeries": "Aún no hay mediciones registradas para este objetivo.", + "startMarker": "Inicio", + "adherenceLane": "Adherencia", + "typicalRange": "Rango habitual", + "levelShift": "Se detectó un cambio de nivel en torno a {date}.", + "retarget": { + "label": "¿No es la medición correcta?", + "placeholder": "Seguir según…", + "apply": "Aplicar", + "reset": "Restablecer al valor predeterminado" + }, + "beforeAfter": { + "summary": "Ø {before} antes → Ø {after} desde entonces: un cambio de {delta}.", + "counts": "Basado en {beforeCount} lecturas antes y {afterCount} desde entonces.", + "needBefore": "Aún no hay suficientes lecturas antes del inicio: se necesitan unas {weeks} semanas antes y después para comparar.", + "needAfter": "Aún no hay suficientes lecturas desde el inicio: se necesitan unas {weeks} semanas antes y después para comparar.", + "needStart": "Añade una fecha de inicio para comparar las lecturas antes y después de empezar.", + "needData": "Aún no hay suficientes lecturas: se necesitan unas {weeks} semanas antes y después del inicio para comparar." + }, + "insights": { + "title": "Efecto de la medicación", + "subtitle": "Lo que coincide con las mediciones que persiguen tus medicamentos: solo asociación, nunca un veredicto.", + "link": "Abrir la vista de efecto", + "empty": "Aún no hay suficientes datos para relacionar tus medicamentos con sus mediciones objetivo." + } + } }, "charts": { "loadingLabel": "Cargando la gráfica…", diff --git a/messages/fr.json b/messages/fr.json index b15587733..16d1ff066 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1593,7 +1593,8 @@ "verlauf": "Historique", "injektion": "Injection", "api": "API", - "erweitert": "Avancé" + "erweitert": "Avancé", + "wirkung": "Effet" }, "shell": { "loadFailed": "Ce médicament n'a pas pu être chargé.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.", "reorderSaved": "Ordre enregistré", "reorderSaveFailed": "L'ordre n'a pas pu être enregistré", - "customize": "Personnaliser les médicaments" + "customize": "Personnaliser les médicaments", + "efficacy": { + "title": "Effet", + "intro": "Comment les mesures que ce médicament vise ont évolué autour du moment où vous avez commencé à le prendre. Cela montre ce qui coïncide dans le temps : une association, pas une cause, et jamais un conseil sur votre traitement ou votre dose.", + "notEligible": "Aucune mesure à relier à ce médicament.", + "startFallback": "Aucune date de début n'est renseignée ; la comparaison utilise donc votre première mesure enregistrée comme référence.", + "noStart": "Renseignez une date de début pour comparer les mesures avant et après le début.", + "disclaimer": "Association seulement : ces chiffres montrent ce qui a changé sur la même période, pas que le médicament a causé ce changement. Discutez de toute décision concernant votre traitement avec votre médecin.", + "primaryBadge": "Cible principale", + "noSeries": "Aucune mesure enregistrée pour cette cible pour le moment.", + "startMarker": "Début", + "adherenceLane": "Observance", + "typicalRange": "Plage habituelle", + "levelShift": "Un changement de niveau a été détecté autour du {date}.", + "retarget": { + "label": "Ce n'est pas la bonne mesure ?", + "placeholder": "Suivre selon…", + "apply": "Appliquer", + "reset": "Réinitialiser par défaut" + }, + "beforeAfter": { + "summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.", + "counts": "D'après {beforeCount} mesures avant et {afterCount} depuis.", + "needBefore": "Pas encore assez de mesures avant le début — il faut environ {weeks} semaines avant et après pour comparer.", + "needAfter": "Pas encore assez de mesures depuis le début — il faut environ {weeks} semaines avant et après pour comparer.", + "needStart": "Renseignez une date de début pour comparer les mesures avant et après le début.", + "needData": "Pas encore assez de mesures — il faut environ {weeks} semaines avant et après le début pour comparer." + }, + "insights": { + "title": "Effet des médicaments", + "subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.", + "link": "Ouvrir la vue Effet", + "empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles." + } + } }, "charts": { "loadingLabel": "Chargement du graphique…", diff --git a/messages/it.json b/messages/it.json index 71a0a2c87..b2a5954fa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1593,7 +1593,8 @@ "verlauf": "Cronologia", "injektion": "Iniezione", "api": "API", - "erweitert": "Avanzate" + "erweitert": "Avanzate", + "wirkung": "Effetto" }, "shell": { "loadFailed": "Impossibile caricare questo farmaco.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.", "reorderSaved": "Ordine salvato", "reorderSaveFailed": "Impossibile salvare l’ordine", - "customize": "Personalizza i farmaci" + "customize": "Personalizza i farmaci", + "efficacy": { + "title": "Effetto", + "intro": "Come sono cambiate le misurazioni su cui agisce questo farmaco intorno al momento in cui hai iniziato a prenderlo. Mostra ciò che coincide nel tempo: un'associazione, non una causa, e mai un consiglio sul trattamento o sulla dose.", + "notEligible": "Non c'è alcuna misurazione da collegare a questo farmaco.", + "startFallback": "Non è impostata una data di inizio, quindi il confronto usa la tua prima misurazione registrata come riferimento.", + "noStart": "Imposta una data di inizio per confrontare le misurazioni prima e dopo l'avvio.", + "disclaimer": "Solo associazione: questi numeri mostrano ciò che è cambiato nello stesso periodo, non che il farmaco abbia causato il cambiamento. Discuti ogni decisione sul tuo trattamento con il medico.", + "primaryBadge": "Obiettivo principale", + "noSeries": "Nessuna misurazione registrata per questo obiettivo.", + "startMarker": "Inizio", + "adherenceLane": "Aderenza", + "typicalRange": "Intervallo abituale", + "levelShift": "È stato rilevato un cambio di livello intorno al {date}.", + "retarget": { + "label": "Non è la misurazione giusta?", + "placeholder": "Monitora in base a…", + "apply": "Applica", + "reset": "Ripristina predefinito" + }, + "beforeAfter": { + "summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.", + "counts": "In base a {beforeCount} misurazioni prima e {afterCount} da allora.", + "needBefore": "Ancora troppe poche misurazioni prima dell'inizio: servono circa {weeks} settimane prima e dopo per confrontare.", + "needAfter": "Ancora troppe poche misurazioni dall'inizio: servono circa {weeks} settimane prima e dopo per confrontare.", + "needStart": "Imposta una data di inizio per confrontare le misurazioni prima e dopo l'avvio.", + "needData": "Ancora troppe poche misurazioni: servono circa {weeks} settimane prima e dopo l'inizio per confrontare." + }, + "insights": { + "title": "Effetto dei farmaci", + "subtitle": "Ciò che coincide con le misurazioni a cui mirano i tuoi farmaci: solo associazione, mai un verdetto.", + "link": "Apri la vista Effetto", + "empty": "Ancora non ci sono dati sufficienti per collegare i tuoi farmaci alle loro misurazioni obiettivo." + } + } }, "charts": { "loadingLabel": "Caricamento del grafico…", diff --git a/messages/pl.json b/messages/pl.json index ad032cec2..5d26255a0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1593,7 +1593,8 @@ "verlauf": "Historia", "injektion": "Iniekcja", "api": "API", - "erweitert": "Zaawansowane" + "erweitert": "Zaawansowane", + "wirkung": "Działanie" }, "shell": { "loadFailed": "Nie udało się załadować tego leku.", @@ -1791,7 +1792,41 @@ "reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.", "reorderSaved": "Kolejność zapisana", "reorderSaveFailed": "Nie udało się zapisać kolejności", - "customize": "Dostosuj leki" + "customize": "Dostosuj leki", + "efficacy": { + "title": "Działanie", + "intro": "Jak zmieniały się pomiary, na które ma wpływać ten lek, w okolicach rozpoczęcia jego przyjmowania. Pokazuje to, co zbiega się w czasie — powiązanie, nie przyczynę — i nigdy porady dotyczącej leczenia ani dawki.", + "notEligible": "Brak pomiaru, który można powiązać z tym lekiem.", + "startFallback": "Nie ustawiono daty rozpoczęcia, więc porównanie używa pierwszego zapisanego pomiaru jako punktu odniesienia.", + "noStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.", + "disclaimer": "Tylko powiązanie: te liczby pokazują, co zmieniło się w tym samym okresie, a nie że lek spowodował tę zmianę. Wszelkie decyzje dotyczące leczenia omów z lekarzem.", + "primaryBadge": "Główny cel", + "noSeries": "Brak zapisanych pomiarów dla tego celu.", + "startMarker": "Początek", + "adherenceLane": "Regularność", + "typicalRange": "Typowy zakres", + "levelShift": "Wykryto zmianę poziomu w okolicach {date}.", + "retarget": { + "label": "To nie ten pomiar?", + "placeholder": "Śledź według…", + "apply": "Zastosuj", + "reset": "Przywróć domyślne" + }, + "beforeAfter": { + "summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.", + "counts": "Na podstawie {beforeCount} pomiarów przedtem i {afterCount} od tego czasu.", + "needBefore": "Za mało pomiarów przed rozpoczęciem — do porównania potrzeba około {weeks} tygodni przed i po.", + "needAfter": "Za mało pomiarów od rozpoczęcia — do porównania potrzeba około {weeks} tygodni przed i po.", + "needStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.", + "needData": "Za mało pomiarów — do porównania potrzeba około {weeks} tygodni przed i po rozpoczęciu." + }, + "insights": { + "title": "Działanie leków", + "subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.", + "link": "Otwórz widok działania", + "empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami." + } + } }, "charts": { "loadingLabel": "Ładowanie wykresu…", diff --git a/src/__tests__/medications-efficacy-verb-guard.test.ts b/src/__tests__/medications-efficacy-verb-guard.test.ts new file mode 100644 index 000000000..a85e3db8c --- /dev/null +++ b/src/__tests__/medications-efficacy-verb-guard.test.ts @@ -0,0 +1,105 @@ +/** + * v1.28 — banned-verb guard for the `medications.efficacy.*` namespace. + * + * The medication-efficacy view ("Wirkung") is a medication-adjacent claim, so + * its safety boundary is structural, not left to reviewer vigilance: the DTO + * carries no verdict / score field, and the copy must stay strictly + * descriptive — an association, never "the drug works / is effective", never a + * dose change. This test walks every efficacy string in all six locales and + * fails if any value contains a verdict or dose-advice verb, so a forbidden + * word can never creep into the bundle. Mirrors the `i18n-english-leak-guard` + * flatten + filter + `toEqual([])` pattern. + * + * If this fails: rewrite the offending string to descriptive phrasing (numbers + * + neutral connectives). Do NOT weaken the denylist to pass. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; + +const MESSAGES = join(__dirname, "..", "..", "messages"); +const LOCALES = readdirSync(MESSAGES).filter((f) => f.endsWith(".json")); + +/** Verdict + efficacy-adjective + dose-advice verbs across the six locales. */ +const BANNED: RegExp[] = [ + // English + /\bworks?\b/i, + /\bworking\b/i, + /\beffective(ly)?\b/i, + /\bineffective\b/i, + /\bcures?\b/i, + /\bcured\b/i, + /\bheals?\b/i, + /\bhealed\b/i, + /\bimprove(d|s|ment)?\b/i, + /\bworsen(ed|s)?\b/i, + /\bbetter\b/i, + /\bworse\b/i, + /\bincrease\b/i, + /\bdecrease\b/i, + /\breduce\b/i, + /\braise\b/i, + /\bsuccess(ful)?\b/i, + // German + /\bwirksam\b/i, + /\bunwirksam\b/i, + /\banschlägt\b/i, + /\bheilt\b/i, + /\bverbessert\b/i, + /\bverschlechtert\b/i, + /\berhöhen\b/i, + /\bsenken\b/i, + /\babsetzen\b/i, + // Spanish + /\bfunciona\b/i, + /\beficaz\b/i, + /\bineficaz\b/i, + /\bmejora\b/i, + /\bempeora\b/i, + // French + /\befficace\b/i, + /\binefficace\b/i, + /\baméliore\b/i, + /\baggrave\b/i, + // Italian + /\bmigliora\b/i, + /\bpeggiora\b/i, + // Polish + /\bskuteczn\w*\b/i, + /\bpoprawia\b/i, + /\bpogarsza\b/i, +]; + +function flatten(obj: unknown, prefix: string, out: [string, string][]): void { + if (obj === null || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj as Record)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.push([key, v]); + else flatten(v, key, out); + } +} + +describe("medications.efficacy — banned verdict/advice verbs", () => { + it.each(LOCALES)("%s carries no forbidden verb", (file) => { + const bundle = JSON.parse(readFileSync(join(MESSAGES, file), "utf8")) as { + medications?: { efficacy?: unknown }; + }; + const efficacy = bundle.medications?.efficacy; + expect(efficacy, `${file} is missing medications.efficacy`).toBeDefined(); + + const flat: [string, string][] = []; + flatten(efficacy, "medications.efficacy", flat); + expect(flat.length).toBeGreaterThan(0); + + const violations = flat + .filter(([, value]) => BANNED.some((re) => re.test(value))) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); + + expect( + violations, + `Forbidden verdict/advice verb in ${file} — the efficacy view is ` + + `association-only. Rewrite to descriptive phrasing:\n` + + violations.map((v) => ` ${v}`).join("\n"), + ).toEqual([]); + }); +}); diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 02eef163f..fc8eade3a 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -17,6 +17,7 @@ import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; import { apiGet } from "@/lib/api/api-fetch"; @@ -280,6 +281,13 @@ export default function InsightsMedikamentePage() { })} + {/* v1.28 — compact efficacy summary: reuses the same server-computed + efficacy DTO the detail-page "Wirkung" tab reads (never a second + computation), association-only, linking through to the full view. */} + ({ id: m.id, name: m.name }))} + /> + {/* v1.12.2 — the assessment is the LAST block on every bespoke diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index f5498f07d..bcc70bfc1 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -40,4 +40,5 @@ export { SleepStageStackedBar } from "@/components/insights/sleep-stage-stacked- export { LabBiomarkerChart } from "@/components/labs/lab-biomarker-chart"; export { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; export { DrugLevelChart } from "@/components/medications/drug-level-chart"; +export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy-chart"; export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart"; diff --git a/src/components/insights/medication-efficacy-summary.tsx b/src/components/insights/medication-efficacy-summary.tsx new file mode 100644 index 000000000..a68d5ae54 --- /dev/null +++ b/src/components/insights/medication-efficacy-summary.tsx @@ -0,0 +1,126 @@ +"use client"; + +/** + * v1.28 — compact, read-only medication-efficacy summary for the Insights + * medication surface. Reads the SAME server-computed efficacy DTO the detail + * page's "Wirkung" tab reads (no second computation) and shows, per eligible + * medication with enough data, a one-line before/after against its target — + * strictly association-framed, never a verdict. Each row links through to the + * medication's Wirkung tab for the full view. + * + * Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6` + * inset (never re-declared), foreground for content, muted for meta. + */ +import Link from "next/link"; +import { Activity, TrendingUp } from "lucide-react"; +import { useQueries } from "@tanstack/react-query"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { + MedicationEfficacyDTO, + EfficacyTargetView, +} from "@/lib/medications/efficacy/build-efficacy"; + +function primaryWithDelta( + dto: MedicationEfficacyDTO, +): EfficacyTargetView | null { + const withDelta = dto.targets.find( + (t) => t.primary && t.beforeAfter.present && t.beforeAfter.delta, + ); + return withDelta ?? null; +} + +export function MedicationEfficacySummary({ + medications, +}: { + medications: { id: string; name: string }[]; +}) { + const { t } = useTranslations(); + + const results = useQueries({ + queries: medications.map((m) => ({ + queryKey: queryKeys.medicationEfficacy(m.id), + queryFn: () => + apiGet(`/api/medications/${m.id}/efficacy`), + staleTime: 60_000, + })), + }); + + if (medications.length === 0) return null; + + const rows = results + .map((r, i) => ({ med: medications[i], dto: r.data })) + .filter( + ( + x, + ): x is { + med: { id: string; name: string }; + dto: MedicationEfficacyDTO; + } => !!x.dto && x.dto.eligible, + ) + .map((x) => ({ med: x.med, target: primaryWithDelta(x.dto) })) + .filter((x) => x.target !== null); + + return ( + + + +

+ {t("medications.efficacy.insights.subtitle")} +

+
+ + {rows.length === 0 ? ( +

+ {t("medications.efficacy.insights.empty")} +

+ ) : ( +
    + {rows.map(({ med, target }) => { + const ba = target!.beforeAfter; + const unit = target!.unit ? ` ${target!.unit}` : ""; + const arrow = (ba.delta?.mean ?? 0) > 0 ? "+" : ""; + return ( +
  • +
    +

    + {med.name} +

    +

    +

    +
    + + {t("medications.efficacy.insights.link")} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-chart.tsx b/src/components/medications/detail/efficacy/efficacy-chart.tsx new file mode 100644 index 000000000..e6e8b1d69 --- /dev/null +++ b/src/components/medications/detail/efficacy/efficacy-chart.tsx @@ -0,0 +1,244 @@ +"use client"; + +/** + * v1.28 — the "Wirkung" target chart. Recharts consumer, so it is exported + * through `chart-runtime` and loaded via `next/dynamic` at the call site (the + * one-shared-chunk rule). Renders the target series over a span around the + * medication's start with: + * - a faint population reference band (context, never a goal line), + * - shaded pause bands, + * - a vertical start marker + quiet dose-change markers, + * - the target line, + * - a faint adherence lane on a hidden right axis. + * Strictly descriptive — no colour-coded verdict, no "good/bad" zones. + */ +import { useMemo } from "react"; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, + ReferenceArea, +} from "recharts"; +import { makeFormatters } from "@/lib/format-locale"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface EfficacyChartTarget { + label: string; + unit: string | null; + referenceBand: { low: number; high: number } | null; + series: { t: string; value: number }[]; +} + +export interface EfficacyChartProps { + target: EfficacyChartTarget; + startMs: number | null; + doseChanges: { at: string; label: string }[]; + pauses: { from: string; to: string | null }[]; + adherence: { date: string; rate: number }[]; + timezone: string; + startLabel: string; + adherenceLabel: string; + typicalRangeLabel: string; +} + +const toMs = (iso: string): number => Date.parse(iso); + +export function EfficacyChart({ + target, + startMs, + doseChanges, + pauses, + adherence, + timezone, + startLabel, + adherenceLabel, + typicalRangeLabel, +}: EfficacyChartProps) { + const { locale } = useTranslations(); + const fmt = useMemo( + () => makeFormatters(locale, timezone), + [locale, timezone], + ); + + const seriesData = useMemo( + () => + target.series + .map((p) => ({ t: toMs(p.t), value: p.value })) + .filter((p) => Number.isFinite(p.t)) + .sort((a, b) => a.t - b.t), + [target.series], + ); + const adherenceData = useMemo( + () => + adherence + .map((p) => ({ t: toMs(p.date), rate: p.rate })) + .filter((p) => Number.isFinite(p.t)) + .sort((a, b) => a.t - b.t), + [adherence], + ); + + const domain = useMemo<[number, number] | null>(() => { + const xs = [ + ...seriesData.map((p) => p.t), + ...adherenceData.map((p) => p.t), + ]; + if (xs.length === 0) return null; + return [Math.min(...xs), Math.max(...xs)]; + }, [seriesData, adherenceData]); + + if (seriesData.length === 0 || !domain) return null; + + // An open pause runs to the chart's right edge (the latest datum); using the + // domain max keeps the render pure (no `Date.now()` in the render body). + const rightEdge = domain[1]; + return ( +
+ + + + fmt.date(new Date(v))} + /> + + + + {/* Population reference band — faint context, labelled "typical + range", explicitly NOT a target line. */} + {target.referenceBand ? ( + + ) : null} + + {/* Shaded pause bands. An open pause runs to now. */} + {pauses.map((p, i) => ( + + ))} + + {/* Adherence lane — faint, hidden right axis. */} + + + {/* The target series. */} + + + {/* Start marker. */} + {startMs !== null ? ( + + ) : null} + + {/* Dose-change markers — quiet, dashed. */} + {doseChanges.map((d, i) => ( + + ))} + + fmt.date(new Date(Number(v)))} + formatter={(value, name) => { + const num = Number(value); + const text = + name === adherenceLabel + ? `${Math.round(num)}%` + : `${num}${target.unit ? ` ${target.unit}` : ""}`; + return [text, String(name)]; + }} + /> + + +
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx new file mode 100644 index 000000000..2d465ecca --- /dev/null +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -0,0 +1,381 @@ +"use client"; + +/** + * v1.28 — the "Wirkung" tab body: a strictly-descriptive, association-only + * view relating a medication to the outcome metric(s)/lab(s) its class is + * prescribed to move, around its start. Reads the server-authoritative + * efficacy DTO (`GET /api/medications/[id]/efficacy`) and renders it — it + * never recomputes a delta, mean, or adherence rate. No verdict, no score, no + * "working / not working" language, no dose advice: the copy is numbers + + * neutral connective phrasing only, mirroring the adherence-storyline posture. + */ +import { useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { Activity, TrendingUp } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import type { + MedicationEfficacyDTO, + EfficacyTargetView, +} from "@/lib/medications/efficacy/build-efficacy"; + +const EfficacyChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.EfficacyChart, + })), + { ssr: false, loading: () => }, +); + +export function EfficacyTab({ + medicationId, + active, +}: { + medicationId: string; + active: boolean; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.medicationEfficacy(medicationId), + queryFn: () => + apiGet( + `/api/medications/${medicationId}/efficacy`, + ), + enabled: active, + staleTime: 60_000, + }); + + if (isLoading || !data) { + return ( + + + + ); + } + + // The tab is hidden upstream when ineligible; this is a defensive fallback. + if (!data.eligible) { + return ( + +

+ {t("medications.efficacy.notEligible")} +

+
+ ); + } + + return ( +
+ +
+

+ {t("medications.efficacy.intro")} +

+ {data.markers.startSource === "firstReading" ? ( +

+ {t("medications.efficacy.startFallback")} +

+ ) : null} + {data.markers.start === null ? ( +

+ {t("medications.efficacy.noStart")} +

+ ) : null} + + queryClient.invalidateQueries({ + queryKey: queryKeys.medicationEfficacy(medicationId), + }) + } + /> +
+
+ + {data.targets.map((target) => ( + + ))} + +

+ {t("medications.efficacy.disclaimer")} +

+
+ ); +} + +function TargetCard({ + target, + dto, + timezone, +}: { + target: EfficacyTargetView; + dto: MedicationEfficacyDTO; + timezone: string; +}) { + const { t } = useTranslations(); + const startMs = dto.markers.start ? Date.parse(dto.markers.start) : null; + + return ( + + + + {t("medications.efficacy.primaryBadge")} + + ) : undefined + } + /> + + + {target.series.length > 0 ? ( + ({ t: p.t, value: p.value })), + }} + startMs={startMs} + doseChanges={dto.markers.doseChanges} + pauses={dto.markers.pauses} + adherence={dto.adherence.map((a) => ({ + date: a.date, + rate: a.rate, + }))} + timezone={timezone} + startLabel={t("medications.efficacy.startMarker")} + adherenceLabel={t("medications.efficacy.adherenceLane")} + typicalRangeLabel={t("medications.efficacy.typicalRange")} + /> + ) : ( +

+ {t("medications.efficacy.noSeries")} +

+ )} + + + + {target.levelShift?.present && target.levelShift.nearStart ? ( +

+ {t("medications.efficacy.levelShift", { + date: formatShort(target.levelShift.at), + })} +

+ ) : null} +
+
+ ); +} + +function BeforeAfterCard({ + target, + minWeeks, +}: { + target: EfficacyTargetView; + minWeeks: number; +}) { + const { t } = useTranslations(); + const ba = target.beforeAfter; + const unit = target.unit ? ` ${target.unit}` : ""; + + if (!ba.present || !ba.before || !ba.after || !ba.delta) { + const reasonKey = + ba.reason === "insufficient_before" + ? "medications.efficacy.beforeAfter.needBefore" + : ba.reason === "insufficient_after" + ? "medications.efficacy.beforeAfter.needAfter" + : ba.reason === "no_start" + ? "medications.efficacy.beforeAfter.needStart" + : "medications.efficacy.beforeAfter.needData"; + return ( +
+

+ {t(reasonKey, { weeks: minWeeks })} +

+
+ ); + } + + const arrow = ba.delta.mean > 0 ? "+" : ""; + return ( +
+

+ {t("medications.efficacy.beforeAfter.summary", { + before: `${ba.before.mean}${unit}`, + after: `${ba.after.mean}${unit}`, + delta: `${arrow}${ba.delta.mean}${unit}`, + })} +

+

+ {t("medications.efficacy.beforeAfter.counts", { + beforeCount: ba.before.count, + afterCount: ba.after.count, + })} +

+
+ ); +} + +function RetargetControl({ + medicationId, + options, + isOverride, + onChanged, +}: { + medicationId: string; + options: MedicationEfficacyDTO["overrideOptions"]; + isOverride: boolean; + onChanged: () => void; +}) { + const { t } = useTranslations(); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + + const items = useMemo( + () => [ + ...options.metrics.map((m) => ({ + value: `metric:${m.key}`, + label: m.label, + })), + ...options.biomarkers.map((b) => ({ + value: `lab:${b.id}`, + label: b.label, + })), + ], + [options], + ); + + const apply = async (clear: boolean) => { + setBusy(true); + try { + const body: { + clear?: boolean; + measurementType?: string; + biomarkerId?: string; + } = {}; + if (clear) { + body.clear = true; + } else if (value.startsWith("metric:")) { + body.measurementType = value.slice("metric:".length); + } else if (value.startsWith("lab:")) { + body.biomarkerId = value.slice("lab:".length); + } else { + setBusy(false); + return; + } + await apiPut(`/api/medications/${medicationId}/efficacy/target`, body); + onChanged(); + if (clear) setValue(""); + } finally { + setBusy(false); + } + }; + + if (items.length === 0) return null; + + return ( +
+
+ ); +} + +function formatShort(iso?: string): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); +} diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index f9a803ae7..fddcd212d 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -46,6 +46,8 @@ import { ArrowLeft, Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; +import { EfficacyTab } from "@/components/medications/detail/efficacy/efficacy-tab"; +import { resolveMedicationTargets } from "@/lib/medications/med-target-map"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars"; import { DoseHistoryLedger } from "@/components/medications/dose-history-ledger"; @@ -157,6 +159,7 @@ export interface MedicationDetailSnapshot { // the tab strip render and the `?tab=` round-trip validation. const TAB_SLUGS = [ "uebersicht", + "wirkung", "zeitplan", "bestand", "verlauf", @@ -236,6 +239,21 @@ export function MedicationDetailTabs({ const isGlp1 = !oneShot && medication.treatmentClass === "GLP1"; const isInjectable = medication.deliveryForm === "INJECTION"; + // v1.28 — the "Wirkung" tab appears only when the medication resolves to a + // known outcome target (ATC class prefix → whole-word name inference) and is + // not a single-dose med. Same pure resolver the server efficacy builder's + // derived tier uses, so the tab's presence agrees with the DTO's eligibility. + const hasEfficacyTarget = useMemo( + () => + !oneShot && + resolveMedicationTargets({ + name: medication.name, + treatmentClass: medication.treatmentClass, + atcCode: medication.atcCode, + }) !== null, + [oneShot, medication.name, medication.treatmentClass, medication.atcCode], + ); + const payload = useMemo( () => snapshotToWizardPayload(medication), [medication], @@ -254,9 +272,10 @@ export function MedicationDetailTabs({ TAB_SLUGS.filter( (slug) => (slug !== "injektion" || isInjectable) && - (slug !== "zeitplan" || !asNeeded), + (slug !== "zeitplan" || !asNeeded) && + (slug !== "wirkung" || hasEfficacyTarget), ), - [isInjectable, asNeeded], + [isInjectable, asNeeded, hasEfficacyTarget], ); const requestedRaw = searchParams?.get("tab"); @@ -535,6 +554,16 @@ export function MedicationDetailTabs({ )} + {/* WIRKUNG — the per-medication efficacy view: the outcome metric(s)/ + lab(s) the drug's class targets, over the span around its start, + with a before/after comparison + adherence lane. Strictly + descriptive; hidden for one-shot + no-target medications. */} + {hasEfficacyTarget && ( + + + + )} + {/* ZEITPLAN — inline edit of the everyday levers: dose times + each dose's on-time window, then the Erinnerung card (the notifications switch + the reminder grace, folded in from the diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts index 429f5ac08..979aefde5 100644 --- a/src/lib/query-keys/medications.ts +++ b/src/lib/query-keys/medications.ts @@ -138,4 +138,13 @@ export const medicationKeys = { */ medicationApiEndpoint: (medicationId: string) => ["medications", medicationId, "api-endpoint"] as const, + /** + * v1.28 — per-medication efficacy view ("Wirkung") + * (`GET /api/medications/[id]/efficacy`), consumed by the detail page's + * Wirkung tab and the Insights medication summary. Rides under the + * `["medications", id, …]` prefix so an intake mutation (which moves the + * adherence lane) reaches it through `medicationDependentKeys`. + */ + medicationEfficacy: (medicationId: string) => + ["medications", medicationId, "efficacy"] as const, };