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

563 lines
20 KiB
R

# Praeambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_asrs.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
ASRS_DISCLAIMER = paste0(
"Fuer den ASRS-v1.1-Screener liegt in dieser Installation keine verifizierte ",
"Cutoff-/Schwellenregel vor. Die aufgefuehrten Antworten sind Rohdaten ohne ",
"automatisierte Interpretation und muessen von der behandelnden Fachperson ",
"eigenstaendig bewertet werden. Die farbliche Kennzeichnung der Antwortstufen ",
"dient ausschliesslich der Lesbarkeit der Einzelantworten und stellt keine ",
"klinische Bewertung oder Einordnung dar."
)
# Reihenfolge der ASRS-Antwortstufen (fuer die Badge-Farbzuordnung); die
# Zuordnung Rohwert -> Text selbst kommt immer aus dem labels-Attribut.
ASRS_STUFEN_REIHENFOLGE = c("nie", "selten", "manchmal", "oft", "sehr oft")
# Verlauf gruen -> dunkelrot, rein zur Lesbarkeit, keine Schweregrad-Aussage.
ASRS_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#F48FB1",
"2" = "#EF5350",
"3" = "#B71C1C",
"4" = "#4A0000"
)
ASRS_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "white",
"3" = "white",
"4" = "white"
)
library(shiny)
library(dplyr)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
# Infrastruktur ####
APP_VERZEICHNIS = normalizePath(getwd())
absPath = function(pfad) {
if (grepl("^([A-Za-z]:[/\\\\]|/)", pfad)) return(pfad)
file.path(APP_VERZEICHNIS, pfad)
}
PFAD_DOWNLOAD_SKRIPT = normalizePath(absPath(PFAD_DOWNLOAD_SKRIPT), mustWork = FALSE)
PFAD_PSEUDONYM_SKRIPT = normalizePath(absPath(PFAD_PSEUDONYM_SKRIPT), mustWork = FALSE)
# Helper ####
# Entfernt Markdown-Sternchen und loest formr's Backslash-Maskierung vor dem
# Punkt nach der Itemnummer (z.B. "1\. Wie oft...") zu einem literalen Punkt
# auf - sonst bleibt der maskierte Punkt als sichtbarer Rest im Fragetext.
bereinige_text = function(x) {
if (is.null(x) || length(x) == 0 || is.na(x[1])) return(NA_character_)
x = trimws(as.character(x[1]))
x = gsub("\\*\\*", "", x)
x = gsub("\\.", ".", x, fixed = TRUE)
trimws(x)
}
# Ordnet den aufgeloesten Antworttext ueber ASRS_STUFEN_REIHENFOLGE einer
# 0-basierten Position zu (nur fuer die Badge-Farbe, nicht fuer die Anzeige
# des Antworttexts selbst, der immer aus dem labels-Attribut stammt).
asrs_stufe_index = function(stufentext) {
if (is.null(stufentext) || length(stufentext) == 0 || is.na(stufentext)) return(NA_integer_)
pos = match(tolower(trimws(stufentext)), tolower(ASRS_STUFEN_REIHENFOLGE))
if (is.na(pos)) return(NA_integer_)
as.integer(pos - 1L)
}
# Leitet die Itemnummer aus dem Spaltennamen ab (asrs_01 -> "1"), falls sie
# nicht aus dem label-Attribut gewonnen werden kann.
asrs_ableite_nr_aus_varname = function(var_name) {
as.character(as.integer(sub("^asrs_", "", var_name)))
}
# Trennt die fuehrende Itemnummer (z.B. "1. ") vom Fragetext im
# bereinigten label-Attribut. Faellt auf den Spaltennamen zurueck, falls
# das label-Attribut keine fuehrende Nummer enthaelt.
asrs_itemnummer_und_text = function(original_col, var_name) {
raw = bereinige_text(attr(original_col, "label"))
if (is.na(raw)) {
return(list(nr = asrs_ableite_nr_aus_varname(var_name), text = NA_character_))
}
m = regmatches(raw, regexpr("^\\d+\\.\\s*", raw))
if (length(m) > 0 && nchar(m) > 0) {
nr = sub("\\.\\s*$", "", trimws(m))
text = trimws(sub("^\\d+\\.\\s*", "", raw))
return(list(nr = nr, text = text))
}
list(nr = asrs_ableite_nr_aus_varname(var_name), text = raw)
}
# Loest den Rohwert ausschliesslich ueber das labels-Attribut der
# ORIGINAL-Spalte (vor Subsetting) zum Antworttext auf - nie hartkodiert.
asrs_get_stufentext = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
labels_vec = attr(original_col, "labels")
if (is.null(labels_vec) || length(labels_vec) == 0) return(NA_character_)
pos = match(as.numeric(wert[1]), as.vector(labels_vec))
if (is.na(pos)) return(NA_character_)
bereinige_text(names(labels_vec)[pos])
}
# Baut die sechs ASRS-Items (Abschnitt A) als Liste.
asrs_build_items = function(daten, zeile) {
vars = paste0("asrs_", sprintf("%02d", 1:6))
lapply(vars, function(var) {
original_col = daten[[var]]
wert = zeile[[var]]
nt = asrs_itemnummer_und_text(original_col, var)
stufentext = asrs_get_stufentext(original_col, wert)
list(
var = var,
nr = nt$nr,
text = if (!is.na(nt$text)) nt$text else var,
stufentext = stufentext,
stufe = asrs_stufe_index(stufentext),
rohwert = if (length(wert) > 0) suppressWarnings(as.numeric(wert[1])) else NA_real_
)
})
}
# 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;
}
.alert-warnung {
background: #FFF3E0; border-left: 5px solid #E65100;
padding: 10px 16px; border-radius: 4px; color: #BF360C;
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 9px 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; }
.item-rohwert { font-size: 0.78em; color: #999; margin-left: 6px; }
.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: #F48FB1; color: #333333; }
.stufe-badge-2 { background: #EF5350; color: white; }
.stufe-badge-3 { background: #B71C1C; color: white; }
.stufe-badge-4 { background: #4A0000; color: white; }
.stufe-unbeantwortet { color: #888; font-style: italic; font-size: 0.85em; flex-shrink: 0; }
"
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("ASRS-v1.1-Screener - Abschnitt A"),
tags$p("Adult ADHD Self-Report Scale | Anzeige der sechs Rohantworten")
),
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_asrs_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_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_unbeantw = fp_text(italic = TRUE, font.size = 10, color = "#888888")
doc = body_add_fpar(doc, fpar(ftext("ASRS-v1.1-Screener - Abschnitt A", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(erg$chiffre, fp_normal),
ftext(" Datum: ", fp_label),
ftext(erg$ausfuelldatum, 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_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(ASRS_DISCLAIMER, fp_disclaimer)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
for (item in erg$items) {
sk = if (!is.na(item$stufe) && item$stufe >= 0L && item$stufe <= 4L)
as.character(item$stufe) else NA_character_
if (is.na(item$stufentext)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(item$nr, ". ", item$text, " "), fp_normal),
ftext(" nicht beantwortet ", fp_unbeantw)
))
} else if (is.na(sk)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(item$nr, ". ", item$text, " "), fp_normal),
ftext(paste0(" ", item$stufentext, " "), fp_text(bold = TRUE, font.size = 10))
))
} else {
fp_badge = fp_text(
color = ASRS_BADGE_TEXT_FARBEN[[sk]],
bold = TRUE,
shading.color = ASRS_BADGE_FARBEN[[sk]],
font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(item$nr, ". ", item$text, " "), fp_normal),
ftext(paste0(" ", item$stufentext, " "), fp_badge)
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(ASRS_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_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", chiffre = chiffre))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "pfad_fehler",
meldung = paste0("Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "pfad_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
}
ok = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok$ok) return(list(typ = "skript_fehler", meldung = ok$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 = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok$ok) return(list(typ = "skript_fehler", meldung = ok$msg))
if (!exists("daten_asrs", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen",
meldung = "Objekt 'daten_asrs' nach dem Sourcen nicht gefunden. Bitte Download-Skript pruefen."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen",
meldung = "Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript pruefen."))
}
daten_asrs = get("daten_asrs", envir = .GlobalEnv)
pseudo = get("pseudo", envir = .GlobalEnv)
alle_session_ids = character(0)
if (nchar(chiffre) > 0) {
treffer_ps = pseudo[pseudo$chiffre == chiffre, ]
alle_session_ids = unique(treffer_ps$pseudonym)
}
if (nchar(trimws(input$pseudonym)) > 0) {
alle_session_ids = trimws(input$pseudonym)
if (nchar(chiffre) == 0) {
pw_treffer = pseudo[pseudo$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
}
if (length(alle_session_ids) == 0 || all(is.na(alle_session_ids)) ||
all(trimws(as.character(alle_session_ids)) == "")) {
return(list(typ = "kein_treffer", meldung = "Chiffre/Pseudonym nicht gefunden."))
}
treffer_dat = daten_asrs[daten_asrs$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0) {
return(list(typ = "kein_treffer",
meldung = paste0("Kein ASRS-Datensatz fuer Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprueft)")))
}
info_mehrere = 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"
)
info_mehrere = paste0(
"Mehrere Ausfuellungen gefunden (", n, " Eintraege). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
)
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
# Ausfuelldatum aus dem Zeitstempelfeld 'created' der formr-Ergebnisdaten,
# nicht aus Sys.Date().
ausfuelldatum = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
list(
typ = "erfolg",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
info_mehrere = info_mehrere,
items = asrs_build_items(daten_asrs, zeile)
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (erg$typ == "leere_eingabe") {
div(class = "alert-fehler", erg$meldung)
} else if (erg$typ == "format_fehler") {
div(class = "alert-fehler",
paste0("Ungueltige Chiffre '", erg$chiffre, "'. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123)."))
} else if (erg$typ == "pfad_fehler") {
div(class = "alert-fehler", erg$meldung)
} else if (erg$typ == "skript_fehler") {
div(class = "alert-fehler", paste0("Fehler beim Ausfuehren eines Skripts: ", erg$meldung))
} else if (erg$typ == "daten_fehlen") {
div(class = "alert-fehler", erg$meldung)
} else if (erg$typ == "kein_treffer") {
div(class = "alert-fehler", erg$meldung)
}
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (erg$typ != "erfolg" || is.null(erg$info_mehrere)) return(NULL)
div(class = "alert-warnung", erg$info_mehrere)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
erg = ergebnis_r()
if (erg$typ != "erfolg") return(NULL)
baue_item_zeile = function(item) {
sk = if (!is.na(item$stufe) && item$stufe >= 0L && item$stufe <= 4L)
as.character(item$stufe) else NA_character_
antwort = if (is.na(item$stufentext)) {
span(class = "stufe-unbeantwortet", "nicht beantwortet")
} else if (is.na(sk)) {
tagList(
span(class = "stufe-badge", style = "background:#E0E0E0; color:#333;", item$stufentext),
if (!is.na(item$rohwert)) span(class = "item-rohwert", paste0("(", item$rohwert, ")"))
)
} else {
tagList(
span(class = paste0("stufe-badge stufe-badge-", sk), item$stufentext),
if (!is.na(item$rohwert)) span(class = "item-rohwert", paste0("(", item$rohwert, ")"))
)
}
div(class = "item-zeile",
div(class = "item-nr", paste0(item$nr, ".")),
div(class = "item-text", item$text),
antwort
)
}
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "ASRS-v1.1 - Abschnitt A"),
div(class = "meta-block",
tags$strong("Chiffre: "), erg$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), erg$ausfuelldatum
),
div(class = "abschnitt-karte", style = "border-left: 5px solid #9E9E9E; background: #FAFAFA; box-shadow: none; margin-bottom: 0; padding: 14px 18px; font-size: 0.88em; color: #555;",
ASRS_DISCLAIMER
),
tags$hr(),
div(lapply(erg$items, baue_item_zeile))
)
})
output$download_word = downloadHandler(
filename = function() {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(erg) && identical(erg$typ, "erfolg") && nchar(erg$chiffre) > 0)
erg$chiffre else "export"
ausfuelldatum_fn = if (is.list(erg) && identical(erg$typ, "erfolg") && !is.null(erg$ausfuelldatum))
tryCatch(
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
else
format(Sys.Date(), "%Y%m%d")
paste0("ASRS_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(erg) && identical(erg$typ, "erfolg")
if (!daten_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_asrs_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, server)