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

742 lines
29 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 ####
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
AKZENT_FARBE = "#8B2635"
# Facetten-Mapping laut Strukturprotokoll (Primärquelle, Abschnitt "Auswertung"
# des Originaldokuments Strosahl & Robinson, 2014). Facettennamen bewusst wie im
# Original ("Abstand", "Selbstmitgefühl"), Anzeigereihenfolge = Listenreihenfolge.
# Item-Nummern beziehen sich auf die im Fragebogen sichtbare Nummerierung.
FFMQ_FACETTEN = list(
list(name = "Beobachten", items = c(6, 10, 15, 20), revers = integer(0), range_summe = "420"),
list(name = "Beschreiben", items = c(1, 2, 5, 11, 16), revers = c(5, 11), range_summe = "525"),
list(name = "Abstand", items = c(3, 9, 13, 18, 21), revers = integer(0), range_summe = "525"),
list(name = "Achtsam handeln", items = c(8, 12, 17, 22, 23), revers = c(8, 12, 17, 22, 23), range_summe = "525"),
list(name = "Selbstmitgefühl", items = c(4, 7, 14, 19, 24), revers = c(4, 7, 14, 19, 24), range_summe = "525")
)
# Farbverlauf hell -> AKZENT_FARBE für die korrigierten Itemwerte 1-5. Ein hoher
# Wert bedeutet mehr Achtsamkeit, keine klinische Schwere-Konnotation.
FFMQ_BADGE_BG = c("#F0D9DB", "#DBAAB0", "#C17D85", "#A24F58", "#8B2635")
FFMQ_BADGE_FG = c("#5A1F25", "#4A1A1F", "#FFFFFF", "#FFFFFF", "#FFFFFF")
FFMQ_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Es liegen keine validierten Cutoff- oder Normwerte fuer dieses Instrument vor; ",
"die dargestellten Werte sind rein deskriptiv."
)
# Infrastruktur ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_ffmq24.R" # liefert: daten_ffmq24
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
APP_VERZEICHNIS = normalizePath(getwd())
absPath = function(pfad) {
if (grepl("^([A-Za-z]:[/\\\\]|/)", pfad)) return(pfad)
file.path(APP_VERZEICHNIS, pfad)
}
PFAD_DOWNLOAD_SKRIPT = normalizePath(absPath(PFAD_DOWNLOAD_SKRIPT), mustWork = FALSE)
PFAD_PSEUDONYM_SKRIPT = normalizePath(absPath(PFAD_PSEUDONYM_SKRIPT), mustWork = FALSE)
# Helper ####
# Markdown-Sternchen aus dem xlsx-Label entfernen.
ffmq_clean_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
trimws(gsub("\\*\\*", "", as.character(text[1])))
}
# label-Attribut hat die Form "**N. Text**". Nummer und Text aus dem Label lesen
# (nicht aus dem Spaltennamen), da das Label die im xlsx sichtbare Nummerierung ist.
ffmq_parse_label = function(roh_label, spalte) {
fallback_nr = suppressWarnings(as.integer(sub("^ffmq_0*", "", spalte)))
clean = ffmq_clean_label(roh_label)
if (is.na(clean)) {
return(list(nr = fallback_nr, text = paste0("Item ", fallback_nr)))
}
m = regmatches(clean, regexec("^(\\d+)\\.\\s*(.+)$", clean))[[1]]
if (length(m) == 3) {
list(nr = as.integer(m[2]), text = trimws(m[3]))
} else {
list(nr = fallback_nr, text = clean)
}
}
# mc-Items liegen im formr-Export vermutlich als dbl+lbl vor. Die Annahme ist für
# den Itemtyp mc NICHT am echten Export verifiziert. Als valide gilt nur ein
# labels-Attribut mit genau 5 Einträgen und numerischen Werten 1-5.
ffmq_labels_valide = function(labels_attr) {
!is.null(labels_attr) &&
length(labels_attr) == 5 &&
is.numeric(as.vector(labels_attr)) &&
setequal(as.vector(labels_attr), 1:5)
}
# Rohwert (1-5) und aufgelösten Klartext ausschließlich über das labels-Attribut
# der Original-Spalte bestimmen, niemals über eine hartkodierte Zuordnung.
ffmq_wert_und_text = function(original_col, wert) {
labels_attr = attr(original_col, "labels")
roh = suppressWarnings(as.numeric(wert[1]))
klartext = NA_character_
if (!is.null(labels_attr) && length(labels_attr) > 0 && !is.na(roh)) {
pos = which(as.vector(labels_attr) == roh)
if (length(pos) > 0) klartext = names(labels_attr)[pos[1]]
}
list(wert_roh = roh, klartext = klartext)
}
# Data-Frame der 5 Facetten in fester Anzeigereihenfolge für Grafik und Report.
ffmq_facetten_df = function(facetten) {
data.frame(
facette = factor(vapply(facetten, function(f) f$name, character(1)),
levels = vapply(facetten, function(f) f$name, character(1))),
mittelwert = vapply(facetten, function(f) f$mittelwert, numeric(1)),
summe = vapply(facetten, function(f) f$summe, numeric(1)),
range_summe = vapply(facetten, function(f) f$range_summe, character(1)),
n = vapply(facetten, function(f) f$n, numeric(1)),
stringsAsFactors = FALSE
)
}
# Radar-/Spinnennetzdiagramm über die normalisierten Facettenmittelwerte (1-5).
# Manuelle Trigonometrie + coord_fixed() statt coord_polar(): coord_polar() krümmt
# die geraden Polygonkanten zwischen den Achsen zu Bögen (bekanntes ggplot2-
# Artefakt). Gleiches Vorgehen wie iipc/app.R und vds36/app.R.
ffmq_radar_plot = function(fac_df, akzent) {
n = nrow(fac_df)
winkel = pi / 2 - (seq_len(n) - 1) * (2 * pi / n)
labels_wrapped = vapply(as.character(fac_df$facette),
function(t) paste(strwrap(t, width = 12), collapse = "\n"),
character(1), USE.NAMES = FALSE)
poly = data.frame(
x = fac_df$mittelwert * cos(winkel),
y = fac_df$mittelwert * sin(winkel)
)
poly_geschlossen = rbind(poly, poly[1, ])
ringe = 1:5
ring_df = do.call(rbind, lapply(ringe, function(rw) {
d = data.frame(x = rw * cos(winkel), y = rw * sin(winkel), ring = rw)
rbind(d, d[1, ])
}))
achsen_df = data.frame(x0 = 0, y0 = 0, x1 = 5 * cos(winkel), y1 = 5 * sin(winkel))
label_df = data.frame(x = 6.2 * cos(winkel), y = 6.2 * sin(winkel), label = labels_wrapped)
ring_label_df = data.frame(x = 0.18, y = ringe, label = as.character(ringe))
ggplot() +
geom_path(data = ring_df, aes(x = x, y = y, group = ring), color = "#DDDDDD") +
geom_segment(data = achsen_df, aes(x = x0, y = y0, xend = x1, yend = y1), color = "#DDDDDD") +
geom_polygon(data = poly_geschlossen, aes(x = x, y = y),
fill = akzent, alpha = 0.28, color = akzent, linewidth = 0.9) +
geom_point(data = poly_geschlossen, aes(x = x, y = y), color = akzent, size = 1.9) +
geom_text(data = label_df, aes(x = x, y = y, label = label), size = 3.1, lineheight = 0.85) +
geom_text(data = ring_label_df, aes(x = x, y = y, label = label), size = 2.7, color = "#888888") +
coord_fixed(xlim = c(-8, 8), ylim = c(-7.5, 7.5), clip = "off") +
theme_void(base_size = 11)
}
# Horizontales Balkendiagramm über die normalisierten Facettenmittelwerte (1-5).
# Zusätzlich Mittelwert numerisch und Rohsumme mit Range je Balken.
ffmq_balken_plot = function(fac_df, akzent) {
df = fac_df
df$facette = factor(as.character(df$facette), levels = rev(as.character(df$facette)))
df$beschriftung = sprintf("%.2f Summe: %d (Range %s)",
df$mittelwert, as.integer(df$summe), df$range_summe)
ggplot(df, aes(x = mittelwert, y = facette)) +
geom_col(fill = akzent, width = 0.6) +
geom_text(aes(label = beschriftung), hjust = -0.03, size = 3.2, color = "#333333") +
scale_x_continuous(breaks = 1:5, expand = expansion(mult = c(0, 0.02))) +
coord_cartesian(xlim = c(1, 5), clip = "off") +
labs(x = "Mittelwert pro Item (15)", y = NULL) +
theme_minimal(base_size = 12) +
theme(
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
plot.margin = margin(t = 6, r = 165, b = 6, l = 6)
)
}
# 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; }
#download_word {
background: #8B2635; color: white; border: none;
font-weight: 600; padding: 8px 20px; border-radius: 4px;
}
#download_word:hover { background: #6d1e29; color: white; }
.alert-fehler {
background: #FFEBEE; border-left: 5px solid #C62828;
padding: 12px 16px; border-radius: 4px; color: #B71C1C;
margin-bottom: 12px; font-weight: 500;
}
.alert-warnung {
background: #FFF3E0; border-left: 5px solid #E65100;
padding: 10px 16px; border-radius: 4px; color: #BF360C;
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 6px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.facette-zeile {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 18px;
padding: 9px 0; border-bottom: 1px solid #F0F0F0;
}
.facette-name { font-weight: 700; color: #333; min-width: 190px; }
.facette-kennwert { color: #444; font-size: 0.92em; }
.facette-mw { color: #8B2635; font-weight: 700; }
.facetten-gruppe { margin-bottom: 18px; }
.facetten-gruppe-titel {
font-weight: 700; color: #8B2635; margin: 10px 0 6px;
font-size: 0.98em;
}
.item-zeile {
display: flex; align-items: center; gap: 10px;
padding: 6px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 28px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.item-antwort {
color: #666; font-size: 0.85em; min-width: 190px;
text-align: right; white-space: nowrap; flex-shrink: 0;
}
.revers-mark {
color: #8B2635; font-size: 0.72em; margin-left: 3px; cursor: help;
}
.item-wert-badge-1, .item-wert-badge-2, .item-wert-badge-3,
.item-wert-badge-4, .item-wert-badge-5, .item-wert-badge-na {
border-radius: 4px; padding: 2px 9px; font-weight: 700; font-size: 0.82em;
white-space: nowrap; display: inline-block; flex-shrink: 0; min-width: 26px;
text-align: center;
}
.item-wert-badge-1 { background: #F0D9DB; color: #5A1F25; }
.item-wert-badge-2 { background: #DBAAB0; color: #4A1A1F; }
.item-wert-badge-3 { background: #C17D85; color: #FFFFFF; }
.item-wert-badge-4 { background: #A24F58; color: #FFFFFF; }
.item-wert-badge-5 { background: #8B2635; color: #FFFFFF; }
.item-wert-badge-na { background: #E0E0E0; color: #555555; }
.disclaimer-text { font-size: 0.85em; color: #555; line-height: 1.5; }
"
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("FFMQ-24 Fünf-Facetten-Achtsamkeitsfragebogen (Kurzform)"),
tags$p("Strosahl & Robinson (2014) | 24 Items, 5 Facetten rein deskriptive 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_ffmq24_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_warnung = fp_text(font.size = 10, italic = TRUE, color = "#BF360C")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_gruppe = fp_text(bold = TRUE, font.size = 11, color = AKZENT_FARBE)
doc = body_add_fpar(doc, fpar(ftext("FFMQ-24 Auswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label), ftext(erg$datum_str, fp_normal)
))
for (w in erg$warnungen) {
doc = body_add_fpar(doc, fpar(ftext(w, fp_warnung)))
}
doc = body_add_par(doc, "", style = "Normal")
fac_df = ffmq_facetten_df(erg$facetten)
doc = body_add_fpar(doc, fpar(ftext("Facettenprofil (Mittelwerte pro Item, 15)", fp_abschnitt)))
radar_png = tempfile(fileext = ".png")
ok_radar = tryCatch({
ggsave(radar_png, plot = ffmq_radar_plot(fac_df, AKZENT_FARBE),
width = 5, height = 4.6, dpi = 150, bg = "white")
TRUE
}, error = function(e) FALSE)
if (ok_radar) doc = body_add_img(doc, src = radar_png, width = 4.8, height = 4.4)
balken_png = tempfile(fileext = ".png")
ok_balken = tryCatch({
ggsave(balken_png, plot = ffmq_balken_plot(fac_df, AKZENT_FARBE),
width = 7.5, height = 3.2, dpi = 150, bg = "white")
TRUE
}, error = function(e) FALSE)
if (ok_balken) doc = body_add_img(doc, src = balken_png, width = 6.5, height = 2.8)
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Facetten deskriptive Kennwerte", fp_abschnitt)))
for (f in erg$facetten) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(f$name, ": "), fp_label),
ftext(sprintf("Mittelwert %.2f", f$mittelwert), fp_normal),
ftext(sprintf(" | Summe %d (Range %s) | %d Items",
as.integer(f$summe), f$range_summe, f$n), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems nach Facette", fp_abschnitt)))
aktuelle_gruppe = ""
for (it in erg$items) {
if (!identical(it$facette, aktuelle_gruppe)) {
aktuelle_gruppe = it$facette
doc = body_add_fpar(doc, fpar(ftext(aktuelle_gruppe, fp_gruppe)))
}
korr = it$wert_korrigiert
idx = if (!is.na(korr) && korr >= 1 && korr <= 5) as.integer(korr) else NA_integer_
fp_badge = if (is.na(idx)) {
fp_text(bold = TRUE, font.size = 10, color = "#555555", shading.color = "#E0E0E0")
} else {
fp_text(bold = TRUE, font.size = 10,
color = FFMQ_BADGE_FG[idx], shading.color = FFMQ_BADGE_BG[idx])
}
antwort_txt = if (is.na(it$klartext)) "k. A." else it$klartext
badge_txt = if (is.na(korr)) " k. A. " else sprintf(" %d ", as.integer(korr))
revers_txt = if (isTRUE(it$revers)) " (reverskodiert: 6 - Rohantwort)" else ""
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0("Antwort: ", antwort_txt, " → "), fp_text(font.size = 10, color = "#555555")),
ftext(badge_txt, fp_badge),
ftext(revers_txt, fp_text(font.size = 9, italic = TRUE, color = "#777777"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(FFMQ_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(ok = FALSE, typ = "leere_eingabe",
meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(ok = FALSE, typ = "format_fehler", chiffre = chiffre,
meldung = sprintf(
"Chiffre '%s' hat kein gueltiges Format (erwartet: ein Grossbuchstabe + 6 Ziffern, z.B. P000123).",
chiffre)))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(ok = FALSE, meldung = sprintf("Download-Skript nicht gefunden:\n%s", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(ok = FALSE, meldung = sprintf("Pseudonym-Skript nicht gefunden:\n%s", PFAD_PSEUDONYM_SKRIPT)))
}
ok_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_dl$ok) {
return(list(ok = FALSE, meldung = paste0("Fehler im Download-Skript: ", ok_dl$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()
wd_ziel = if (!is.null(db_ordner)) db_ordner else
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
setwd(wd_ziel)
on.exit(setwd(alter_wd), add = TRUE)
ok_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_ps$ok) {
return(list(ok = FALSE, meldung = paste0("Fehler im Pseudonym-Skript: ", ok_ps$msg)))
}
if (!exists("daten_ffmq24", envir = .GlobalEnv)) {
return(list(ok = FALSE, meldung = "Objekt 'daten_ffmq24' nach dem Sourcen nicht gefunden. Bitte Download-Skript pruefen."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(ok = FALSE, meldung = "Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript pruefen."))
}
daten_ffmq24 = get("daten_ffmq24", envir = .GlobalEnv)
pseudo = get("pseudo", envir = .GlobalEnv)
if (!("session" %in% colnames(daten_ffmq24))) {
return(list(ok = FALSE, meldung = "Spalte 'session' wurde in daten_ffmq24 nicht gefunden. Spaltenname im Download-Skript pruefen."))
}
if (!("created" %in% colnames(daten_ffmq24))) {
return(list(ok = FALSE, meldung = "Spalte 'created' wurde in daten_ffmq24 nicht gefunden. Spaltenname im Download-Skript pruefen."))
}
# Chiffre-Rueckaufloesung aus dem Pseudonym, damit Kopfzeile/Dateiname auch
# bei reiner Pseudonym-Eingabe die korrekte Chiffre zeigen.
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo[pseudo$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
treffer_ps = pseudo[pseudo$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(ok = FALSE, meldung = sprintf(
"Chiffre '%s' wurde in der Pseudonym-Datenbank nicht gefunden.", chiffre)))
}
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten_ffmq24[daten_ffmq24$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0) {
return(list(ok = FALSE, meldung = sprintf(
"Kein FFMQ-24-Datensatz fuer Chiffre '%s' gefunden. (%d Pseudonym(e) geprueft)",
chiffre, length(alle_session_ids))))
}
warnungen = character(0)
mehrfach_hinweis = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
treffer_dat = treffer_dat[order(treffer_dat$created, decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat$created[1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum")
mehrfach_hinweis = sprintf(
"Mehrere Ausfuellungen gefunden (%d Eintraege). Angezeigt wird die neueste vom %s.",
n, datum_neu)
warnungen = c(warnungen, mehrfach_hinweis)
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
datum_str = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y"))
spalten = sprintf("ffmq_%02d", 1:24)
fehlende_spalten = spalten[!(spalten %in% colnames(daten_ffmq24))]
if (length(fehlende_spalten) > 0) {
return(list(ok = FALSE, meldung = sprintf(
"Item-Spalten fehlen im FFMQ-24-Export: %s",
paste(fehlende_spalten, collapse = ", "))))
}
# labels-Attribut jeder Item-Spalte pruefen (5 Eintraege, numerische Werte 1-5).
for (sp in spalten) {
if (!ffmq_labels_valide(attr(daten_ffmq24[[sp]], "labels"))) {
return(list(ok = FALSE, meldung = paste0(
"Datenstruktur des FFMQ-24-Exports weicht vom erwarteten Format ab, ",
"bitte Exportformat pruefen. (betroffene Spalte: ", sp, ")")))
}
}
items_raw = lapply(spalten, function(sp) {
lab = ffmq_parse_label(attr(daten_ffmq24[[sp]], "label"), sp)
wt = ffmq_wert_und_text(daten_ffmq24[[sp]], zeile[[sp]])
list(spalte = sp, nr = lab$nr, text = lab$text,
wert_roh = wt$wert_roh, klartext = wt$klartext)
})
nrs = vapply(items_raw, function(x) x$nr, integer(1))
if (!setequal(nrs, 1:24)) {
for (i in seq_along(items_raw)) items_raw[[i]]$nr = i
warnungen = c(warnungen, paste0(
"Item-Nummern konnten nicht eindeutig aus den Labels gelesen werden; ",
"die Reihenfolge der Export-Spalten wird verwendet."))
}
n_fehlend = sum(vapply(items_raw, function(x)
is.na(x$wert_roh) || x$wert_roh < 1 || x$wert_roh > 5, logical(1)))
if (n_fehlend > 0) {
warnungen = c(warnungen, sprintf(
"%d von 24 Items ohne gueltige Antwort (1-5); Facettenwerte werden ohne diese Items berechnet.",
n_fehlend))
}
wert_nach_nr = setNames(
vapply(items_raw, function(x) x$wert_roh, numeric(1)),
as.character(vapply(items_raw, function(x) x$nr, numeric(1)))
)
facetten = lapply(FFMQ_FACETTEN, function(f) {
roh = as.numeric(wert_nach_nr[as.character(f$items)])
roh[roh < 1 | roh > 5] = NA
korr = ifelse(f$items %in% f$revers, 6 - roh, roh)
summe = sum(korr, na.rm = TRUE)
list(name = f$name, items = f$items, revers = f$revers, n = length(f$items),
range_summe = f$range_summe, summe = summe,
mittelwert = summe / length(f$items))
})
items = do.call(c, lapply(FFMQ_FACETTEN, function(f) {
lapply(f$items, function(nr) {
pos = which(vapply(items_raw, function(x) x$nr, numeric(1)) == nr)
it = items_raw[[pos[1]]]
revcoded = nr %in% f$revers
roh = it$wert_roh
roh_gueltig = !is.na(roh) && roh >= 1 && roh <= 5
korr = if (!roh_gueltig) NA_real_ else if (revcoded) 6 - roh else roh
list(facette = f$name, nr = nr, text = it$text,
klartext = if (roh_gueltig) it$klartext else NA_character_,
wert_roh = if (roh_gueltig) roh else NA_real_,
wert_korrigiert = korr, revers = revcoded)
})
}))
list(
ok = TRUE,
chiffre = chiffre,
datum_str = datum_str,
ausfuelldatum = datum_str,
mehrfach_hinweis = mehrfach_hinweis,
facetten = facetten,
items = items,
warnungen = warnungen,
meldung = NULL
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (!isTRUE(erg$ok)) div(class = "abschnitt-karte", div(class = "alert-fehler", erg$meldung))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (!isTRUE(erg$ok) || length(erg$warnungen) == 0) return(NULL)
div(class = "abschnitt-karte",
lapply(erg$warnungen, function(w) div(class = "alert-warnung", w)))
})
output$radar_plot = renderPlot({
req(input$btn_suchen)
erg = ergebnis_r()
req(isTRUE(erg$ok))
ffmq_radar_plot(ffmq_facetten_df(erg$facetten), AKZENT_FARBE)
}, bg = "transparent")
output$balken_plot = renderPlot({
req(input$btn_suchen)
erg = ergebnis_r()
req(isTRUE(erg$ok))
ffmq_balken_plot(ffmq_facetten_df(erg$facetten), AKZENT_FARBE)
}, bg = "transparent")
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (!isTRUE(erg$ok)) return(NULL)
facetten_zeilen = lapply(erg$facetten, function(f) {
div(class = "facette-zeile",
div(class = "facette-name", f$name),
div(class = "facette-kennwert",
"Mittelwert ", span(class = "facette-mw", sprintf("%.2f", f$mittelwert))),
div(class = "facette-kennwert",
sprintf("Summe %d (Range %s)", as.integer(f$summe), f$range_summe)),
div(class = "facette-kennwert", sprintf("%d Items", f$n))
)
})
gruppen_namen = unique(vapply(erg$items, function(x) x$facette, character(1)))
item_gruppen = lapply(gruppen_namen, function(gn) {
zeilen = lapply(Filter(function(x) identical(x$facette, gn), erg$items), function(it) {
korr = it$wert_korrigiert
badge_klasse = if (is.na(korr)) "item-wert-badge-na" else paste0("item-wert-badge-", as.integer(korr))
badge_text = if (is.na(korr)) "k. A." else as.character(as.integer(korr))
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
div(class = "item-antwort", if (is.na(it$klartext)) "k. A." else it$klartext),
span(class = badge_klasse, badge_text),
if (isTRUE(it$revers))
tags$sup(class = "revers-mark",
title = "reverskodiert: Badge-Wert = 6 - Rohantwort", "(R)")
)
})
div(class = "facetten-gruppe",
div(class = "facetten-gruppe-titel", gn),
div(zeilen)
)
})
div(
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "FFMQ-24 Ergebnisübersicht"),
div(class = "meta-block",
tags$strong("Chiffre: "), erg$chiffre,
tags$span(style = "color:#ccc; margin:0 8px;", "|"),
tags$strong("Ausfülldatum: "), erg$datum_str
)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Facettenprofil (Mittelwerte pro Item, 15)"),
plotOutput("radar_plot", height = "380px"),
plotOutput("balken_plot", height = "260px")
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Facetten deskriptive Kennwerte"),
div(facetten_zeilen)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Einzelitems nach Facette"),
div(style = "font-size:0.82em; color:#777; margin-bottom:10px;",
"(R) = reverskodiertes Item, Badge zeigt den korrigierten Wert (6 - Rohantwort)."),
div(item_gruppen)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Hinweis zur Interpretation"),
p(class = "disclaimer-text", FFMQ_DISCLAIMER)
)
)
})
output$download_word = downloadHandler(
filename = function() {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
if (is.null(erg) || !isTRUE(erg$ok)) return("FFMQ24_Auswertung.docx")
chiffre_esc = gsub("[^A-Za-z0-9]", "", erg$chiffre)
ausfuelldatum_fn = tryCatch(
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d"))
paste0("FFMQ24_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
if (is.null(erg) || !isTRUE(erg$ok)) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre oder Pseudonym eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_ffmq24_docx(erg),
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 = ui, server = server)