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

869 lines
32 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.

# Praeambel ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_iss20r.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
PFAD_NORMTABELLEN = "normtabellen"
ISS20R_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel für klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
)
ISS20R_HINWEIS_CUTOFF = paste0(
"Die verwendeten Schwellenwerte (ab 50 Punkten 'internetsuchtgefährdet', ab 60 Punkten ",
"'internetsüchtig') beruhen auf einer fachlich-inhaltlichen Festlegung der Testautoren ",
"und nicht auf einer statistisch ermittelten Trennschärfe. Es handelt sich um eine ",
"Orientierungsgrösse, keine empirisch scharf abgegrenzte diagnostische Grenze."
)
ISS20R_HINWEIS_NORMBASIS = paste0(
"Die Normwerttabellen stammen aus der Konstruktionsstichprobe von 1999, die mit der ",
"ursprünglichen Fassung von Item 4 (KV4) erhoben wurde. Die hier eingesetzte revidierte ",
"Fassung dieses Items gilt nach Angaben der Testautoren wegen hoher Iteminterkorrelation ",
"und kaum veränderter interner Konsistenz als weitgehend austauschbar. Die Normwerte ",
"selbst wurden jedoch nicht empirisch neu an der revidierten Fassung berechnet."
)
# Farbverlauf gruen -> dunkelrot fuer die 4 Antwortstufen (1 = trifft nicht zu, 4 = trifft genau zu).
ISS20R_BADGE_FARBEN = c(
"1" = "#4CAF50",
"2" = "#F48FB1",
"3" = "#EF5350",
"4" = "#B71C1C"
)
ISS20R_BADGE_TEXT_FARBEN = c(
"1" = "white",
"2" = "#333333",
"3" = "white",
"4" = "white"
)
ISS20R_KLASS_WORD_FARBEN = list(
unauffaellig = list(bg = "#E8F5E9", text = "#2E7D32"),
gefaehrdet = list(bg = "#FFF3E0", text = "#E65100"),
suechtig = list(bg = "#FFEBEE", text = "#B71C1C")
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# 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)
PFAD_NORMTABELLEN = normalizePath(absPath(PFAD_NORMTABELLEN), mustWork = FALSE)
# Helper ####
ISS20R_SUBSKALEN = data.frame(
kurz = c("KV", "EE", "TT", "NA", "NS"),
name = c(
"Kontrollverlust",
"Entzugserscheinungen",
"Toleranzentwicklung",
"Negative Konsequenzen Arbeit/Leistung",
"Negative Konsequenzen soziale Beziehungen"
),
stringsAsFactors = FALSE
)
ISS20R_ITEMS = data.frame(
nr = 1:20,
var = c(
"iss01_kv1", "iss02_kv2", "iss03_kv3", "iss04_kv4",
"iss05_ee1", "iss06_ee2", "iss07_ee3", "iss08_ee4",
"iss09_tt1", "iss10_tt2", "iss11_tt3", "iss12_tt4",
"iss13_na1", "iss14_na2", "iss15_na3", "iss16_na4",
"iss17_ns1", "iss18_ns2", "iss19_ns3", "iss20_ns4"
),
subskala = rep(c("KV", "EE", "TT", "NA", "NS"), each = 4),
stringsAsFactors = FALSE
)
ISS20R_ANTWORTSTUFEN = c("trifft nicht zu", "trifft kaum zu", "trifft eher zu", "trifft genau zu")
ALTERSGRUPPEN_LABEL = c(
bis19 = "bis 19 Jahre",
"20_29" = "20-29 Jahre",
"30_39" = "30-39 Jahre",
"40_49" = "40-49 Jahre",
ab50 = "ab 50 Jahre"
)
ALTERSGRUPPEN_DATEI = c(
bis19 = "alter_bis19.csv",
"20_29" = "alter_20_29.csv",
"30_39" = "alter_30_39.csv",
"40_49" = "alter_40_49.csv",
ab50 = "alter_ab50.csv"
)
# Altersgruppe wird ausschliesslich fuer die Auswahl der Normtabelle gebraucht,
# keine eigene inhaltliche Bedeutung ueber die Testmanual-Vorgabe hinaus.
iss20r_altersgruppe = function(alter) {
if (is.null(alter) || length(alter) == 0 || is.na(alter)) return(NA_character_)
a = as.numeric(alter)
if (is.na(a)) return(NA_character_)
if (a <= 19) return("bis19")
if (a <= 29) return("20_29")
if (a <= 39) return("30_39")
if (a <= 49) return("40_49")
"ab50"
}
# Stufe (1-4) IMMER ueber das labels-Attribut der Original-Spalte ableiten,
# nie ueber eine angenommene Zahl-Text-Zuordnung (Kodierung kann variieren).
iss20r_item_stufe = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0) return(NA_integer_)
w = wert[1]
if (is.na(w)) return(NA_integer_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
lbl_sortiert = sort(as.vector(lbl_attr))
pos = which(lbl_sortiert == as.numeric(w))
if (length(pos) > 0) return(as.integer(pos[1]))
}
if (is.factor(w)) return(as.integer(w))
txt = gsub("\\*\\*", "", trimws(as.character(w)))
pos = which(tolower(ISS20R_ANTWORTSTUFEN) == tolower(txt))
if (length(pos) > 0) return(as.integer(pos[1]))
# Letzter Fallback: numerischer Rohwert direkt als Stufe (1-4), keine Verschiebung
# (laut Testmanual entspricht Stufe 1 direkt Rohwert 1, keine 0-Indizierung).
as.integer(round(as.numeric(w)))
}
iss20r_item_anker = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0) return(NA_character_)
w = wert[1]
if (is.na(w)) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == as.numeric(w))
if (length(pos) > 0) return(trimws(gsub("\\*\\*", "", names(lbl_attr)[pos[1]])))
}
if (is.factor(w)) return(trimws(gsub("\\*\\*", "", as.character(w))))
if (is.character(w)) return(trimws(gsub("\\*\\*", "", w)))
stufe = suppressWarnings(as.integer(round(as.numeric(w))))
if (!is.na(stufe) && stufe >= 1L && stufe <= 4L) return(ISS20R_ANTWORTSTUFEN[stufe])
NA_character_
}
iss20r_item_label = function(original_col) {
lbl = attr(original_col, "label")
if (is.null(lbl) || length(lbl) == 0 || is.na(lbl[1])) return(NA_character_)
trimws(gsub("\\*\\*", "", as.character(lbl[1])))
}
# Geschlecht-Text NIE ueber hartkodierte Zahlenwerte, immer ueber das labels-Attribut.
iss20r_geschlecht_text = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) > 0) return(names(lbl_attr)[pos[1]])
}
if (is.factor(wert[1])) return(as.character(wert[1]))
as.character(wert[1])
}
iss20r_geschlecht_norm = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text)) return(NA_character_)
if (grepl("^weiblich$", text, ignore.case = TRUE)) return("weiblich")
# Deckt "maennlich" (ae-Transliteration), "männlich" und "mannlich" ab,
# da die Schreibweise des Umlauts je nach Sheet-Export variieren kann.
if (grepl("^m(a|ä|ae)nnlich$", text, ignore.case = TRUE)) return("maennlich")
NA_character_
}
iss20r_klassifikation = function(summenscore) {
# key bleibt ASCII, da er als CSS-Klassensuffix (klass-<key>) und als Lookup-Name in
# ISS20R_KLASS_WORD_FARBEN verwendet wird; label ist der Anzeigetext mit Umlauten.
if (isTRUE(summenscore > 59)) {
return(list(key = "suechtig", label = "internetsüchtig", farbe = "#B71C1C"))
}
if (isTRUE(summenscore >= 50)) {
return(list(key = "gefaehrdet", label = "internetsuchtgefährdet", farbe = "#E65100"))
}
list(key = "unauffaellig", label = "unauffällig", farbe = "#2E7D32")
}
# normtabellen: benannte Liste aller 6 geladenen Normtabellen (siehe Datenaufbereitung).
iss20r_norm_lookup = function(normtabellen, summenscore, altersgruppe, geschlecht) {
if (is.na(altersgruppe) || is.na(geschlecht) || !(geschlecht %in% c("weiblich", "maennlich"))) {
tab = normtabellen[["gesamt"]]
zeile = tab[tab$rohwert == summenscore, , drop = FALSE]
return(list(
pr = if (nrow(zeile) > 0) suppressWarnings(as.numeric(zeile$pr_total[1])) else NA_real_,
t = if (nrow(zeile) > 0) suppressWarnings(as.numeric(zeile$t_total[1])) else NA_real_,
gruppe_text = "Gesamtstichprobe ohne Alters-/Geschlechtsdifferenzierung",
fallback = TRUE
))
}
tab = normtabellen[[altersgruppe]]
zeile = tab[tab$rohwert == summenscore, , drop = FALSE]
pr_col = paste0("pr_", geschlecht)
t_col = paste0("t_", geschlecht)
geschlecht_anzeige = if (geschlecht == "weiblich") "weiblich" else "männlich"
list(
pr = if (nrow(zeile) > 0) suppressWarnings(as.numeric(zeile[[pr_col]][1])) else NA_real_,
t = if (nrow(zeile) > 0) suppressWarnings(as.numeric(zeile[[t_col]][1])) else NA_real_,
gruppe_text = paste0(ALTERSGRUPPEN_LABEL[[altersgruppe]], ", ", geschlecht_anzeige),
fallback = FALSE
)
}
make_gauge_iss20r = function(score) {
ggplot() +
geom_rect(aes(xmin = 20, xmax = 50, ymin = 0, ymax = 1), fill = "#E8F5E9", color = NA) +
geom_rect(aes(xmin = 50, xmax = 60, ymin = 0, ymax = 1), fill = "#FFF3E0", color = NA) +
geom_rect(aes(xmin = 60, xmax = 80, ymin = 0, ymax = 1), fill = "#FFEBEE", color = NA) +
geom_rect(aes(xmin = 20, xmax = 80, ymin = 0, ymax = 1), fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_vline(xintercept = 50, color = "#E65100", linetype = "dashed", linewidth = 0.8) +
geom_vline(xintercept = 60, color = "#B71C1C", linetype = "dashed", linewidth = 0.8) +
geom_segment(aes(x = score, xend = score, y = -0.25, yend = 1.25),
color = AKZENT_FARBE, linewidth = 2.5) +
geom_label(aes(x = score, y = 1.6, label = paste0("Score: ", score)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = 35, y = 0.5, label = "unauffällig",
color = "#2E7D32", size = 3.3, fontface = "italic") +
annotate("text", x = 55, y = 0.5, label = "gefährdet",
color = "#E65100", size = 3.3, fontface = "italic") +
annotate("text", x = 70, y = 0.5, label = "süchtig",
color = "#B71C1C", size = 3.3, fontface = "italic") +
scale_x_continuous(limits = c(17, 83), breaks = c(20, 30, 40, 50, 60, 70, 80)) +
scale_y_continuous(limits = c(-0.8, 2.0)) +
theme_minimal(base_size = 12) +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.title.y = element_blank(),
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
) +
labs(x = "ISS-20r Summenscore (20-80)", y = NULL)
}
# Datenaufbereitung ####
iss20r_lade_normtabellen = function(ordner) {
normtabellen = list()
for (ag in names(ALTERSGRUPPEN_DATEI)) {
normtabellen[[ag]] = read.csv(
file.path(ordner, ALTERSGRUPPEN_DATEI[[ag]]),
stringsAsFactors = FALSE
)
}
normtabellen[["gesamt"]] = read.csv(
file.path(ordner, "gesamt_ohne_altersdifferenzierung.csv"),
stringsAsFactors = FALSE
)
normtabellen
}
ISS20R_NORMTABELLEN = iss20r_lade_normtabellen(PFAD_NORMTABELLEN)
# 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;
}
.hinweis-box {
background: #F5F5F5; border-left: 5px solid #9E9E9E;
padding: 12px 16px; border-radius: 4px; color: #555;
margin-bottom: 12px; font-size: 0.88em; line-height: 1.55;
}
.hinweis-box p { margin: 4px 0; }
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.kontext-zeile {
display: flex; gap: 8px; align-items: baseline;
padding: 4px 0; color: #444; font-size: 0.93em;
}
.kontext-label { font-weight: 600; color: #333; min-width: 140px; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.95em; margin-top: 4px; }
.klass-unauffaellig { color: #2E7D32; font-weight: 700; }
.klass-gefaehrdet { color: #E65100; font-weight: 700; }
.klass-suechtig { color: #B71C1C; font-weight: 700; }
.norm-info { font-size: 0.88em; color: #666; margin-top: 6px; }
.subskala-zeile {
display: flex; align-items: center; gap: 10px; padding: 6px 0;
}
.subskala-label { min-width: 300px; font-size: 0.92em; color: #333; font-weight: 600; }
.subskala-balken {
flex: 1; background: #F0F0F0; border-radius: 4px; height: 14px; overflow: hidden;
}
.subskala-fuellung { background: #8B2635; height: 100%; }
.subskala-wert { min-width: 60px; text-align: right; font-size: 0.9em; color: #555; }
.subskala-hinweis { font-size: 0.85em; color: #888; font-style: italic; margin-top: 8px; }
.subskala-titel-item {
color: #8B2635; font-weight: 700; margin-top: 16px; margin-bottom: 4px;
font-size: 0.95em; border-bottom: 1px solid #eee; padding-bottom: 3px;
}
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.stufe-badge {
border-radius: 4px; padding: 2px 9px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
}
.stufe-badge-1 { background: #4CAF50; color: white; }
.stufe-badge-2 { background: #F48FB1; color: #333333; }
.stufe-badge-3 { background: #EF5350; color: white; }
.stufe-badge-4 { background: #B71C1C; color: white; }
"
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("ISS-20r Internetsuchtskala (revidiert)"),
tags$p("20 Items, 5 Subskalen | Screening, kein diagnostisches Instrument")
),
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_iss20r_docx = function(erg) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_abschnitt = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 13)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_normal = fp_text(font.size = 11)
fp_klein = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
klass_farben = ISS20R_KLASS_WORD_FARBEN[[erg$klass$key]]
fp_klass = fp_text(bold = TRUE, font.size = 12,
color = klass_farben$text, shading.color = klass_farben$bg)
doc = body_add_fpar(doc, fpar(ftext("ISS-20r 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$ausfuelldatum, fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("Alter: ", fp_label),
ftext(if (is.na(erg$alter_num)) "k. A." else as.character(erg$alter_num), fp_normal),
ftext(" Geschlecht: ", fp_label),
ftext(if (is.na(erg$geschlecht_anzeige)) "k. A." else erg$geschlecht_anzeige, fp_normal)
))
if (!is.null(erg$info_mehrere)) {
doc = body_add_fpar(doc, fpar(ftext(erg$info_mehrere, fp_klein)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Gesamtskala", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Summenscore: ", fp_label),
ftext(paste0(erg$gesamtscore, " / 80"), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext(paste0(toupper(substr(erg$klass$label, 1, 1)), substr(erg$klass$label, 2, nchar(erg$klass$label))),
fp_klass)
))
pr_txt = if (is.na(erg$norm$pr)) "k. A." else sprintf("%.1f", erg$norm$pr)
t_txt = if (is.na(erg$norm$t)) "k. A." else sprintf("%.1f", erg$norm$t)
doc = body_add_fpar(doc, fpar(
ftext(paste0("Prozentrang: ", pr_txt, " T-Wert: ", t_txt), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext(paste0("Verwendete Norm-Untergruppe: ", erg$norm$gruppe_text), fp_klein)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Subskalen (Rohwerte, kein Normvergleich verfügbar)", fp_abschnitt)))
for (i in seq_len(nrow(ISS20R_SUBSKALEN))) {
kurz = ISS20R_SUBSKALEN$kurz[i]
name = ISS20R_SUBSKALEN$name[i]
wert = erg$subskalen_werte[[kurz]]
doc = body_add_fpar(doc, fpar(
ftext(paste0(kurz, " ", name, ": "), fp_label),
ftext(paste0(wert, " / 16"), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(ISS20R_HINWEIS_CUTOFF, fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(ISS20R_HINWEIS_NORMBASIS, fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
for (sk_i in seq_len(nrow(ISS20R_SUBSKALEN))) {
kurz = ISS20R_SUBSKALEN$kurz[sk_i]
name = ISS20R_SUBSKALEN$name[sk_i]
doc = body_add_fpar(doc, fpar(ftext(paste0(kurz, " ", name), fp_label)))
vars = ISS20R_ITEMS$var[ISS20R_ITEMS$subskala == kurz]
nrs = ISS20R_ITEMS$nr[ISS20R_ITEMS$subskala == kurz]
for (j in seq_along(vars)) {
v = vars[j]
nr = nrs[j]
stufe = erg$item_stufen[[v]]
anker = erg$item_anker[[v]]
stufe_key = if (!is.na(stufe) && stufe >= 1L && stufe <= 4L) as.character(stufe) else "1"
anker_txt = if (!is.na(anker)) anker else ISS20R_ANTWORTSTUFEN[as.integer(stufe_key)]
item_txt = if (!is.na(erg$item_texte[[v]])) erg$item_texte[[v]] else paste0("Item ", nr)
fp_badge = fp_text(
color = ISS20R_BADGE_TEXT_FARBEN[[stufe_key]],
bold = TRUE,
shading.color = ISS20R_BADGE_FARBEN[[stufe_key]],
font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(nr, ". ", item_txt, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(ISS20R_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
# --- pseudonym-support-injection v1 ---
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0)) {
return(list(error = "Bitte eine Patientenchiffre eingeben."))
}
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre))) {
return(list(error = paste0(
"Ungültige Chiffre. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123).")))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(error = paste0(
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(error = paste0(
"Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
}
res_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!res_dl$ok) return(list(error = paste0("Fehler im Download-Skript: ", res_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)
res_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
if (nchar(trimws(input$pseudonym)) > 0) {
.pw_wert = trimws(input$pseudonym)
.pw_tab = get("pseudo", envir = .GlobalEnv)
.pw_treffer = .pw_tab[.pw_tab$pseudonym == .pw_wert, ]
if (nrow(.pw_treffer) > 0) chiffre = toupper(trimws(.pw_treffer$chiffre[1]))
}
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!res_ps$ok) return(list(error = paste0("Fehler im Pseudonym-Skript: ", res_ps$msg)))
if (!exists("daten_iss20r", envir = .GlobalEnv)) {
return(list(error = paste0(
"Objekt 'daten_iss20r' nach dem Sourcen nicht gefunden. Bitte Download-Skript prüfen.")))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(error = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript prüfen.")))
}
daten = get("daten_iss20r", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(error = paste0(
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
}
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0) {
return(list(error = paste0(
"Kein ISS-20r-Datensatz für Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprüft)")))
}
warnungen = character(0)
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"
)
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
))
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
ausfuelldatum = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
alter_num = suppressWarnings(as.numeric(zeile[["alter"]][1]))
altersgruppe = iss20r_altersgruppe(alter_num)
geschlecht_anzeige = iss20r_geschlecht_text(daten[["geschlecht"]], zeile[["geschlecht"]])
geschlecht_norm = iss20r_geschlecht_norm(geschlecht_anzeige)
item_stufen = setNames(
sapply(ISS20R_ITEMS$var, function(v) iss20r_item_stufe(daten[[v]], zeile[[v]])),
ISS20R_ITEMS$var
)
item_anker = setNames(
sapply(ISS20R_ITEMS$var, function(v) iss20r_item_anker(daten[[v]], zeile[[v]])),
ISS20R_ITEMS$var
)
item_texte = setNames(
sapply(ISS20R_ITEMS$var, function(v) iss20r_item_label(daten[[v]])),
ISS20R_ITEMS$var
)
gesamtscore = sum(item_stufen, na.rm = TRUE)
subskalen_werte = setNames(
sapply(ISS20R_SUBSKALEN$kurz, function(sk) {
vars = ISS20R_ITEMS$var[ISS20R_ITEMS$subskala == sk]
sum(item_stufen[vars], na.rm = TRUE)
}),
ISS20R_SUBSKALEN$kurz
)
klass = iss20r_klassifikation(gesamtscore)
norm = iss20r_norm_lookup(ISS20R_NORMTABELLEN, gesamtscore, altersgruppe, geschlecht_norm)
if (isTRUE(norm$fallback)) {
warnungen = c(warnungen, paste0(
"Alter und/oder Geschlecht konnten nicht eindeutig zugeordnet werden. ",
"Es wurde die Gesamtstichprobe ohne Alters-/Geschlechtsdifferenzierung als Norm verwendet."
))
}
list(
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
info_mehrere = if (length(warnungen) > 0) warnungen[1] else NULL,
warnungen = warnungen,
alter_num = alter_num,
geschlecht_anzeige = geschlecht_anzeige,
item_stufen = item_stufen,
item_anker = item_anker,
item_texte = item_texte,
gesamtscore = gesamtscore,
subskalen_werte = subskalen_werte,
klass = klass,
norm = norm,
error = NULL
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) div(class = "alert-fehler", d$error)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error) || 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_r()
if (!is.null(d$error)) return(NULL)
klass_css = paste0("klass-", d$klass$key)
pr_txt = if (is.na(d$norm$pr)) "k. A." else sprintf("%.1f", d$norm$pr)
t_txt = if (is.na(d$norm$t)) "k. A." else sprintf("%.1f", d$norm$t)
subskalen_ui = lapply(seq_len(nrow(ISS20R_SUBSKALEN)), function(i) {
kurz = ISS20R_SUBSKALEN$kurz[i]
name = ISS20R_SUBSKALEN$name[i]
wert = d$subskalen_werte[[kurz]]
pct = max(0, min(100, (wert - 4) / (16 - 4) * 100))
div(class = "subskala-zeile",
div(class = "subskala-label", paste0(kurz, " ", name)),
div(class = "subskala-balken",
div(class = "subskala-fuellung", style = paste0("width:", pct, "%;"))
),
div(class = "subskala-wert", paste0(wert, " / 16"))
)
})
items_ui = lapply(seq_len(nrow(ISS20R_SUBSKALEN)), function(sk_i) {
kurz = ISS20R_SUBSKALEN$kurz[sk_i]
name = ISS20R_SUBSKALEN$name[sk_i]
vars = ISS20R_ITEMS$var[ISS20R_ITEMS$subskala == kurz]
nrs = ISS20R_ITEMS$nr[ISS20R_ITEMS$subskala == kurz]
zeilen = lapply(seq_along(vars), function(j) {
v = vars[j]
nr = nrs[j]
stufe = d$item_stufen[[v]]
anker = d$item_anker[[v]]
sk_key = if (!is.na(stufe) && stufe >= 1L && stufe <= 4L) as.character(stufe) else "1"
anker_txt = if (!is.na(anker)) anker else ISS20R_ANTWORTSTUFEN[as.integer(sk_key)]
item_txt = if (!is.na(d$item_texte[[v]])) d$item_texte[[v]] else paste0("Item ", nr)
div(class = "item-zeile",
div(class = "item-nr", paste0(nr, ".")),
div(class = "item-text", item_txt),
span(class = paste0("stufe-badge stufe-badge-", sk_key), anker_txt)
)
})
tagList(
div(class = "subskala-titel-item", paste0(kurz, " ", name)),
zeilen
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "ISS-20r"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Alter: "), if (is.na(d$alter_num)) "k. A." else d$alter_num,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Geschlecht: "), if (is.na(d$geschlecht_anzeige)) "k. A." else d$geschlecht_anzeige
),
tags$hr(),
fluidRow(
column(3,
div(
div(class = "score-zahl", d$gesamtscore),
div("Summenscore (20-80)", style = "color:#555;"),
div(class = "cutoff-info",
span(class = klass_css, tools::toTitleCase(d$klass$label))
),
div(class = "norm-info",
paste0("Prozentrang: ", pr_txt, " T-Wert: ", t_txt)
),
div(class = "norm-info", d$norm$gruppe_text)
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
tags$hr(),
tags$h5("Subskalen"),
div(subskalen_ui),
div(class = "subskala-hinweis", "Rohwert je Subskala (4-16), kein Normvergleich verfügbar."),
tags$hr(),
div(class = "hinweis-box",
tags$p(ISS20R_HINWEIS_CUTOFF),
tags$p(ISS20R_HINWEIS_NORMBASIS)
),
tags$hr(),
tags$h5("Einzelitems"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error))
make_gauge_iss20r(d$gesamtscore)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && is.null(d$error) && nchar(d$chiffre) > 0)
d$chiffre else "export"
ausfuelldatum_fn = if (is.list(d) && is.null(d$error) && !is.null(d$ausfuelldatum))
tryCatch(
format(as.Date(d$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
else
format(Sys.Date(), "%Y%m%d")
paste0("ISS20R_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && is.null(d$error)
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_iss20r_docx(d),
error = function(e) {
err_doc = read_docx()
body_add_par(err_doc,
paste0("Fehler beim Erstellen des Word-Dokuments: ", e$message),
style = "Normal")
}
)
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui, server)