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

1092 lines
47 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 ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_vds29.R" # liefert: daten_vds29
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
VDS29_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
)
VDS29_DISCLAIMER = gsub("fuer", "für", VDS29_DISCLAIMER, fixed = TRUE)
# Fallback-Anker, nur falls das labels-Attribut an einer Rating-Spalte fehlen
# sollte. Der Regelfall liest die Stufenbeschriftung immer aus den echten Daten
# (labels-Attribut der Original-Spalte), nie aus diesen Konstanten.
VDS29_STUFEN_TEXTE_STANDARD = c("0 = nicht", "1 = leicht", "2 = mittel", "3 = sehr")
# Teil 3 hat einen eigenen Anker (Stufe 1 = "etwas", nicht "leicht") - kein Fehler,
# sondern so aus der Quelle (siehe Spezifikation).
VDS29_STUFEN_TEXTE_T3 = c("0 = nicht", "1 = etwas", "2 = mittel", "3 = sehr")
VDS29_STUFEN_TEXTE_DYSFUNKTIONAL = c(
"0 = keine Dysfunktionalität", "1 = leichte Dysfunktionalität",
"2 = mittlere Dysfunktionalität", "3 = große Dysfunktionalität"
)
# Wortlaut analog zur Prioritaets-Skala anderer VDS-Apps dieser Serie (z.B. VDS28
# Teil 4 Item 7); dient nur als Fallback, falls das labels-Attribut fehlen sollte -
# der Regelfall liest den vollen Wortlaut immer aus den echten Daten.
VDS29_STUFEN_TEXTE_PRIORITAET = c(
"0 = Therapieziel geringer Priorität (kann als Ziel aufgenommen werden)",
"1 = Therapieziel mittlerer Priorität (sollte als Ziel aufgenommen werden)",
"2 = Therapieziel hoher Priorität (muss eines von 5 Hauptzielen sein)",
"3 = Therapieziel höchster Priorität (muss 1. oder 2. Hauptziel werden)"
)
# Badge-Farben fuer die 4 Antwortstufen, konsistent fuer alle vier Teile inkl.
# Teil 4 Item 7 (eigene 0-3-Ordinalstruktur, siehe Spezifikationsabschnitt
# "CSS-Klassen": dieselben Badge-Farben trotz eigenem Wortlaut).
VDS29_BADGE_FARBEN = c("0" = "#4CAF50", "1" = "#F48FB1", "2" = "#EF5350", "3" = "#B71C1C")
VDS29_BADGE_TEXT_FARBEN = c("0" = "white", "1" = "#333333", "2" = "white", "3" = "white")
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 ####
# Stufe (0-3) wird ausschliesslich aus der Position des Rohwerts im labels-Attribut
# der ORIGINAL-Spalte (vor Subsetting) bestimmt, nie aus einem hartkodierten
# Zahlenwert - analog zum BDI-II-Muster der Spezifikation, hier auf eine einzelne
# Zeile (original_spalte, wert) statt eine ganze Spalte angewendet, weil das
# labels-Attribut nach dem Subsetting auf eine einzelne Zeile verloren gehen kann.
#
# Der Rohwert wird bewusst per unclass() statt as.numeric()/as.double() verglichen:
# manche select_one-Felder (formr-Choices) speichern den internen Choice-Code als
# CHARACTER, nicht als Zahl. as.numeric() auf einem haven_labelled-Objekt loest in
# diesem Fall ueber vctrs einen Cast-Fehler aus ("Can't convert `vec_data(x)`
# <character> to <double>"). unclass() liefert den rohen Speicherwert unabhaengig
# vom Typ, ein einfacher ==-Vergleich funktioniert dann fuer numerisch UND
# character-kodierte Choices gleichermassen.
#
# Manche select_one-Wahlfelder in diesem formr-Export speichern das labels-Attribut
# mit vertauschten Rollen (NAMEN = interner Choice-Code wie "wut2", WERTE = der
# Klartext) statt der ueblichen haven-Konvention (NAMEN = Klartext, WERTE = Code,
# so wie es bei den normalen 0-3-Rating-Items dieser Datei tatsaechlich vorliegt).
# Deshalb wird hier IMMER zuerst gegen die NAMEN und danach gegen die WERTE des
# labels-Attributs verglichen (match_in_labels()) statt nur eine Richtung anzunehmen.
match_in_labels = function(labs, roh) {
idx = which(names(labs) == roh)
if (length(idx) > 0) return(list(name = names(labs)[idx[1]], wert = unclass(labs)[[idx[1]]]))
idx = which(unclass(labs) == roh)
if (length(idx) > 0) return(list(name = names(labs)[idx[1]], wert = unclass(labs)[[idx[1]]]))
NULL
}
stufe_aus_label = function(original_spalte, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_integer_)
roh = unclass(wert)[1]
labs = attr(original_spalte, "labels")
if (!is.null(labs) && length(labs) > 0) {
treffer = match_in_labels(labs, roh)
if (!is.null(treffer)) {
# Die Stufenziffer steht immer im Klartext-Anker (z.B. "1 = leicht"), nie im
# internen Code - je nach Labels-Richtung ist das der Name oder der Wert.
quelle = if (grepl("^\\d+\\s*=", treffer$name)) treffer$name else as.character(treffer$wert)
stufe = suppressWarnings(as.integer(sub("^(\\d+)\\s*=.*$", "\\1", quelle)))
if (!is.na(stufe)) return(stufe)
}
}
# Fallback nur falls labels-Attribut fehlt oder kein Treffer: Rohwert direkt als Stufe.
suppressWarnings(as.integer(round(as.numeric(roh))))
}
# Voller Anker-/Auswahltext (z.B. "2 = mittel" oder ein Gruppenname bei
# select_one-Feldern) direkt aus dem labels-Attribut der ORIGINAL-Spalte - dieselbe
# Mechanik bedient sowohl die Rating-Anker (Teil 1-4) als auch die vier
# select_one-Wahlfelder. Es gibt bewusst keine separat gepflegte Zuordnungstabelle,
# um Drift zwischen App und formr-Setup zu vermeiden.
label_text_aus_labels = function(original_spalte, wert, fallback_texte = NULL) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
roh = unclass(wert)[1]
labs = attr(original_spalte, "labels")
if (!is.null(labs) && length(labs) > 0) {
treffer = match_in_labels(labs, roh)
if (!is.null(treffer)) {
# Klartext ist stets die laengere, "0/1/2/3 = "-artige bzw. beschreibende
# Seite - bei Rating-Items der Name, bei den vertauschten Wahlfeldern der Wert.
return(trimws(if (grepl("^\\d+\\s*=", treffer$name)) treffer$name else as.character(treffer$wert)))
}
}
if (!is.null(fallback_texte)) {
stufe = stufe_aus_label(original_spalte, wert)
if (!is.na(stufe) && stufe >= 0L && stufe <= 3L) return(fallback_texte[stufe + 1L])
}
NA_character_
}
# Itemtext aus dem label-Attribut der ORIGINAL-Spalte, Nummerierungsartefakt am
# Anfang entfernt (z.B. "101. " oder "101 "). Fallback nur falls das Attribut fehlt
# ODER falls im label-Attribut noch unaufgeloester formr-Piping-Code steckt (z.B.
# Teil-4-Items dieses Exports, deren label ein rohes "`r c(wut1=...)`"-Template
# enthaelt statt des gerenderten Textes - siehe Spezifikation "Kein Piping").
item_text_aus_label = function(var, original_spalte, fallback_map) {
lbl = attr(original_spalte, "label")
brauchbar = !is.null(lbl) && length(lbl) > 0 && !is.na(lbl[1]) &&
nchar(trimws(lbl[1])) > 0 && !grepl("`r ", lbl[1], fixed = TRUE)
text = if (brauchbar) lbl[1] else fallback_map[[var]]
sub("^\\d+[.)]?\\s*", "", trimws(as.character(text)))
}
# Freitextfeld lesen, leere/NA-Werte einheitlich als NA_character_. Feldname darf
# fehlen (z.B. abweichender Spaltenname im echten Export) - dann NA statt Fehler.
text_feld_lesen = function(zeile, feldname) {
if (!(feldname %in% names(zeile))) return(NA_character_)
roh = zeile[[feldname]][1]
if (is.null(roh) || is.na(roh)) return(NA_character_)
txt = trimws(as.character(roh))
if (nchar(txt) == 0) NA_character_ else txt
}
# Ordnet ein gewaehltes Ranking-Wahlfeld (Teil 2/3) einem der Items zu, indem der
# aufgeloeste Wahltext mit den Itemtexten verglichen wird (exakter Treffer, sonst
# Teilstring-Treffer als Sicherheitsnetz) - keine separat gepflegte Code-Zuordnung,
# da diese vom formr-Setup abweichen und driften koennte.
rang_markierung = function(item_texte, gewaehlter_text) {
n = length(item_texte)
markierung = rep(FALSE, n)
if (is.na(gewaehlter_text) || nchar(trimws(gewaehlter_text)) == 0) return(markierung)
norm = function(x) tolower(trimws(gsub("\\s+", " ", as.character(x))))
ziel = norm(gewaehlter_text)
texte_norm = norm(item_texte)
treffer = which(texte_norm == ziel)
if (length(treffer) == 0) {
treffer = which(vapply(texte_norm, function(a)
grepl(a, ziel, fixed = TRUE) || grepl(ziel, a, fixed = TRUE), logical(1)))
}
if (length(treffer) > 0) markierung[treffer[1]] = TRUE
markierung
}
# CSS-Klasse fuer die Stufen-Badges (0-3, siehe Praeambel-Konstanten). Fehlende
# Stufe (NA) bekommt bewusst kein Klassen-Badge, sondern eine neutrale Anzeige.
badge_klasse = function(stufe) {
if (is.na(stufe) || stufe < 0 || stufe > 3) return(NA_character_)
paste0("stufe-badge stufe-badge-", as.integer(stufe))
}
# Profildiagramm der 7 Gruppenmittelwerte + Gesamtmittelwert (0-3-Skala): flache
# Akzentfarbe, keine Farbzonen/Cutoff-Linie/Ampel, da fuer VDS29 kein Normwert
# dokumentiert ist.
make_vds29_profil_plot = function(gruppen_ergebnisse, gesamtmittelwert_03) {
df = data.frame(
label = c(
vapply(gruppen_ergebnisse, function(g) paste0(g$nr, ". ", g$name_kurz), character(1)),
"Gesamt (Ø aller 18 Items)"
),
mittelwert = c(vapply(gruppen_ergebnisse, function(g) g$mittelwert, numeric(1)), gesamtmittelwert_03),
stringsAsFactors = FALSE
)
df$label = factor(df$label, levels = rev(df$label))
df$balken = ifelse(is.na(df$mittelwert), 0, df$mittelwert)
ggplot(df, aes(x = label, y = balken)) +
geom_col(fill = AKZENT_FARBE, width = 0.65) +
geom_text(
data = subset(df, !is.na(mittelwert)),
aes(label = sprintf("%.2f", mittelwert)),
hjust = -0.2, size = 3.5, color = "#333333"
) +
geom_text(
data = subset(df, is.na(mittelwert)),
aes(y = 0.08, label = "keine Angabe"),
hjust = 0, size = 3.0, color = "#888888", fontface = "italic"
) +
coord_flip(clip = "off") +
scale_y_continuous(limits = c(0, 3.5), breaks = 0:3) +
theme_minimal(base_size = 12) +
theme(
axis.title = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
plot.margin = margin(t = 5, r = 34, b = 5, l = 5)
)
}
# Datenaufbereitung ####
# 7 Gruppen, 18 Items (Abschnitt "Auswertungslogik" der Spezifikation).
VDS29_GRUPPEN_ITEMS = list(
list(nr = 1, name = "Vernichtungswut", name_kurz = "Vernichtungswut",
items = c("vds29_101", "vds29_102", "vds29_103")),
list(nr = 2, name = "Trennungswut", name_kurz = "Trennungswut",
items = c("vds29_201", "vds29_202", "vds29_203")),
list(nr = 3, name = "Wut, Kontrolle über andere zu gewinnen", name_kurz = "Kontrolle über andere gewinnen",
items = c("vds29_301", "vds29_302")),
list(nr = 4, name = "Wut, die Kontrolle über sich selbst zu verlieren", name_kurz = "Kontrolle über sich verlieren",
items = c("vds29_401")),
list(nr = 5, name = "Wut: Entzug der Zuneigung, Liebe", name_kurz = "Entzug Zuneigung/Liebe",
items = c("vds29_501", "vds29_502", "vds29_503")),
list(nr = 6, name = "Gegenaggression", name_kurz = "Gegenaggression",
items = c("vds29_601", "vds29_602", "vds29_603")),
list(nr = 7, name = "Hörig machen", name_kurz = "Hörig machen",
items = c("vds29_701", "vds29_702", "vds29_703"))
)
.vds29_kontrollsumme_t1 = sum(vapply(VDS29_GRUPPEN_ITEMS, function(g) length(g$items), integer(1)))
if (.vds29_kontrollsumme_t1 != 18) {
stop(
"VDS29: Kontrollsumme der Gruppen-Item-Zuordnung ist ", .vds29_kontrollsumme_t1,
", erwartet 18. Bitte 'VDS29_GRUPPEN_ITEMS' pruefen (Copy-Paste-Fehler?)."
)
}
VDS29_T1_VARS = unlist(lapply(VDS29_GRUPPEN_ITEMS, function(g) g$items), use.names = FALSE)
# Itemtexte Teil 1 (18) - dienen nur als Fallback, falls das label-Attribut der
# jeweiligen Spalte fehlen sollte. Primärquelle bleibt immer das label-Attribut.
VDS29_ITEMTEXTE_T1 = c(
vds29_101 = "101. empfinden: \"Dich gibt es nicht mehr für mich!\"",
vds29_102 = "102. vernichten",
vds29_103 = "103. ausschließen",
vds29_201 = "201. allein lassen",
vds29_202 = "202. trennen",
vds29_203 = "203. weg gehen (um Dir durch mein Weggehen weh zu tun)",
vds29_301 = "301. den anderen völlig bestimmen und beeinflussen, meinem Willen unterwerfen",
vds29_302 = "302. auf den anderen einwirken, dass er in meinem Sinne handelt",
vds29_401 = "401. ohne Rücksicht und Kontrolle Wut und Ärger rauslassen",
vds29_501 = "501. dem anderen sehr böse sein - Ärger, Unmut empfinden",
vds29_502 = "502. nicht mehr mögen oder lieben",
vds29_503 = "503. ablehnen, nicht mehr annehmen",
vds29_601 = "601. die Regeln zwischenmenschlichen Umgangs außer Kraft setzen",
vds29_602 = "602. massive Gegenaggression, wenn ich angegriffen werde",
vds29_603 = "603. Anarchie oder Chaos herstellen und verbreiten",
vds29_701 = "701. den anderen zur einseitigen Hingabe verleiten, so dass er mir ausgeliefert ist",
vds29_702 = "702. den andern dazu bringen, sich zu verlieren, z.B. durch intensive Gefühle",
vds29_703 = "703. den anderen in eine Beziehung emotional hineinziehen, so dass er nicht mehr über sich verfügen kann"
)
# Teil 2 - "wie ich wirklich reagiere", 10 Items.
VDS29_T2_VARS = sprintf("vds29_9%02d", 1:10)
VDS29_ITEMTEXTE_T2 = setNames(
c(
"Ich werde sehr laut, schimpfe, bis die Wut verraucht ist",
"Ich sage nichts, koche aber innerlich vor Wut",
"Ich reagiere mich an Gegenständen ab (z. B. Türen schlagen)",
"Ich gehe auf den andern los (mit Worten oder mit Taten)",
"Ich gehe weg, um dem anderen damit meine Wut zu zeigen oder weh zu tun",
"Ich gehe weg, um Schlimmeres zu verhindern",
"Ich kriege sofort ein schlechtes Gewissen, Schuldgefühl",
"Ich kriege gleich Angst",
"Ich habe gleich Verständnis für den anderen",
"Ich regle die Sache mit Vernunft und kühlem Kopf"
),
VDS29_T2_VARS
)
# Teil 3 - Reaktion wichtiger Bezugspersonen, 8 Items, eigener Anker.
VDS29_T3_VARS = paste0("vds29_t3_", 1:8)
VDS29_ITEMTEXTE_T3 = setNames(
c(
"Er/sie entschuldigt sich so, dass ich es annehmen kann",
"Er/sie rechtfertigt sich",
"Er/sie versucht, sich mit Ausreden rauszureden",
"Er/sie sagt nichts mehr, verstummt",
"Er/sie reagiert sehr verärgert und wütend",
"Er/sie rennt einfach weg, raus",
"Er/sie zeigt keinerlei Gefühle, bleibt kalt",
"Er/sie merkt gar nicht, dass ich wütend bin"
),
VDS29_T3_VARS
)
# Teil 4 - Therapieziel: 6 Dysfunktionalitaets-Items (paarweise 1./2. Hauptwut) + Item 7.
VDS29_T4_VARS = paste0("vds29_t4_", 1:6)
VDS29_T4_TEXT_VARS = paste0(VDS29_T4_VARS, "_text")
VDS29_T4_VAR7 = "vds29_t4_7"
# Fallback-Fragetexte Teil 4 (kein Piping - siehe Spezifikation: die echten Labels
# verweisen nur statisch auf "Ihre 1./2. genannte zentrale Wut/Ärger", ohne den
# gewaehlten Wortlaut automatisch einzublenden).
VDS29_ITEMTEXTE_T4 = setNames(
c(
"Ist Ihre 1. genannte zentrale Wut/Ärger dysfunktional?",
"Ist der Umgang mit Ihrer 1. genannten zentralen Wut/Ärger dysfunktional?",
"Ist die Reaktion anderer auf die Art des Umgangs mit Ihrer 1. genannten zentralen Wut/Ärger dysfunktional?",
"Ist Ihre 2. genannte zentrale Wut/Ärger dysfunktional?",
"Ist der Umgang mit Ihrer 2. genannten zentralen Wut/Ärger dysfunktional?",
"Ist die Reaktion anderer auf die Art des Umgangs mit Ihrer 2. genannten zentralen Wut/Ärger dysfunktional?"
),
VDS29_T4_VARS
)
VDS29_ITEMTEXT_T4_ITEM7 = setNames(
"Ist zentrale Wut/Ärger, der Umgang mit ihr oder die Reaktionen anderer ein wichtiges Therapieziel?",
VDS29_T4_VAR7
)
# Meta-Feldnamen Teil 1 (Hauptwut-Auswahl + Gruppen-Ranking)
VDS29_FELD_HAUPTWUT1 = "vds29_801_wahl"
VDS29_FELD_HAUPTWUT2 = "vds29_8011_wahl"
VDS29_FELD_GRUPPE1 = "vds29_802_wahl"
VDS29_FELD_GRUPPE2 = "vds29_803_wahl"
# Ranking-Feldnamen Teil 2 / Teil 3
VDS29_FELD_T2_RANG1 = "vds29_911_wahl"
VDS29_FELD_T2_RANG2 = "vds29_912_wahl"
VDS29_FELD_T3_RANG1 = "vds29_t3_09_wahl"
VDS29_FELD_T3_RANG2 = "vds29_t3_10_wahl"
# 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; }
.gesamt-zeile { display: flex; align-items: center; gap: 18px; margin-top: 10px; }
.score-zahl { font-size: 2.0rem; font-weight: 800; color: #8B2635; }
.score-label { color: #555; font-size: 0.95em; }
.hinweis-zeile { font-size: 0.85em; color: #777; font-style: italic; margin-bottom: 10px; }
.meta-auswahl-zeile { padding: 10px 0; border-bottom: 1px solid #F0F0F0; }
.meta-auswahl-zeile:last-child { border-bottom: none; }
.meta-auswahl-label { font-weight: 600; color: #8B2635; font-size: 0.88em; margin-bottom: 3px; }
.meta-auswahl-text { color: #333; font-size: 0.95em; }
.kontext-hinweis {
background: #FAFAFA; border-left: 3px solid #8B2635; padding: 8px 12px;
margin-bottom: 10px; font-size: 0.9em; color: #444; border-radius: 0 4px 4px 0;
}
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0; flex-wrap: wrap;
}
.item-zeile:last-child { border-bottom: none; }
.item-nr { font-weight: 600; color: #8B2635; min-width: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; min-width: 200px; }
.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; }
.rang-hinweis { font-style: italic; color: #8B2635; font-size: 0.78em; margin-left: 4px; }
.item-freitext-block {
flex-basis: 100%; margin-top: 6px; padding: 8px 10px; background: #FAFAFA;
border-left: 3px solid #8B2635; color: #444; font-size: 0.88em; border-radius: 0 4px 4px 0;
}
.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("VDS29 Ärger und Wut"),
tags$p("Verhaltensdiagnostiksystem nach Serge Sulz 4 Teile, 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_vds29_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_freitext = fp_text(font.size = 10, italic = TRUE, color = "#555555")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
badge_fp = function(stufe) {
if (is.na(stufe) || stufe < 0 || stufe > 3) {
return(fp_text(font.size = 10, bold = TRUE, color = "#555555", shading.color = "#E0E0E0"))
}
k = as.character(as.integer(stufe))
fp_text(font.size = 10, bold = TRUE,
color = VDS29_BADGE_TEXT_FARBEN[[k]], shading.color = VDS29_BADGE_FARBEN[[k]])
}
# 1. Titel + Metadaten
doc = body_add_fpar(doc, fpar(ftext("VDS29 Ärger und Wut", 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)
))
for (w in erg$warnungen) {
doc = body_add_fpar(doc, fpar(ftext(w, fp_text(font.size = 10, italic = TRUE, color = "#555555"))))
}
doc = body_add_par(doc, "", style = "Normal")
# 2. Teil 1: Gruppen-Mittelwerte-Tabelle + Gesamtwert
doc = body_add_fpar(doc, fpar(ftext("Teil 1 Zentrale Wutinhalte (7 Gruppen)", fp_abschnitt)))
profil_df = data.frame(
Gruppe = vapply(erg$teil1$gruppen, function(g) paste0(g$nr, ". ", g$name), character(1)),
"Mittelwert (03)" = vapply(erg$teil1$gruppen, function(g)
if (is.na(g$mittelwert)) "keine Angabe" else sprintf("%.2f", g$mittelwert), character(1)),
check.names = FALSE, stringsAsFactors = FALSE
)
doc = body_add_table(doc, profil_df)
doc = body_add_fpar(doc, fpar(
ftext("Wut insgesamt: ", fp_label),
ftext(if (is.na(erg$teil1$gesamt_prozent)) "keine Angabe" else paste0(round(erg$teil1$gesamt_prozent), " %"),
fp_text(bold = TRUE, font.size = 12, color = AKZENT_FARBE))
))
doc = body_add_fpar(doc, fpar(
ftext("Gesamtmittelwert (03-Skala, Zusatzwert): ", fp_label),
ftext(if (is.na(erg$teil1$gesamtmittelwert_03)) "keine Angabe" else sprintf("%.2f", erg$teil1$gesamtmittelwert_03),
fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
# Profildiagramm als eingebettetes Bild
png_datei = tempfile(fileext = ".png")
ggsave(png_datei, plot = make_vds29_profil_plot(erg$teil1$gruppen, erg$teil1$gesamtmittelwert_03),
width = 7, height = 4, dpi = 150, bg = "white")
doc = body_add_img(doc, png_datei, width = 6, height = 3.4)
doc = body_add_par(doc, "", style = "Normal")
# 3. Hauptwut & Gruppen-Ranking (getrennt dargestellt)
doc = body_add_fpar(doc, fpar(ftext("Hauptwut und Gruppen-Ranking", fp_abschnitt)))
meta_zeilen = list(
list(label = "1. Hauptwut:", text = erg$teil1$hauptwut1_text),
list(label = "2. Hauptwut:", text = erg$teil1$hauptwut2_text),
list(label = "1. wichtigste Gruppe:", text = erg$teil1$gruppe1_text),
list(label = "2. wichtigste Gruppe:", text = erg$teil1$gruppe2_text)
)
for (m in meta_zeilen) {
doc = body_add_fpar(doc, fpar(
ftext(m$label, fp_label), ftext(" ", fp_normal),
ftext(if (is.na(m$text)) "keine Angabe" else m$text, fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
# 4. Teil 2
doc = body_add_fpar(doc, fpar(ftext("Teil 2 Wie ich wirklich reagiere", fp_abschnitt)))
for (r in seq_len(nrow(erg$teil2$item_tabelle))) {
z = erg$teil2$item_tabelle[r, ]
rang_txt = if (isTRUE(z$rang1)) " [trifft am meisten zu]" else if (isTRUE(z$rang2)) " [trifft am 2.-meisten zu]" else ""
doc = body_add_fpar(doc, fpar(
ftext(paste0(r, ". ", z$text, rang_txt, " "), fp_normal),
ftext(paste0(" ", if (is.na(z$anker)) "keine Angabe" else z$anker, " "), badge_fp(z$stufe))
))
}
doc = body_add_par(doc, "", style = "Normal")
# 5. Teil 3 (eigener Anker)
doc = body_add_fpar(doc, fpar(ftext("Teil 3 So reagierten wichtige Bezugspersonen", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
"Anker dieses Teils: 0 = nicht / 1 = etwas / 2 = mittel / 3 = sehr (abweichend von Teil 1/2).",
fp_freitext)))
for (r in seq_len(nrow(erg$teil3$item_tabelle))) {
z = erg$teil3$item_tabelle[r, ]
rang_txt = if (isTRUE(z$rang1)) " [typischste Reaktion]" else if (isTRUE(z$rang2)) " [zweittypischste Reaktion]" else ""
doc = body_add_fpar(doc, fpar(
ftext(paste0(r, ". ", z$text, rang_txt, " "), fp_normal),
ftext(paste0(" ", if (is.na(z$anker)) "keine Angabe" else z$anker, " "), badge_fp(z$stufe))
))
}
doc = body_add_par(doc, "", style = "Normal")
# 6. Teil 4 6 Dysfunktionalitaets-Items + Item 7
doc = body_add_fpar(doc, fpar(ftext("Teil 4 Wut/Ärger als Therapieziel?", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("1. Hauptwut: ", fp_label),
ftext(if (is.na(erg$teil1$hauptwut1_text)) "keine Angabe" else erg$teil1$hauptwut1_text, fp_freitext)
))
for (r in 1:3) {
z = erg$teil4$item_tabelle[r, ]
doc = body_add_fpar(doc, fpar(
ftext(paste0(r, ". ", z$frage, " "), fp_normal),
ftext(paste0(" ", if (is.na(z$anker)) "keine Angabe" else z$anker, " "), badge_fp(z$stufe))
))
if (!is.na(z$freitext)) {
doc = body_add_fpar(doc, fpar(ftext(paste0(" Wenn ja, inwiefern? ", z$freitext), fp_freitext)))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(
ftext("2. Hauptwut: ", fp_label),
ftext(if (is.na(erg$teil1$hauptwut2_text)) "keine Angabe" else erg$teil1$hauptwut2_text, fp_freitext)
))
for (r in 4:6) {
z = erg$teil4$item_tabelle[r, ]
doc = body_add_fpar(doc, fpar(
ftext(paste0(r, ". ", z$frage, " "), fp_normal),
ftext(paste0(" ", if (is.na(z$anker)) "keine Angabe" else z$anker, " "), badge_fp(z$stufe))
))
if (!is.na(z$freitext)) {
doc = body_add_fpar(doc, fpar(ftext(paste0(" Wenn ja, inwiefern? ", z$freitext), fp_freitext)))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(
ftext(paste0("7. ", erg$teil4$item7_frage, " "), fp_normal),
ftext(paste0(" ", if (is.na(erg$teil4$item7_anker)) "keine Angabe" else erg$teil4$item7_anker, " "),
badge_fp(erg$teil4$item7_stufe))
))
doc = body_add_par(doc, "", style = "Normal")
# 7. Disclaimer als letzter Absatz
doc = body_add_fpar(doc, fpar(ftext(VDS29_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 = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", chiffre = chiffre))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
}
# Schritt 1: Download-Skript sourcen
ok_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_dl$ok) return(list(typ = "skript_fehler", meldung = ok_dl$msg))
# Schritt 2: pseudonyme.db suchen (bis zu 5 Ebenen ueber dem Pseudonym-Skript)
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
})
if (is.null(db_ordner)) return(list(typ = "db_nicht_gefunden"))
# Schritt 3: Pseudonym-Skript sourcen (relativer DB-Zugriff, daher setwd + on.exit)
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
ok_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_ps$ok) return(list(typ = "skript_fehler", meldung = ok_ps$msg))
if (!exists("daten_vds29", envir = .GlobalEnv) || !exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen"))
}
daten = get("daten_vds29", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
if (!("session" %in% names(daten))) {
return(list(typ = "daten_fehlen",
meldung = paste0(
"Erwartete Spalte 'session' nicht in 'daten_vds29' gefunden. ",
"Bitte Session-ID-Spaltenname vor Produktiveinsatz pruefen."
)))
}
# Schritt 4: Chiffre-Rueckaufloesung, falls nur Pseudonym eingegeben wurde
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
# Schritt 5: Chiffre -> moegliche Pseudonyme (Session-IDs)
treffer_ps = pseudo_df[toupper(trimws(pseudo_df$chiffre)) == chiffre, ]
if (nrow(treffer_ps) == 0) return(list(typ = "kein_pseudonym_treffer", chiffre = chiffre))
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
# Schritt 6: passende Datensaetze in daten_vds29 finden
treffer_daten = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_daten) == 0) return(list(typ = "keine_daten", chiffre = chiffre))
warnungen = character(0)
if (nrow(treffer_daten) > 1) {
n = nrow(treffer_daten)
if ("created" %in% names(treffer_daten)) {
treffer_daten = treffer_daten[order(treffer_daten$created, decreasing = TRUE), ]
}
treffer_daten = treffer_daten[1, , drop = FALSE]
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge) — es wird die neueste angezeigt."))
}
zeile = treffer_daten[1, , drop = FALSE]
# Ausfuelldatum: mehrere moegliche Quellspalten probieren, analog zum Vorgehen
# anderer Apps dieser Serie (z.B. bai, bipolar). Je nach Version des
# Download-Skripts kann eine eigene 'ausfuelldatum'-Spalte vorhanden sein -
# 'created' existiert aber immer und ist der verlaessliche Regelfall, kein
# warnungswuerdiger Ausnahmefall. Eine Warnung gibt es nur, wenn wirklich
# keine der Kandidatenspalten ein auswertbares Datum liefert.
spalte_datum = NA_character_
for (kandidat in c("ausfuelldatum", "created", "ended", "modified", "expired")) {
if (kandidat %in% names(zeile)) {
wert = zeile[[kandidat]][1]
if (!is.null(wert) && !is.na(wert) && trimws(as.character(wert)) != "") {
spalte_datum = kandidat
break
}
}
}
datum_geparst = if (!is.na(spalte_datum)) {
roh = trimws(as.character(zeile[[spalte_datum]][1]))
tryCatch({
# 'ausfuelldatum' liegt ggf. bereits als "%d.%m.%Y"-String vor, 'created'
# und die anderen Kandidaten als Timestamp - beide Formen abdecken.
if (grepl("^\\d{2}\\.\\d{2}\\.\\d{4}$", roh)) {
as.POSIXct(as.Date(roh, "%d.%m.%Y"))
} else {
d = as.POSIXct(zeile[[spalte_datum]][1])
if (is.na(d)) NULL else d
}
}, error = function(e) NULL)
} else NULL
if (!is.null(datum_geparst)) {
ausfuelldatum = format(datum_geparst, "%d.%m.%Y")
} else {
ausfuelldatum = format(Sys.Date(), "%d.%m.%Y")
warnungen = c(warnungen, paste0(
"Warnung: Kein auswertbares Ausfülldatum in den Daten gefunden (weder 'ausfuelldatum' ",
"noch 'created' o.ä.). Für Anzeige und Dateinamen wird ersatzweise das heutige Datum verwendet."))
}
# ---- Teil 1: Gruppen-Mittelwerte + Gesamtwerte ----
gruppen_ergebnisse = lapply(VDS29_GRUPPEN_ITEMS, function(g) {
stufen = vapply(g$items, function(var) stufe_aus_label(daten[[var]], zeile[[var]][1]), integer(1))
anker = vapply(g$items, function(var)
label_text_aus_labels(daten[[var]], zeile[[var]][1], VDS29_STUFEN_TEXTE_STANDARD), character(1))
texte = vapply(g$items, function(var)
item_text_aus_label(var, daten[[var]], VDS29_ITEMTEXTE_T1), character(1))
summe = if (any(is.na(stufen))) NA_real_ else sum(stufen)
mittelwert = if (is.na(summe)) NA_real_ else summe / length(g$items)
c(g, list(stufen = stufen, anker = anker, texte = texte, summe = summe, mittelwert = mittelwert))
})
alle_stufen_t1 = unlist(lapply(gruppen_ergebnisse, function(g) g$stufen))
summe_der_summen = if (any(is.na(alle_stufen_t1))) NA_real_ else sum(alle_stufen_t1) # Bereich 0-54
wut_gesamt_prozent = if (is.na(summe_der_summen)) NA_real_ else summe_der_summen / 54 * 100
# ANNAHME (nicht im Auswertungsdokument dokumentiert): Gesamtwert fuer die 0-3-Skala
# des Profildiagramms = einfacher Itemmittelwert ueber alle 18 Items. Die
# Auswertungsanleitung dokumentiert fuer den Gesamtwert nur die Prozentformel
# (Summe/54*100). Vor Produktiveinsatz ggf. gegen das Original-docx pruefen, ob
# dort eine andere 0-3-Aggregation gemeint war.
gesamtmittelwert_03 = if (is.na(summe_der_summen)) NA_real_ else summe_der_summen / 18
hauptwut1_text = label_text_aus_labels(daten[[VDS29_FELD_HAUPTWUT1]], zeile[[VDS29_FELD_HAUPTWUT1]][1])
hauptwut2_text = label_text_aus_labels(daten[[VDS29_FELD_HAUPTWUT2]], zeile[[VDS29_FELD_HAUPTWUT2]][1])
gruppe1_text = label_text_aus_labels(daten[[VDS29_FELD_GRUPPE1]], zeile[[VDS29_FELD_GRUPPE1]][1])
gruppe2_text = label_text_aus_labels(daten[[VDS29_FELD_GRUPPE2]], zeile[[VDS29_FELD_GRUPPE2]][1])
teil1 = list(
gruppen = gruppen_ergebnisse,
gesamt_prozent = wut_gesamt_prozent,
gesamtmittelwert_03 = gesamtmittelwert_03,
hauptwut1_text = hauptwut1_text,
hauptwut2_text = hauptwut2_text,
gruppe1_text = gruppe1_text,
gruppe2_text = gruppe2_text
)
# ---- Teil 2: rein deskriptiv, kein Score ----
t2_texte = vapply(VDS29_T2_VARS, function(var)
item_text_aus_label(var, daten[[var]], VDS29_ITEMTEXTE_T2), character(1))
t2_rang1_text = label_text_aus_labels(daten[[VDS29_FELD_T2_RANG1]], zeile[[VDS29_FELD_T2_RANG1]][1])
t2_rang2_text = label_text_aus_labels(daten[[VDS29_FELD_T2_RANG2]], zeile[[VDS29_FELD_T2_RANG2]][1])
t2_rang1_mark = rang_markierung(t2_texte, t2_rang1_text)
t2_rang2_mark = rang_markierung(t2_texte, t2_rang2_text)
t2_item_tabelle = do.call(rbind, lapply(seq_along(VDS29_T2_VARS), function(i) {
var = VDS29_T2_VARS[i]
data.frame(
text = t2_texte[i],
stufe = stufe_aus_label(daten[[var]], zeile[[var]][1]),
anker = label_text_aus_labels(daten[[var]], zeile[[var]][1], VDS29_STUFEN_TEXTE_STANDARD),
rang1 = t2_rang1_mark[i],
rang2 = t2_rang2_mark[i],
stringsAsFactors = FALSE
)
}))
teil2 = list(item_tabelle = t2_item_tabelle,
rang1_text = t2_rang1_text, rang2_text = t2_rang2_text)
# ---- Teil 3: rein deskriptiv, eigener Anker, kein Score ----
t3_texte = vapply(VDS29_T3_VARS, function(var)
item_text_aus_label(var, daten[[var]], VDS29_ITEMTEXTE_T3), character(1))
t3_rang1_text = label_text_aus_labels(daten[[VDS29_FELD_T3_RANG1]], zeile[[VDS29_FELD_T3_RANG1]][1])
t3_rang2_text = label_text_aus_labels(daten[[VDS29_FELD_T3_RANG2]], zeile[[VDS29_FELD_T3_RANG2]][1])
t3_rang1_mark = rang_markierung(t3_texte, t3_rang1_text)
t3_rang2_mark = rang_markierung(t3_texte, t3_rang2_text)
t3_item_tabelle = do.call(rbind, lapply(seq_along(VDS29_T3_VARS), function(i) {
var = VDS29_T3_VARS[i]
data.frame(
text = t3_texte[i],
stufe = stufe_aus_label(daten[[var]], zeile[[var]][1]),
anker = label_text_aus_labels(daten[[var]], zeile[[var]][1], VDS29_STUFEN_TEXTE_T3),
rang1 = t3_rang1_mark[i],
rang2 = t3_rang2_mark[i],
stringsAsFactors = FALSE
)
}))
teil3 = list(item_tabelle = t3_item_tabelle,
rang1_text = t3_rang1_text, rang2_text = t3_rang2_text)
# ---- Teil 4: rein deskriptiv, kein Piping (siehe Spezifikation) ----
t4_item_tabelle = do.call(rbind, lapply(seq_along(VDS29_T4_VARS), function(i) {
var = VDS29_T4_VARS[i]
textvar = VDS29_T4_TEXT_VARS[i]
data.frame(
frage = item_text_aus_label(var, daten[[var]], VDS29_ITEMTEXTE_T4),
stufe = stufe_aus_label(daten[[var]], zeile[[var]][1]),
anker = label_text_aus_labels(daten[[var]], zeile[[var]][1], VDS29_STUFEN_TEXTE_DYSFUNKTIONAL),
freitext = text_feld_lesen(zeile, textvar),
stringsAsFactors = FALSE
)
}))
item7_frage = item_text_aus_label(VDS29_T4_VAR7, daten[[VDS29_T4_VAR7]], VDS29_ITEMTEXT_T4_ITEM7)
item7_stufe = stufe_aus_label(daten[[VDS29_T4_VAR7]], zeile[[VDS29_T4_VAR7]][1])
item7_anker = label_text_aus_labels(daten[[VDS29_T4_VAR7]], zeile[[VDS29_T4_VAR7]][1], VDS29_STUFEN_TEXTE_PRIORITAET)
teil4 = list(
item_tabelle = t4_item_tabelle,
item7_frage = item7_frage,
item7_stufe = item7_stufe,
item7_anker = item7_anker
)
list(
typ = "erfolg",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
warnungen = warnungen,
teil1 = teil1,
teil2 = teil2,
teil3 = teil3,
teil4 = teil4
)
})
vds29_fehlermeldung = function(d) {
switch(d$typ,
"leere_eingabe" = d$meldung,
"format_fehler" = paste0("Ungültige Chiffre '", d$chiffre, "'. Erwartet: ein Großbuchstabe + 6 Ziffern (z.B. P000123)."),
"skript_fehler" = paste0("Fehler beim Sourcen eines externen Skripts: ", d$meldung),
"db_nicht_gefunden" = "Die Datei 'pseudonyme.db' konnte in den übergeordneten Verzeichnissen nicht gefunden werden.",
"daten_fehlen" = if (!is.null(d$meldung)) d$meldung else "Nach dem Sourcen der Skripte fehlen die erwarteten Objekte 'daten_vds29' oder 'pseudo'.",
"kein_pseudonym_treffer" = paste0("Chiffre '", d$chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."),
"keine_daten" = paste0("Kein VDS29-Datensatz für Chiffre '", d$chiffre, "' gefunden."),
"Unbekannter Fehler."
)
}
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") div(class = "alert-fehler", vds29_fehlermeldung(d))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg" || length(d$warnungen) == 0) return(NULL)
tagList(lapply(d$warnungen, function(w) div(class = "alert-warnung", w)))
})
vds29_item_zeile = function(nr, text, stufe, anker, rang_label = NULL, freitext = NA_character_) {
bk = badge_klasse(stufe)
div(class = "item-zeile",
div(class = "item-nr", paste0(nr, ".")),
div(class = "item-text", text,
if (!is.null(rang_label)) tags$span(class = "rang-hinweis", paste0("(", rang_label, ")"))
),
if (!is.na(bk)) span(class = bk, anker) else span(class = "stufe-badge", style = "background:#E0E0E0;color:#555555;", "keine Angabe"),
if (!is.na(freitext)) div(class = "item-freitext-block", tags$strong("Wenn ja, inwiefern? "), freitext)
)
}
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") return(NULL)
# 1. Kopfbereich + Teil 1 Profil
karte_profil = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Teil 1 Zentrale Wutinhalte"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
),
tags$hr(),
div(lapply(d$teil1$gruppen, function(g) {
div(style = "margin-bottom: 16px;",
tags$h5(paste0(g$nr, ". ", g$name)),
div(lapply(seq_along(g$items), function(i)
vds29_item_zeile(paste0(g$nr, ".", i), g$texte[i], g$stufen[i], g$anker[i])
)),
div(style = "margin-top: 6px; font-weight: 600; color: #555;",
paste0("Gruppen-Mittelwert: ", if (is.na(g$mittelwert)) "keine Angabe" else sprintf("%.2f", g$mittelwert), " (03)"))
)
})),
tags$hr(),
plotOutput("profil_plot", height = "340px"),
div(class = "gesamt-zeile",
div(
div(class = "score-zahl",
if (is.na(d$teil1$gesamt_prozent)) "" else paste0(round(d$teil1$gesamt_prozent), " %")),
div(class = "score-label", "Wut insgesamt (Summe/54 × 100)")
),
div(
div(class = "score-zahl", style = "font-size:1.3rem;",
if (is.na(d$teil1$gesamtmittelwert_03)) "" else sprintf("%.2f", d$teil1$gesamtmittelwert_03)),
div(class = "score-label", "Gesamtmittelwert (03, Zusatzwert für Profildiagramm)")
)
)
)
# 2. Hauptwut & Gruppen-Ranking (getrennt dargestellt, nicht vermischt)
meta_zeile = function(label, text) {
div(class = "meta-auswahl-zeile",
div(class = "meta-auswahl-label", label),
div(class = "meta-auswahl-text", if (is.na(text)) "keine Angabe" else text)
)
}
karte_meta = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Hauptwut und Gruppen-Ranking"),
tags$h5("Hauptwut (Einzelaussage)"),
meta_zeile("1. Hauptwut", d$teil1$hauptwut1_text),
meta_zeile("2. Hauptwut", d$teil1$hauptwut2_text),
tags$hr(),
tags$h5("Wichtigste Wutgruppen (Ranking)"),
meta_zeile("1. wichtigste Gruppe", d$teil1$gruppe1_text),
meta_zeile("2. wichtigste Gruppe", d$teil1$gruppe2_text)
)
# 3. Teil 2
t2_items_ui = lapply(seq_len(nrow(d$teil2$item_tabelle)), function(r) {
z = d$teil2$item_tabelle[r, ]
rang_label = if (isTRUE(z$rang1)) "trifft am meisten zu" else if (isTRUE(z$rang2)) "trifft am 2.-meisten zu" else NULL
vds29_item_zeile(r, z$text, z$stufe, z$anker, rang_label)
})
karte_teil2 = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Teil 2 Wie ich wirklich reagiere"),
div(t2_items_ui)
)
# 4. Teil 3 (eigener Anker, im Hinweistext genannt)
t3_items_ui = lapply(seq_len(nrow(d$teil3$item_tabelle)), function(r) {
z = d$teil3$item_tabelle[r, ]
rang_label = if (isTRUE(z$rang1)) "typischste Reaktion" else if (isTRUE(z$rang2)) "zweittypischste Reaktion" else NULL
vds29_item_zeile(r, z$text, z$stufe, z$anker, rang_label)
})
karte_teil3 = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Teil 3 So reagierten wichtige Bezugspersonen"),
div(class = "hinweis-zeile",
"Anker dieses Teils: 0 = nicht / 1 = etwas / 2 = mittel / 3 = sehr (abweichend von Teil 1/2)."),
div(t3_items_ui)
)
# 5. Teil 4 Kontext-Hinweis + Items 1-3 (1. Hauptwut), Kontext-Hinweis + Items 4-6 (2. Hauptwut), Item 7
t4 = d$teil4
block_1_3 = lapply(1:3, function(r) {
z = t4$item_tabelle[r, ]
vds29_item_zeile(r, z$frage, z$stufe, z$anker, NULL, z$freitext)
})
block_4_6 = lapply(4:6, function(r) {
z = t4$item_tabelle[r, ]
vds29_item_zeile(r, z$frage, z$stufe, z$anker, NULL, z$freitext)
})
karte_teil4 = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Teil 4 Wut/Ärger als Therapieziel?"),
div(class = "kontext-hinweis",
tags$strong("Gewählte 1. Hauptwut: "),
if (is.na(d$teil1$hauptwut1_text)) "keine Angabe" else d$teil1$hauptwut1_text),
div(block_1_3),
tags$hr(),
div(class = "kontext-hinweis",
tags$strong("Gewählte 2. Hauptwut: "),
if (is.na(d$teil1$hauptwut2_text)) "keine Angabe" else d$teil1$hauptwut2_text),
div(block_4_6),
tags$hr(),
vds29_item_zeile(7, t4$item7_frage, t4$item7_stufe, t4$item7_anker)
)
tagList(
karte_profil,
karte_meta,
karte_teil2,
karte_teil3,
karte_teil4,
div(class = "disclaimer-zeile", VDS29_DISCLAIMER)
)
})
output$profil_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis()
req(d$typ == "erfolg")
make_vds29_profil_plot(d$teil1$gruppen, d$teil1$gesamtmittelwert_03)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
chiffre_esc = if (erfolgreich && nchar(d$chiffre) > 0) gsub("[^A-Za-z0-9_-]", "_", d$chiffre) else "export"
ausfuelldatum_fn = if (erfolgreich) {
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("VDS29_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
if (!erfolgreich) {
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_vds29_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)