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

946 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.

# Präambel ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_sekes.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
SEKES_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Fuer den SEK-ES liegen laut Verfahrensdokumentation ",
"aktuell keine validierten Normwerte vor; die angegebenen Vergleichswerte sind ",
"deskriptive Kennwerte der Validierungsstichprobe (Stand 2014). Die Interpretation ",
"obliegt der behandelnden Person."
)
SEKES_ROHWERT_HINWEIS = paste0(
"Rohwert-Interpretation der Kompetenzitems (Teil B) und der Teil-A-Items ",
"(PANAS, Zusatzskalen) noch nicht mit Testdatensatz verifiziert."
)
SEKES_SKALA4X_HINWEIS = paste0(
"Hoehere Werte = variableres Kompetenzniveau ueber die untersuchten Emotionen hinweg."
)
SEKES_ZUSATZSKALEN_HINWEIS = paste0(
"Diese Zusatzskalen (Bewaeltigungs-Emotionen, EMO-Check Gesamt) sind aus der ",
"Item-Skalen-Zuordnung rekonstruiert, ihr Verwendungszweck ueber die reine ",
"Summenbildung hinaus ist in der Verfahrensdokumentation nicht belegt. Auch die ",
"verwendete Basis-Skala (1-5) ist eine Annahme, keine dokumentierte Vorgabe."
)
# Reihenfolge und Konfiguration der acht Bloecke. B6/B7 tragen ihren Namen in einem
# Freitextfeld, B8 (Positive Gefuehle) hat eine andere Item-/Formelstruktur (kein
# geometrisches Mittel, kein Aufmerksamkeits-Doppelitem) und ist von Skala 3.x/4.x
# ausgeschlossen.
SEKES_BLOECKE = list(
list(key = "b1", spalte_praefix = "sekes_b1", label_default = "Stress/Anspannung",
benannt = FALSE, namensfeld = NULL, positiv = FALSE),
list(key = "b2", spalte_praefix = "sekes_b2", label_default = "Angst",
benannt = FALSE, namensfeld = NULL, positiv = FALSE),
list(key = "b3", spalte_praefix = "sekes_b3", label_default = "Ärger",
benannt = FALSE, namensfeld = NULL, positiv = FALSE),
list(key = "b4", spalte_praefix = "sekes_b4", label_default = "Traurigkeit",
benannt = FALSE, namensfeld = NULL, positiv = FALSE),
list(key = "b5", spalte_praefix = "sekes_b5", label_default = "Depressive Stimmung",
benannt = FALSE, namensfeld = NULL, positiv = FALSE),
list(key = "b6", spalte_praefix = "sekes_b6", label_default = "Gefühl X",
benannt = TRUE, namensfeld = "sekes_gefuehl_x", positiv = FALSE),
list(key = "b7", spalte_praefix = "sekes_b7", label_default = "Gefühl Y",
benannt = TRUE, namensfeld = "sekes_gefuehl_y", positiv = FALSE),
list(key = "b8", spalte_praefix = "sekes_b8", label_default = "Positive Gefühle",
benannt = FALSE, namensfeld = NULL, positiv = TRUE)
)
names(SEKES_BLOECKE) = sapply(SEKES_BLOECKE, function(b) b$key)
# Tabelle 2 der Quelle: deskriptive Kennwerte der Validierungsstichprobe (Stand 2014),
# KEINE Normwerte. Nur fuer den Screening-Wert (Intensitaet 0-10) vorgesehen.
SEKES_REFERENZ_SCREENING = list(
b1 = list(kg_m = 6.15, kg_sd = 2.44, eg_m = 7.68, eg_sd = 2.12),
b2 = list(kg_m = 2.46, kg_sd = 2.45, eg_m = 5.38, eg_sd = 3.10),
b3 = list(kg_m = 4.80, kg_sd = 2.76, eg_m = 5.17, eg_sd = 2.85),
b4 = list(kg_m = 2.86, kg_sd = 2.82, eg_m = 6.22, eg_sd = 3.01),
b5 = list(kg_m = 1.87, kg_sd = 2.54, eg_m = 5.22, eg_sd = 3.09),
b6 = list(kg_m = 2.57, kg_sd = 3.60, eg_m = 4.17, eg_sd = 3.95),
b7 = list(kg_m = 0.59, kg_sd = 1.96, eg_m = 1.96, eg_sd = 3.46),
b8 = list(kg_m = 7.89, kg_sd = 1.73, eg_m = 5.10, eg_sd = 2.60)
)
# Reihenfolge und Bezeichnung der zehn Skala-3.x/4.x-Kompetenzen. Position 8 jedes
# Blocks fliesst NUR in Skala 2.x ein und hat bewusst keine eigene Zeile hier.
SEKES_SKALA_3X_NAMEN = c(
"3.1" = "Konstruktive Aufmerksamkeitslenkung",
"3.2" = "Klarheit",
"3.3" = "Verstehen",
"3.4" = "Akzeptieren",
"3.5" = "Toleranz",
"3.6a" = "Akzeptanz/Toleranz (kombiniert)",
"3.6b" = "Konfrontationsbereitschaft",
"3.7" = "Effektive Selbstunterstützung",
"3.8" = "Modifikationserfolg",
"3.9" = "Veränderungsbezogene Selbsteffizienz",
"3.10" = "Modifikationskompetenz (kombiniert)"
)
# Teil A (PANAS + Zusatzskalen) - Item-Spaltennamen. PANAS-Items 4-23 entsprechen
# wortgetreu und in identischer Reihenfolge der deutschen PANAS-20 (Krohne, Egloff,
# Kohlmann & Tausch, 1996), die die Verfahrensdokumentation explizit als externes
# Korrelat der Kriteriumsvaliditaet nennt - PANAS ist damit ueber eine belegte,
# publizierte Konvention auswertbar (Summe der Raenge 1-5, Wertebereich 10-50).
SEKES_PANAS_POSITIV_ITEMS = sprintf("sekes_a_%02d", 4:13)
SEKES_PANAS_NEGATIV_ITEMS = sprintf("sekes_a_%02d", 14:23)
# Bewaeltigungs-Emotionen und EMO-Check-Gesamt sind laut Item-Skalen-Zuordnung
# eindeutig aus Teil-A-Items zusammengesetzt, aber OHNE externen Zitations-/
# Zweckbeleg in der Verfahrensdokumentation (anders als PANAS). Die verwendete
# Basis-Skala (1-5, wie PANAS) ist eine Annahme aus Konsistenzgruenden, keine
# belegte Vorgabe - siehe SEKES_ZUSATZSKALEN_HINWEIS.
SEKES_BEWAELTIGUNG_ITEMS = sprintf("sekes_a_%02d", c(1, 3, 5, 7, 9, 12, 24, 28, 29, 36, 40))
SEKES_EMOCHECK_POSITIV_ITEMS = sprintf("sekes_a_%02d",
c(1, 3:13, 24, 28, 29, 36, 40:43, 45:47, 49, 50))
SEKES_EMOCHECK_NEGATIV_ITEMS = sprintf("sekes_a_%02d",
c(2, 14:23, 25:27, 30:35, 37:39, 44, 48))
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)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# Infrastruktur ####
# (Pfadaufloesung bereits in der Praeambel erledigt, siehe APP_VERZEICHNIS/absPath.)
# Helper ####
# Ordnet einem rohen mc-Exportwert die 0-4-Kompetenzstufe zu, robust ueber das
# labels-Attribut der ORIGINAL-Spalte (nicht von einer gefilterten Zeile lesen).
# Kleinster Rohwert -> Stufe 0 ("ueberhaupt nicht"), groesster -> Stufe 4 ("immer").
sekes_stufe_aus_item = function(rohwerte, labels_attr) {
if (is.null(labels_attr) || length(labels_attr) != 5) {
warning("SEK-ES: Labels-Struktur unerwartet (erwartet: 5 Stufen). ",
"Rohwert-Interpretation ist NICHT verifiziert, Fallback auf Wert-1 wird verwendet. ",
"Bitte mit echtem Testdatensatz gegenpruefen.")
return(as.numeric(rohwerte) - 1)
}
labels_sortiert = sort(labels_attr)
stufen_mapping = setNames(seq_along(labels_sortiert) - 1, as.character(labels_sortiert))
stufen = stufen_mapping[as.character(as.numeric(rohwerte))]
as.numeric(stufen)
}
# Analog zu sekes_stufe_aus_item(), aber 1-basiert statt 0-basiert, weil die
# publizierte PANAS-Konvention die rohe 1-5-Likert-Skala direkt summiert
# (Wertebereich 10-50 fuer 10 Items), nicht die SEK-ES-interne 0-4-Skala.
sekes_rang_aus_item = function(rohwerte, labels_attr) {
if (is.null(labels_attr) || length(labels_attr) != 5) {
warning("SEK-ES Teil A: Labels-Struktur unerwartet (erwartet: 5 Stufen). ",
"Rohwert-Interpretation ist NICHT verifiziert, Rohwert wird unveraendert verwendet. ",
"Bitte mit echtem Testdatensatz gegenpruefen.")
return(as.numeric(rohwerte))
}
labels_sortiert = sort(labels_attr)
rang_mapping = setNames(seq_along(labels_sortiert), as.character(labels_sortiert))
rang = rang_mapping[as.character(as.numeric(rohwerte))]
as.numeric(rang)
}
# Summe der Raenge (1-5) ueber eine Menge von Teil-A-Spalten. Fehlende
# Einzelwerte fuehren zu NA fuer die gesamte Summe, kein stilles Ignorieren.
sekes_summe_raenge = function(daten, zeile, spalten) {
raenge = sapply(spalten, function(spalte) {
labels_attr = attr(daten[[spalte]], "labels")
sekes_rang_aus_item(zeile[[spalte]], labels_attr)
})
if (any(is.na(raenge))) return(NA_real_)
sum(raenge)
}
sekes_wert_text = function(x) {
if (length(x) == 0 || is.na(x)) "nicht auswertbar (fehlende Items)" else as.character(x)
}
sekes_populationsvarianz = function(x) {
x = x[!is.na(x)]
n = length(x)
if (n < 2) return(NA_real_)
sum((x - mean(x))^2) / n
}
sekes_ausfuelldatum = function(zeile) {
kandidaten = c("created", "ended", "expired", "modified")
for (spalte in kandidaten) {
if (spalte %in% names(zeile) && !is.na(zeile[[spalte]][1])) {
datum = suppressWarnings(as.Date(zeile[[spalte]][1]))
if (!is.na(datum)) return(datum)
}
}
NA
}
# Feinere Zeitaufloesung (fuer die Sortierung mehrerer Treffer nach Aktualitaet),
# dieselbe Kandidatenkette wie sekes_ausfuelldatum(), aber als Zeitstempel.
sekes_zeitstempel_sortierwert = function(zeile) {
kandidaten = c("created", "ended", "expired", "modified")
for (spalte in kandidaten) {
if (spalte %in% names(zeile) && !is.na(zeile[[spalte]][1])) {
zeitpunkt = suppressWarnings(as.POSIXct(zeile[[spalte]][1]))
if (!is.na(zeitpunkt)) return(as.numeric(zeitpunkt))
}
}
NA_real_
}
# Nie hartkodiert - immer aus dem labels-Attribut der Original-Spalte (haven).
sekes_label_text = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) > 0) return(names(lbl_attr)[pos[1]])
}
NA_character_
}
sekes_block_bearbeitet = function(zeile, block_cfg) {
screen_spalte = paste0(block_cfg$spalte_praefix, "_screen")
screen_wert = suppressWarnings(as.numeric(zeile[[screen_spalte]][1]))
screen_ok = !is.na(screen_wert) && screen_wert != 0
if (!isTRUE(block_cfg$benannt)) return(screen_ok)
name_roh = zeile[[block_cfg$namensfeld]][1]
name_txt = if (is.null(name_roh) || is.na(name_roh)) "" else trimws(as.character(name_roh))
nchar(name_txt) > 0 && screen_ok
}
sekes_block_name = function(zeile, block_cfg) {
if (isTRUE(block_cfg$benannt)) {
name_roh = zeile[[block_cfg$namensfeld]][1]
name_txt = if (is.null(name_roh) || is.na(name_roh)) "" else trimws(as.character(name_roh))
if (nchar(name_txt) > 0) return(name_txt)
}
block_cfg$label_default
}
sekes_block_screening_wert = function(zeile, block_cfg) {
screen_spalte = paste0(block_cfg$spalte_praefix, "_screen")
suppressWarnings(as.numeric(zeile[[screen_spalte]][1]))
}
sekes_block_items_stufen = function(daten, zeile, block_cfg) {
sapply(seq_len(12), function(i) {
spalte = paste0(block_cfg$spalte_praefix, "_", sprintf("%02d", i))
labels_attr = attr(daten[[spalte]], "labels")
sekes_stufe_aus_item(zeile[[spalte]], labels_attr)
})
}
# Entfernt formr-Markdown-Reste aus Itemlabels (z.B. escapte Punkte "1\.") und
# fuehrende Nummerierungen wie "1\. " oder "1. " - die Itemnummer zeigt item-nr
# ohnehin schon separat an, eine doppelte Nummer waere redundant.
bereinige_markdown = function(x) {
if (is.null(x) || length(x) == 0 || is.na(x[1])) return(NA_character_)
x = as.character(x[1])
x = gsub("\\*\\*", "", x)
x = gsub("(?<!\\\\)\\*", "", x, perl = TRUE)
x = gsub("\\\\\\.", ".", x)
trimws(x)
}
sekes_parse_item_label = function(lbl_roh) {
if (is.null(lbl_roh) || length(lbl_roh) == 0 || is.na(lbl_roh[1]) ||
nchar(trimws(as.character(lbl_roh[1]))) == 0) {
return(NA_character_)
}
roh = sub("^\\s*\\d+\\\\?\\.\\s*", "", as.character(lbl_roh[1]))
bereinige_markdown(roh)
}
sekes_block_items_text = function(daten, block_cfg) {
sapply(seq_len(12), function(i) {
spalte = paste0(block_cfg$spalte_praefix, "_", sprintf("%02d", i))
lbl = sekes_parse_item_label(attr(daten[[spalte]], "label"))
if (is.na(lbl) || nchar(lbl) == 0) paste0("Item ", i) else lbl
})
}
# Skala 2.x - Durchschnittskompetenz pro affektiver Reaktion (Abschnitt 6.1).
sekes_skala_2x = function(items_stufen, positiv) {
if (any(is.na(items_stufen))) return(NA_real_)
if (isTRUE(positiv)) return(sum(items_stufen) / 12)
(sqrt(items_stufen[1] * items_stufen[2]) + sum(items_stufen[3:12])) / 11
}
# Rohe Beitraege eines B1-B7-Blocks zu den zehn Skala-3.x-Kompetenzen (Abschnitt 6.2).
# Position 8 fliesst bewusst in keine dieser Formeln ein.
sekes_skala_3x_komponenten = function(items_stufen) {
c(
"3.1" = sqrt(items_stufen[1] * items_stufen[2]),
"3.2" = items_stufen[3],
"3.3" = items_stufen[4],
"3.4" = (items_stufen[5] + items_stufen[7]) / 2,
"3.5" = items_stufen[6],
"3.6a" = (items_stufen[5] + items_stufen[6] + items_stufen[7]) / 3,
"3.6b" = items_stufen[9],
"3.7" = items_stufen[10],
"3.8" = items_stufen[11],
"3.9" = items_stufen[12],
"3.10" = (items_stufen[11] + items_stufen[12]) / 2
)
}
sekes_tabelle_ui = function(kopfzeile, zeilen_ui) {
tags$table(class = "sekes-tabelle",
tags$thead(tags$tr(lapply(kopfzeile, function(h) tags$th(h)))),
tags$tbody(zeilen_ui)
)
}
# Datenaufbereitung ####
# (Die eigentliche Aufbereitung erfolgt je Chiffre-Abfrage im eventReactive-Block
# im Server-Abschnitt, da 'daten_sekes' erst zur Laufzeit per Klick geladen wird.)
# 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; white-space: pre-wrap;
}
.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;
}
.alert-hinweis {
background: #ECEFF1; border-left: 5px solid #607D8B;
padding: 10px 16px; border-radius: 4px; color: #37474F;
margin-bottom: 12px; font-size: 0.9em;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.kontext-zeile {
display: flex; gap: 8px; align-items: baseline;
padding: 4px 0; color: #444; font-size: 0.93em;
}
.kontext-label { font-weight: 600; color: #333; min-width: 220px; }
.sekes-tabelle { width: 100%; border-collapse: collapse; font-size: 0.92em; margin-bottom: 8px; }
.sekes-tabelle th {
text-align: left; color: #8B2635; border-bottom: 2px solid #8B2635;
padding: 6px 10px; font-weight: 700;
}
.sekes-tabelle td { padding: 6px 10px; border-bottom: 1px solid #F0F0F0; color: #333; }
.sekes-tabelle tr.nicht-bearbeitet td { color: #999; font-style: italic; }
.divisor-hinweis { font-size: 0.85em; color: #777; margin-bottom: 10px; font-style: italic; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.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: #AED581; color: #333333; }
.stufe-badge-2 { background: #FFB74D; color: #333333; }
.stufe-badge-3 { background: #EF5350; color: white; }
.stufe-badge-4 { background: #B71C1C; color: white; }
.hinweis-klein { font-size: 0.8em; color: #999; font-style: italic; margin-top: 10px; }
.details-block summary { cursor: pointer; color: #8B2635; font-weight: 600; margin-bottom: 10px; }
"
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("SEK-ES Skalen zur Erfassung der Emotionsregulation bei Stress"),
tags$p("Ebert, Christ & Berking (2013/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("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_sekes_docx = function(erg) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_abschnitt = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 13)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_normal = fp_text(font.size = 11)
fp_klein = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("SEK-ES - Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label),
ftext(erg$ausfuelldatum_str, fp_normal)
))
if (!is.null(erg$info_mehrere)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$info_mehrere, fp_text(font.size = 10, italic = TRUE, color = "#555555"))
))
}
doc = body_add_fpar(doc, fpar(
ftext("Alter: ", fp_label), ftext(erg$demografie$alter_text, fp_normal),
ftext(" Geschlecht: ", fp_label), ftext(erg$demografie$geschlecht_text, fp_normal),
ftext(" Beruf: ", fp_label), ftext(erg$demografie$beruf_text, fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Screening (Intensitaet 0-10)", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
"Vergleichswerte: deskriptive Kennwerte der Validierungsstichprobe, keine Normwerte (Tabelle 2).",
fp_klein
)))
for (b in erg$bloecke) {
ref = SEKES_REFERENZ_SCREENING[[b$key]]
if (isTRUE(b$bearbeitet)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(b$name, ": "), fp_label),
ftext(paste0(
b$screening_wert, " / 10 (KG: ", sprintf("%.2f", ref$kg_m), " [SD ", sprintf("%.2f", ref$kg_sd),
"], EG: ", sprintf("%.2f", ref$eg_m), " [SD ", sprintf("%.2f", ref$eg_sd), "])"
), fp_normal)
))
} else {
doc = body_add_fpar(doc, fpar(
ftext(paste0(b$name, ": "), fp_label),
ftext("nicht bearbeitet", fp_text(font.size = 11, italic = TRUE, color = "#999999"))
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Skala 2.x Durchschnittskompetenz pro affektiver Reaktion", fp_abschnitt)))
bloecke_bearbeitet = Filter(function(b) isTRUE(b$bearbeitet), erg$bloecke)
for (b in bloecke_bearbeitet) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(b$name, ": "), fp_label),
ftext(sprintf("%.2f", b$skala2x), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
if (!is.null(erg$skala3x)) {
doc = body_add_fpar(doc, fpar(ftext("Skala 3.x Spezifische Kompetenzen", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(paste0(
"Berechnet ueber ", erg$n_bearbeitet_b1_b7, " von 7 moeglichen Bloecken (B1-B7)."
), fp_klein)))
for (sk in names(SEKES_SKALA_3X_NAMEN)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk, " ", SEKES_SKALA_3X_NAMEN[[sk]], ": "), fp_label),
ftext(sprintf("%.2f", erg$skala3x[[sk]]), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
}
if (!is.null(erg$skala4x)) {
doc = body_add_fpar(doc, fpar(ftext("Skala 4.x Generalisiertheit", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(SEKES_SKALA4X_HINWEIS, fp_klein)))
for (sk in names(SEKES_SKALA_3X_NAMEN)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk, " ", SEKES_SKALA_3X_NAMEN[[sk]], ": "), fp_label),
ftext(sprintf("%.3f", erg$skala4x[[sk]]), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_fpar(doc, fpar(ftext("Teil A PANAS", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("PANAS-Positiv: ", fp_label), ftext(sekes_wert_text(erg$teil_a$panas_positiv), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("PANAS-Negativ: ", fp_label), ftext(sekes_wert_text(erg$teil_a$panas_negativ), fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Teil A weitere Zusatzskalen", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(SEKES_ZUSATZSKALEN_HINWEIS, fp_klein)))
doc = body_add_fpar(doc, fpar(
ftext("Bewältigungs-Emotionen: ", fp_label), ftext(sekes_wert_text(erg$teil_a$bewaeltigung), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("EMO-Check Positiv: ", fp_label), ftext(sekes_wert_text(erg$teil_a$emocheck_positiv), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("EMO-Check Negativ: ", fp_label), ftext(sekes_wert_text(erg$teil_a$emocheck_negativ), fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(SEKES_ROHWERT_HINWEIS, fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(SEKES_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
# --- pseudonym-support-injection v1 ---
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick auf "Auswerten".
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0)) {
return(list(typ = "format_fehler",
meldung = "Bitte eine Patientenchiffre eingeben."))
}
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre))) {
return(list(typ = "format_fehler", meldung = paste0(
"Ungueltiges Chiffre-Format. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123)."
)))
}
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
)))
}
ok_download = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_download$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript: ", ok_download$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()
on.exit(setwd(alter_wd), add = TRUE)
wd_ziel = if (!is.null(db_ordner)) db_ordner else
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
setwd(wd_ziel)
ok_pseudonym = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
if (nchar(trimws(input$pseudonym)) > 0) {
.pw_wert = trimws(input$pseudonym)
.pw_tab = get("pseudo", envir = .GlobalEnv)
.pw_treffer = .pw_tab[.pw_tab$pseudonym == .pw_wert, ]
if (nrow(.pw_treffer) > 0) chiffre = toupper(trimws(.pw_treffer$chiffre[1]))
}
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_pseudonym$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Pseudonym-Skript: ", ok_pseudonym$msg)))
}
if (!exists("daten_sekes", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler", meldung = paste0(
"Objekt 'daten_sekes' nach dem Sourcen nicht gefunden. Bitte Download-Skript pruefen."
)))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler", meldung = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript pruefen."
)))
}
daten = get("daten_sekes", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
# Kein Filter auf 'instrument' noetig - diese App erhaelt ausschliesslich Daten
# des SEK-ES-Runs, ein reiner Chiffre-Lookup reicht.
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(typ = "chiffre_nicht_gefunden", meldung = 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(typ = "sitzung_nicht_gefunden", meldung = paste0(
"Chiffre '", chiffre, "' wurde gefunden, aber kein SEK-ES-Datensatz zu den ",
"zugehoerigen Sitzungs-IDs (", length(alle_session_ids), " geprueft)."
)))
}
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
zeitstempel = sapply(seq_len(nrow(treffer_dat)), function(i) {
sekes_zeitstempel_sortierwert(treffer_dat[i, , drop = FALSE])
})
treffer_dat = treffer_dat[order(zeitstempel, decreasing = TRUE), ]
info_mehrere = paste0(
"Mehrere Ausfuellungen fuer diese Chiffre gefunden (", n, " Eintraege). ",
"Es wird die Ausfuellung mit dem neuesten Zeitstempel angezeigt."
)
}
zeile = treffer_dat[1, , drop = FALSE]
ausfuelldatum = sekes_ausfuelldatum(zeile)
ausfuelldatum_str = if (is.na(ausfuelldatum)) "Ausfülldatum nicht ermittelbar" else
format(ausfuelldatum, "%d.%m.%Y")
ausfuelldatum_dateiname = if (is.na(ausfuelldatum)) "unbekannt" else
format(ausfuelldatum, "%Y%m%d")
alter_num = suppressWarnings(as.numeric(zeile[["sekes_alter"]][1]))
alter_text = if (is.na(alter_num)) "k. A." else as.character(alter_num)
geschlecht_text_roh = sekes_label_text(daten[["sekes_geschlecht"]], zeile[["sekes_geschlecht"]])
geschlecht_text = if (is.na(geschlecht_text_roh)) "k. A." else geschlecht_text_roh
beruf_roh = zeile[["sekes_beruf"]][1]
beruf_text = if (is.null(beruf_roh) || is.na(beruf_roh) || nchar(trimws(as.character(beruf_roh))) == 0)
"k. A." else trimws(as.character(beruf_roh))
bloecke = lapply(SEKES_BLOECKE, function(block_cfg) {
bearbeitet = sekes_block_bearbeitet(zeile, block_cfg)
name = sekes_block_name(zeile, block_cfg)
if (!bearbeitet) {
return(list(
key = block_cfg$key, name = name, bearbeitet = FALSE,
screening_wert = NA_real_, skala2x = NA_real_,
items_stufen = rep(NA_real_, 12), items_text = sekes_block_items_text(daten, block_cfg),
positiv = block_cfg$positiv
))
}
items_stufen = sekes_block_items_stufen(daten, zeile, block_cfg)
items_text = sekes_block_items_text(daten, block_cfg)
screening = sekes_block_screening_wert(zeile, block_cfg)
skala2x = sekes_skala_2x(items_stufen, block_cfg$positiv)
komponenten = if (!isTRUE(block_cfg$positiv)) sekes_skala_3x_komponenten(items_stufen) else NULL
list(
key = block_cfg$key, name = name, bearbeitet = TRUE,
screening_wert = screening, skala2x = skala2x,
items_stufen = items_stufen, items_text = items_text,
positiv = block_cfg$positiv, komponenten = komponenten
)
})
bloecke_b1_b7 = Filter(function(b) b$key != "b8" && isTRUE(b$bearbeitet), bloecke)
n_bearbeitet_b1_b7 = length(bloecke_b1_b7)
skala3x = NULL
skala4x = NULL
if (n_bearbeitet_b1_b7 >= 1) {
komp_matrix = do.call(rbind, lapply(bloecke_b1_b7, function(b) b$komponenten))
skala3x = as.list(colSums(komp_matrix) / n_bearbeitet_b1_b7)
}
if (n_bearbeitet_b1_b7 >= 2) {
komp_matrix = do.call(rbind, lapply(bloecke_b1_b7, function(b) b$komponenten))
skala4x = as.list(apply(komp_matrix, 2, sekes_populationsvarianz))
}
teil_a = list(
panas_positiv = sekes_summe_raenge(daten, zeile, SEKES_PANAS_POSITIV_ITEMS),
panas_negativ = sekes_summe_raenge(daten, zeile, SEKES_PANAS_NEGATIV_ITEMS),
bewaeltigung = sekes_summe_raenge(daten, zeile, SEKES_BEWAELTIGUNG_ITEMS),
emocheck_positiv = sekes_summe_raenge(daten, zeile, SEKES_EMOCHECK_POSITIV_ITEMS),
emocheck_negativ = sekes_summe_raenge(daten, zeile, SEKES_EMOCHECK_NEGATIV_ITEMS)
)
list(
typ = "ok",
chiffre = chiffre,
ausfuelldatum_str = ausfuelldatum_str,
ausfuelldatum_dateiname = ausfuelldatum_dateiname,
info_mehrere = info_mehrere,
demografie = list(alter_text = alter_text, geschlecht_text = geschlecht_text, beruf_text = beruf_text),
bloecke = bloecke,
n_bearbeitet_b1_b7 = n_bearbeitet_b1_b7,
skala3x = skala3x,
skala4x = skala4x,
teil_a = teil_a
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ %in% c("format_fehler", "skript_fehler", "chiffre_nicht_gefunden", "sitzung_nicht_gefunden")) {
div(class = "alert-fehler", d$meldung)
}
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok" || is.null(d$info_mehrere)) return(NULL)
div(class = "alert-warnung", d$info_mehrere)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") return(NULL)
stufe_badge = function(stufe) {
sk = if (!is.na(stufe) && stufe >= 0 && stufe <= 4) as.character(as.integer(stufe)) else "0"
span(class = paste0("stufe-badge stufe-badge-", sk), stufe)
}
# Screening: alle acht Bloecke, nicht bearbeitete klar als solche markiert.
screening_zeilen = lapply(d$bloecke, function(b) {
ref = SEKES_REFERENZ_SCREENING[[b$key]]
if (isTRUE(b$bearbeitet)) {
tags$tr(
tags$td(b$name), tags$td(paste0(b$screening_wert, " / 10")),
tags$td(sprintf("%.2f (SD %.2f)", ref$kg_m, ref$kg_sd)),
tags$td(sprintf("%.2f (SD %.2f)", ref$eg_m, ref$eg_sd))
)
} else {
tags$tr(class = "nicht-bearbeitet",
tags$td(b$name), tags$td("nicht bearbeitet"), tags$td(""), tags$td("")
)
}
})
skala2x_zeilen = lapply(Filter(function(b) isTRUE(b$bearbeitet), d$bloecke), function(b) {
tags$tr(tags$td(b$name), tags$td(sprintf("%.2f", b$skala2x)))
})
skala3x_ui = if (!is.null(d$skala3x)) tagList(
tags$hr(),
tags$h5("Skala 3.x Spezifische Kompetenzen"),
div(class = "divisor-hinweis",
paste0("Berechnet über ", d$n_bearbeitet_b1_b7, " von 7 möglichen Blöcken (B1B7).")),
sekes_tabelle_ui(c("Skala", "Kompetenz", "Wert"),
lapply(names(SEKES_SKALA_3X_NAMEN), function(sk) {
tags$tr(tags$td(sk), tags$td(SEKES_SKALA_3X_NAMEN[[sk]]), tags$td(sprintf("%.2f", d$skala3x[[sk]])))
})
)
) else NULL
skala4x_ui = if (!is.null(d$skala4x)) tagList(
tags$hr(),
tags$h5("Skala 4.x Generalisiertheit"),
div(class = "divisor-hinweis", SEKES_SKALA4X_HINWEIS),
sekes_tabelle_ui(c("Skala", "Kompetenz", "Populationsvarianz"),
lapply(names(SEKES_SKALA_3X_NAMEN), function(sk) {
tags$tr(tags$td(sk), tags$td(SEKES_SKALA_3X_NAMEN[[sk]]), tags$td(sprintf("%.3f", d$skala4x[[sk]])))
})
)
) else NULL
panas_ui = tagList(
tags$hr(),
tags$h5("Teil A PANAS"),
sekes_tabelle_ui(c("Skala", "Summenwert (1050)"), list(
tags$tr(tags$td("PANAS-Positiv"), tags$td(sekes_wert_text(d$teil_a$panas_positiv))),
tags$tr(tags$td("PANAS-Negativ"), tags$td(sekes_wert_text(d$teil_a$panas_negativ)))
))
)
zusatzskalen_ui = tags$details(class = "details-block",
tags$summary("Teil A weitere Zusatzskalen (unverifiziert)"),
div(class = "alert-warnung", SEKES_ZUSATZSKALEN_HINWEIS),
sekes_tabelle_ui(c("Skala", "Summenwert"), list(
tags$tr(tags$td("Bewältigungs-Emotionen"), tags$td(sekes_wert_text(d$teil_a$bewaeltigung))),
tags$tr(tags$td("EMO-Check Positiv"), tags$td(sekes_wert_text(d$teil_a$emocheck_positiv))),
tags$tr(tags$td("EMO-Check Negativ"), tags$td(sekes_wert_text(d$teil_a$emocheck_negativ)))
))
)
itemrohdaten_ui = tags$details(class = "details-block",
tags$summary("Itemrohdaten (Qualitätskontrolle, keine Interpretationsgrundlage)"),
lapply(Filter(function(b) isTRUE(b$bearbeitet), d$bloecke), function(b) {
tagList(
tags$h5(b$name),
div(lapply(seq_len(12), function(i) {
div(class = "item-zeile",
div(class = "item-nr", paste0(i, ".")),
div(class = "item-text", b$items_text[i]),
stufe_badge(b$items_stufen[i])
)
}))
)
})
)
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "SEK-ES"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum_str
),
div(class = "kontext-zeile", div(class = "kontext-label", "Alter:"), div(d$demografie$alter_text)),
div(class = "kontext-zeile", div(class = "kontext-label", "Geschlecht:"), div(d$demografie$geschlecht_text)),
div(class = "kontext-zeile", div(class = "kontext-label", "Beruf:"), div(d$demografie$beruf_text)),
tags$hr(),
tags$h5("Screening (Intensität 010)"),
div(class = "alert-hinweis",
"Vergleichswerte (KG/EG): deskriptive Kennwerte der Validierungsstichprobe, keine Normwerte."),
sekes_tabelle_ui(c("Emotion/Block", "Screeningwert", "KG M (SD)", "EG M (SD)"), screening_zeilen),
tags$hr(),
tags$h5("Skala 2.x Durchschnittskompetenz pro affektiver Reaktion"),
sekes_tabelle_ui(c("Emotion/Block", "Skala 2.x"), skala2x_zeilen),
skala3x_ui,
skala4x_ui,
panas_ui,
zusatzskalen_ui,
tags$hr(),
itemrohdaten_ui,
div(class = "hinweis-klein", SEKES_ROHWERT_HINWEIS)
)
})
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
if (is.list(d) && identical(d$typ, "ok")) {
paste0("SEKES_", d$chiffre, "_", d$ausfuelldatum_dateiname, ".docx")
} else {
"SEKES_export.docx"
}
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
if (!is.list(d) || !identical(d$typ, "ok")) {
doc = read_docx()
meldung = if (is.list(d) && !is.null(d$meldung)) d$meldung else
"Kein Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Auswerten' klicken."
doc = body_add_par(doc, meldung, style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_sekes_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)