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

793 lines
29 KiB
R
Raw Permalink 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 ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_desii.R" # liefert: daten_desii
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
DESII_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt keine ",
"klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
# 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 ####
desii_validiere_chiffre = function(chiffre_roh) {
chiffre = toupper(trimws(chiffre_roh))
list(chiffre = chiffre, gueltig = grepl("^[A-Z][0-9]{6}$", chiffre))
}
# Entfernt escapte Backslashes aus dem Itemtext (z.B. "1\. Manche..." -> "1. Manche...").
# Wortlaut der Regel exakt gemaess Spezifikation Abschnitt 3.
desii_entferne_backslashes = function(text) {
gsub("\\\\", "", text)
}
# Der formr-Label-Text hat die Form "**<Nr>\. <Itemtext>. ** <Instruktionssatz>", wobei der
# Instruktionssatz ("Geben Sie nun an, in wie viel Prozent Ihrer Zeit Sie diese Erfahrung
# machen.") bei allen 28 Items identisch ist und nicht mit angezeigt werden soll. Der
# eigentliche Itemtext steht zwischen den beiden Markdown-Fettungs-Markern ("**...**") -
# das ist robuster als ein hartkodierter Vergleich mit dem Instruktionssatz, da dessen exakter
# Wortlaut nicht Teil der Spezifikation ist. Die Itemnummer wird zusaetzlich entfernt, da die
# App sie separat in .item-nr anzeigt (sonst doppelt) - eine reine Anzeige-/Formatierungs-
# entscheidung (kein psychometrischer Eingriff), analog zu clean_item_label()/bsl_item_label()
# in den Schwester-Apps dieses Projekts (z.B. pg13r, bsl).
desii_bereinige_itemtext = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
t = desii_entferne_backslashes(as.character(text[1]))
t = trimws(t)
treffer = regmatches(t, regexec("^\\*\\*(.*?)\\*\\*", t))[[1]]
if (length(treffer) == 2) {
t = treffer[2]
} else {
# Fallback, falls kein **-Rahmen gefunden wird: Sternchen entfernen und den
# Instruktionssatz per Mustertreffer am Ende abschneiden.
t = gsub("\\*\\*", "", t, fixed = TRUE)
t = sub("\\s*Geben Sie.*$", "", t)
}
t = sub("^\\d+[.)]\\s*", "", trimws(t))
trimws(t)
}
desii_item_label = function(spalte_original, fallback) {
lbl = attr(spalte_original, "label")
if (is.null(lbl) || length(lbl) == 0 || is.na(lbl[1]) ||
nchar(trimws(as.character(lbl[1]))) == 0) {
return(fallback)
}
desii_bereinige_itemtext(lbl[1])
}
# Extrahiert den numerischen Prozentwert (0-100) eines range_ticks-Items.
# UNVERIFIZIERT - mit echten Testdaten pruefen: liegt der Wert im formr-Export direkt
# numerisch vor, oder als haven_labelled-Objekt mit labels-Attribut? Beide Faelle werden
# hier defensiv abgedeckt; ein nicht auswertbarer Wert ergibt NA statt eines geratenen Werts.
desii_extrahiere_vas_wert = function(spalte_original, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_real_)
w = wert[1]
if (haven::is.labelled(spalte_original)) {
lbl_attr = attr(spalte_original, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == suppressWarnings(as.numeric(w)))
if (length(pos) > 0) {
n = suppressWarnings(as.numeric(names(lbl_attr)[pos[1]]))
if (!is.na(n) && n >= 0 && n <= 100) return(n)
}
}
# Kein passendes Label gefunden: defensiver Fallback auf den numerischen Rohwert.
n_fallback = suppressWarnings(as.numeric(w))
if (!is.na(n_fallback) && n_fallback >= 0 && n_fallback <= 100) return(n_fallback)
return(NA_real_)
}
n = suppressWarnings(as.numeric(w))
if (is.na(n) || n < 0 || n > 100) return(NA_real_)
n
}
# Missing-Value-Regel woertlich aus der Testquelle: > 3 unbeantwortete Items -> Test nicht
# auswertbar, kein Score. <= 3 unbeantwortete Items -> Mittelwert nur ueber die beantworteten
# Items (Score = arithmetisches Mittel, KEIN Summenscore).
desii_berechne_score = function(werte) {
n_fehlend = sum(is.na(werte))
if (n_fehlend > 3) {
return(list(auswertbar = FALSE, n_fehlend = n_fehlend, score = NA_real_))
}
list(auswertbar = TRUE, n_fehlend = n_fehlend, score = mean(werte, na.rm = TRUE))
}
# Itemliste absteigend nach Auspraegung (Prozentwert) sortieren, fuer Anzeige und Word-Export.
# Fehlende Items (NA) werden unabhaengig von der Sortierrichtung ans Ende gestellt
# (na.last = TRUE), die Itemnummer (nr) bleibt die urspruengliche Position im Fragebogen.
desii_sortiere_items_nach_auspraegung = function(items) {
werte = vapply(items, function(it) it$wert, numeric(1))
items[order(werte, decreasing = TRUE, na.last = TRUE)]
}
# Zusatzhinweis, der bei JEDER Klassifikationsanzeige erscheint (alle 3 Zonen), siehe
# Spezifikation Abschnitt 2a: weitere Diagnostik indiziert + ca. 20% falsch-negativ-Rate.
DESII_KLASSIFIKATION_ZUSATZ = paste0(
"Laut Testmanual ist bei Verdacht weitere Diagnostik indiziert. Das Verfahren weist laut ",
"Manual in ca. 20 % der Faelle ein falsch-negatives Ergebnis auf - ein niedriger Wert ",
"schliesst eine schwere dissoziative Stoerung also nicht sicher aus. Betroffene sind laut ",
"Manual in der Regel aber stabiler als Personen mit hohem DES-Wert."
)
# Cutoff-Stufung (Klassifikation, Abschnitt 2a): Quelle nennt alternativ 25% oder 30% ohne
# sich festzulegen - Nutzerentscheidung: beide als Stufung verwenden (< 25 gruen,
# 25-30 orange, >= 30 rot).
desii_klassifikation = function(score) {
if (score >= 30) {
return(list(
zone = "rot",
farbe = "#B71C1C",
label = "Starker Verdacht auf schwere dissoziative Stoerung"
))
}
if (score >= 25) {
return(list(
zone = "orange",
farbe = "#E65100",
label = "Verdacht auf schwere dissoziative Stoerung"
))
}
list(
zone = "gruen",
farbe = "#2E7D32",
label = "Kein Hinweis auf eine schwere dissoziative Stoerung nach diesem Kriterium"
)
}
# EMDR-Sicherheitshinweis (Abschnitt 2b): UNABHAENGIG von der 25/30-Klassifikation, greift
# bereits ab Score > 20. Schulungskontextspezifischer Hinweis, keine allgemeine
# diagnostische Aussage.
desii_emdr_hinweis_noetig = function(score) {
isTRUE(score > 20)
}
DESII_EMDR_HINWEISTEXT = paste0(
"Bei einem DES-II-Wert ueber 20 % soll laut Testmanual nach dem EMDR-Einfuehrungsseminar ",
"kein EMDR durchgefuehrt werden."
)
DESII_EMDR_KENNZEICHNUNG = paste0(
"Dies ist ein schulungskontextspezifischer Hinweis (bezogen auf die Ausbildungssituation ",
"EMDR-Einfuehrungsseminar), keine allgemeine diagnostische Aussage."
)
# Sucht in dieser Reihenfolge nach der ersten vorhandenen, fuer die betreffende Zeile
# nicht-NA Ausfuelldatum-Spalte. Spaltenname ist UNVERIFIZIERT (siehe Abschnitt 3/10),
# kein Rateergebnis: ohne Treffer wird explizit NA_character_ zurueckgegeben, damit der
# Aufrufer sichtbar auf Sys.Date() zurueckfallen und dies kennzeichnen kann.
desii_finde_datumsspalte = function(daten, zeile) {
kandidaten = c("ended", "created", "modified", "expired")
for (k in kandidaten) {
if (k %in% names(daten)) {
wert = zeile[[k]][1]
if (!is.null(wert) && !is.na(wert)) return(k)
}
}
NA_character_
}
make_gauge_desii = function(score) {
ggplot() +
geom_rect(aes(xmin = 0, xmax = 25, ymin = 0, ymax = 1), fill = "#E8F5E9", color = NA) +
geom_rect(aes(xmin = 25, xmax = 30, ymin = 0, ymax = 1), fill = "#FFF3E0", color = NA) +
geom_rect(aes(xmin = 30, xmax = 100, ymin = 0, ymax = 1), fill = "#FFEBEE", color = NA) +
geom_rect(aes(xmin = 0, xmax = 100, ymin = 0, ymax = 1), fill = NA, color = "#9E9E9E",
linewidth = 0.6) +
geom_vline(xintercept = 25, color = "#E65100", linetype = "dashed", linewidth = 0.8) +
geom_vline(xintercept = 30, color = "#B71C1C", linetype = "dashed", linewidth = 0.8) +
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: ", round(score, 1))),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = 25, y = -0.55, label = "25", color = "#E65100", size = 3.2) +
annotate("text", x = 30, y = -0.9, label = "30", color = "#B71C1C", size = 3.2) +
scale_x_continuous(limits = c(-4, 104), breaks = seq(0, 100, 10)) +
scale_y_continuous(limits = c(-1.2, 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 = "DES-II Score (Mittelwert 0-100)", y = NULL)
}
# 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;
}
.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; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.hinweis-box {
border-radius: 6px; padding: 14px 18px; margin: 12px 0; border-left: 5px solid;
}
.hinweis-box-titel { font-weight: 700; font-size: 1.05rem; margin-bottom: 6px; }
.hinweis-box-text { font-size: 0.93em; line-height: 1.55; }
.hinweis-box-zusatz {
font-size: 0.85em; margin-top: 10px; padding-top: 8px;
border-top: 1px solid rgba(0,0,0,.1); line-height: 1.5;
}
.hinweis-box-gruen { background: #E8F5E9; border-color: #A5D6A7; color: #2E7D32; }
.hinweis-box-orange { background: #FFF3E0; border-color: #FFCC80; color: #E65100; }
.hinweis-box-rot { background: #FFEBEE; border-color: #EF9A9A; color: #B71C1C; }
.schulungshinweis-box {
background: #FFE0B2; border-left: 5px solid #FB8C00; color: #7A4100;
border-radius: 6px; padding: 14px 18px; margin: 12px 0;
}
.schulungshinweis-titel { font-weight: 700; margin-bottom: 6px; }
.schulungshinweis-kennzeichnung { font-size: 0.85em; font-style: italic; margin-top: 8px; }
.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: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.item-prozent {
font-weight: 700; color: #333; min-width: 54px; text-align: right; flex-shrink: 0;
}
"
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("DES-II Dissociative Experience Scale"),
tags$p("Deutsche Uebersetzung Dr. med. Arne Hofmann, EMDR Institut Deutschland")
),
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_desii_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_klein = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("DES-II - Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label), ftext(erg$ausfuelldatum, fp_normal)
))
if (isTRUE(erg$ausfuelldatum_fallback)) {
doc = body_add_fpar(doc, fpar(ftext(
"Ausfuelldatum nicht in Daten gefunden, Erstellungsdatum des Reports verwendet.",
fp_klein
)))
}
if (length(erg$warnungen) > 0) {
for (w in erg$warnungen) doc = body_add_fpar(doc, fpar(ftext(w, fp_klein)))
}
doc = body_add_par(doc, "", style = "Normal")
if (!erg$auswertbar) {
doc = body_add_fpar(doc, fpar(ftext("Auswertung", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
paste0(
"Mehr als 3 Items unbeantwortet (", erg$n_fehlend, " von 28) - Test laut ",
"Testmanual nicht auswertbar."
),
fp_text(bold = TRUE, font.size = 11, color = "#B71C1C")
)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(DESII_DISCLAIMER, fp_disclaimer)))
return(doc)
}
fp_score = fp_text(bold = TRUE, font.size = 12, color = erg$klass$farbe)
doc = body_add_fpar(doc, fpar(ftext("Auswertung", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Score (Mittelwert, 0-100): ", fp_label),
ftext(paste0(round(erg$score, 1), " %"), fp_score)
))
if (erg$n_fehlend > 0) {
doc = body_add_fpar(doc, fpar(ftext(
paste0(
erg$n_fehlend, " von 28 Items unbeantwortet - Mittelwert wurde nur ueber die ",
"beantworteten Items gebildet."
),
fp_klein
)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Klassifikation", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(erg$klass$label, fp_score)))
doc = body_add_fpar(doc, fpar(ftext(DESII_KLASSIFIKATION_ZUSATZ, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
if (erg$emdr_noetig) {
fp_emdr_titel = fp_text(bold = TRUE, font.size = 12, color = "#E65100")
fp_emdr_text = fp_text(font.size = 10, color = "#E65100")
doc = body_add_fpar(doc, fpar(ftext("EMDR-Sicherheitshinweis", fp_emdr_titel)))
doc = body_add_fpar(doc, fpar(ftext(DESII_EMDR_HINWEISTEXT, fp_emdr_text)))
doc = body_add_fpar(doc, fpar(ftext(
paste0(DESII_EMDR_KENNZEICHNUNG, " (schulungskontextspezifischer Hinweis, keine ",
"diagnostische Aussage)"),
fp_klein
)))
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_fpar(doc, fpar(ftext("DES-II Einzelitems (sortiert nach Auspraegung)", fp_abschnitt)))
for (it in erg$items) {
wert_txt = if (is.na(it$wert)) "fehlend" else paste0(round(it$wert), " %")
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(wert_txt, fp_label)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(DESII_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)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick auf "Auswerten".
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre_check = desii_validiere_chiffre(input$chiffre)
chiffre = chiffre_check$chiffre
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0)) {
return(list(error = "Bitte eine Patientenchiffre eingeben."))
}
if (!chiffre_check$gueltig) {
return(list(error = paste0(
"Ungueltige Chiffre. Erwartet: ein Grossbuchstabe + 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_desii", envir = .GlobalEnv)) {
return(list(error = paste0(
"Objekt 'daten_desii' nach dem Sourcen nicht gefunden. Bitte Download-Skript pruefen.")))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(error = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript pruefen.")))
}
daten = get("daten_desii", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
# Kein Filter auf 'instrument' noetig, da diese App nur einen Fragebogen-Run erhaelt
# (siehe Spezifikation Abschnitt 3) - daten_desii enthaelt ohnehin nur Sessions dieses Runs.
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, , drop = FALSE]
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)
# ANNAHME (siehe Abschnitt 10, Offener Punkt 3): Session-ID-Spalte in daten_desii heisst
# 'session'. Mit echten Testdaten zu verifizieren.
if (!("session" %in% names(daten))) {
return(list(error = paste0(
"Spalte 'session' in 'daten_desii' nicht gefunden. Erwarteter Spaltenname ist ",
"unverifiziert (siehe Abschnitt 10) - bitte tatsaechlichen Spaltennamen pruefen.")))
}
treffer_dat = daten[daten$session %in% alle_session_ids, , drop = FALSE]
if (nrow(treffer_dat) == 0) {
return(list(error = paste0(
"Kein DES-II-Datensatz fuer diese Chiffre gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprueft)")))
}
warnungen = character(0)
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
datumsspalten_je_zeile = vapply(
seq_len(n),
function(i) desii_finde_datumsspalte(daten, treffer_dat[i, , drop = FALSE]),
character(1)
)
spalte_sortierung = datumsspalten_je_zeile[1]
if (!is.na(spalte_sortierung) && all(!is.na(datumsspalten_je_zeile))) {
treffer_dat = treffer_dat[order(treffer_dat[[spalte_sortierung]], decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat[[spalte_sortierung]][1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
warnungen = c(warnungen, paste0(
"Mehrfach ausgefuellt (", n, " Eintraege) - es wird der neueste Datensatz vom ",
datum_neu, " verwendet."
))
} else {
warnungen = c(warnungen, paste0(
"Mehrfach ausgefuellt (", n, " Eintraege), Reihenfolge konnte mangels ",
"eindeutiger Ausfuelldatum-Spalte nicht sicher bestimmt werden - der erste ",
"gefundene Datensatz wird verwendet."
))
}
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
spalte_datum = desii_finde_datumsspalte(daten, zeile)
ausfuelldatum_fallback = is.na(spalte_datum)
datum_posix = if (!ausfuelldatum_fallback) {
tryCatch(as.POSIXct(zeile[[spalte_datum]][1]), error = function(e) NULL)
} else NULL
datum_ok = !is.null(datum_posix) && length(datum_posix) > 0 && !is.na(datum_posix)
ausfuelldatum = if (datum_ok) format(datum_posix, "%d.%m.%Y") else format(Sys.Date(), "%d.%m.%Y")
ausfuelldatum_dateikennung = if (datum_ok) format(datum_posix, "%Y%m%d") else format(Sys.Date(), "%Y%m%d")
ausfuelldatum_fallback = ausfuelldatum_fallback || !datum_ok
# 28 Items -> Prozentwerte (0-100) und bereinigte Itemtexte.
item_cols = sprintf("desii_%02d", 1:28)
fehlende_cols = item_cols[!(item_cols %in% names(daten))]
if (length(fehlende_cols) > 0) {
return(list(error = paste0(
"Folgende erwartete Item-Spalten fehlen in 'daten_desii': ",
paste(fehlende_cols, collapse = ", "),
". Feldnamen sind ggf. mit Survey-Namenspraefix versehen (siehe Abschnitt 1) - ",
"bitte pruefen."
)))
}
werte = vapply(item_cols, function(col) {
desii_extrahiere_vas_wert(daten[[col]], zeile[[col]])
}, numeric(1))
item_texte = vapply(seq_along(item_cols), function(i) {
desii_item_label(daten[[item_cols[i]]], paste0("Item ", i))
}, character(1))
sc = desii_berechne_score(werte)
erg = list(
error = NULL,
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
ausfuelldatum_fallback = ausfuelldatum_fallback,
ausfuelldatum_dateikennung = ausfuelldatum_dateikennung,
warnungen = warnungen,
n_fehlend = sc$n_fehlend,
auswertbar = sc$auswertbar
)
if (!sc$auswertbar) {
erg$score = NA_real_
return(erg)
}
erg$score = sc$score
erg$klass = desii_klassifikation(sc$score)
erg$emdr_noetig = desii_emdr_hinweis_noetig(sc$score)
erg$items = lapply(seq_along(item_cols), function(i) {
list(nr = i, text = item_texte[i], wert = werte[i])
})
erg$items = desii_sortiere_items_nach_auspraegung(erg$items)
erg
})
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)) return(NULL)
zusatz = character(0)
if (isTRUE(d$ausfuelldatum_fallback)) {
zusatz = c(zusatz, paste0(
"Ausfuelldatum nicht in Daten gefunden, Erstellungsdatum des Reports verwendet."
))
}
alle = c(d$warnungen, zusatz)
if (length(alle) == 0) return(NULL)
tagList(lapply(alle, function(w) div(class = "alert-warnung", w)))
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
if (!d$auswertbar) {
return(div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "DES-II - Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum
),
tags$hr(),
div(class = "alert-fehler",
paste0(
"Mehr als 3 Items unbeantwortet (", d$n_fehlend, " von 28) - Test laut ",
"Testmanual nicht auswertbar."
)
)
))
}
items_ui = lapply(d$items, function(it) {
wert_txt = if (is.na(it$wert)) "fehlend" else paste0(round(it$wert), " %")
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
div(class = "item-prozent", wert_txt)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "DES-II - Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum
),
tags$hr(),
fluidRow(
column(3,
div(
div(class = "score-zahl", paste0(round(d$score, 1), " %")),
div("Score (Mittelwert 0-100)", style = "color:#555;"),
if (d$n_fehlend > 0) div(
style = "color:#BF360C; font-size:0.85em; margin-top:4px;",
paste0(
d$n_fehlend, " von 28 Items unbeantwortet - Mittelwert nur ueber die ",
"beantworteten Items gebildet."
)
)
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
tags$hr(),
tags$h5("Klassifikation"),
div(class = paste0("hinweis-box hinweis-box-", d$klass$zone),
div(class = "hinweis-box-titel", d$klass$label),
div(class = "hinweis-box-zusatz", DESII_KLASSIFIKATION_ZUSATZ)
),
if (d$emdr_noetig) tagList(
div(class = "schulungshinweis-box",
div(class = "schulungshinweis-titel", "EMDR-Sicherheitshinweis"),
div(DESII_EMDR_HINWEISTEXT),
div(class = "schulungshinweis-kennzeichnung", DESII_EMDR_KENNZEICHNUNG)
)
),
tags$hr(),
tags$h5("DES-II Einzelitems (sortiert nach Auspraegung)"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error), isTRUE(d$auswertbar))
make_gauge_desii(d$score)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && is.null(d$error) && nchar(d$chiffre) > 0)
d$chiffre else "export"
datum_fn = if (is.list(d) && is.null(d$error) && !is.null(d$ausfuelldatum_dateikennung))
d$ausfuelldatum_dateikennung else format(Sys.Date(), "%Y%m%d")
paste0("DESII_", chiffre_esc, "_", datum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && is.null(d$error)
if (!daten_ok) {
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_desii_docx(d),
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)