mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
725f69097f
commit
8617fa7129
14 files changed
+1127
-14
No files matched your search
+37
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user