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

760 lines
30 KiB
R
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_maas.R" # liefert: daten_maas
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
# MAAS (Mindful Attention and Awareness Scale), 15 Items, dt. Fassung
# Michalak, Heidenreich, Stroehle & Nachtigall (2008); Original Brown & Ryan (2003).
# Einfaktorielles Instrument, keine Subskalen, kein Reverse-Scoring, kein Cutoff.
MAAS_N_ITEMS = 15
# 6-stufige Antwortskala mit reinen Wortankern (im Bogen keine Ziffern sichtbar).
# Position 1 = "Beinahe immer" -> Zahlenwert 1 ... Position 6 = "Beinahe nie" -> 6.
MAAS_ANKER_TAB = data.frame(
wert = 1:6,
anker = c("Beinahe immer",
"Sehr häufig",
"Mehr oder weniger regelmäßig",
"Eher unregelmäßig",
"Sehr unregelmäßig",
"Beinahe nie"),
stringsAsFactors = FALSE
)
MAAS_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Fuer die MAAS liegen keine publizierten Cutoff- oder Normwerte vor; der berichtete ",
"Wert ist ausschliesslich dimensional zu interpretieren."
)
MAAS_RICHTUNGSHINWEIS = paste0(
"Hoehere Werte = mehr dispositionelle Achtsamkeit im Alltag. ",
"Es existieren keine publizierten Norm- oder Cutoffwerte; eine Einteilung in ",
"gering/mittel/hoch ist nicht vorgesehen."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# DBI und RSQLite werden hier nicht per library() geladen: sie werden nur vom
# gesourcten ../get_pseudo.R gebraucht und gelangen ueber renv::snapshot(type = "all")
# in die renv.lock (siehe setup_renv.R).
# 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)
# Kandidatenspalten fuer den Zeitstempel im formr-Export. Der tatsaechlich
# vorhandene Name ist vor Produktivbetrieb am echten Export zu verifizieren;
# der Code prueft die Existenz zur Laufzeit und faellt sonst sauber zurueck.
MAAS_DATUM_SPALTEN = c("created", "ended", "modified", "expired")
# Helper ####
komma1 = function(x) format(round(as.numeric(x), 1), decimal.mark = ",", nsmall = 1, trim = TRUE)
# Normalisiert einen Antwort-/Ankertext fuer den whitespace- und schreibweisen-
# toleranten Abgleich: Markdown-Sternchen weg, Umlaute gefaltet, Kleinschreibung,
# Whitespace kollabiert, Satzzeichen entfernt.
maas_normalisiere = function(x) {
if (is.null(x) || length(x) == 0 || is.na(x[1])) return(NA_character_)
s = as.character(x[1])
s = gsub("\\*\\*", "", s)
s = gsub("[\r\n]+", " ", s)
s = gsub("[Ää]", "ae", s)
s = gsub("[Öö]", "oe", s)
s = gsub("[Üü]", "ue", s)
s = gsub("ß", "ss", s)
s = tolower(s)
s = gsub("[[:punct:]]", " ", s)
s = gsub("[[:space:]]+", " ", s)
trimws(s)
}
MAAS_ANKER_KEY = vapply(MAAS_ANKER_TAB$anker, maas_normalisiere, character(1))
# Ordnet einen Wortanker-Text dem kanonischen Zahlenwert 1-6 zu (oder NA).
maas_anker_zu_wert = function(text) {
k = maas_normalisiere(text)
if (is.na(k) || !nzchar(k)) return(NA_integer_)
pos = which(MAAS_ANKER_KEY == k)
if (length(pos) == 1) return(as.integer(MAAS_ANKER_TAB$wert[pos]))
# Teilstring-toleranter Match, nur bei Eindeutigkeit akzeptiert.
pos = which(vapply(MAAS_ANKER_KEY,
function(a) a == k || startsWith(k, a) || startsWith(a, k),
logical(1)))
if (length(pos) == 1) return(as.integer(MAAS_ANKER_TAB$wert[pos]))
NA_integer_
}
# Defensive Wert-Extraktion fuer eine Item-Zelle. Kein blindes as.numeric():
# original_col = die komplette Originalspalte aus daten_maas (nicht subgesettet)
# roh = der gespeicherte Rohwert der betreffenden Zelle
# Rueckgabe: kanonischer Zahlenwert 1-6 oder NA_integer_.
maas_item_wert = function(original_col, roh) {
if (is.null(roh) || length(roh) == 0 || is.na(roh[1])) return(NA_integer_)
roh1 = roh[1]
lbl_attr = if (!is.null(original_col)) attr(original_col, "labels") else NULL
# Schritt 1/2: ueber das labels-Attribut der Original-Spalte.
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
treffer_name = NULL
roh_num = suppressWarnings(as.numeric(unclass(roh1)))
if (!is.na(roh_num)) {
pos = which(as.numeric(lbl_attr) == roh_num)
if (length(pos) > 0) treffer_name = names(lbl_attr)[pos[1]]
}
# Rohwert ist evtl. bereits der Antworttext und taucht als Label-Name auf.
if (is.null(treffer_name)) {
pos = which(vapply(names(lbl_attr), maas_normalisiere, character(1)) ==
maas_normalisiere(roh1))
if (length(pos) > 0) treffer_name = names(lbl_attr)[pos[1]]
}
if (!is.null(treffer_name)) {
w = maas_anker_zu_wert(treffer_name)
if (!is.na(w)) return(w)
}
}
# Schritt 3: Fallback ueber direkten Textabgleich mit der Wortanker-Tabelle.
w = maas_anker_zu_wert(roh1)
if (!is.na(w)) return(w)
# Schritt 4: kein gueltiger Wert im Bereich 1-6 ermittelbar -> NA.
NA_integer_
}
# Vollen Fragetext einer Item-Spalte aufbereiten (Markdown-Sternchen entfernen).
maas_itemtext = function(original_col, nr) {
if (is.null(original_col)) return(paste0("Item ", nr, " (Spalte fehlt im Export)"))
lb = attr(original_col, "label")
if (is.null(lb) || length(lb) == 0 || !nzchar(trimws(as.character(lb)[1]))) {
return(paste0("Item ", nr, " (Wortlaut nicht im Datensatz)"))
}
s = as.character(lb)[1]
s = gsub("\\*\\*", "", s)
s = gsub("[\r\n]+", " ", s)
s = sub("^\\s*\\d+\\s*\\\\?[.)]\\s*", "", s) # fuehrende formr-Nummerierung "1\. " / "1) "
s = gsub("[[:space:]]+", " ", s)
trimws(s)
}
# Zeitstempel-Vektor eines Dataframes fuer die Sortierung mehrerer Treffer.
maas_datum_vec = function(df) {
for (ds in MAAS_DATUM_SPALTEN) {
if (!ds %in% names(df)) next
v = suppressWarnings(as.POSIXct(trimws(as.character(df[[ds]])), tz = "UTC"))
if (any(!is.na(v))) return(v)
}
NULL
}
# Einzeldatum + Quellspalte aus einer Zeile. Kein Sys.Date()-Fallback.
maas_datum_einzeln = function(row1) {
for (ds in MAAS_DATUM_SPALTEN) {
if (!ds %in% names(row1)) next
k = suppressWarnings(as.POSIXct(trimws(as.character(row1[[ds]][1])), tz = "UTC"))
if (length(k) == 1 && !is.na(k)) return(list(datum = as.Date(k), quelle = ds))
}
list(datum = as.Date(NA), quelle = NA_character_)
}
# Verknuepfungsspalte zwischen daten_maas und den Pseudonym-Session-IDs finden.
# Bevorzugt die uebliche formr-Spalte 'session'; sonst die erste Spalte, deren
# Werte mit den gesuchten IDs ueberlappen. Vor Produktivbetrieb am echten Export
# verifizieren.
maas_session_spalte = function(df, ids) {
if ("session" %in% names(df)) return("session")
for (sp in names(df)) {
if (any(as.character(df[[sp]]) %in% ids)) return(sp)
}
NA_character_
}
# Horizontale Skalenanzeige 1-6 ohne Klassifikationszonen: nur ein Marker fuer
# den aktuellen Mittelwert plus zwei duenne, klar beschriftete Referenzlinien
# (Brown & Ryan, 2003) - ausdruecklich keine Cutoff-Linien.
maas_skala_plot = function(mittelwert) {
ref = data.frame(
x = c(4.20, 3.85),
y = c(1.32, 1.14),
lab = c("Erwachsene M ~ 4,2", "Studierende M ~ 3,8-3,9"),
stringsAsFactors = FALSE
)
marker_x = min(max(mittelwert, 1.05), 5.95)
ggplot() +
geom_rect(aes(xmin = 1, xmax = 6, ymin = 0, ymax = 1),
fill = "#ECECEC", color = "#BDBDBD", linewidth = 0.5) +
geom_vline(xintercept = ref$x, linetype = "dashed",
color = "#9E9E9E", linewidth = 0.5) +
geom_text(data = ref, aes(x = x, y = y, label = lab),
size = 2.7, color = "#8A8A8A", hjust = 0) +
annotate("segment", x = mittelwert, xend = mittelwert, y = -0.12, yend = 1.05,
color = AKZENT_FARBE, linewidth = 1.8) +
annotate("point", x = mittelwert, y = 1.05, size = 3, color = AKZENT_FARBE) +
annotate("label", x = marker_x, y = -0.36,
label = paste0("MAAS-Mittelwert: ", komma1(mittelwert)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
label.size = 0, size = 3.8) +
scale_x_continuous(limits = c(1, 6), breaks = 1:6, expand = c(0.02, 0)) +
scale_y_continuous(limits = c(-0.62, 1.62), expand = c(0, 0)) +
labs(x = "MAAS-Mittelwert (1-6), hoehere Werte = mehr dispositionelle Achtsamkeit",
y = NULL,
caption = paste0("Gestrichelt: Stichprobenmittelwert (Brown & Ryan, 2003), ",
"keine Norm, nicht direkt vergleichbar")) +
theme_minimal(base_size = 12) +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid = element_blank(),
axis.title.y = element_blank(),
axis.text.x = element_text(size = 10, color = "#444444"),
plot.caption = element_text(hjust = 0, size = 8, color = "#8A8A8A", face = "italic"),
plot.margin = margin(t = 4, r = 14, b = 4, l = 14)
)
}
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; color: #222; }
.container-fluid { max-width: 1080px; }
.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: #fff; 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: #fff !important; border: none !important;
border-radius: 4px !important; padding: 8px 20px !important; font-weight: 600 !important;
}
.btn-laden:hover { background: #6d1e29 !important; color: #fff !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-weight: 500;
}
.abschnitt-karte {
background: #fff; 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;
}
.kennwert-zahl { font-size: 2.7rem; font-weight: 800; line-height: 1; color: #8B2635; }
.kennwert-sub { color: #555; font-size: 0.95rem; margin-top: 2px; }
.disclaimer { font-size: 0.9em; color: #555; line-height: 1.6; }
.item-zeile {
display: flex; align-items: baseline; gap: 12px; padding: 7px 0;
border-bottom: 1px solid #F0F0F0;
}
.item-zeile:last-child { border-bottom: none; }
.item-nr {
font-weight: 700; color: #8B2635; min-width: 30px; flex-shrink: 0;
text-align: right; font-variant-numeric: tabular-nums;
}
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.item-antwort {
flex: 0 0 230px; color: #444; font-style: italic; font-size: 0.86em;
text-align: right;
}
.item-fehlt {
flex: 0 0 230px; color: #BF360C; font-style: italic; font-size: 0.86em;
text-align: right; font-weight: 600;
}
"
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("MAAS Mindful Attention and Awareness Scale"),
tags$p("Brown & Ryan 2003 | dt. Fassung Michalak, Heidenreich, Stroehle & Nachtigall 2008 | lokale 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_maas_docx = function(erg) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_meta = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 11)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_norm = fp_text(font.size = 11)
fp_kennwert = fp_text(bold = TRUE, font.size = 13)
fp_klein = fp_text(italic = TRUE, font.size = 9, color = "#777777")
fp_disclaimer = fp_text(italic = TRUE, font.size = 9, color = "#555555")
doc = body_add_fpar(doc, fpar(ftext("MAAS-Auswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(as.character(erg$chiffre), fp_meta),
ftext(" Ausfuelldatum: ", fp_label),
ftext(if (is.na(erg$ausfuelldatum)) "nicht gefunden (undatiert)" else erg$ausfuelldatum, fp_norm)
))
doc = body_add_fpar(doc, fpar(ftext(
paste0("Bericht erstellt am ", format(Sys.time(), "%d.%m.%Y %H:%M")), fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
if (is.na(erg$mittelwert)) {
doc = body_add_fpar(doc, fpar(ftext(
paste0(erg$n_fehlend, " von ", MAAS_N_ITEMS, " Items konnten nicht eindeutig ",
"einer Antwortstufe zugeordnet werden. Es wird kein MAAS-Mittelwert berichtet."),
fp_text(bold = TRUE, font.size = 11, color = "#B03A2E"))))
} else {
doc = body_add_fpar(doc, fpar(
ftext("MAAS-Mittelwert: ", fp_label),
ftext(paste0(komma1(erg$mittelwert), " (Skala 1,0-6,0)"), fp_kennwert)))
doc = body_add_fpar(doc, fpar(
ftext("Rohwertsumme: ", fp_label),
ftext(paste0(erg$rohwertsumme, " (Range ", MAAS_N_ITEMS, "-", MAAS_N_ITEMS * 6, ")"), fp_norm)))
doc = body_add_fpar(doc, fpar(ftext(MAAS_RICHTUNGSHINWEIS, fp_klein)))
}
doc = body_add_par(doc, "", style = "Normal")
if (length(erg$warnungen) > 0) {
for (w in erg$warnungen) doc = body_add_fpar(doc, fpar(ftext(w, fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_fpar(doc, fpar(ftext(
paste0("Einzelitems (1-", MAAS_N_ITEMS, ")"), fp_text(bold = TRUE, font.size = 12))))
for (i in seq_len(nrow(erg$items))) {
it = erg$items[i, ]
antwort = if (!is.na(it$wert)) {
paste0(it$anker, " (", it$wert, ")")
} else {
paste0("nicht zuzuordnen (roh: \"", it$roh, "\")")
}
doc = body_add_fpar(doc, fpar(
ftext(sprintf("%2d. ", it$nr), fp_text(bold = TRUE, color = AKZENT_FARBE, font.size = 10)),
ftext(paste0(it$text, " - "), fp_text(font.size = 10)),
ftext(antwort, fp_text(italic = TRUE, font.size = 10))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(MAAS_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)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick auf "Auswerten".
ergebnis = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
# Validierung inline (direkter Zugriff auf input$pseudonym noetig, damit der
# Pseudonym-Bypass greift).
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))
}
# Skriptpfade pruefen.
if (!file.exists(PFAD_DOWNLOAD_SKRIPT) || !file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "pfad_fehler", meldung = paste0(
"Benoetigtes Skript nicht gefunden:\n",
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) paste0(" ", PFAD_DOWNLOAD_SKRIPT, "\n") else "",
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) paste0(" ", PFAD_PSEUDONYM_SKRIPT, "\n") else "")))
}
# Download-Skript sourcen.
ok_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!ok_dl$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler beim Ausfuehren des Download-Skripts:\n", ok_dl$msg)))
}
# pseudonyme.db suchen: ab dem Ordner des Pseudonym-Skripts bis zu 5 Ebenen hoch.
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
})
# Pseudonym-Skript oeffnet die DB relativ -> vorher ins DB-Verzeichnis wechseln.
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_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
setwd(alter_wd)
if (!ok_ps$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler beim Ausfuehren des Pseudonym-Skripts:\n", ok_ps$msg)))
}
# Objekte pruefen.
if (!exists("daten_maas", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Das Download-Skript hat kein Objekt 'daten_maas' erzeugt."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Das Pseudonym-Skript hat kein Objekt 'pseudo' erzeugt."))
}
daten_maas = get("daten_maas", envir = .GlobalEnv)
pseudo = get("pseudo", envir = .GlobalEnv)
# Chiffre-Rueckaufloesung aus dem Pseudonym.
if (nchar(trimws(input$pseudonym)) > 0 && all(c("pseudonym", "chiffre") %in% names(pseudo))) {
pw_treffer = pseudo[!is.na(pseudo$pseudonym) &
as.character(pseudo$pseudonym) == trimws(input$pseudonym), , drop = FALSE]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(as.character(pw_treffer$chiffre[1])))
}
# Chiffre -> Pseudonym(e) / Session-ID(s). Kein Filter auf pseudo$instrument
# noetig, da diese App ausschliesslich MAAS-Daten aus daten_maas erhaelt.
treffer_ps = pseudo[0, , drop = FALSE]
if ("chiffre" %in% names(pseudo)) {
treffer_ps = pseudo[!is.na(pseudo$chiffre) &
toupper(trimws(as.character(pseudo$chiffre))) == chiffre, , drop = FALSE]
}
if (nchar(trimws(input$pseudonym)) == 0 && nrow(treffer_ps) == 0) {
return(list(typ = "kein_treffer_pseudo",
meldung = paste0("Chiffre '", chiffre,
"' wurde in der Pseudonym-Datenbank nicht gefunden.")))
}
# Eindeutigkeits-Override bei explizitem Pseudonym.
alle_session_ids = unique(as.character(treffer_ps$pseudonym))
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
alle_session_ids = alle_session_ids[!is.na(alle_session_ids) & nzchar(alle_session_ids)]
if (length(alle_session_ids) == 0) {
return(list(typ = "kein_treffer_pseudo",
meldung = "Zu dieser Eingabe wurde keine Session-ID in der Pseudonymliste gefunden."))
}
# daten_maas nach der/den ermittelten Session-ID(s) filtern.
sess_sp = maas_session_spalte(daten_maas, alle_session_ids)
if (is.na(sess_sp)) {
return(list(typ = "kein_treffer_daten", meldung = paste0(
"In 'daten_maas' wurde keine Spalte gefunden, die zu den ermittelten ",
"Session-IDs passt (erwartet: 'session'). Spaltennamen am echten Export verifizieren.")))
}
treffer_dat = daten_maas[as.character(daten_maas[[sess_sp]]) %in% alle_session_ids, , drop = FALSE]
if (nrow(treffer_dat) == 0) {
return(list(typ = "kein_treffer_daten", meldung = paste0(
"Kein MAAS-Datensatz fuer die ermittelte(n) Session-ID(s) gefunden (",
length(alle_session_ids), " geprueft).")))
}
warnungen = character(0)
dv = maas_datum_vec(treffer_dat)
if (!is.null(dv)) treffer_dat = treffer_dat[order(dv, decreasing = TRUE), , drop = FALSE]
if (nrow(treffer_dat) > 1) {
warnungen = c(warnungen, sprintf(paste0(
"Zu dieser Eingabe wurden %d MAAS-Datensaetze gefunden (Bogen mehrfach ausgefuellt). ",
"Angezeigt wird der neueste Datensatz. Fuer einen bestimmten Durchgang bitte das ",
"zugehoerige Pseudonym oben eingeben."), nrow(treffer_dat)))
}
row1 = treffer_dat[1, , drop = FALSE]
# Ausfuelldatum aus den Daten (nicht Sys.Date()).
dat = maas_datum_einzeln(row1)
datum_fehlt = is.na(dat$datum)
ausfuelldatum = if (datum_fehlt) NA_character_ else format(dat$datum, "%d.%m.%Y")
if (datum_fehlt) {
warnungen = c(warnungen, paste0(
"In den Daten wurde keine verwertbare Datumsspalte (",
paste(MAAS_DATUM_SPALTEN, collapse = " / "), ") gefunden. ",
"Der Word-Dateiname traegt den Zusatz 'undatiert'."))
}
# Wert-Extraktion je Item (maas_01 ... maas_15). Nicht-Item-Zeilen wie
# maas_intro / maas_submit werden nicht angesprochen und damit ignoriert.
item_vars = sprintf("maas_%02d", seq_len(MAAS_N_ITEMS))
items = do.call(rbind, lapply(seq_len(MAAS_N_ITEMS), function(i) {
v = item_vars[i]
oc = if (v %in% names(daten_maas)) daten_maas[[v]] else NULL
roh = if (v %in% names(row1)) row1[[v]][1] else NA
w = maas_item_wert(oc, roh)
data.frame(
nr = i,
text = maas_itemtext(oc, i),
roh = if (length(roh) == 0 || is.na(roh)) "NA" else as.character(roh)[1],
wert = w,
anker = if (!is.na(w)) MAAS_ANKER_TAB$anker[w] else NA_character_,
stringsAsFactors = FALSE
)
}))
n_fehlend = sum(is.na(items$wert))
if (n_fehlend == 0) {
rohwertsumme = sum(items$wert)
mittelwert = rohwertsumme / MAAS_N_ITEMS
} else {
rohwertsumme = NA_real_
mittelwert = NA_real_
warnungen = c(warnungen, sprintf(paste0(
"%d von %d Items konnten nicht eindeutig einer Antwortstufe zugeordnet werden. ",
"Ohne vollstaendige Itemwerte wird kein MAAS-Mittelwert berechnet. Betroffene ",
"Items sind unten markiert."), n_fehlend, MAAS_N_ITEMS))
}
chiffre_esc = gsub("[^A-Za-z0-9_.-]", "",
if (nzchar(chiffre)) chiffre else trimws(input$pseudonym))
if (!nzchar(chiffre_esc)) chiffre_esc = "unbekannt"
list(
typ = "ok",
chiffre = if (nzchar(chiffre)) chiffre else
paste0("(ohne Chiffre; Pseudonym ", substr(trimws(input$pseudonym), 1, 20), ")"),
chiffre_esc = chiffre_esc,
ausfuelldatum = ausfuelldatum,
datum_quelle = dat$quelle,
datum_fehlt = datum_fehlt,
session_sp = sess_sp,
items = items,
n_fehlend = n_fehlend,
rohwertsumme = rohwertsumme,
mittelwert = mittelwert,
warnungen = warnungen
)
})
fehler_praefix = function(typ) {
switch(typ,
leere_eingabe = "Eingabe unvollstaendig: ",
format_fehler = "Ungueltige Eingabe: ",
pfad_fehler = "Datenzugriff nicht moeglich: ",
skript_fehler = "Datenzugriff nicht moeglich: ",
kein_treffer_pseudo = "Kein Datensatz: ",
kein_treffer_daten = "Kein Datensatz: ",
"Fehler: "
)
}
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (identical(d$typ, "ok")) return(NULL)
if (identical(d$typ, "format_fehler")) {
return(div(class = "alert-fehler", paste0(
"Die Chiffre '", d$chiffre, "' hat nicht das erwartete Format ",
"(ein Grossbuchstabe gefolgt von 6 Ziffern, z.B. P000123). ",
"Alternativ ein Pseudonym eingeben.")))
}
div(class = "alert-fehler",
tags$strong(fehler_praefix(d$typ)),
tags$pre(style = "white-space:pre-wrap; margin:6px 0 0; font-family:inherit; font-weight:400;",
d$meldung))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (!identical(d$typ, "ok") || length(d$warnungen) == 0) return(NULL)
tagList(lapply(d$warnungen, function(w) div(class = "alert-warnung", w)))
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
req(identical(d$typ, "ok"))
datum_str = if (d$datum_fehlt) "nicht gefunden (undatiert)" else d$ausfuelldatum
items_ui = lapply(seq_len(nrow(d$items)), function(i) {
it = d$items[i, ]
if (!is.na(it$wert)) {
antwort = span(class = "item-antwort", paste0(it$anker, " (", it$wert, ")"))
} else {
antwort = span(class = "item-fehlt",
paste0("nicht zuzuordnen (roh: \"", it$roh, "\")"))
}
div(class = "item-zeile",
span(class = "item-nr", it$nr),
span(class = "item-text", it$text),
antwort
)
})
kennwert_block = if (is.na(d$mittelwert)) {
div(class = "alert-warnung",
paste0("Kein MAAS-Mittelwert berechnet: ", d$n_fehlend, " von ", MAAS_N_ITEMS,
" Itemwerten fehlen. Die Einzelitems unten zeigen, welche."))
} else {
tagList(
div(style = "display:flex; align-items:center; gap:24px; flex-wrap:wrap;",
div(
div(class = "kennwert-zahl", komma1(d$mittelwert),
tags$span(" / 6,0", style = "font-size:1.2rem; color:#888; font-weight:600;")),
div(class = "kennwert-sub", "MAAS-Mittelwert (primaerer Kennwert)"),
div(class = "kennwert-sub",
paste0("Rohwertsumme ", d$rohwertsumme, " (Range ",
MAAS_N_ITEMS, "-", MAAS_N_ITEMS * 6, ")"))
),
div(style = "flex:1; min-width:320px;", plotOutput("skala", height = "185px"))
),
div(style = "margin-top:12px; padding:12px 16px; border-radius:6px; border-left:5px solid #9E9E9E; background:#F3F3F3; font-size:0.95em; line-height:1.55;",
MAAS_RICHTUNGSHINWEIS)
)
}
tagList(
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "MAAS-Auswertung"),
div(style = "color:#555; margin-bottom:14px;",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), datum_str
),
kennwert_block
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Hinweis zur Interpretation"),
div(class = "disclaimer", MAAS_DISCLAIMER)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", paste0("Einzelitems (1-", MAAS_N_ITEMS, ")")),
div(style = "font-size:0.82em; color:#777; margin-bottom:10px; line-height:1.5;",
paste0("Alle ", MAAS_N_ITEMS, " Items in Reihenfolge, mit gewaehltem Antworttext ",
"und zugehoerigem Zahlenwert (1 = Beinahe immer ... 6 = Beinahe nie). ",
"Kein Reverse-Scoring, keine Subskalen.")),
div(items_ui)
)
)
})
output$skala = renderPlot({
req(input$btn_suchen)
d = ergebnis()
req(identical(d$typ, "ok"))
req(!is.na(d$mittelwert))
maas_skala_plot(d$mittelwert)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
erg = tryCatch(ergebnis(), error = function(e) NULL)
if (!is.list(erg) || !identical(erg$typ, "ok")) return("MAAS_keine_auswertung.docx")
chiffre_esc = erg$chiffre_esc
ausfuelldatum_fn = if (is.na(erg$ausfuelldatum)) "undatiert" else
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d")
paste0("MAAS_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
erg = tryCatch(ergebnis(), error = function(e) NULL)
if (!is.list(erg) || !identical(erg$typ, "ok")) {
doc = read_docx()
doc = body_add_par(doc, paste0(
"Es liegt keine gueltige MAAS-Auswertung vor. Bitte zuerst im Fenster eine ",
"Auswertung erzeugen (Chiffre oder Pseudonym eingeben, 'Auswerten')."),
style = "Normal")
print(doc, target = file)
return(invisible(NULL))
}
doc = tryCatch(erstelle_maas_docx(erg), error = function(e) {
ed = read_docx()
body_add_par(ed, paste0("Fehler beim Erstellen des Word-Dokuments: ", conditionMessage(e)),
style = "Normal")
})
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui = ui, server = server)