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

994 lines
37 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.

# Praeambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_vds23.R" # liefert: daten_vds23
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
VDS23_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Es liegen keine Normwerte oder Cutoffs fuer dieses ",
"Instrument vor; die dargestellten Werte sind deskriptive Positionsangaben auf ",
"der 0 bis 5 Skala, keine Vergleichswerte gegen eine Referenzstichprobe. Die ",
"Interpretation obliegt der behandelnden Person."
)
VDS23_DISCLAIMER = gsub("fuer", "für", VDS23_DISCLAIMER, fixed = TRUE)
VDS23_STUFEN_TEXTE = c("nicht", "kaum", "etwas", "deutlich", "sehr", "extrem")
# 6 Stufen (0-5), gruen -> dunkelrot. Generische Klassennamen .stufe-badge-0 .. -5,
# siehe app_css weiter unten.
VDS23_BADGE_FARBEN = c(
"0" = "#4CAF50", # gruen
"1" = "#E6EE9C", # helles Gelbgruen
"2" = "#F48FB1", # helles Rosa
"3" = "#EF5350", # mittleres Rot
"4" = "#C62828", # kraeftiges Rot
"5" = "#4A0000" # dunkles Rot
)
VDS23_BADGE_TEXT_FARBEN = c(
"0" = "white", "1" = "#333333", "2" = "#333333",
"3" = "white", "4" = "white", "5" = "white"
)
# Item-Faktor-Zuordnung, fest aus dem Testmanual uebernommen (nicht aus dem
# Itemtext neu abgeleitet). 11 Items sind doppelt zugeordnet, 5 Items keinem
# Faktor (21, 43, 54, 55, 62).
vds23_faktoren = list(
"1" = list(
name = "Beziehung nimmt Schaden",
items = c(3, 6, 7, 11, 12, 13, 14, 15, 16, 17, 18, 19, 22, 29, 30, 32, 37, 38, 44, 47, 48, 50, 51, 58, 59)
),
"2" = list(
name = "Abgrenzung gelingt nicht",
items = c(17, 18, 23, 24, 25, 26, 40, 41, 42, 49, 52, 53, 60)
),
"3" = list(
name = "Bedürfnis-Frustration",
items = c(33, 35, 39, 45, 51, 53, 56, 57, 58, 61)
),
"4" = list(
name = "Abgelehnt werden",
items = c(2, 4, 5, 10, 15, 27, 28, 35, 38)
),
"5" = list(
name = "Einfluss-Verlust",
items = c(8, 9, 31, 46, 49, 63)
),
"6" = list(
name = "Unterlegen sein",
items = c(1, 20, 25, 33, 34, 36)
)
)
# Kontrollsumme: 25 + 13 + 10 + 9 + 6 + 6 = 69 Zuordnungen bei 63 Items.
# Bei Abweichung sofort abbrechen statt still weiterzurechnen, das wuerde
# einen Copy-Paste-Fehler in der Struktur oben sonst verschleiern.
.vds23_kontrollsumme = sum(sapply(vds23_faktoren, function(f) length(f$items)))
if (.vds23_kontrollsumme != 69) {
stop(
"VDS23: Kontrollsumme der Item-Faktor-Zuordnung ist ", .vds23_kontrollsumme,
", erwartet 69. Bitte 'vds23_faktoren' pruefen (Copy-Paste-Fehler?)."
)
}
VDS23_OHNE_FAKTOR = c(21, 43, 54, 55, 62)
vds23_itemtexte = c(
"01" = "Ich allein bin",
"02" = "Ich mit wenig vertrauten Personen zu tun habe",
"03" = "Ich mit einer wichtigen Bezugsperson zu tun habe",
"04" = "Ich nicht erkennen kann, worauf es ankommt",
"05" = "Andere darauf achten, was ich wie tue",
"06" = "Andere etwas von mir erwarten, das ich nicht kann",
"07" = "Andere etwas von mir erwarten, das ich nicht will",
"08" = "Ich Nein sagen müsste",
"09" = "Ich Forderungen stellen müsste",
"10" = "Ich Kontakt mit jemandem aufnehmen müsste, jemand ansprechen müsste",
"11" = "Andere mich links liegen lassen",
"12" = "Andere mich nicht akzeptieren, nicht aufnehmen",
"13" = "Andere gegen mich vorgehen",
"14" = "Andere mir etwas vorwerfen, mich kritisieren",
"15" = "Andere mit mir konkurrieren, rivalisieren",
"16" = "Andere kein Verständnis für mich haben",
"17" = "Andere mich ausnutzen wollen",
"18" = "Andere mich übervorteilen, betrügen wollen",
"19" = "Andere nicht einsehen wollen, dass ich Recht habe",
"20" = "Andere sich mir einfach entziehen",
"21" = "Sich andere mit Ausreden rauswinden",
"22" = "Andere mich beschämen (die mir peinlich sind)",
"23" = "Ich meine Pflicht nicht erfülle, erfüllen kann",
"24" = "Ich nicht erreiche, was ich will",
"25" = "Mich in meinen Kräften oder Fähigkeiten überfordern",
"26" = "Ich versage",
"27" = "Ich im Mittelpunkt stehe",
"28" = "Ich einen Fehler zugeben müsste",
"29" = "Ich bei etwas Unrechtem ertappt werden",
"30" = "Ich unangenehm auffalle",
"31" = "Die Harmonie gestört ist",
"32" = "Ich einen großen Verlust erleide",
"33" = "Ich etwas hergeben muss",
"34" = "Andere besser sind als ich",
"35" = "Ich eine schwierige Entscheidung treffen sollte",
"36" = "Ich tun muss, was andere mir anordnen",
"37" = "Ich es nicht schaffe, attraktiv genug zu sein",
"38" = "Mir meine Gefühle einen Strich durch die Rechnung machen",
"39" = "Sehr intim sind",
"40" = "Andere meine Grenzen überschreiten, übergriffig werden",
"41" = "Ich eingeengt und unfrei bin",
"42" = "Andere mir zu nahe kommen",
"43" = "Ich nicht bekomme, was ich brauche, so viel wie ich brauche",
"44" = "Ich im Stich gelassen werde",
"45" = "Meine Forderungen abgelehnt werden",
"46" = "Sich andere nicht durch mich beeinflussen lassen",
"47" = "Es mir nicht gelingt, die Kontrolle über mich zu bewahren",
"48" = "Meine Liebesbeziehung in Gefahr ist",
"49" = "Umgangsregeln nicht eingehalten werden",
"50" = "Meine Gefühle verletzt werden",
"51" = "Ich nicht gebraucht werde",
"52" = "Meine Meinungsfreiheit eingeschränkt wird",
"53" = "Meine Sicherheit und Existenz bedroht ist",
"54" = "Mein Glaube angefochten wird",
"55" = "Gefahr besteht, meine Familie zu verlieren",
"56" = "Ich gehindert werde, meinen Genüssen nachzugehen",
"57" = "Ich wichtige innere Gebote nicht eingehalten habe",
"58" = "Ich wichtige Verbote überschritten habe",
"59" = "Ich in einem großen Konflikt stehe",
"60" = "Die ewig wiederkehrenden Probleme mit einer wichtigen Bezugsperson auftreten",
"61" = "Ein sexuelles Problem auftritt",
"62" = "Meine Wünsche erfüllt, meine Bedürfnisse befriedigt werden",
"63" = "Jemand gut zu mir ist"
)
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 ####
# OFFENER PUNKT FUER ERSTEN TESTLAUF (siehe Projekt-Notizen, Abschnitt 16.1):
# Ob formr mc-Felder beim Export den Choice-Text oder den 1-basierten
# Choice-Index liefert, ist fuer diese Instanz nicht verifiziert. Diese
# Funktion deckt beide Faelle robust ueber das labels-Attribut der
# ORIGINAL-Spalte ab (nicht ueber hartkodierte Zahlenwerte) und liefert bei
# unbekanntem Format NA statt einer geratenen Zahl.
vds23_wert_inhaltlich = function(rohwert, labels_attr) {
mapping_text = c(
"nicht" = 0L, "kaum" = 1L, "etwas" = 2L,
"deutlich" = 3L, "sehr" = 4L, "extrem" = 5L
)
if (is.null(rohwert) || length(rohwert) == 0 || is.na(rohwert[1])) {
return(list(wert = NA_integer_, status = "na"))
}
roh_chr = trimws(as.character(rohwert[1]))
roh_lower = tolower(roh_chr)
roh_num = suppressWarnings(as.numeric(roh_chr))
# Fall A: Rohwert ist bereits direkt einer der 6 bekannten Choice-Texte.
if (roh_lower %in% names(mapping_text)) {
return(list(wert = unname(mapping_text[[roh_lower]]), status = "text_direkt"))
}
if (!is.null(labels_attr) && length(labels_attr) > 0) {
lbl_namen = tolower(trimws(names(labels_attr)))
lbl_werte = as.vector(labels_attr)
pos = if (!is.na(roh_num)) which(lbl_werte == roh_num) else which(lbl_namen == roh_lower)
if (length(pos) > 0) {
choice_text = lbl_namen[pos[1]]
# Fall B: Rohwert entspricht (ueber labels) einem bekannten Choice-Text.
if (choice_text %in% names(mapping_text)) {
return(list(wert = unname(mapping_text[[choice_text]]), status = "labels_text"))
}
# Fall C: Choice-Text nicht erkannt, aber die sortierte Position des
# Rohwerts unter allen labels-Werten ergibt einen 1-basierten Index 1-6.
lbl_sortiert = sort(lbl_werte)
idx_pos = which(lbl_sortiert == lbl_werte[pos[1]])
if (length(idx_pos) > 0 && idx_pos[1] >= 1 && idx_pos[1] <= 6) {
return(list(wert = as.integer(idx_pos[1] - 1L), status = "labels_index"))
}
}
}
# Fall D: kein (nutzbares) labels-Attribut, Rohwert ist eine Zahl 1-6 ->
# 1-basierter Index wird angenommen.
if (!is.na(roh_num) && roh_num >= 1 && roh_num <= 6 && roh_num == round(roh_num)) {
return(list(wert = as.integer(roh_num - 1L), status = "index_fallback"))
}
# Fall E: Rohwert ist bereits eine Zahl 0-5 (schon inhaltlich kodiert).
if (!is.na(roh_num) && roh_num >= 0 && roh_num <= 5 && roh_num == round(roh_num)) {
return(list(wert = as.integer(roh_num), status = "bereits_inhaltlich"))
}
list(wert = NA_integer_, status = "unbekannt")
}
# Faktor-Score = arithmetisches Mittel der zugeordneten Item-Rohwerte
# (0-5), kein Reverse-Coding. na.rm = TRUE fuer den defensiven Fall
# einzelner fehlender Werte trotz Pflichtfeld-Annahme.
vds23_faktor_score = function(werte_vektor, item_nummern) {
werte = werte_vektor[item_nummern]
n_vorhanden = sum(!is.na(werte))
n_gesamt = length(werte)
score = if (n_vorhanden > 0) mean(werte, na.rm = TRUE) else NA_real_
list(score = score, n_vorhanden = n_vorhanden, n_gesamt = n_gesamt)
}
# Choice-Format lt. vds23.xlsx fuer vds23_top5_auswahl: "N. Itemtext" (z.B.
# "5. Andere darauf achten, was ich wie tue"). Trennzeichen zwischen mehreren
# Ausgewaehlten ist in dieser formr-Instanz weiterhin nicht live verifiziert,
# es wird defensiv an ",", ";" und "|" gesplittet. Der Itemtext wird pro
# Fragment IMMER aus der festen vds23_itemtexte-Tabelle nachgeschlagen (ueber
# die per Regex gezogene fuehrende Nummer), nicht aus dem Fragment selbst
# uebernommen - das liefert auch dann den vollen Text, wenn formr ein
# Fragment nur als blosse Nummer ("5" statt "5. Andere darauf achten ...")
# exportiert. Nur wenn weder eine fuehrende Nummer noch ein exakter
# Text-Treffer gefunden wird, bleibt es beim unveraenderten Rohtext statt
# einer Ratewert-Zuordnung.
vds23_parse_top5 = function(rohtext, itemtexte) {
leer_ergebnis = data.frame(
nummer = integer(0), text = character(0), roh = character(0),
stringsAsFactors = FALSE
)
if (is.null(rohtext) || length(rohtext) == 0 || is.na(rohtext[1]) ||
trimws(as.character(rohtext[1])) == "") {
return(leer_ergebnis)
}
txt = trimws(as.character(rohtext[1]))
fragmente = trimws(strsplit(txt, "[,;|]")[[1]])
fragmente = fragmente[nchar(fragmente) > 0]
if (length(fragmente) == 0) return(leer_ergebnis)
itemtexte_lower = tolower(trimws(itemtexte))
zeilen = lapply(fragmente, function(frag) {
frag_trim = trimws(frag)
# Fall A: Fragment beginnt mit der Itemnummer (mit oder ohne Text
# dahinter) - Text immer aus der Itemtexte-Tabelle nachschlagen.
nr_match = regmatches(frag_trim, regexpr("^[0-9]{1,2}", frag_trim))
if (length(nr_match) > 0 && nchar(nr_match) > 0) {
nr = as.integer(nr_match)
key = sprintf("%02d", nr)
if (nr >= 1 && nr <= 63 && key %in% names(itemtexte)) {
return(data.frame(nummer = nr, text = unname(itemtexte[[key]]), roh = frag,
stringsAsFactors = FALSE))
}
}
# Fall B: Fragment ist der vollstaendige Itemtext ohne fuehrende Nummer.
frag_lower = tolower(frag_trim)
treffer = which(itemtexte_lower == frag_lower)
if (length(treffer) == 1) {
return(data.frame(nummer = as.integer(names(itemtexte)[treffer]),
text = unname(itemtexte[treffer]), roh = frag,
stringsAsFactors = FALSE))
}
data.frame(nummer = NA_integer_, text = NA_character_, roh = frag,
stringsAsFactors = FALSE)
})
do.call(rbind, zeilen)
}
vds23_badge_klasse = function(wert) {
if (is.na(wert)) return("stufe-badge-na")
paste0("stufe-badge-", max(0L, min(5L, as.integer(wert))))
}
vds23_badge_text = function(wert) {
if (is.na(wert)) return("k. A.")
VDS23_STUFEN_TEXTE[max(0L, min(5L, as.integer(wert))) + 1L]
}
# Neutrale 0-5-Achse ohne Farbzonen (kein Cutoff dokumentiert), nur eine
# Markierung an der Position des Faktor-Mittelwerts.
vds23_achse_plot = function(score) {
ggplot() +
geom_segment(aes(x = 0, xend = 5, y = 0, yend = 0), color = "#BDBDBD", linewidth = 1.2) +
geom_point(aes(x = 0:5, y = 0), color = "#9E9E9E", size = 2) +
geom_segment(aes(x = score, xend = score, y = -0.35, yend = 0.35),
color = AKZENT_FARBE, linewidth = 2.2) +
geom_label(aes(x = score, y = 0.8, label = format(round(score, 1), nsmall = 1)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
scale_x_continuous(limits = c(-0.3, 5.3), breaks = 0:5) +
scale_y_continuous(limits = c(-0.6, 1.15)) +
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(),
axis.title.x = element_blank(),
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
)
}
# 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; }
.faktor-score-block { display: flex; align-items: baseline; gap: 14px; margin-bottom: 6px; }
.faktor-score-zahl { font-size: 2.0rem; font-weight: 800; color: #8B2635; }
.faktor-score-label { color: #555; font-size: 0.9em; }
.faktor-missing-hinweis { font-size: 0.85em; color: #E65100; margin-bottom: 8px; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-zeile:last-child { border-bottom: none; }
.item-nr { font-weight: 600; color: #8B2635; min-width: 26px; flex-shrink: 0; }
.item-text-block { flex: 1; }
.item-text { color: #333; font-size: 0.92em; }
.item-beispiel {
color: #777; font-size: 0.86em; font-style: italic; margin-top: 2px;
}
.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-color: #4CAF50; color: white; }
.stufe-badge-1 { background-color: #E6EE9C; color: #333333; }
.stufe-badge-2 { background-color: #F48FB1; color: #333333; }
.stufe-badge-3 { background-color: #EF5350; color: white; }
.stufe-badge-4 { background-color: #C62828; color: white; }
.stufe-badge-5 { background-color: #4A0000; color: white; }
.stufe-badge-na { background-color: #E0E0E0; color: #555555; }
.ohne-faktor-hinweis { font-size: 0.85em; color: #777; font-style: italic; margin-bottom: 10px; }
.top5-liste { margin: 6px 0 0 0; padding-left: 20px; }
.top5-eintrag { padding: 3px 0; color: #333; font-size: 0.94em; }
.freitext-block { margin-bottom: 14px; }
.freitext-frage { font-weight: 600; color: #8B2635; font-size: 0.95em; margin-bottom: 3px; }
.freitext-antwort { color: #333; font-size: 0.93em; white-space: pre-wrap; line-height: 1.5; }
.qualitativ-hinweis {
font-size: 0.82em; color: #777; font-style: italic;
margin-bottom: 12px; border-bottom: 1px dashed #ddd; padding-bottom: 8px;
}
.disclaimer-zeile {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 10px; border-top: 1px solid rgba(0,0,0,.1); padding-top: 8px;
}
"
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("VDS23 Situationsanalyse"),
tags$p("63 Items zu unangenehmen Situationen, 6 Faktoren, keine Normwerte")
),
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_vds23_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_beispiel = fp_text(font.size = 9.5, italic = TRUE, color = "#777777")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("VDS23, Situationsanalyse", 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"))
))
}
if (!is.null(erg$kodierwarnung)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$kodierwarnung, fp_text(font.size = 10, italic = TRUE, color = "#BF360C"))
))
}
doc = body_add_par(doc, "", style = "Normal")
for (fe in erg$faktor_ergebnisse) {
doc = body_add_fpar(doc, fpar(ftext(fe$name, fp_abschnitt)))
score_txt = if (is.na(fe$score)) "keine auswertbaren Items" else
paste0(format(round(fe$score, 1), nsmall = 1), " auf der Skala 0 bis 5")
doc = body_add_fpar(doc, fpar(
ftext("Faktor-Mittelwert: ", fp_label),
ftext(score_txt, fp_normal)
))
if (!is.na(fe$score) && fe$n_vorhanden < fe$n_gesamt) {
doc = body_add_fpar(doc, fpar(ftext(
paste0("Faktor-Score basiert auf ", fe$n_vorhanden, " von ", fe$n_gesamt, " Items."),
fp_text(font.size = 9.5, italic = TRUE, color = "#BF360C")
)))
}
for (r in seq_len(nrow(fe$items))) {
zeile = fe$items[r, ]
badge_key = if (is.na(zeile$wert)) NA_character_ else
as.character(max(0L, min(5L, as.integer(zeile$wert))))
if (is.na(badge_key)) {
fp_badge = fp_text(color = "#555555", bold = TRUE, shading.color = "#E0E0E0", font.size = 10)
badge_txt = "k. A."
} else {
fp_badge = fp_text(
color = VDS23_BADGE_TEXT_FARBEN[[badge_key]], bold = TRUE,
shading.color = VDS23_BADGE_FARBEN[[badge_key]], font.size = 10
)
badge_txt = VDS23_STUFEN_TEXTE[as.integer(badge_key) + 1L]
}
doc = body_add_fpar(doc, fpar(
ftext(paste0(zeile$nummer, ". ", zeile$text, " "), fp_normal),
ftext(paste0(" ", badge_txt, " "), fp_badge)
))
if (!is.na(zeile$beispiel)) {
doc = body_add_fpar(doc, fpar(ftext(paste0("Beispiel: ", zeile$beispiel), fp_beispiel)))
}
}
doc = body_add_par(doc, "", style = "Normal")
}
if (nrow(erg$ohne_faktor) > 0) {
doc = body_add_fpar(doc, fpar(ftext(
"Weitere erhobene Situationen (keinem Faktor zugeordnet)", fp_abschnitt)))
for (r in seq_len(nrow(erg$ohne_faktor))) {
zeile = erg$ohne_faktor[r, ]
badge_key = if (is.na(zeile$wert)) NA_character_ else
as.character(max(0L, min(5L, as.integer(zeile$wert))))
if (is.na(badge_key)) {
fp_badge = fp_text(color = "#555555", bold = TRUE, shading.color = "#E0E0E0", font.size = 10)
badge_txt = "k. A."
} else {
fp_badge = fp_text(
color = VDS23_BADGE_TEXT_FARBEN[[badge_key]], bold = TRUE,
shading.color = VDS23_BADGE_FARBEN[[badge_key]], font.size = 10
)
badge_txt = VDS23_STUFEN_TEXTE[as.integer(badge_key) + 1L]
}
doc = body_add_fpar(doc, fpar(
ftext(paste0(zeile$nummer, ". ", zeile$text, " "), fp_normal),
ftext(paste0(" ", badge_txt, " "), fp_badge)
))
if (!is.na(zeile$beispiel)) {
doc = body_add_fpar(doc, fpar(ftext(paste0("Beispiel: ", zeile$beispiel), fp_beispiel)))
}
}
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_break(doc)
doc = body_add_fpar(doc, fpar(ftext("Top5-Auswahl und Reflexionsfragen", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
"Qualitative Zusatzinformation, kein Zahlenwert.",
fp_text(font.size = 9.5, italic = TRUE, color = "#777777")
)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Ausgewählte unangenehmste Situationen", fp_label)))
if (nrow(erg$top5) == 0) {
doc = body_add_fpar(doc, fpar(ftext("Keine Angabe.", fp_normal)))
} else {
for (r in seq_len(nrow(erg$top5))) {
zeile = erg$top5[r, ]
txt = if (!is.na(zeile$nummer)) paste0(zeile$nummer, ". ", zeile$text) else zeile$roh
doc = body_add_fpar(doc, fpar(ftext(paste0("• ", txt), fp_normal)))
}
}
doc = body_add_par(doc, "", style = "Normal")
for (rf in erg$reflexionen) {
if (is.na(rf$antwort)) next
doc = body_add_fpar(doc, fpar(ftext(rf$frage, fp_label)))
doc = body_add_fpar(doc, fpar(ftext(rf$antwort, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_fpar(doc, fpar(ftext(VDS23_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
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 Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(error = paste0(
"Ungültige Chiffre. Erwartet: ein Großbuchstabe + 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_treffer = pseudo[pseudo$pseudonym == trimws(input$pseudonym), ]
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_vds23", envir = .GlobalEnv)) {
return(list(error = paste0(
"Objekt 'daten_vds23' 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_vds23", 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 VDS23-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)
treffer_dat = treffer_dat[order(treffer_dat$created, decreasing = 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]
datum_str = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
# --- 63 Item-Werte + Beispieltexte ---
werte_inhaltlich = integer(63)
werte_status = character(63)
beispiel_texte = character(63)
for (i in seq_len(63)) {
var = paste0("vds23_", sprintf("%02d", i))
var_beispiel = paste0(var, "_beispiel")
roh = if (var %in% names(zeile)) zeile[[var]][1] else NA
lbl_attr = if (var %in% names(daten)) attr(daten[[var]], "labels") else NULL
konv = vds23_wert_inhaltlich(roh, lbl_attr)
werte_inhaltlich[i] = konv$wert
werte_status[i] = konv$status
beispiel_roh = if (var_beispiel %in% names(zeile)) zeile[[var_beispiel]][1] else NA
beispiel_texte[i] = if (!is.na(beispiel_roh) &&
trimws(as.character(beispiel_roh)) != "") {
trimws(as.character(beispiel_roh))
} else {
NA_character_
}
}
# Debug-Ausgabe je Auswertung, damit die Umrechnung beim ersten echten
# Testlauf mit formr-Daten gegengeprueft werden kann (Item 1 als Beispiel).
message(
"VDS23-DEBUG: Rohwert vds23_01 = '",
if ("vds23_01" %in% names(zeile)) as.character(zeile[["vds23_01"]][1]) else "NA",
"' -> inhaltlicher Wert = ", werte_inhaltlich[1],
" (Status: ", werte_status[1], ")"
)
kodierwarnung = NULL
n_unbekannt = sum(werte_status == "unbekannt")
if (n_unbekannt > 0) {
kodierwarnung = paste0(
"Warnung: Die Kodierung konnte für ", n_unbekannt,
" Item(s) nicht eindeutig bestimmt werden. Die betroffenen Items ",
"werden als 'k. A.' angezeigt statt eines möglicherweise falschen Werts."
)
}
faktor_ergebnisse = lapply(names(vds23_faktoren), function(fid) {
fak = vds23_faktoren[[fid]]
fs = vds23_faktor_score(werte_inhaltlich, fak$items)
items_df = data.frame(
nummer = fak$items,
text = unname(vds23_itemtexte[sprintf("%02d", fak$items)]),
wert = werte_inhaltlich[fak$items],
beispiel = beispiel_texte[fak$items],
stringsAsFactors = FALSE
)
list(id = fid, name = fak$name, score = fs$score,
n_vorhanden = fs$n_vorhanden, n_gesamt = fs$n_gesamt, items = items_df)
})
ohne_faktor = data.frame(
nummer = VDS23_OHNE_FAKTOR,
text = unname(vds23_itemtexte[sprintf("%02d", VDS23_OHNE_FAKTOR)]),
wert = werte_inhaltlich[VDS23_OHNE_FAKTOR],
beispiel = beispiel_texte[VDS23_OHNE_FAKTOR],
stringsAsFactors = FALSE
)
top5_roh = if ("vds23_top5_auswahl" %in% names(zeile)) zeile[["vds23_top5_auswahl"]][1] else NA
top5 = vds23_parse_top5(top5_roh, vds23_itemtexte)
reflexion_felder = list(
list(spalte = "vds23_reflexion_bezugspersonen_heute",
frage = "Heute: häufigste Verhaltensweisen der Bezugspersonen"),
list(spalte = "vds23_reflexion_eigene_reaktion",
frage = "Eigene Reaktion darauf"),
list(spalte = "vds23_reflexion_situationsausgang",
frage = "Ausgang der Situationen"),
list(spalte = "vds23_reflexion_mutter_kindheit",
frage = "Kindheit, Mutter"),
list(spalte = "vds23_reflexion_vater_kindheit",
frage = "Kindheit, Vater")
)
reflexionen = lapply(reflexion_felder, function(f) {
roh = if (f$spalte %in% names(zeile)) zeile[[f$spalte]][1] else NA
antwort = if (!is.na(roh) && trimws(as.character(roh)) != "") {
trimws(as.character(roh))
} else {
NA_character_
}
list(frage = f$frage, antwort = antwort)
})
list(
chiffre = chiffre,
ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
kodierwarnung = kodierwarnung,
faktor_ergebnisse = faktor_ergebnisse,
ohne_faktor = ohne_faktor,
top5 = top5,
reflexionen = reflexionen,
error = NULL
)
})
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)
tagList(
if (!is.null(d$info_mehrere)) div(class = "alert-warnung", d$info_mehrere),
if (!is.null(d$kodierwarnung)) div(class = "alert-warnung", d$kodierwarnung)
)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
faktor_karten = lapply(seq_along(d$faktor_ergebnisse), function(idx) {
fe = d$faktor_ergebnisse[[idx]]
items_ui = lapply(seq_len(nrow(fe$items)), function(r) {
zeile = fe$items[r, ]
div(class = "item-zeile",
div(class = "item-nr", paste0(zeile$nummer, ".")),
div(class = "item-text-block",
div(class = "item-text", zeile$text),
if (!is.na(zeile$beispiel))
div(class = "item-beispiel", paste0("Beispiel: ", zeile$beispiel))
),
span(class = paste0("stufe-badge ", vds23_badge_klasse(zeile$wert)),
vds23_badge_text(zeile$wert))
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", paste0("Faktor ", fe$id, ": ", fe$name)),
div(class = "faktor-score-block",
div(class = "faktor-score-zahl",
if (is.na(fe$score)) "" else format(round(fe$score, 1), nsmall = 1)),
div(class = "faktor-score-label", "Mittelwert auf der Skala 0 bis 5")
),
if (!is.na(fe$score) && fe$n_vorhanden < fe$n_gesamt)
div(class = "faktor-missing-hinweis",
paste0("Faktor-Score basiert auf ", fe$n_vorhanden, " von ", fe$n_gesamt, " Items.")),
plotOutput(paste0("gauge_", idx), height = "110px"),
tags$hr(),
div(items_ui)
)
})
ohne_faktor_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Weitere erhobene Situationen (keinem Faktor zugeordnet)"),
div(class = "ohne-faktor-hinweis",
"Diese Items fließen in keinen Faktor-Score ein."),
div(lapply(seq_len(nrow(d$ohne_faktor)), function(r) {
zeile = d$ohne_faktor[r, ]
div(class = "item-zeile",
div(class = "item-nr", paste0(zeile$nummer, ".")),
div(class = "item-text-block",
div(class = "item-text", zeile$text),
if (!is.na(zeile$beispiel))
div(class = "item-beispiel", paste0("Beispiel: ", zeile$beispiel))
),
span(class = paste0("stufe-badge ", vds23_badge_klasse(zeile$wert)),
vds23_badge_text(zeile$wert))
)
}))
)
top5_ui = if (nrow(d$top5) == 0) {
div(style = "color:#777; font-style:italic;", "Keine Angabe.")
} else {
tags$ul(class = "top5-liste",
lapply(seq_len(nrow(d$top5)), function(r) {
zeile = d$top5[r, ]
txt = if (!is.na(zeile$nummer)) paste0(zeile$nummer, ". ", zeile$text) else zeile$roh
tags$li(class = "top5-eintrag", txt)
})
)
}
reflexionen_vorhanden = Filter(function(rf) !is.na(rf$antwort), d$reflexionen)
reflexionen_ui = if (length(reflexionen_vorhanden) == 0) {
div(style = "color:#777; font-style:italic;", "Keine Angaben zu den Reflexionsfragen vorhanden.")
} else {
lapply(reflexionen_vorhanden, function(rf) {
div(class = "freitext-block",
div(class = "freitext-frage", rf$frage),
div(class = "freitext-antwort", rf$antwort)
)
})
}
qualitativ_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Top5-Auswahl und Reflexionsfragen"),
div(class = "qualitativ-hinweis",
"Qualitative Zusatzinformation, kein Zahlenwert."),
tags$h5("Ausgewählte unangenehmste Situationen"),
top5_ui,
tags$hr(),
reflexionen_ui
)
tagList(
div(class = "abschnitt-karte",
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
)
),
faktor_karten,
ohne_faktor_karte,
qualitativ_karte,
div(class = "disclaimer-zeile", VDS23_DISCLAIMER)
)
})
lapply(1:6, function(idx) {
output[[paste0("gauge_", idx)]] = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error))
fe = d$faktor_ergebnisse[[idx]]
if (is.na(fe$score)) return(NULL)
vds23_achse_plot(fe$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)
gsub("[^A-Za-z0-9_-]", "_", d$chiffre) else "export"
ausfuelldatum_fn = if (is.list(d) && is.null(d$error) && !is.null(d$ausfuelldatum)) {
tryCatch(
format(as.Date(d$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
} else {
format(Sys.Date(), "%Y%m%d")
}
paste0("VDS23_", chiffre_esc, "_", ausfuelldatum_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 oder Pseudonym eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_vds23_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)