760 lines
30 KiB
R
760 lines
30 KiB
R
# 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)
|