Initial commit

This commit is contained in:
Jonas Karneboge 2026-09-22 18:35:43 +02:00
commit 3cba772836
1341 changed files with 532924 additions and 0 deletions

813
PSWQ/app.R Normal file
View file

@ -0,0 +1,813 @@
# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_pswq.R" # liefert beim Sourcen: daten_pswq
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert beim Sourcen: pseudo
AKZENT_FARBE = "#8B2635"
PSWQ_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Die verwendeten Einstufungsbaender stammen nicht aus einer verifizierten Testquelle ",
"und sind als vorlaeufige Orientierung, nicht als etablierte klinische Norm zu verstehen."
)
# Kurzer Herkunftshinweis, der direkt bei der Klassifikation angezeigt wird
# (zusaetzlich zum vollstaendigen PSWQ_DISCLAIMER am Seitenende / im Word-Export).
PSWQ_BAND_HINWEIS = paste0(
"Hinweis: Diese Einstufungsbaender sind nicht quellenverifiziert und stellen ",
"eine vorlaeufige Orientierung dar, keine etablierte klinische Norm."
)
# 5 Kontra-Items (Umpolung: 6 - Rohwert), alle uebrigen 11 Items unrecodiert.
PSWQ_KONTRA_ITEMS = c("pswq_01", "pswq_03", "pswq_08", "pswq_10", "pswq_11")
# Verlauf gruen -> dunkelrot entspricht den 5 Antwortstufen 1-5 NACH Umpolung.
PSWQ_BADGE_FARBEN = c(
"1" = "#4CAF50",
"2" = "#AED581",
"3" = "#FFD54F",
"4" = "#EF5350",
"5" = "#B71C1C"
)
PSWQ_BADGE_TEXT_FARBEN = c(
"1" = "white",
"2" = "#333333",
"3" = "#333333",
"4" = "white",
"5" = "white"
)
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 ####
# Entfernt formr-Markdown-Reste aus Item- und Introtexten: escapete Nummerierung
# am Zeilenanfang (z.B. "16\. " oder "16. ") und doppelte Sternchen. Ueberall
# anwenden, wo Item- oder Introtext angezeigt wird - nie Rohtext direkt ausgeben.
bereinige_markdown = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
txt = trimws(as.character(text[1]))
txt = sub("^(\\d+)\\\\([.)])", "\\1\\2", txt)
txt = sub("^\\d+[.)]\\s*", "", txt)
txt = gsub("\\*\\*", "", txt)
trimws(txt)
}
# Loest einen labelled-Wert (haven dbl+lbl) ueber das labels-Attribut der
# Originalspalte in seinen Antworttext auf, mit as_factor()-Fallback. Liefert
# NA_character_, wenn keiner der beiden Wege einen Text liefert (kein Rateergebnis).
pswq_labeltext = function(spalte_orig, wert) {
if (length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(spalte_orig, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == suppressWarnings(as.numeric(wert[1])))
if (length(pos) > 0) {
txt = trimws(names(lbl_attr)[pos[1]])
if (nchar(txt) > 0) return(txt)
}
}
txt_af = tryCatch(as.character(haven::as_factor(wert[1])), error = function(e) NA_character_)
if (!is.na(txt_af) && nchar(trimws(txt_af)) > 0) return(trimws(txt_af))
NA_character_
}
# Titel eines Items aus dem formr-Label der Spalte (enthaelt die Itemnummer,
# z.B. "2. Meine Sorgen wachsen mir ueber den Kopf."), mit Fallback auf den
# Spaltennamen, falls kein label-Attribut vorhanden ist.
pswq_item_titel = function(spalte_orig, fallback) {
lbl = attr(spalte_orig, "label")
if (is.null(lbl) || length(lbl) == 0 || is.na(lbl[1]) || nchar(trimws(as.character(lbl[1]))) == 0) {
return(fallback)
}
bereinige_markdown(lbl[1])
}
# Liest EIN Item (EINE Person) aus einer Item-Spalte: Choice-Index 1-5 fuer das
# Scoring (immer der numerische Wert hinter dem labelled-Objekt), Anzeigetext
# ueber labels-Attribut/as_factor, sowie den umgepolten Wert fuer Kontra-Items.
# Nicht zuordenbare Werte (Code ausserhalb 1-5) werden NA, aber sichtbar vermerkt -
# kein stiller Default.
pswq_lese_item = function(spalte_orig, wert, ist_kontra) {
if (length(wert) == 0 || is.na(wert[1])) {
return(list(choice_index = NA_real_, wert_umgepolt = NA_real_, text = NA_character_,
zuordenbar = TRUE, hinweis = "keine Angabe"))
}
choice_index = suppressWarnings(as.numeric(wert[1]))
if (is.na(choice_index) || !(choice_index %in% 1:5)) {
return(list(choice_index = NA_real_, wert_umgepolt = NA_real_, text = NA_character_,
zuordenbar = FALSE, hinweis = paste0("nicht zuordenbarer Wert (", wert[1], ")")))
}
text_label = pswq_labeltext(spalte_orig, wert)
text_anzeige = if (is.na(text_label)) {
paste0("Wert ", choice_index, " (kein Antworttext verfuegbar)")
} else {
bereinige_markdown(text_label)
}
wert_umgepolt = if (isTRUE(ist_kontra)) 6 - choice_index else choice_index
list(choice_index = choice_index, wert_umgepolt = wert_umgepolt, text = text_anzeige,
zuordenbar = TRUE, hinweis = NULL)
}
# Gesamtscore = Summe der 16 (ggf. umgepolten) Rohwerte, Range 16-80. Bei
# fehlenden/nicht zuordenbaren Items wird kein Schaetzwert erfunden (anders als
# z.B. bei Skalen mit einer im Manual definierten Missing-Value-Regel) - der
# Score gilt dann als nicht vollstaendig auswertbar, sichtbar mit Warnung.
pswq_score = function(werte_umgepolt) {
n_fehlt = sum(is.na(werte_umgepolt))
if (n_fehlt > 0) {
return(list(score = NA_real_, missing_n = n_fehlt, auswertbar = FALSE))
}
list(score = sum(werte_umgepolt), missing_n = 0L, auswertbar = TRUE)
}
# Einstufung anhand der vom Anwender vorgegebenen (nicht quellenverifizierten)
# Baender. Siehe PSWQ_BAND_HINWEIS / PSWQ_DISCLAIMER fuer den Pflichthinweis.
pswq_klassifikation = function(score) {
if (is.na(score)) {
return(list(kategorie = NA_character_, label = "nicht auswertbar",
farbe = "#777777", bg = "#F5F5F5"))
}
if (score <= 39) {
return(list(kategorie = "niedrig", label = "Niedrige Sorge",
farbe = "#2E7D32", bg = "#E8F5E9"))
}
if (score <= 59) {
return(list(kategorie = "moderat", label = "Moderate Sorge",
farbe = "#E65100", bg = "#FFF3E0"))
}
list(kategorie = "hoch", label = "Hohe Sorge", farbe = "#B71C1C", bg = "#FFEBEE")
}
# Horizontaler Balken 16-80 mit den drei Klassifikationszonen farbig hinterlegt
# und Markierung des aktuellen Gesamtscores. Kein externes Gauge-Paket noetig.
erstelle_gauge_pswq = function(score) {
p = ggplot() +
geom_rect(aes(xmin = 16, xmax = 40, ymin = 0, ymax = 1),
fill = "#E8F5E9", color = NA) +
geom_rect(aes(xmin = 40, xmax = 60, ymin = 0, ymax = 1),
fill = "#FFF3E0", color = NA) +
geom_rect(aes(xmin = 60, xmax = 80, ymin = 0, ymax = 1),
fill = "#FFEBEE", color = NA) +
geom_rect(aes(xmin = 16, xmax = 80, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
annotate("text", x = 28, y = 0.5, label = "Niedrig",
color = "#2E7D32", size = 3.3, fontface = "italic") +
annotate("text", x = 50, y = 0.5, label = "Moderat",
color = "#E65100", size = 3.3, fontface = "italic") +
annotate("text", x = 70, y = 0.5, label = "Hoch",
color = "#B71C1C", size = 3.3, fontface = "italic") +
scale_x_continuous(limits = c(13, 83), breaks = c(16, 40, 60, 80)) +
scale_y_continuous(limits = c(-0.35, 1.6)) +
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 = "PSWQ Gesamtscore (16-80)", y = NULL)
if (!is.na(score)) {
p = p +
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.45, label = paste0("Score: ", score)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4)
}
p
}
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.container-fluid { max-width: 1100px; }
.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-fehler h4 { color: #C62828; margin-top: 0; margin-bottom: 8px; }
.alert-fehler p, .alert-fehler li { color: #444; font-weight: 400; font-size: 0.92em; }
.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; }
.score-label { color: #555; }
.klassifikation-box {
border-radius: 6px; padding: 12px 16px; margin-top: 10px;
border-left: 5px solid;
}
.klassifikation-titel { font-weight: 700; font-size: 1.02rem; margin-bottom: 4px; }
.klassifikation-hinweis {
font-size: 0.8em; color: #777; font-style: italic; margin-top: 6px;
}
.disclaimer-block {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 10px; border-top: 1px solid rgba(0,0,0,.1); padding-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: 2; color: #333; font-size: 0.92em; }
.item-antwort { flex: 1; display: flex; justify-content: flex-end; min-width: 160px; }
.stufe-badge {
display: inline-block; border-radius: 4px; padding: 2px 10px;
font-size: 0.85em; font-weight: 700; min-width: 90px; text-align: center;
flex-shrink: 0; white-space: nowrap; background-color: #EDEDED; color: #444;
}
.stufe-badge-1 { background-color: #4CAF50; color: white; }
.stufe-badge-2 { background-color: #AED581; color: #333333; }
.stufe-badge-3 { background-color: #FFD54F; color: #333333; }
.stufe-badge-4 { background-color: #EF5350; color: white; }
.stufe-badge-5 { background-color: #B71C1C; color: white; }
.stufe-badge-fehlend { background-color: #FEECEB; color: #B71C1C; }
.start-hinweis {
text-align: center; color: #bbb; padding: 40px 0; font-size: 0.95em;
}
"
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("PSWQ - Penn State Worry Questionnaire"),
tags$p("Deutsche Version nach Stoeber 1995 / Gloeckner-Rist & Rist 2014 - Einzelfall-Auswertung")
),
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("ergebnis_ui")
)
)
# Word-Export ####
erstelle_pswq_docx = function(erg) {
fmt_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fmt_meta = fp_text(color = "#555555", bold = FALSE, font.size = 10)
fmt_warn = fp_text(color = "#B8860B", italic = TRUE, font.size = 9)
fmt_score_l = fp_text(color = "#888888", bold = FALSE, font.size = 10)
fmt_abschn = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 12, underlined = TRUE)
fmt_item_nr = fp_text(color = "#888888", bold = TRUE, font.size = 10)
fmt_item_tit = fp_text(color = "#333333", bold = FALSE, font.size = 10)
fmt_herkunft = fp_text(color = "#888888", italic = TRUE, font.size = 8.5)
fmt_disclaimer = fp_text(color = "#888888", italic = TRUE, font.size = 9)
kat = erg$klassifikation
fmt_kat_titel = fp_text(bold = TRUE, font.size = 12, color = kat$farbe, shading.color = kat$bg)
fmt_kat_score = fp_text(bold = TRUE, font.size = 16, color = kat$farbe)
doc = read_docx()
doc = body_add_fpar(doc, fpar(ftext("PSWQ - Penn State Worry Questionnaire", fmt_titel)))
doc = body_add_fpar(doc, fpar(ftext(
paste0("Chiffre: ", erg$chiffre, " Ausfuelldatum: ", erg$ausfuelldatum),
fmt_meta
)))
if (!is.null(erg$warnung_mehrfach)) {
doc = body_add_fpar(doc, fpar(ftext(paste0("Hinweis: ", erg$warnung_mehrfach), fmt_warn)))
}
if (erg$missing_n > 0) {
doc = body_add_fpar(doc, fpar(ftext(paste0(
"Hinweis: ", erg$missing_n, " Item(s) ohne zuordenbare Angabe, Gesamtscore ",
"daher nicht vollstaendig auswertbar."
), fmt_warn)))
}
doc = body_add_par(doc, "")
doc = body_add_fpar(doc, fpar(ftext("Gesamtscore und Einstufung", fmt_abschn)))
if (erg$auswertbar) {
doc = body_add_fpar(doc, fpar(
ftext("Gesamtscore: ", fmt_score_l),
ftext(paste0(erg$score, " / 80"), fmt_kat_score)
))
doc = body_add_fpar(doc, fpar(ftext(paste0("Einstufung: ", kat$label), fmt_kat_titel)))
} else {
doc = body_add_fpar(doc, fpar(
ftext("Gesamtscore: ", fmt_score_l),
ftext(paste0("nicht auswertbar (", erg$missing_n, " fehlende/nicht zuordenbare Items)"),
fmt_kat_score)
))
}
doc = body_add_fpar(doc, fpar(ftext(PSWQ_BAND_HINWEIS, fmt_herkunft)))
doc = body_add_par(doc, "")
doc = body_add_fpar(doc, fpar(ftext("PSWQ Einzelitems (16 Items)", fmt_abschn)))
for (it in erg$items) {
unresolved = !it$zuordenbar || is.na(it$wert_umgepolt)
stufe_key = if (!unresolved && as.character(it$wert_umgepolt) %in% names(PSWQ_BADGE_FARBEN)) {
as.character(it$wert_umgepolt)
} else {
NA_character_
}
if (unresolved || is.na(stufe_key)) {
fmt_badge = fp_text(color = "#B71C1C", bold = TRUE, shading.color = "#FEECEB", font.size = 10)
badge_txt = paste0(" (", it$hinweis, ") ")
} else {
fmt_badge = fp_text(
color = PSWQ_BADGE_TEXT_FARBEN[[stufe_key]],
bold = TRUE,
shading.color = PSWQ_BADGE_FARBEN[[stufe_key]],
font.size = 10
)
wert_zusatz = if (it$ist_kontra) paste0(" (Choice-Index: ", it$choice_index, ")") else ""
badge_txt = paste0(" ", it$text, " [Wert: ", it$wert_umgepolt, wert_zusatz, "] ")
}
doc = body_add_fpar(doc, fpar(
ftext(sprintf("%2d. ", it$nr), fmt_item_nr),
ftext(paste0(it$titel, " - "), fmt_item_tit),
ftext(badge_txt, fmt_badge)
))
}
doc = body_add_par(doc, "")
doc = body_add_fpar(doc, fpar(ftext(PSWQ_DISCLAIMER, fmt_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)))
}
})
# Die beiden externen Skripte werden bewusst NICHT beim App-Start gesourct, sondern
# erst hier, beim Klick auf "Auswerten" (Source-bei-Klick-Muster).
ergebnis_r = 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))
}
pfadfehler = character(0)
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
pfadfehler = c(pfadfehler,
paste0("Download-Skript nicht gefunden: >>", PFAD_DOWNLOAD_SKRIPT, "<<"))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
pfadfehler = c(pfadfehler,
paste0("Pseudonym-Skript nicht gefunden: >>", PFAD_PSEUDONYM_SKRIPT, "<<"))
}
if (length(pfadfehler) > 0) {
return(list(typ = "pfad_fehler",
meldung = paste("Bitte Pfade am Kopf der app.R anpassen:",
paste(pfadfehler, collapse = "\n"), sep = "\n")))
}
# Rueckgabewert-Pattern exakt so, kein `<=` statt `=` - das waere syntaktisch
# gueltig, aber semantisch ein stiller Bug, der Fehler verschluckt.
ok_download = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!ok_download$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript (",
basename(PFAD_DOWNLOAD_SKRIPT), "):\n", ok_download$msg)))
}
if (!exists("daten_pswq", envir = .GlobalEnv) ||
!is.data.frame(get("daten_pswq", envir = .GlobalEnv))) {
return(list(typ = "daten_fehler",
meldung = paste0("Objekt 'daten_pswq' fehlt nach dem Sourcen von:\n",
PFAD_DOWNLOAD_SKRIPT)))
}
daten = get("daten_pswq", envir = .GlobalEnv)
# Ordner mit pseudonyme.db suchen, ausgehend vom Pseudonym-Skript-Ordner, bis
# zu 5 Ebenen nach oben.
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_fehler",
meldung = paste0(
"pseudonyme.db nicht gefunden.\n",
"Gesucht ausgehend vom Pseudonym-Skript-Ordner bis zu 5 Ebenen nach oben.\n",
"Bitte sicherstellen, dass pseudonyme.db im selben oder einem ",
"uebergeordneten Ordner liegt."
)))
}
# Arbeitsverzeichnis fuer die Dauer des Sourcens auf den DB-Ordner setzen (das
# Pseudonym-Skript oeffnet die DB relativ) und danach zuverlaessig zuruecksetzen.
# add = TRUE ist Pflicht, sonst wuerden ggf. bereits registrierte on.exit()-Handler
# ueberschrieben statt ergaenzt.
ok_pseudonym = tryCatch({
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!ok_pseudonym$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Pseudonym-Skript (",
basename(PFAD_PSEUDONYM_SKRIPT), "):\n", ok_pseudonym$msg)))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = paste0("Objekt 'pseudo' fehlt nach dem Sourcen von:\n",
PFAD_PSEUDONYM_SKRIPT)))
}
pseudo = get("pseudo", envir = .GlobalEnv)
# Falls ein Pseudonym eingegeben wurde, Chiffre daraus rueckaufloesen, damit
# Kopfzeile und Dateiname im Word-Export trotzdem die korrekte Chiffre zeigen.
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]))
}
treffer_ps = pseudo[toupper(trimws(as.character(pseudo$chiffre))) == chiffre, , drop = FALSE]
if (nrow(treffer_ps) == 0) {
return(list(typ = "chiffre_nicht_gefunden", chiffre = chiffre))
}
# Eindeutigkeits-Override: wenn ein Pseudonym explizit eingegeben wurde,
# dieses statt eines Defaults (z.B. "neueste Ausfuellung") verwenden.
alle_session_ids = unique(as.character(treffer_ps$pseudonym))
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer = daten[as.character(daten$session) %in% alle_session_ids, , drop = FALSE]
if (nrow(treffer) == 0) {
return(list(typ = "session_nicht_gefunden", chiffre = chiffre))
}
# Mehrfachtreffer (Bogen mehrfach ausgefuellt): nicht stillschweigend den ersten
# nehmen, sondern den neuesten (nach 'created') waehlen und sichtbar warnen -
# ausser ein Pseudonym wurde explizit eingegeben (dann ist die Auswahl eindeutig).
warnung_mehrfach = NULL
if (nrow(treffer) > 1 && nchar(trimws(input$pseudonym)) == 0) {
n_ausfuell = nrow(treffer)
reihenfolge = order(as.POSIXct(treffer$created), decreasing = TRUE)
treffer = treffer[reihenfolge, , drop = FALSE]
datum_neuestes = tryCatch(
format(as.POSIXct(treffer$created[1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
warnung_mehrfach = paste0(
"Es wurden ", n_ausfuell, " Ausfuellungen gefunden, es wird die neueste vom ",
datum_neuestes, " angezeigt."
)
treffer = treffer[1, , drop = FALSE]
} else if (nrow(treffer) > 1) {
treffer = treffer[order(as.POSIXct(treffer$created), decreasing = TRUE), , drop = FALSE]
treffer = treffer[1, , drop = FALSE]
}
zeile = treffer[1, , drop = FALSE]
ausfuelldatum_geparst = tryCatch(as.POSIXct(zeile$created[1]), error = function(e) NA)
ausfuelldatum_fallback = is.null(ausfuelldatum_geparst) || is.na(ausfuelldatum_geparst)
ausfuelldatum = if (!ausfuelldatum_fallback) format(ausfuelldatum_geparst, "%d.%m.%Y") else
format(Sys.Date(), "%d.%m.%Y")
# 16 Items einlesen (Choice-Index 1-5 + Anzeigetext + Umpolung), fehlende
# Item-Spalten sind ein Konfigurationsfehler (daten_fehler), nicht zuordenbare
# Einzelwerte werden pro Item als NA mit sichtbarem Hinweis behandelt.
item_cols = sprintf("pswq_%02d", 1:16)
fehlende_item_spalten = item_cols[!(item_cols %in% names(daten))]
if (length(fehlende_item_spalten) > 0) {
return(list(typ = "daten_fehler",
meldung = paste0(
"Folgende erwartete Item-Spalten fehlen in 'daten_pswq': ",
paste(fehlende_item_spalten, collapse = ", "), "."
)))
}
items = lapply(seq_along(item_cols), function(i) {
col = item_cols[i]
spalte_gesamt = daten[[col]]
roh_wert = zeile[[col]][1]
ist_kontra = col %in% PSWQ_KONTRA_ITEMS
gelesen = pswq_lese_item(spalte_gesamt, roh_wert, ist_kontra)
titel = pswq_item_titel(spalte_gesamt, paste0("Item ", i))
list(nr = i, spalte = col, titel = titel, ist_kontra = ist_kontra,
choice_index = gelesen$choice_index, wert_umgepolt = gelesen$wert_umgepolt,
text = gelesen$text, zuordenbar = gelesen$zuordenbar, hinweis = gelesen$hinweis)
})
werte_umgepolt = vapply(items, `[[`, numeric(1), "wert_umgepolt")
score_erg = pswq_score(werte_umgepolt)
klass = pswq_klassifikation(score_erg$score)
list(
typ = "ok",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
warnung_mehrfach = warnung_mehrfach,
items = items,
missing_n = score_erg$missing_n,
auswertbar = score_erg$auswertbar,
score = score_erg$score,
klassifikation = klass
)
})
output$ergebnis_ui = renderUI({
if (input$btn_suchen == 0) {
return(div(class = "start-hinweis",
"Chiffre oder Pseudonym eingeben und auf \"Auswerten\" klicken."
))
}
erg = ergebnis_r()
if (erg$typ == "leere_eingabe") {
return(div(class = "alert-warnung", erg$meldung))
}
if (erg$typ == "format_fehler") {
return(div(class = "alert-warnung",
"Ungueltige Chiffre. Erwartet wird ein Grossbuchstabe gefolgt von 6 Ziffern, ",
"z.B. P000123."
))
}
if (erg$typ == "pfad_fehler") {
return(div(class = "alert-fehler",
tags$h4("Konfigurationsfehler: Pfad nicht gefunden"),
tags$pre(style = "font-size:0.88em; white-space:pre-wrap;", erg$meldung)
))
}
if (erg$typ == "skript_fehler") {
return(div(class = "alert-fehler",
tags$h4("Fehler beim Sourcen eines externen Skripts"),
tags$pre(style = "font-size:0.88em; white-space:pre-wrap;", erg$meldung)
))
}
if (erg$typ == "daten_fehler") {
return(div(class = "alert-fehler",
tags$h4("Datenfehler"),
tags$pre(style = "font-size:0.88em; white-space:pre-wrap;", erg$meldung)
))
}
if (erg$typ == "db_fehler") {
return(div(class = "alert-fehler",
tags$h4("pseudonyme.db nicht gefunden"),
tags$pre(style = "font-size:0.88em; white-space:pre-wrap;", erg$meldung)
))
}
if (erg$typ == "chiffre_nicht_gefunden") {
return(div(class = "alert-fehler",
tags$h4("Chiffre nicht gefunden"),
tags$p("Die Chiffre ", tags$b(erg$chiffre),
" ist in der Pseudonymtabelle nicht vorhanden."),
tags$p("Bitte Schreibweise pruefen oder Pseudonymtabelle aktualisieren.")
))
}
if (erg$typ == "session_nicht_gefunden") {
return(div(class = "alert-fehler",
tags$h4("Kein PSWQ-Datensatz gefunden"),
tags$p("Zur Chiffre ", tags$b(erg$chiffre),
" existiert ein Pseudonymeintrag, aber kein Datensatz in ",
tags$code("daten_pswq"), "."),
tags$p("Moegliche Ursachen: Bogen noch nicht ausgefuellt, ",
"oder Daten noch nicht heruntergeladen.")
))
}
# typ == "ok"
kat = erg$klassifikation
kopf_block = div(class = "abschnitt-karte",
div(class = "meta-block",
tags$strong("Chiffre: "), erg$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), erg$ausfuelldatum
),
if (!is.null(erg$warnung_mehrfach))
div(class = "alert-warnung", erg$warnung_mehrfach),
if (erg$missing_n > 0)
div(class = "alert-warnung", erg$missing_n,
" Item(s) ohne zuordenbare Angabe - Gesamtscore nicht vollstaendig auswertbar.")
)
score_block = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Gesamtscore und Einstufung"),
fluidRow(
column(3,
if (erg$auswertbar) {
div(class = "score-zahl", erg$score)
} else {
div(class = "score-zahl", style = "font-size:1.3rem;", "nicht auswertbar")
},
div(class = "score-label", "Gesamtscore (Range 16-80)"),
div(class = paste0("klassifikation-box"),
style = paste0("border-color:", kat$farbe, "; background:", kat$bg, ";"),
div(class = "klassifikation-titel", style = paste0("color:", kat$farbe, ";"),
kat$label),
div(class = "klassifikation-hinweis", PSWQ_BAND_HINWEIS)
)
),
column(9, if (erg$auswertbar) plotOutput("gauge_plot", height = "160px"))
)
)
item_zeilen = lapply(erg$items, function(it) {
unresolved = !it$zuordenbar || is.na(it$wert_umgepolt)
stufe_key = if (!unresolved && as.character(it$wert_umgepolt) %in% names(PSWQ_BADGE_FARBEN)) {
as.character(it$wert_umgepolt)
} else {
NA_character_
}
badge_klasse = if (unresolved || is.na(stufe_key)) {
"stufe-badge stufe-badge-fehlend"
} else {
paste0("stufe-badge stufe-badge-", stufe_key)
}
antwort_anzeige = if (unresolved) {
paste0("(", it$hinweis, ")")
} else if (it$ist_kontra) {
paste0(it$text, " [", it$wert_umgepolt, " (Choice: ", it$choice_index, ")]")
} else {
paste0(it$text, " [", it$wert_umgepolt, "]")
}
div(class = "item-zeile",
span(class = "item-nr", paste0(it$nr, ".")),
span(class = "item-text", it$titel),
span(class = "item-antwort",
tags$span(class = badge_klasse, antwort_anzeige)
)
)
})
item_block = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "PSWQ Einzelitems (16 Items)"),
div(item_zeilen)
)
tagList(
kopf_block, score_block, item_block,
div(class = "disclaimer-block", PSWQ_DISCLAIMER)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen > 0)
erg = ergebnis_r()
req(erg$typ == "ok", erg$auswertbar)
erstelle_gauge_pswq(erg$score)
}, bg = "white")
output$download_word = downloadHandler(
filename = function() {
erg = ergebnis_r()
if (is.null(erg) || erg$typ != "ok") return("PSWQ_Auswertung.docx")
chiffre_esc = gsub("[^A-Za-z0-9_-]", "_", erg$chiffre)
ausfuelldatum_fn = tryCatch(
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
paste0("PSWQ_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
req(ergebnis_r()$typ == "ok")
doc = erstelle_pswq_docx(ergebnis_r())
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui = ui, server = server)