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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-09 13:02:02 +02:00
1 parent 725f69097f
commit 8617fa7129
14 files changed
+1127 -14

No files matched your search

+37 -2
View File
@@ -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…",
+37 -2
View File
@@ -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…",
+37 -2
View File
@@ -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…",
+37 -2
View File
@@ -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…",
+37 -2
View File
@@ -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…",
+37 -2
View File
@@ -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…",
@@ -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<string, unknown>)) {
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([]);
});
});
+8
View File
@@ -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() {
})}
</div>
{/* 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. */}
<MedicationEfficacySummary
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
/>
<MetricTargetSummary slug="medications" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
+1
View File
@@ -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";
@@ -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<MedicationEfficacyDTO>(`/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 (
<Card data-slot="insights-medication-efficacy">
<CardHeader>
<TileHeader
icon={Activity}
title={t("medications.efficacy.insights.title")}
/>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.insights.subtitle")}
</p>
</CardHeader>
<CardContent className="space-y-3">
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.insights.empty")}
</p>
) : (
<ul className="divide-border/60 divide-y">
{rows.map(({ med, target }) => {
const ba = target!.beforeAfter;
const unit = target!.unit ? ` ${target!.unit}` : "";
const arrow = (ba.delta?.mean ?? 0) > 0 ? "+" : "";
return (
<li
key={med.id}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2.5 first:pt-0 last:pb-0"
data-slot="insights-medication-efficacy-row"
>
<div className="min-w-0">
<p className="text-foreground text-sm font-medium">
{med.name}
</p>
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
<TrendingUp aria-hidden="true" className="h-3.5 w-3.5" />
<span>
{target!.label}: {ba.before?.mean}
{unit} → {ba.after?.mean}
{unit} ({arrow}
{ba.delta?.mean}
{unit})
</span>
</p>
</div>
<Link
href={`/medications/${med.id}?tab=wirkung`}
className="text-primary focus-visible:ring-ring shrink-0 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot="insights-medication-efficacy-link"
>
{t("medications.efficacy.insights.link")}
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -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 (
<div className="touch-pan-y">
<ResponsiveContainer width="100%" height={220}>
<LineChart margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
<XAxis
dataKey="t"
type="number"
scale="time"
domain={domain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => fmt.date(new Date(v))}
/>
<YAxis
yAxisId="target"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={40}
unit={target.unit ? ` ${target.unit}` : undefined}
/>
<YAxis
yAxisId="adherence"
orientation="right"
domain={[0, 100]}
hide
/>
{/* Population reference band — faint context, labelled "typical
range", explicitly NOT a target line. */}
{target.referenceBand ? (
<ReferenceArea
yAxisId="target"
y1={target.referenceBand.low}
y2={target.referenceBand.high}
fill="var(--muted-foreground)"
fillOpacity={0.08}
stroke="none"
label={{
value: typicalRangeLabel,
position: "insideTopLeft",
fill: "var(--muted-foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Shaded pause bands. An open pause runs to now. */}
{pauses.map((p, i) => (
<ReferenceArea
key={`pause-${i}`}
yAxisId="target"
x1={toMs(p.from)}
x2={p.to ? toMs(p.to) : rightEdge}
fill="var(--foreground)"
fillOpacity={0.06}
stroke="none"
/>
))}
{/* Adherence lane — faint, hidden right axis. */}
<Line
yAxisId="adherence"
data={adherenceData}
dataKey="rate"
name={adherenceLabel}
type="stepAfter"
stroke="var(--chart-4)"
strokeWidth={1.5}
strokeOpacity={0.5}
dot={false}
connectNulls
isAnimationActive={false}
/>
{/* The target series. */}
<Line
yAxisId="target"
data={seriesData}
dataKey="value"
name={target.label}
type="monotone"
stroke="var(--chart-1)"
strokeWidth={2}
dot={{ r: 2, fill: "var(--chart-1)" }}
activeDot={{ r: 4 }}
connectNulls
isAnimationActive={false}
/>
{/* Start marker. */}
{startMs !== null ? (
<ReferenceLine
yAxisId="target"
x={startMs}
stroke="var(--primary)"
strokeWidth={1.5}
label={{
value: startLabel,
position: "insideTopRight",
fill: "var(--foreground)",
fontSize: 10,
}}
/>
) : null}
{/* Dose-change markers — quiet, dashed. */}
{doseChanges.map((d, i) => (
<ReferenceLine
key={`dose-${i}`}
yAxisId="target"
x={toMs(d.at)}
stroke="var(--muted-foreground)"
strokeDasharray="4 4"
strokeOpacity={0.6}
/>
))}
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.8125rem",
}}
labelFormatter={(v) => 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)];
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -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: () => <ChartSkeleton /> },
);
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<MedicationEfficacyDTO>(
`/api/medications/${medicationId}/efficacy`,
),
enabled: active,
staleTime: 60_000,
});
if (isLoading || !data) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<ChartSkeleton />
</MedicationDetailSection>
);
}
// The tab is hidden upstream when ineligible; this is a defensive fallback.
if (!data.eligible) {
return (
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.notEligible")}
</p>
</MedicationDetailSection>
);
}
return (
<div className="space-y-6" data-slot="medication-wirkung-section">
<MedicationDetailSection
titleId="medication-wirkung-heading"
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-intro"
>
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.intro")}
</p>
{data.markers.startSource === "firstReading" ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.startFallback")}
</p>
) : null}
{data.markers.start === null ? (
<p className="text-muted-foreground text-xs">
{t("medications.efficacy.noStart")}
</p>
) : null}
<RetargetControl
medicationId={medicationId}
options={data.overrideOptions}
isOverride={data.resolution.tier === "override"}
onChanged={() =>
queryClient.invalidateQueries({
queryKey: queryKeys.medicationEfficacy(medicationId),
})
}
/>
</div>
</MedicationDetailSection>
{data.targets.map((target) => (
<TargetCard
key={`${target.kind}:${target.key}`}
target={target}
dto={data}
timezone={timezone}
/>
))}
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-disclaimer"
>
{t("medications.efficacy.disclaimer")}
</p>
</div>
);
}
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 (
<Card data-slot={`medication-wirkung-target-${target.kind}`}>
<CardHeader>
<TileHeader
icon={TrendingUp}
title={target.label}
right={
target.primary ? (
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.primaryBadge")}
</span>
) : undefined
}
/>
</CardHeader>
<CardContent className="space-y-4">
{target.series.length > 0 ? (
<EfficacyChart
target={{
label: target.label,
unit: target.unit,
referenceBand: target.referenceBand,
series: target.series.map((p) => ({ 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")}
/>
) : (
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.noSeries")}
</p>
)}
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.levelShift?.present && target.levelShift.nearStart ? (
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-levelshift"
>
{t("medications.efficacy.levelShift", {
date: formatShort(target.levelShift.at),
})}
</p>
) : null}
</CardContent>
</Card>
);
}
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 (
<div
className="border-border/60 rounded-lg border border-dashed p-3"
data-slot="medication-wirkung-beforeafter-empty"
>
<p className="text-muted-foreground text-sm">
{t(reasonKey, { weeks: minWeeks })}
</p>
</div>
);
}
const arrow = ba.delta.mean > 0 ? "+" : "";
return (
<div
className="bg-muted/40 rounded-lg p-3"
data-slot="medication-wirkung-beforeafter"
>
<p className="text-foreground text-sm">
{t("medications.efficacy.beforeAfter.summary", {
before: `${ba.before.mean}${unit}`,
after: `${ba.after.mean}${unit}`,
delta: `${arrow}${ba.delta.mean}${unit}`,
})}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("medications.efficacy.beforeAfter.counts", {
beforeCount: ba.before.count,
afterCount: ba.after.count,
})}
</p>
</div>
);
}
function RetargetControl({
medicationId,
options,
isOverride,
onChanged,
}: {
medicationId: string;
options: MedicationEfficacyDTO["overrideOptions"];
isOverride: boolean;
onChanged: () => void;
}) {
const { t } = useTranslations();
const [value, setValue] = useState<string>("");
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 (
<div
className="flex flex-wrap items-center gap-2"
data-slot="medication-wirkung-retarget"
>
<Activity aria-hidden="true" className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground text-xs">
{t("medications.efficacy.retarget.label")}
</span>
<Select value={value} onValueChange={setValue}>
<SelectTrigger size="sm" className="w-56">
<SelectValue
placeholder={t("medications.efficacy.retarget.placeholder")}
/>
</SelectTrigger>
<SelectContent>
{items.map((it) => (
<SelectItem key={it.value} value={it.value}>
{it.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={busy || value === ""}
onClick={() => apply(false)}
>
{t("medications.efficacy.retarget.apply")}
</Button>
{isOverride ? (
<Button
size="sm"
variant="ghost"
disabled={busy}
onClick={() => apply(true)}
>
{t("medications.efficacy.retarget.reset")}
</Button>
) : null}
</div>
);
}
function formatShort(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}
@@ -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({
)}
</TabsContent>
{/* 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 && (
<TabsContent value="wirkung" className="space-y-6 pt-2">
<EfficacyTab medicationId={id} active={activeTab === "wirkung"} />
</TabsContent>
)}
{/* 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
+9
View File
@@ -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,
};