DiagnostikApps/PDS-5/app.R
2026-09-22 18:35:43 +02:00

934 lines
33 KiB
R
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Präambel ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_pds5.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
PDS5_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel für klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Klassifikation 4b (DSM-5-Kriterienlogik) ist von den Testautoren nicht als ",
"validiert gekennzeichnet und dient nur als Zusatzinformation."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# Infrastruktur ####
APP_VERZEICHNIS = normalizePath(getwd())
absPath = function(pfad) {
if (grepl("^([A-Za-z]:[/\\\\]|/)", pfad)) return(pfad)
file.path(APP_VERZEICHNIS, pfad)
}
PFAD_DOWNLOAD_SKRIPT = normalizePath(absPath(PFAD_DOWNLOAD_SKRIPT), mustWork = FALSE)
PFAD_PSEUDONYM_SKRIPT = normalizePath(absPath(PFAD_PSEUDONYM_SKRIPT), mustWork = FALSE)
# Helper ####
PDS5_TRAUMA_BUCHSTABEN = c("a", "b", "c", "d", "e", "f", "g", "h", "i")
PDS5_TRAUMA_EREIGNISSE = c(
a = "Schwere, lebensbedrohliche Krankheit",
b = "Körperliche Gewalt",
c = "Sexuelle Gewalt",
d = "Kampfeinsatz im Krieg oder in einem Kriegsgebiet gelebt haben",
e = "Kindesmissbrauch",
f = "Unfall",
g = "Folter oder Gefangenschaft",
h = "Naturkatastrophe",
i = "Anderes Ereignis"
)
PDS5_STUFEN_TEXT = c(
"0" = "gar nicht",
"1" = "1x pro Woche oder seltener/ wenig belastend",
"2" = "2-3x pro Woche/ etwas belastend",
"3" = "4-5x pro Woche/ sehr belastend",
"4" = "mehr als 5x pro Woche/ schwer belastend"
)
PDS5_BADGE_FARBEN = c(
"0" = "#4CAF50", "1" = "#F48FB1", "2" = "#EF5350",
"3" = "#B71C1C", "4" = "#4A0000"
)
PDS5_BADGE_TEXT_FARBEN = c(
"0" = "white", "1" = "#333333", "2" = "white",
"3" = "white", "4" = "white"
)
PDS5_KAT_WORD_FARBEN = list(
unauffaellig = list(bg = "#E8F5E9", text = "#2E7D32"),
auffaellig = list(bg = "#FFEBEE", text = "#B71C1C"),
neutral = list(bg = "#F5F5F5", text = "#424242")
)
# labels-Attribut der ORIGINAL-Spalte lesen (nicht der subgesetteten Zeile),
# weil das Attribut beim Subsetting verloren gehen oder abweichen kann.
pds5_get_label_text = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) return(NA_character_)
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) == 0) return(NA_character_)
trimws(names(lbl_attr)[pos[1]])
}
pds5_is_ja = function(original_col, wert) {
txt = pds5_get_label_text(original_col, wert)
if (is.na(txt)) return(NA)
toupper(trimws(txt)) == "JA"
}
# Stufe 0-4 wird NIE aus dem Rohwert abgeleitet, sondern aus der Position
# des Rohwerts innerhalb der aufsteigend sortierten labels-Codes. So bleibt
# die Zuordnung unabhängig von der tatsächlichen internen Kodierung.
pds5_get_level = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_integer_)
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) return(NA_integer_)
lbl_sortiert = sort(as.vector(lbl_attr))
pos = which(lbl_sortiert == as.numeric(wert[1]))
if (length(pos) == 0) return(NA_integer_)
as.integer(pos[1]) - 1L
}
pds5_get_anker = function(original_col, wert, stufe) {
txt = pds5_get_label_text(original_col, wert)
if (!is.na(txt)) return(txt)
if (!is.na(stufe) && as.character(stufe) %in% names(PDS5_STUFEN_TEXT))
return(PDS5_STUFEN_TEXT[[as.character(stufe)]])
NA_character_
}
# Entfernt formr-Nummerierungsartefakte am Anfang des Fragetexts
# (z.B. "1. " oder "01) "), die manchmal im label-Attribut erscheinen.
clean_item_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
sub("^\\d+[.)\\s]\\s*", "", trimws(as.character(text[1])))
}
pds5_cluster_scores = function(levels) {
lapply(PDS5_CLUSTER, function(cl) {
werte = levels[cl$items]
score = sum(werte, na.rm = TRUE)
count = sum(werte >= 1, na.rm = TRUE)
list(
name = cl$name, items = cl$items, max = cl$max, min_ok = cl$min_ok,
score = score, count = count, ok = count >= cl$min_ok
)
})
}
pds5_klassifikation_4a = function(score) {
erfuellt = isTRUE(score >= 36)
list(
erfuellt = erfuellt,
text = if (erfuellt)
"Indikator für eine wahrscheinliche PTBS-Diagnose (validierter Cutoff, Wittmann et al. 2021)."
else
"Score liegt unterhalb des validierten Cutoffs von 36."
)
}
pds5_klassifikation_4b = function(cluster_ok, trauma_vorhanden, keines_erlebt_nein,
dauer_ok, belastung_stufe, beeintraechtigung_stufe) {
bedingung1 = isTRUE(trauma_vorhanden) && isTRUE(keines_erlebt_nein)
beeintr_ok = isTRUE(belastung_stufe >= 1) || isTRUE(beeintraechtigung_stufe >= 1)
gesamt = bedingung1 &&
isTRUE(cluster_ok$wiedererleben$ok) && isTRUE(cluster_ok$vermeidung$ok) &&
isTRUE(cluster_ok$neg_veraend$ok) && isTRUE(cluster_ok$erregung$ok) &&
beeintr_ok && isTRUE(dauer_ok)
list(
bedingung1_ok = bedingung1,
beeintr_ok = beeintr_ok,
dauer_ok = isTRUE(dauer_ok),
gesamt = gesamt
)
}
make_gauge_pds5 = function(score) {
ggplot() +
geom_rect(aes(xmin = 0, xmax = 36, ymin = 0, ymax = 1),
fill = "#E8F5E9", color = NA) +
geom_rect(aes(xmin = 36, xmax = 80, ymin = 0, ymax = 1),
fill = "#FFEBEE", color = NA) +
geom_rect(aes(xmin = 0, xmax = 80, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_vline(xintercept = 36, color = "#E65100", linetype = "dashed", linewidth = 1) +
geom_segment(aes(x = score, xend = score, y = -0.25, yend = 1.25),
color = AKZENT_FARBE, linewidth = 2.5) +
geom_label(aes(x = score, y = 1.6, label = paste0("Score: ", score)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = 36, y = -0.55, label = "Cutoff: 36",
color = "#E65100", size = 3.2, hjust = 0.5) +
annotate("text", x = 18, y = 0.5, label = "< 36",
color = "#2E7D32", size = 3.5, fontface = "italic") +
annotate("text", x = 58, y = 0.5, label = ">= 36",
color = "#B71C1C", size = 3.5, fontface = "italic") +
scale_x_continuous(limits = c(0, 83),
breaks = c(0, 10, 20, 30, 36, 40, 50, 60, 70, 80)) +
scale_y_continuous(limits = c(-0.8, 2.0)) +
theme_minimal(base_size = 12) +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.title.y = element_blank(),
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
) +
labs(x = "PDS-5 Gesamtscore (0-80)", y = NULL)
}
# Datenaufbereitung ####
PDS5_CLUSTER = list(
wiedererleben = list(name = "Wiedererleben",
items = 1:5, max = 20, min_ok = 1),
vermeidung = list(name = "Vermeidung",
items = 6:7, max = 8, min_ok = 1),
neg_veraend = list(name = "Negative Veränderungen von Gedanken und Stimmung",
items = 8:14, max = 28, min_ok = 2),
erregung = list(name = "Veränderung in Erregung und Reagibilität",
items = 15:20, max = 24, min_ok = 2)
)
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.app-header {
background: #8B2635; color: white; padding: 18px 24px 14px;
margin-bottom: 20px; border-radius: 0 0 6px 6px;
}
.app-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.app-header p { margin: 4px 0 0; opacity: 0.85; font-size: 0.9rem; }
.input-panel {
background: white; border-radius: 6px; padding: 16px 20px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
}
.input-panel .form-group { margin-bottom: 0; }
.input-panel label { font-weight: 600; color: #333; }
.btn-laden {
background: #8B2635 !important; color: white !important;
border: none !important; border-radius: 4px !important;
padding: 8px 20px !important; font-weight: 600 !important; cursor: pointer;
}
.btn-laden:hover { background: #6d1e29 !important; }
.alert-fehler {
background: #FFEBEE; border-left: 5px solid #C62828;
padding: 12px 16px; border-radius: 4px; color: #B71C1C;
margin-bottom: 12px; font-weight: 500;
}
.alert-warnung {
background: #FFF3E0; border-left: 5px solid #E65100;
padding: 10px 16px; border-radius: 4px; color: #BF360C;
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
}
.info-box {
background: #F5F5F5; border-left: 5px solid #9E9E9E;
padding: 14px 18px; border-radius: 4px; color: #424242;
margin-bottom: 12px; font-weight: 500;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.kontext-zeile {
display: flex; gap: 8px; align-items: baseline;
padding: 4px 0; color: #444; font-size: 0.93em;
}
.kontext-label { font-weight: 600; color: #333; min-width: 220px; }
.trauma-treffer {
display: inline-block; padding: 4px 10px; margin: 3px 4px 3px 0;
border-radius: 4px; background: #FFEBEE; color: #B71C1C;
font-size: 0.88em; font-weight: 600;
}
.diagnose-box {
border-radius: 6px; padding: 14px 18px; margin: 12px 0;
border-left: 5px solid;
}
.diagnose-titel { font-weight: 700; font-size: 1.05rem; margin-bottom: 6px; }
.diagnose-hinweis { font-size: 0.93em; line-height: 1.55; }
.kat-unauffaellig { background: #E8F5E9; border-color: #A5D6A7; color: #2E7D32; }
.kat-auffaellig { background: #FFEBEE; border-color: #EF9A9A; color: #B71C1C; }
.kat-neutral { background: #F5F5F5; border-color: #9E9E9E; color: #424242; }
.cluster-box {
display: inline-block; padding: 10px 14px; border-radius: 6px;
margin: 4px; text-align: center; min-width: 150px; vertical-align: top;
}
.cluster-ok { background: #E8F5E9; border: 1px solid #A5D6A7; }
.cluster-nok { background: #FFEBEE; border: 1px solid #EF9A9A; }
.cluster-name { font-weight: 700; font-size: 0.92em; color: #333; }
.cluster-score { font-size: 0.9em; color: #555; margin: 2px 0; }
.cluster-kriterium { font-size: 0.82em; font-weight: 600; }
.cluster-kriterium-ok { color: #2E7D32; }
.cluster-kriterium-nok { color: #C62828; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 28px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.stufe-badge {
border-radius: 4px; padding: 2px 9px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
}
.stufe-badge-0 { background: #4CAF50; color: white; }
.stufe-badge-1 { background: #F48FB1; color: #333333; }
.stufe-badge-2 { background: #EF5350; color: white; }
.stufe-badge-3 { background: #B71C1C; color: white; }
.stufe-badge-4 { background: #4A0000; color: white; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.88em; color: #555; margin-top: 4px; }
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("PDS-5 - Posttraumatic Diagnostic Scale for DSM-5"),
tags$p("Einzelauswertung")
),
div(class = "container-fluid",
div(class = "input-panel",
div(style = "min-width: 360px; white-space: nowrap;",
textInput("pseudonym",
label = tagList(
"Pseudonym",
tags$span(style = "font-weight: normal; font-style: italic; font-size: 0.78em; color: #888; margin-left: 4px; white-space: nowrap;",
"optional, hat Vorrang vor Chiffre")
),
placeholder = "optional", width = "340px")
),
div(style = "min-width: 200px;",
textInput("chiffre", label = "Patientenchiffre",
placeholder = "z.B. P000123", width = "100%")
),
actionButton("btn_suchen", "Auswerten", class = "btn btn-primary btn-laden"),
div(style = "margin-left: auto;",
downloadButton("download_word", "Word-Export (.docx)")
)
),
uiOutput("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_pds5_docx = function(erg) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_abschnitt = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 13)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_normal = fp_text(font.size = 11)
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("PDS-5 - Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label),
ftext(erg$ausfuelldatum, fp_normal)
))
if (!is.null(erg$info_mehrere)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$info_mehrere,
fp_text(font.size = 10, italic = TRUE, color = "#555555"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Trauma-Checkliste", fp_abschnitt)))
if (length(erg$trauma$treffer_kurz) > 0) {
doc = body_add_fpar(doc, fpar(
ftext("Berichtete Ereignisse: ", fp_label),
ftext(paste(erg$trauma$treffer_kurz, collapse = "; "), fp_normal)
))
} else {
doc = body_add_fpar(doc, fpar(
ftext("Berichtete Ereignisse: ", fp_label),
ftext("keine", fp_normal)
))
}
doc = body_add_fpar(doc, fpar(
ftext("Am meisten belastend: ", fp_label),
ftext(if (is.na(erg$trauma$am_meisten)) "keine Angabe" else erg$trauma$am_meisten, fp_normal)
))
if (!is.na(erg$trauma$freitext) && nchar(trimws(erg$trauma$freitext)) > 0) {
doc = body_add_fpar(doc, fpar(
ftext("Freitext (anderes Ereignis): ", fp_label),
ftext(erg$trauma$freitext, fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
if (erg$kein_trauma) {
doc = body_add_fpar(doc, fpar(ftext(
"Kein traumatisches Ereignis berichtet - Auswertung nicht anwendbar.",
fp_text(bold = TRUE, font.size = 12, color = "#424242")
)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(PDS5_DISCLAIMER, fp_disclaimer)))
return(doc)
}
fp_score = if (isTRUE(erg$gesamtscore >= 36))
fp_text(bold = TRUE, font.size = 12, color = "#C62828")
else
fp_text(bold = TRUE, font.size = 12, color = "#2E7D32")
doc = body_add_fpar(doc, fpar(ftext("Gesamtscore und validierte Klassifikation (4a)", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Gesamtscore: ", fp_label),
ftext(paste0(erg$gesamtscore, " / 80 (Cutoff: 36)"), fp_score)
))
doc = body_add_fpar(doc, fpar(ftext(erg$klass_4a$text, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("DSM-5-Kriterienalgorithmus (4b, unvalidiert)", fp_abschnitt)))
dsm5_txt = if (isTRUE(erg$klass_4b$gesamt))
"Wahrscheinliche PTBS nach DSM-5-Kriterienlogik"
else
"Kriterien nach DSM-5-Kriterienlogik nicht vollständig erfüllt"
doc = body_add_fpar(doc, fpar(ftext(dsm5_txt, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Subskalen", fp_abschnitt)))
for (key in names(PDS5_CLUSTER)) {
cl = erg$cluster[[key]]
doc = body_add_fpar(doc, fpar(
ftext(paste0(cl$name, ": "), fp_label),
ftext(paste0(cl$score, " / ", cl$max, " Pkt"), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems (Teil 2)", fp_abschnitt)))
for (zeile_item in erg$items) {
stufe_key = if (!is.na(zeile_item$stufe)) as.character(zeile_item$stufe) else "0"
fp_badge = fp_text(
color = PDS5_BADGE_TEXT_FARBEN[[stufe_key]],
bold = TRUE,
shading.color = PDS5_BADGE_FARBEN[[stufe_key]],
font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(zeile_item$label, ": ", zeile_item$text, " "), fp_normal),
ftext(paste0(" ", zeile_item$anker, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(PDS5_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
# --- pseudonym-support-injection v1 ---
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0))
return(list(error = "Bitte eine Patientenchiffre eingeben."))
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre)))
return(list(error = paste0(
"Ungültige Chiffre \"", chiffre, "\". ",
"Erwartet: ein Großbuchstabe gefolgt von 6 Ziffern, z.B. P000123."
)))
if (!file.exists(PFAD_DOWNLOAD_SKRIPT))
return(list(error = paste0(
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
if (!file.exists(PFAD_PSEUDONYM_SKRIPT))
return(list(error = paste0(
"Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
res_dl = tryCatch(
{ source(PFAD_DOWNLOAD_SKRIPT, local = FALSE); list(ok = TRUE) },
error = function(e) list(ok = FALSE, msg = e$message)
)
if (!res_dl$ok)
return(list(error = paste0("Fehler im Download-Skript: ", res_dl$msg)))
db_ordner = local({
ordner = dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
gefunden = NULL
for (i in 1:5) {
if (file.exists(file.path(ordner, "pseudonyme.db"))) {
gefunden = ordner
break
}
elternteil = dirname(ordner)
if (elternteil == ordner) break
ordner = elternteil
}
gefunden
})
alter_wd = getwd()
wd_ziel = if (!is.null(db_ordner)) db_ordner else
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
setwd(wd_ziel)
on.exit(setwd(alter_wd), add = TRUE)
res_ps = tryCatch(
{ source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
if (nchar(trimws(input$pseudonym)) > 0) {
.pw_wert = trimws(input$pseudonym)
.pw_tab = get("pseudo", envir = .GlobalEnv)
.pw_treffer = .pw_tab[.pw_tab$pseudonym == .pw_wert, ]
if (nrow(.pw_treffer) > 0) chiffre = toupper(trimws(.pw_treffer$chiffre[1]))
}; list(ok = TRUE) },
error = function(e) list(ok = FALSE, msg = e$message)
)
if (!res_ps$ok)
return(list(error = paste0("Fehler im Pseudonym-Skript: ", res_ps$msg)))
if (!exists("daten_pds5", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'daten_pds5' nach dem Sourcen nicht gefunden. ",
"Bitte Download-Skript prüfen.")))
if (!exists("pseudo", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. ",
"Bitte Pseudonym-Skript prüfen.")))
daten = get("daten_pds5", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0)
return(list(error = paste0(
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0)
return(list(error = paste0(
"Kein PDS-5-Datensatz für Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprüft)")))
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
sortier_key = suppressWarnings(as.POSIXct(treffer_dat$created))
treffer_dat = treffer_dat[order(sortier_key, decreasing = TRUE, na.last = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat$created[1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
info_mehrere = paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
)
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
created_raw = zeile[["created"]][1]
ausfuelldatum = if (inherits(created_raw, "POSIXt") || inherits(created_raw, "Date")) {
format(created_raw, "%d.%m.%Y")
} else {
tryCatch(
format(as.POSIXct(created_raw), "%d.%m.%Y"),
error = function(e) as.character(created_raw)
)
}
# Teil 1: Trauma-Checkliste
trauma_ja_flags = sapply(PDS5_TRAUMA_BUCHSTABEN, function(b) {
var = paste0("pds_", b)
isTRUE(pds5_is_ja(daten[[var]], zeile[[var]]))
})
names(trauma_ja_flags) = PDS5_TRAUMA_BUCHSTABEN
treffer_kurz = sapply(PDS5_TRAUMA_BUCHSTABEN[trauma_ja_flags], function(b) {
paste0(toupper(b), ": ", PDS5_TRAUMA_EREIGNISSE[[b]])
})
keines_erlebt_text = pds5_get_label_text(daten[["pds_keines_erlebt"]],
zeile[["pds_keines_erlebt"]])
keines_erlebt_ja = isTRUE(toupper(trimws(keines_erlebt_text)) == "JA")
keines_erlebt_nein = isTRUE(toupper(trimws(keines_erlebt_text)) == "NEIN")
am_meisten = pds5_get_label_text(daten[["pds_am_meisten"]], zeile[["pds_am_meisten"]])
freitext_raw = zeile[["pds_i_freitext"]][1]
freitext = if (is.null(freitext_raw) || is.na(freitext_raw)) NA_character_
else as.character(freitext_raw)
trauma = list(
ja_flags = trauma_ja_flags,
treffer_kurz = unname(treffer_kurz),
am_meisten = am_meisten,
freitext = freitext
)
basis_ergebnis = list(
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
info_mehrere = info_mehrere,
trauma = trauma,
error = NULL
)
# Fall "kein Trauma berichtet": explizit auf pds_keines_erlebt = JA
# prüfen, nicht nur auf NA in Teil 2 (showif an der echten Instanz
# noch nicht verifiziert).
if (keines_erlebt_ja) {
return(c(basis_ergebnis, list(kein_trauma = TRUE)))
}
# Teil 2: Symptomitems
pds5_levels = sapply(1:20, function(i) {
var = paste0("pds_", sprintf("%02d", i))
pds5_get_level(daten[[var]], zeile[[var]])
})
item_infos = lapply(1:20, function(i) {
var = paste0("pds_", sprintf("%02d", i))
stufe = pds5_levels[i]
item_text = clean_item_label(attr(daten[[var]], "label"))
if (is.na(item_text)) item_text = var
list(
label = paste0("Item ", i),
text = item_text,
stufe = stufe,
anker = pds5_get_anker(daten[[var]], zeile[[var]], stufe)
)
})
belastung_stufe = pds5_get_level(daten[["pds_belastung"]], zeile[["pds_belastung"]])
belastung_text = clean_item_label(attr(daten[["pds_belastung"]], "label"))
if (is.na(belastung_text)) belastung_text = "pds_belastung"
item_infos[[21]] = list(
label = "Belastung",
text = belastung_text,
stufe = belastung_stufe,
anker = pds5_get_anker(daten[["pds_belastung"]], zeile[["pds_belastung"]], belastung_stufe)
)
beeintraechtigung_stufe = pds5_get_level(daten[["pds_beeintraechtigung"]],
zeile[["pds_beeintraechtigung"]])
beeintraechtigung_text = clean_item_label(attr(daten[["pds_beeintraechtigung"]], "label"))
if (is.na(beeintraechtigung_text)) beeintraechtigung_text = "pds_beeintraechtigung"
item_infos[[22]] = list(
label = "Beeinträchtigung",
text = beeintraechtigung_text,
stufe = beeintraechtigung_stufe,
anker = pds5_get_anker(daten[["pds_beeintraechtigung"]], zeile[["pds_beeintraechtigung"]],
beeintraechtigung_stufe)
)
gesamtscore = sum(pds5_levels, na.rm = TRUE)
cluster = pds5_cluster_scores(pds5_levels)
beginn_text = pds5_get_label_text(daten[["pds_beginn"]], zeile[["pds_beginn"]])
dauer_text = pds5_get_label_text(daten[["pds_dauer"]], zeile[["pds_dauer"]])
dauer_ok = isTRUE(grepl("mehr als 1 Monat", dauer_text, fixed = TRUE))
trauma_vorhanden = any(trauma_ja_flags, na.rm = TRUE)
klass_4a = pds5_klassifikation_4a(gesamtscore)
klass_4b = pds5_klassifikation_4b(
cluster_ok = cluster,
trauma_vorhanden = trauma_vorhanden,
keines_erlebt_nein = keines_erlebt_nein,
dauer_ok = dauer_ok,
belastung_stufe = belastung_stufe,
beeintraechtigung_stufe = beeintraechtigung_stufe
)
c(basis_ergebnis, list(
kein_trauma = FALSE,
gesamtscore = gesamtscore,
cluster = cluster,
klass_4a = klass_4a,
klass_4b = klass_4b,
items = item_infos,
beginn_text = beginn_text,
dauer_text = dauer_text
))
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) div(class = "alert-fehler", d$error)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error) || is.null(d$info_mehrere)) return(NULL)
div(class = "alert-warnung", d$info_mehrere)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
trauma_ui = div(
tags$h5("Trauma-Checkliste"),
if (length(d$trauma$treffer_kurz) > 0) {
div(lapply(d$trauma$treffer_kurz, function(t)
span(class = "trauma-treffer", t)))
} else {
div(style = "color:#555;", "Keine der Ereignisse A-I mit JA beantwortet.")
},
div(class = "kontext-zeile",
div(class = "kontext-label", "Am meisten belastend:"),
div(if (is.na(d$trauma$am_meisten)) "keine Angabe" else d$trauma$am_meisten)
),
if (!is.na(d$trauma$freitext) && nchar(trimws(d$trauma$freitext)) > 0) {
div(class = "kontext-zeile",
div(class = "kontext-label", "Freitext (anderes Ereignis):"),
div(d$trauma$freitext)
)
}
)
if (isTRUE(d$kein_trauma)) {
return(
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "PDS-5"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
),
tags$hr(),
trauma_ui,
tags$hr(),
div(class = "info-box",
"Kein traumatisches Ereignis berichtet Auswertung nicht anwendbar.")
)
)
}
cluster_defs = list(
list(key = "wiedererleben"),
list(key = "vermeidung"),
list(key = "neg_veraend"),
list(key = "erregung")
)
cluster_boxes = lapply(cluster_defs, function(cd) {
cl = d$cluster[[cd$key]]
div(class = paste0("cluster-box ", if (cl$ok) "cluster-ok" else "cluster-nok"),
div(class = "cluster-name", cl$name),
div(class = "cluster-score", paste0(cl$score, " / ", cl$max, " Pkt")),
div(class = paste0("cluster-kriterium ",
if (cl$ok) "cluster-kriterium-ok" else "cluster-kriterium-nok"),
paste0(cl$count, " von mind. ", cl$min_ok, if (cl$ok) " ✓" else " ✗"))
)
})
kat_4a_klasse = if (d$klass_4a$erfuellt) "kat-auffaellig" else "kat-unauffaellig"
kat_4b_klasse = if (d$klass_4b$gesamt) "kat-auffaellig" else "kat-unauffaellig"
items_ui = lapply(d$items, function(it) {
sk = if (!is.na(it$stufe)) as.character(it$stufe) else "0"
anker_txt = if (!is.na(it$anker)) it$anker else PDS5_STUFEN_TEXT[[sk]]
div(class = "item-zeile",
div(class = "item-nr", it$label),
div(class = "item-text", it$text),
span(class = paste0("stufe-badge stufe-badge-", sk), anker_txt)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "PDS-5"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
),
tags$hr(),
trauma_ui,
tags$hr(),
fluidRow(
column(3,
div(
div(class = "score-zahl", d$gesamtscore),
div("Gesamtscore (0-80)", style = "color:#555;"),
div(class = "cutoff-info",
if (isTRUE(d$gesamtscore >= 36))
tags$span(style = "color:#B71C1C; font-weight:600;",
paste0(d$gesamtscore, " >= 36: Cutoff erreicht"))
else
tags$span(style = "color:#2E7D32; font-weight:600;",
paste0(d$gesamtscore, " < 36: Unterhalb Cutoff"))
)
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
tags$hr(),
tags$h5("Klassifikation 4a (validierter Cutoff)"),
div(class = paste0("diagnose-box ", kat_4a_klasse),
div(class = "diagnose-titel", "Validierter Cutoff (Wittmann et al. 2021)"),
div(class = "diagnose-hinweis", d$klass_4a$text)
),
tags$h5("Klassifikation 4b (DSM-5-Kriterienalgorithmus)"),
div(class = paste0("diagnose-box ", kat_4b_klasse),
div(class = "diagnose-titel",
if (d$klass_4b$gesamt)
"Wahrscheinliche PTBS nach DSM-5-Kriterienlogik"
else
"Kriterien nach DSM-5-Kriterienlogik nicht vollständig erfüllt"
),
div(class = "diagnose-hinweis",
paste0("Trauma-/Zeitkriterium: ", if (d$klass_4b$bedingung1_ok) "erfüllt" else "nicht erfüllt",
" | Belastung/Beeinträchtigung: ", if (d$klass_4b$beeintr_ok) "erfüllt" else "nicht erfüllt",
" | Dauer > 1 Monat: ", if (d$klass_4b$dauer_ok) "erfüllt" else "nicht erfüllt")
)
),
tags$hr(),
tags$h5("Subskalen"),
div(style = "margin-bottom: 6px;", tagList(cluster_boxes)),
tags$hr(),
tags$h5("Zusatzangaben"),
div(class = "kontext-zeile",
div(class = "kontext-label", "Beginn der Symptome:"),
div(if (is.na(d$beginn_text)) "k. A." else d$beginn_text)
),
div(class = "kontext-zeile",
div(class = "kontext-label", "Dauer der Symptome:"),
div(if (is.na(d$dauer_text)) "k. A." else d$dauer_text)
),
tags$hr(),
tags$h5("Einzelitems (Teil 2)"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error))
req(!isTRUE(d$kein_trauma))
make_gauge_pds5(d$gesamtscore)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
if (!is.list(erg) || !is.null(erg$error)) return("PDS5_Auswertung.docx")
chiffre_esc = gsub("[^A-Za-z0-9_-]", "_", erg$chiffre)
ausfuelldatum_fn = tryCatch(
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
paste0("PDS5_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
if (!is.list(erg) || !is.null(erg$error)) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_pds5_docx(erg),
error = function(e) {
err_doc = read_docx()
body_add_par(err_doc,
paste0("Fehler beim Erstellen des Word-Dokuments: ", e$message),
style = "Normal")
}
)
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui, server)