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

1275 lines
52 KiB
R
Raw Blame History

This file contains ambiguous Unicode characters

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

# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_ist_screening_b.R" # liefert: daten_ist_screening_b
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
PFAD_NORMTABELLEN = "normtabellen"
IST_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Die Testsituation der digitalen Selbstausfuellung entspricht nicht der im Manual ",
"beschriebenen testleiter-administrierten Gruppentestung."
)
# --- Lösungsschlüssel Testheft B (Teil des App-Codes, NICHT der formr-xlsx) ----
# Analogien (Items 1-20): Vergleich ueber den Choice-TEXT im labels-Attribut,
# nie ueber den 1-5-Index. Wortlaut exakt wie in der xlsx.
IST_B_ANALOGIEN_LOESUNG = c(
"schlecht", "schmecken", "Erholung", "Auge", "Kreide",
"Zweifel", "Fuß", "Flugzeug", "Schirm", "Sprosse",
"Gitarre", "Bild", "ungewohnt", "Boss", "schwimmen",
"klopfen", "Einfachheit", "Papier", "extrem", "Kreis"
)
# Zahlenreihen (Items 21-40): direkter Zahlenvergleich.
IST_B_ZAHLENREIHEN_LOESUNG = c(
32, 6, 21, 28, 14, 13, 14, 36, 20, 21,
12, 49, 11, 36, 87, 26, 10, 20, 25, 152
)
# Matrizen (Items 41-60): Vergleich ueber das alt-Attribut der Bild-Choice
# (alt="a" ... alt="e"), nie ueber den 1-5-Index.
IST_B_MATRIZEN_LOESUNG = c(
"c", "d", "d", "e", "c", "d", "e", "a", "e", "d",
"b", "b", "d", "c", "e", "b", "b", "a", "b", "c"
)
# --- SW-Luecke unter 70 (vom Nutzer fachlich festgelegt, zentral aenderbar) ----
SW_A13_MINIMUM = 70
IST_IQ_UNTER_MINIMUM_TEXT = "< 55"
IST_PR_UNTER_MINIMUM = 0
# Menschenlesbare Bezeichnung der Normgruppen (Dateikuerzel -> Text).
IST_NORM_LABEL = c(
A1 = "Gymnasiasten, 15-16 Jahre",
A2 = "Gymnasiasten, 17-18 Jahre",
A3 = "Gymnasiasten, 19-20 Jahre",
A4 = "Gymnasiasten, 21-25 Jahre",
A5 = "Gymnasiasten, 26-30 Jahre",
A6 = "Nicht-Gymnasiasten, 15-20 Jahre",
A7 = "Nicht-Gymnasiasten, 21-25 Jahre",
A8 = "Nicht-Gymnasiasten, 26-30 Jahre",
A9 = "Gesamtgruppe (ohne Schulform), 15-20 Jahre",
A10 = "Gesamtgruppe (ohne Schulform), 21-25 Jahre",
A11 = "Gesamtgruppe (ohne Schulform), 26-30 Jahre",
A12 = "Gesamtstichprobe (ohne Alters-/Schulformdifferenzierung)"
)
IST_HINWEIS_A13_UNTERTESTS = paste0(
"Prozentrang und IQ werden hier auch auf die drei Untertest-Standardwerte ",
"angewendet. Ob Tabelle A13 dafuer gedacht ist, ist nicht explizit im Manual ",
"belegt, sondern aus der linearen SW->IQ-Transformation abgeleitet ",
"(Interpretationsfreiheit, keine gesicherte Manual-Aussage)."
)
IST_HINWEIS_SW_UNTER_70 = paste0(
"Tabelle A13 beginnt bei Standardwert 70. Fuer niedrigere Standardwerte zeigt ",
"die App IQ \"", IST_IQ_UNTER_MINIMUM_TEXT, "\" und Prozentrang ",
IST_PR_UNTER_MINIMUM, " (Bodenwert). Der Standardwert selbst wird normal ",
"ausgewiesen. Bewusst festgelegte Konvention, keine Manual-Aussage."
)
IST_HINWEIS_KODIERUNG = paste0(
"Die Zuordnung der mc-/mc_button-Antworten erfolgt textbasiert ueber das ",
"labels-Attribut (Analogien: Antworttext, Matrizen: alt-Attribut der Bild-Choice), ",
"nicht ueber den 1-5-Index. Der ausklappbare Rohdaten-Check in der App zeigt fuer ",
"je drei Analogie- und Matrizen-Items den gespeicherten Rohwert und das komplette ",
"labels-Attribut, damit vor Nutzung der Auswertung manuell verifiziert werden kann, ",
"dass das Text-Matching greift."
)
library(shiny)
library(dplyr)
library(ggplot2)
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)
PFAD_NORMTABELLEN = normalizePath(absPath(PFAD_NORMTABELLEN), mustWork = FALSE)
# Helper ####
ist_col = function(i) sprintf("ist_b_%02d", i)
# Erste vorhandene Spalte aus einer Prioritaetenliste. NA_character_, wenn keine
# passt (dann bricht die Auswertung mit klarer Meldung ab).
ist_finde_spalte = function(df, kandidaten) {
vorhanden = kandidaten[kandidaten %in% names(df)]
if (length(vorhanden) == 0) return(NA_character_)
vorhanden[1]
}
# Rohwert einer (labelled/character/logical) Item-Zelle als Zahl, NA wenn nicht
# beantwortet. formr liefert komplett unbeantwortete Items als reine NA-Spalten
# (Typ logical, ohne labels-Attribut) -> das ist eine Nichtbeantwortung, kein
# technischer Fehler.
ist_zellwert = function(wert) {
if (is.null(wert) || length(wert) == 0) return(NA_real_)
w = wert[1]
if (is.na(w)) return(NA_real_)
if (is.character(w) && !nzchar(trimws(w))) return(NA_real_)
suppressWarnings(as.numeric(w))
}
# Analogie-Item: Vergleich ueber den Choice-TEXT im labels-Attribut der
# Original-Spalte. Antwort vorhanden, aber Text nicht im labels-Attribut ->
# "nicht auswertbar" (kein stiller Rueckfall auf Index-Vergleich). Keine Antwort
# -> normale Nichtbeantwortung (zaehlt als falsch).
ist_pruefe_analogie = function(spalte_original, wert, korrekter_text) {
gegeben = ist_zellwert(wert)
if (is.na(gegeben)) {
return(list(status = "ok", richtig = FALSE, beantwortet = FALSE,
gegeben = NA_real_, korrekter_code = NA_real_))
}
labels_attr = attr(spalte_original, "labels")
if (is.null(labels_attr) || length(labels_attr) == 0) {
return(list(status = "nicht_auswertbar", richtig = FALSE, beantwortet = TRUE,
grund = "Antwort vorhanden, aber Spalte ohne labels-Attribut",
gegeben = gegeben, korrekter_code = NA_real_))
}
namen = names(labels_attr)
pos = which(namen == korrekter_text)
if (length(pos) == 0) pos = which(trimws(namen) == trimws(korrekter_text))
if (length(pos) == 0) {
return(list(status = "nicht_auswertbar", richtig = FALSE, beantwortet = TRUE,
grund = paste0("Choice-Text '", korrekter_text, "' nicht im labels-Attribut"),
gegeben = gegeben, korrekter_code = NA_real_))
}
korrekter_code = suppressWarnings(as.numeric(labels_attr[pos[1]]))
richtig = !is.na(korrekter_code) && gegeben == korrekter_code
list(status = "ok", richtig = isTRUE(richtig), beantwortet = TRUE,
gegeben = gegeben, korrekter_code = korrekter_code)
}
# Zahlenreihen-Item: direkter Zahlenvergleich (number-Feld ggf. als String).
ist_pruefe_zahlenreihe = function(wert, korrekte_zahl) {
gegeben = ist_zellwert(wert)
list(status = "ok", richtig = !is.na(gegeben) && gegeben == as.numeric(korrekte_zahl),
beantwortet = !is.na(gegeben),
gegeben = gegeben, korrekter_code = as.numeric(korrekte_zahl))
}
# Matrizen-Item: Buchstabe->Code-Zuordnung aus den <img alt="x">-Choice-Labels
# der Original-Spalte aufbauen, dann ueber den Buchstaben vergleichen. Antwort
# vorhanden, aber kein alt-Attribut lesbar -> "nicht auswertbar" (kein Rueckfall
# auf Index). Keine Antwort -> normale Nichtbeantwortung (zaehlt als falsch).
ist_pruefe_matrix = function(spalte_original, wert, korrekter_buchstabe) {
gegeben = ist_zellwert(wert)
if (is.na(gegeben)) {
return(list(status = "ok", richtig = FALSE, beantwortet = FALSE,
gegeben = NA_real_, korrekter_code = NA_real_))
}
labels_attr = attr(spalte_original, "labels")
if (is.null(labels_attr) || length(labels_attr) == 0) {
return(list(status = "nicht_auswertbar", richtig = FALSE, beantwortet = TRUE,
grund = "Antwort vorhanden, aber Spalte ohne labels-Attribut",
gegeben = gegeben, korrekter_code = NA_real_))
}
namen = names(labels_attr)
mapping = list()
for (i in seq_along(namen)) {
# Strikt: alt="a" / alt='a'. Tolerant: alt = a, alt="a)" etc. -> erster
# isolierter Buchstabe a-e direkt hinter alt=.
m = regmatches(namen[i], regexec('alt\\s*=\\s*["\']?\\s*([a-eA-E])\\b', namen[i]))[[1]]
if (length(m) == 2) mapping[[tolower(m[2])]] = suppressWarnings(as.numeric(labels_attr[i]))
}
if (length(mapping) == 0) {
return(list(status = "nicht_auswertbar", richtig = FALSE, beantwortet = TRUE,
grund = "kein alt=\"a\"..\"e\" in den Choice-Labels gefunden",
gegeben = gegeben, korrekter_code = NA_real_))
}
korrekter_code = mapping[[tolower(korrekter_buchstabe)]]
if (is.null(korrekter_code) || is.na(korrekter_code)) {
return(list(status = "nicht_auswertbar", richtig = FALSE, beantwortet = TRUE,
grund = paste0("Buchstabe '", korrekter_buchstabe,
"' nicht in der aus den Labels gebauten Zuordnung"),
gegeben = gegeben, korrekter_code = NA_real_))
}
list(status = "ok", richtig = isTRUE(gegeben == korrekter_code), beantwortet = TRUE,
gegeben = gegeben, korrekter_code = korrekter_code)
}
# Untertest-SW: exakter Rohwert-Match in einer rw_X/sw_X-Spalte (RW 0-20).
ist_sw_untertest = function(tab, rw_spalte, sw_spalte, rohwert) {
if (is.null(tab) || !(rw_spalte %in% names(tab)) || !(sw_spalte %in% names(tab))) return(NA_real_)
zeile = tab[suppressWarnings(as.numeric(tab[[rw_spalte]])) == rohwert, , drop = FALSE]
if (nrow(zeile) == 0) return(NA_real_)
suppressWarnings(as.numeric(zeile[[sw_spalte]][1]))
}
# Gesamtwert-SW: Rohwert 0-60 in einen Klassen-String ("58-60") einordnen.
# Einzelwerte koennen als "n" oder als "n-n" (von == bis) vorkommen.
ist_sw_gesamt = function(tab, rw_spalte, sw_spalte, rohwert) {
if (is.null(tab) || !(rw_spalte %in% names(tab)) || !(sw_spalte %in% names(tab))) return(NA_real_)
klassen = as.character(tab[[rw_spalte]])
for (i in seq_along(klassen)) {
k = trimws(klassen[i])
m = regmatches(k, regexec("^([0-9]+)\\s*-\\s*([0-9]+)$", k))[[1]]
if (length(m) == 3) {
von = as.numeric(m[2]); bis = as.numeric(m[3])
if (!is.na(von) && !is.na(bis) && rohwert >= von && rohwert <= bis) {
return(suppressWarnings(as.numeric(tab[[sw_spalte]][i])))
}
} else {
einzel = suppressWarnings(as.numeric(k))
if (!is.na(einzel) && rohwert == einzel) {
return(suppressWarnings(as.numeric(tab[[sw_spalte]][i])))
}
}
}
NA_real_
}
ist_iq_anzeige = function(x) {
if (is.null(x) || length(x) == 0 || is.na(x)) return(NA_character_)
sub(".", ",", sprintf("%.2f", x), fixed = TRUE)
}
# A13: Standardwert -> Prozentrang / IQ. Unterhalb SW 70 gilt die festgelegte
# Konvention (IQ-Text "< 55", PR 0). Oberhalb des tabellierten Bereichs: n. t.
ist_pr_iq_von_sw = function(a13, sw) {
if (is.null(sw) || length(sw) == 0 || is.na(sw)) {
return(list(pr = NA_character_, iq = NA_character_, unter_minimum = FALSE, ausserhalb = FALSE))
}
if (sw < SW_A13_MINIMUM) {
return(list(pr = as.character(IST_PR_UNTER_MINIMUM), iq = IST_IQ_UNTER_MINIMUM_TEXT,
unter_minimum = TRUE, ausserhalb = FALSE))
}
zeile = a13[a13$sw == round(sw), , drop = FALSE]
if (nrow(zeile) == 0) {
return(list(pr = NA_character_, iq = NA_character_, unter_minimum = FALSE, ausserhalb = TRUE))
}
list(pr = as.character(zeile$pr[1]), iq = ist_iq_anzeige(zeile$iq_num[1]),
unter_minimum = FALSE, ausserhalb = FALSE)
}
# Primaere schulform-spezifische Normtabelle (Dateikuerzel) oder NULL.
ist_primaere_tabelle = function(schulform_gruppe, alter) {
if (is.na(schulform_gruppe) || is.na(alter) || alter < 15 || alter > 30) return(NULL)
if (schulform_gruppe == "Gymnasiasten") {
if (alter <= 16) return("A1")
if (alter <= 18) return("A2")
if (alter <= 20) return("A3")
if (alter <= 25) return("A4")
return("A5")
}
if (alter <= 20) return("A6")
if (alter <= 25) return("A7")
return("A8")
}
# Schulform-unabhaengige Gesamtgruppen-Norm (A9-A11) oder NULL.
ist_gesamtgruppe_tabelle = function(alter) {
if (is.na(alter) || alter < 15 || alter > 30) return(NULL)
if (alter <= 20) return("A9")
if (alter <= 25) return("A10")
return("A11")
}
# Ein Normblock: SW je Komponente plus PR/IQ (A13) je Komponente.
ist_berechne_normblock = function(tab_key, rw_ana, rw_zr, rw_mat, rw_ges,
nur_gesamt = FALSE) {
tab = ist_normtabellen[[tab_key]]
a13 = ist_normtabellen$A13
res = list(tab_key = tab_key, label = unname(IST_NORM_LABEL[tab_key]))
sw_g = ist_sw_gesamt(tab, "rw_gesamtwert", "sw_gesamtwert", rw_ges)
res$gesamt = c(list(rw = rw_ges, sw = sw_g), ist_pr_iq_von_sw(a13, sw_g))
if (nur_gesamt) return(res)
sw_a = ist_sw_untertest(tab, "rw_analogien", "sw_analogien", rw_ana)
sw_z = ist_sw_untertest(tab, "rw_zahlenreihen", "sw_zahlenreihen", rw_zr)
sw_m = ist_sw_untertest(tab, "rw_matrizen", "sw_matrizen", rw_mat)
res$analogien = c(list(rw = rw_ana, sw = sw_a), ist_pr_iq_von_sw(a13, sw_a))
res$zahlenreihen = c(list(rw = rw_zr, sw = sw_z), ist_pr_iq_von_sw(a13, sw_z))
res$matrizen = c(list(rw = rw_mat, sw = sw_m), ist_pr_iq_von_sw(a13, sw_m))
res
}
ist_oder_strich = function(x) {
if (is.null(x) || length(x) == 0 || is.na(x)) return("")
as.character(x)
}
# PR-/IQ-Anzeigetext einer Komponente (beruecksichtigt Boden-/Randfaelle).
ist_pr_text = function(comp) {
if (isTRUE(comp$ausserhalb)) return("n. t.")
if (is.null(comp$pr) || is.na(comp$pr)) return("")
as.character(comp$pr)
}
ist_iq_text = function(comp) {
if (isTRUE(comp$ausserhalb)) return("n. t.")
if (is.null(comp$iq) || is.na(comp$iq)) return("")
as.character(comp$iq)
}
# Datenaufbereitung ####
IST_NORM_DATEIEN = c(
A1 = "A1_gymnasiasten_15-16.csv",
A2 = "A2_gymnasiasten_17-18.csv",
A3 = "A3_gymnasiasten_19-20.csv",
A4 = "A4_gymnasiasten_21-25.csv",
A5 = "A5_gymnasiasten_26-30.csv",
A6 = "A6_nicht_gymnasiasten_15-20.csv",
A7 = "A7_nicht_gymnasiasten_21-25.csv",
A8 = "A8_nicht_gymnasiasten_26-30.csv",
A9 = "A9_gesamtgruppe_15-20.csv",
A10 = "A10_gesamtgruppe_21-25.csv",
A11 = "A11_gesamtgruppe_26-30.csv",
A12 = "A12_gesamtwert_gesamtgruppe.csv",
A13 = "tabelle_A13_standardwert_prozentrang_iq.csv"
)
IST_SPALTEN_A1_11 = c(
"rw_analogien", "sw_analogien", "rw_zahlenreihen", "sw_zahlenreihen",
"rw_matrizen", "sw_matrizen", "rw_gesamtwert", "sw_gesamtwert"
)
if (!dir.exists(PFAD_NORMTABELLEN)) {
stop(paste0(
"Normtabellen-Ordner nicht gefunden: ", PFAD_NORMTABELLEN,
"\nBitte den Unterordner 'normtabellen/' mit den 13 CSV-Dateien neben app.R ablegen."))
}
ist_normtabellen = list()
for (key in names(IST_NORM_DATEIEN)) {
pfad = file.path(PFAD_NORMTABELLEN, IST_NORM_DATEIEN[[key]])
if (!file.exists(pfad)) {
stop(paste0(
"Normtabelle nicht gefunden: ", pfad,
"\nDie 13 Normtabellen-CSVs (A1-A13) muessen vom Nutzer bereitgestellt werden ",
"und sind nicht Teil der App."))
}
tab = tryCatch(
read.csv(pfad, stringsAsFactors = FALSE, na.strings = c("", "NA"),
fileEncoding = "UTF-8", check.names = FALSE),
error = function(e) stop(paste0("Fehler beim Einlesen von '", IST_NORM_DATEIEN[[key]],
"': ", e$message))
)
ist_normtabellen[[key]] = tab
}
for (key in c(paste0("A", 1:11))) {
fehlend = setdiff(IST_SPALTEN_A1_11, names(ist_normtabellen[[key]]))
if (length(fehlend) > 0) {
stop(paste0("Normtabelle '", IST_NORM_DATEIEN[[key]], "' fehlt Spalte(n): ",
paste(fehlend, collapse = ", ")))
}
}
if (!all(c("rw_gesamtwert", "sw_gesamtwert") %in% names(ist_normtabellen$A12))) {
stop(paste0("Normtabelle '", IST_NORM_DATEIEN[["A12"]],
"' braucht die Spalten rw_gesamtwert und sw_gesamtwert."))
}
if (!all(c("sw", "iq", "pr") %in% names(ist_normtabellen$A13))) {
stop(paste0("Normtabelle '", IST_NORM_DATEIEN[["A13"]],
"' braucht die Spalten sw, iq und pr."))
}
# A13: iq ist ein String mit Komma als Dezimaltrennzeichen ("101,50").
ist_normtabellen$A13$sw = suppressWarnings(as.numeric(ist_normtabellen$A13$sw))
ist_normtabellen$A13$pr = suppressWarnings(as.integer(ist_normtabellen$A13$pr))
ist_normtabellen$A13$iq_num = suppressWarnings(as.numeric(gsub(",", ".", ist_normtabellen$A13$iq)))
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.container-fluid { max-width: 1100px; }
.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; white-space: pre-wrap;
}
.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; }
.hinweis-block {
font-size: 0.86em; color: #777; font-style: italic; margin: 8px 0 12px;
}
.norm-tabelle { width: 100%; border-collapse: collapse; margin: 6px 0 4px; }
.norm-tabelle th, .norm-tabelle td {
border: 1px solid #E0E0E0; padding: 7px 12px; text-align: center; font-size: 0.95em;
}
.norm-tabelle th { background: #F5F5F5; color: #333; font-weight: 700; }
.norm-tabelle td:first-child, .norm-tabelle th:first-child { text-align: left; }
.norm-tabelle tr.gesamt-zeile td { font-weight: 700; background: #FAF3F4; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 6px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 46px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.9em; }
.rohcheck-box {
background: #FAFAFA; border: 1px solid #E0E0E0; border-radius: 4px;
padding: 8px 12px; margin: 6px 0; font-family: 'Consolas', monospace;
font-size: 0.8em; color: #444; white-space: pre-wrap; word-break: break-all;
}
details.rohcheck { margin-top: 8px; }
details.rohcheck > summary {
cursor: pointer; font-weight: 600; color: #8B2635; font-size: 0.9em;
}
"
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("IST-Screening Testheft B — Auswertung"),
tags$p("Intelligenz-Struktur-Test Screening (Hogrefe) | Liepmann/Beauducel/Brocke/Nettelnstroth | Einzelfall-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_ist_screening_b_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_hinweis = fp_text(font.size = 9, italic = TRUE, color = "#777777")
norm_tabelle_df = function(block) {
max_rw = c(20, 20, 20, 60)
data.frame(
Bereich = c("Analogien", "Zahlenreihen", "Matrizen", "Gesamtwert"),
RW = sprintf("%s / %d",
c(block$analogien$rw, block$zahlenreihen$rw, block$matrizen$rw, block$gesamt$rw),
max_rw),
SW = c(ist_oder_strich(block$analogien$sw), ist_oder_strich(block$zahlenreihen$sw),
ist_oder_strich(block$matrizen$sw), ist_oder_strich(block$gesamt$sw)),
PR = c(ist_pr_text(block$analogien), ist_pr_text(block$zahlenreihen),
ist_pr_text(block$matrizen), ist_pr_text(block$gesamt)),
IQ = c(ist_iq_text(block$analogien), ist_iq_text(block$zahlenreihen),
ist_iq_text(block$matrizen), ist_iq_text(block$gesamt)),
check.names = FALSE, stringsAsFactors = FALSE
)
}
doc = body_add_fpar(doc, fpar(ftext("IST-Screening Testheft B — Auswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label), ftext(erg$ausfuelldatum, fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("Alter: ", fp_label),
ftext(if (is.na(erg$alter)) "k. A." else as.character(erg$alter), fp_normal),
ftext(" Schulform: ", fp_label),
ftext(if (is.na(erg$schulform_text)) "k. A." else erg$schulform_text, fp_normal)
))
if (!is.null(erg$mehrfach_warnung)) {
doc = body_add_fpar(doc, fpar(ftext(
paste0("Mehrere Ausfuellungen gefunden (", erg$mehrfach_warnung$n,
" Eintraege), angezeigt wird die neueste."), fp_warnung)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Primaere Norm", fp_abschnitt)))
if (is.null(erg$norm_primaer)) {
doc = body_add_fpar(doc, fpar(ftext(erg$keine_norm_grund, fp_warnung)))
roh_df = data.frame(
Bereich = c("Analogien", "Zahlenreihen", "Matrizen", "Gesamtwert"),
RW = sprintf("%s / %d",
c(erg$rw_analogien, erg$rw_zahlenreihen, erg$rw_matrizen, erg$rw_gesamt),
c(20, 20, 20, 60)),
check.names = FALSE, stringsAsFactors = FALSE
)
doc = body_add_table(doc, roh_df, style = "table_template")
} else {
doc = body_add_fpar(doc, fpar(ftext(paste0("Normgruppe: ", erg$norm_primaer$label), fp_normal)))
doc = body_add_table(doc, norm_tabelle_df(erg$norm_primaer), style = "table_template")
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Zusatzangabe: Gesamtgruppe (ohne Schulform)", fp_abschnitt)))
if (is.null(erg$norm_gesamtgruppe)) {
doc = body_add_fpar(doc, fpar(ftext(
"Nicht verfuegbar (Alter ausserhalb 15-30 Jahre oder fehlend).", fp_warnung)))
} else {
doc = body_add_fpar(doc, fpar(ftext(paste0("Normgruppe: ", erg$norm_gesamtgruppe$label), fp_normal)))
doc = body_add_table(doc, norm_tabelle_df(erg$norm_gesamtgruppe), style = "table_template")
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Zusatzangabe: Gesamtstichprobe (Gesamtwert, Tabelle A12)", fp_abschnitt)))
g12 = erg$norm_a12$gesamt
doc = body_add_fpar(doc, fpar(
ftext("Gesamtwert: ", fp_label),
ftext(sprintf("RW %d / 60 SW %s PR %s IQ %s",
g12$rw, ist_oder_strich(g12$sw), ist_pr_text(g12), ist_iq_text(g12)), fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Technische Hinweise", fp_abschnitt)))
for (h in erg$tech_hinweise) {
doc = body_add_fpar(doc, fpar(ftext(h, fp_hinweis)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(IST_DISCLAIMER, fp_hinweis)))
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))
pseudonym_eingabe = trimws(input$pseudonym)
# 1. Leere Eingabe
if (nchar(pseudonym_eingabe) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe",
meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(pseudonym_eingabe) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler",
meldung = paste0("Ungueltige Chiffre '", chiffre,
"'. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123).")))
}
# 2. Skriptpfade
fehlende_skripte = c(
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) PFAD_DOWNLOAD_SKRIPT,
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) PFAD_PSEUDONYM_SKRIPT
)
if (length(fehlende_skripte) > 0) {
return(list(typ = "pfad_fehler",
meldung = paste0("Skript(e) nicht gefunden:\n", paste(fehlende_skripte, collapse = "\n"))))
}
# 3. Download-Skript sourcen
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(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript: ", ok_dl$msg)))
}
# 4. daten_ist_screening_b vorhanden?
if (!exists("daten_ist_screening_b", envir = .GlobalEnv) ||
!is.data.frame(get("daten_ist_screening_b", envir = .GlobalEnv))) {
return(list(typ = "daten_fehler",
meldung = paste0("Objekt 'daten_ist_screening_b' nach dem Sourcen des ",
"Download-Skripts nicht gefunden oder kein Dataframe.")))
}
daten_ist_b = get("daten_ist_screening_b", envir = .GlobalEnv)
# 4b. Notwendige Spaltenmuster zur Laufzeit verifizieren
alle_item_cols = vapply(1:60, ist_col, character(1))
fehlende_items = alle_item_cols[!(alle_item_cols %in% names(daten_ist_b))]
if (length(fehlende_items) == length(alle_item_cols)) {
return(list(typ = "struktur_fehler",
meldung = "Keine der 60 Item-Spalten (ist_b_01 ... ist_b_60) im Datensatz gefunden."))
}
if (length(fehlende_items) > 0) {
return(list(typ = "struktur_fehler",
meldung = paste0("Es fehlen Item-Spalten im Datensatz: ",
paste(fehlende_items, collapse = ", "))))
}
session_spalte = ist_finde_spalte(daten_ist_b,
c("session", "session_id", "code", "pseudonym"))
if (is.na(session_spalte)) {
return(list(typ = "struktur_fehler",
meldung = paste0("Keine Session-/Pseudonym-Spalte im Datensatz identifizierbar ",
"(gesucht: session, session_id, code, pseudonym).")))
}
zeitstempel_kandidaten = intersect(c("ended", "created", "modified", "expired"),
names(daten_ist_b))
if (length(zeitstempel_kandidaten) == 0) {
return(list(typ = "struktur_fehler",
meldung = paste0("Keine Zeitstempel-Spalte im Datensatz identifizierbar ",
"(gesucht: ended, created, modified, expired). Fuer den Word-Export ",
"wird ein Ausfuelldatum benoetigt.")))
}
hat_alter = "alter" %in% names(daten_ist_b)
hat_schulform = "schulform" %in% names(daten_ist_b)
# 5. pseudonyme.db suchen
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()
on.exit(setwd(alter_wd), add = TRUE)
setwd(if (!is.null(db_ordner)) db_ordner
else dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE)))
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(typ = "db_fehler",
meldung = paste0("Fehler im Pseudonym-Skript: ", ok_ps$msg)))
}
# 6. pseudo vorhanden?
if (!exists("pseudo", envir = .GlobalEnv) ||
!is.data.frame(get("pseudo", envir = .GlobalEnv))) {
return(list(typ = "db_fehler",
meldung = "Objekt 'pseudo' nach dem Sourcen nicht gefunden oder kein Dataframe."))
}
pseudo = get("pseudo", envir = .GlobalEnv)
# 7. Chiffre-Rueckaufloesung aus Pseudonym
if (nchar(pseudonym_eingabe) > 0) {
pw_treffer = pseudo[trimws(as.character(pseudo$pseudonym)) == pseudonym_eingabe, , drop = FALSE]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(as.character(pw_treffer$chiffre[1])))
}
# 8. Chiffre -> Pseudonym(e)
treffer_ps = pseudo[toupper(trimws(as.character(pseudo$chiffre))) == chiffre, , drop = FALSE]
if (nrow(treffer_ps) == 0) {
meldung = if (nchar(pseudonym_eingabe) > 0 && nchar(chiffre) == 0) {
paste0("Pseudonym '", pseudonym_eingabe, "' wurde in der Pseudonym-Datenbank nicht gefunden.")
} else {
paste0("Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")
}
return(list(typ = "chiffre_nicht_gefunden", meldung = meldung))
}
alle_session_ids = unique(as.character(treffer_ps$pseudonym))
if (nchar(pseudonym_eingabe) > 0) alle_session_ids = pseudonym_eingabe
idx_kandidaten = which(as.character(daten_ist_b[[session_spalte]]) %in% alle_session_ids)
if (length(idx_kandidaten) == 0) {
return(list(typ = "datensatz_nicht_gefunden",
meldung = paste0("Kein IST-Screening-B-Datensatz fuer Chiffre '", chiffre, "' gefunden. (",
length(alle_session_ids), " Pseudonym(e) geprueft)")))
}
mehrfach_warnung = NULL
if (length(idx_kandidaten) > 1) {
zeitwerte = suppressWarnings(as.POSIXct(daten_ist_b[[zeitstempel_kandidaten[1]]][idx_kandidaten]))
idx_final = idx_kandidaten[which.max(zeitwerte)]
if (length(idx_final) == 0) idx_final = idx_kandidaten[length(idx_kandidaten)]
mehrfach_warnung = list(n = length(idx_kandidaten))
} else {
idx_final = idx_kandidaten[1]
}
zeile = daten_ist_b[idx_final, , drop = FALSE]
# --- Ausfuelldatum ---
zt = NULL
for (sp in zeitstempel_kandidaten) {
ts_try = suppressWarnings(as.POSIXct(zeile[[sp]][1]))
if (!is.na(ts_try)) { zt = list(ts = ts_try, spalte = sp); break }
}
if (is.null(zt)) zt = list(ts = as.POSIXct(NA), spalte = zeitstempel_kandidaten[1])
ausfuelldatum = if (is.na(zt$ts)) "unbekannt" else format(zt$ts, "%d.%m.%Y")
ausfuelldatum_ymd = if (is.na(zt$ts)) NA_character_ else format(zt$ts, "%Y%m%d")
# --- Alter / Schulform ---
alter_roh = if (hat_alter) suppressWarnings(as.numeric(zeile[["alter"]][1])) else NA_real_
alter = if (!is.na(alter_roh) && alter_roh > 0) alter_roh else NA_real_
schulform_text = NA_character_
if (hat_schulform) {
schulform_factor = tryCatch(haven::as_factor(daten_ist_b[["schulform"]]),
error = function(e) NULL)
if (!is.null(schulform_factor)) {
st = as.character(schulform_factor[idx_final])
schulform_text = if (is.na(st) || !nzchar(trimws(st))) NA_character_ else trimws(st)
}
}
schulform_gruppe = if (is.na(schulform_text)) {
NA_character_
} else if (schulform_text == "Gymnasium") {
"Gymnasiasten"
} else if (schulform_text %in% c("Hauptschule", "Realschule")) {
"Nicht-Gymnasiasten"
} else {
NA_character_
}
# --- Itemauswertung ---
analogien_res = lapply(1:20, function(i) {
col = ist_col(i)
ist_pruefe_analogie(daten_ist_b[[col]], zeile[[col]], IST_B_ANALOGIEN_LOESUNG[i])
})
zahlenreihen_res = lapply(21:40, function(i) {
col = ist_col(i)
ist_pruefe_zahlenreihe(zeile[[col]], IST_B_ZAHLENREIHEN_LOESUNG[i - 20])
})
matrizen_res = lapply(41:60, function(i) {
col = ist_col(i)
ist_pruefe_matrix(daten_ist_b[[col]], zeile[[col]], IST_B_MATRIZEN_LOESUNG[i - 40])
})
zusammenfassen = function(res_liste, offset) {
richtig = sum(vapply(res_liste, function(x) isTRUE(x$richtig), logical(1)))
na_idx = which(vapply(res_liste, function(x) identical(x$status, "nicht_auswertbar"), logical(1)))
nb_idx = which(vapply(res_liste, function(x) isFALSE(x$beantwortet), logical(1)))
list(
rw = richtig,
n_nicht_auswertbar = length(na_idx),
nicht_auswertbar_items = if (length(na_idx)) offset + na_idx else integer(0),
n_nicht_beantwortet = length(nb_idx),
nicht_beantwortet_items = if (length(nb_idx)) offset + nb_idx else integer(0)
)
}
z_ana = zusammenfassen(analogien_res, 0)
z_zr = zusammenfassen(zahlenreihen_res, 20)
z_mat = zusammenfassen(matrizen_res, 40)
rw_analogien = z_ana$rw
rw_zahlenreihen = z_zr$rw
rw_matrizen = z_mat$rw
rw_gesamt = rw_analogien + rw_zahlenreihen + rw_matrizen
# --- Normbloecke ---
prim_key = ist_primaere_tabelle(schulform_gruppe, alter)
norm_primaer = NULL
keine_norm_grund = NULL
if (is.null(prim_key)) {
keine_norm_grund = if (is.na(schulform_gruppe) && (is.na(alter) || alter < 15 || alter > 30)) {
"Keine Norm verfuegbar: Schulform fehlt und Alter ausserhalb 15-30 Jahre (oder fehlend)."
} else if (is.na(schulform_gruppe)) {
"Keine Norm verfuegbar: Schulform fehlt oder ist nicht zuordenbar."
} else {
"Keine Norm verfuegbar: Alter ausserhalb des normierten Bereichs 15-30 Jahre (oder fehlend)."
}
} else {
norm_primaer = ist_berechne_normblock(prim_key, rw_analogien, rw_zahlenreihen,
rw_matrizen, rw_gesamt)
}
gg_key = ist_gesamtgruppe_tabelle(alter)
norm_gesamtgruppe = if (is.null(gg_key)) NULL else
ist_berechne_normblock(gg_key, rw_analogien, rw_zahlenreihen, rw_matrizen, rw_gesamt)
norm_a12 = ist_berechne_normblock("A12", rw_analogien, rw_zahlenreihen,
rw_matrizen, rw_gesamt, nur_gesamt = TRUE)
# --- Rohdaten-Check: feste Stichprobe (Analogien 1-3, Matrizen 41-43) plus
# alle echt "nicht auswertbaren" Items (Antwort da, aber nicht aufloesbar) ---
na_items_alle = c(z_ana$nicht_auswertbar_items, z_zr$nicht_auswertbar_items,
z_mat$nicht_auswertbar_items)
grund_fuer_item = function(i) {
res = if (i <= 20) analogien_res[[i]] else if (i <= 40) zahlenreihen_res[[i - 20]]
else matrizen_res[[i - 40]]
if (!is.null(res$grund)) res$grund else NA_character_
}
dump_item = function(i) {
col = ist_col(i)
la = attr(daten_ist_b[[col]], "labels")
rv = zeile[[col]][1]
list(
item = col,
grund = grund_fuer_item(i),
roh = if (is.null(rv) || is.na(rv) || (is.character(rv) && !nzchar(trimws(rv))))
"NA (nicht beantwortet)" else as.character(rv),
labels = if (is.null(la) || length(la) == 0) "kein labels-Attribut (Spalte ist reine NA-Spalte)" else
paste(sprintf("[%s] = %s", names(la), as.vector(la)), collapse = "\n")
)
}
rohdaten_check = lapply(sort(unique(c(1, 2, 3, 41, 42, 43, na_items_alle))), dump_item)
# --- Einzelitem-Details ---
baue_details = function(res_liste, offset, typ) {
lapply(seq_along(res_liste), function(k) {
r = res_liste[[k]]
list(
nr = offset + k,
typ = typ,
gegeben = if (is.null(r$gegeben) || is.na(r$gegeben)) NA else r$gegeben,
beantwortet = isTRUE(r$beantwortet),
soll_text = switch(typ,
"analogie" = IST_B_ANALOGIEN_LOESUNG[k],
"zahl" = as.character(IST_B_ZAHLENREIHEN_LOESUNG[k]),
"matrix" = IST_B_MATRIZEN_LOESUNG[k]),
status = r$status,
richtig = isTRUE(r$richtig),
grund = if (!is.null(r$grund)) r$grund else NA_character_
)
})
}
details_ana = baue_details(analogien_res, 0, "analogie")
details_zr = baue_details(zahlenreihen_res, 20, "zahl")
details_mat = baue_details(matrizen_res, 40, "matrix")
# --- Technische Hinweise ---
kurz_liste = function(x) if (length(x) <= 6) paste(x, collapse = ", ") else
paste0(paste(range(x), collapse = "-"), " (", length(x), " Items)")
tech_hinweise = c(IST_HINWEIS_KODIERUNG)
# Nicht beantwortete Items: erwartbar (Speed-Test), kein technischer Fehler.
nb_bereiche = c(
if (z_ana$n_nicht_beantwortet > 0)
paste0("Analogien ", kurz_liste(z_ana$nicht_beantwortet_items)),
if (z_zr$n_nicht_beantwortet > 0)
paste0("Zahlenreihen ", kurz_liste(z_zr$nicht_beantwortet_items)),
if (z_mat$n_nicht_beantwortet > 0)
paste0("Matrizen ", kurz_liste(z_mat$nicht_beantwortet_items))
)
if (length(nb_bereiche) > 0) {
tech_hinweise = c(tech_hinweise, paste0(
"Nicht beantwortete Items (als falsch gewertet, Speed-Test-Charakter, kein ",
"technisches Problem): ", paste(nb_bereiche, collapse = "; "), "."))
}
# Echt nicht auswertbare Items: Antwort da, aber nicht aufloesbar -> technisch.
na_bereiche = c(
if (z_ana$n_nicht_auswertbar > 0)
paste0("Analogien ", paste(z_ana$nicht_auswertbar_items, collapse = ", ")),
if (z_zr$n_nicht_auswertbar > 0)
paste0("Zahlenreihen ", paste(z_zr$nicht_auswertbar_items, collapse = ", ")),
if (z_mat$n_nicht_auswertbar > 0)
paste0("Matrizen ", paste(z_mat$nicht_auswertbar_items, collapse = ", "))
)
if (length(na_bereiche) > 0) {
tech_hinweise = c(tech_hinweise, paste0(
"TECHNISCH nicht auswertbare Items (Antwort vorhanden, aber Text-/alt-Zuordnung ",
"fehlgeschlagen; als falsch gewertet): ", paste(na_bereiche, collapse = "; "),
". Bitte den Rohdaten-Check pruefen."))
} else {
tech_hinweise = c(tech_hinweise,
"Alle beantworteten Items konnten regulaer ueber das labels-Attribut zugeordnet werden.")
}
tech_hinweise = c(tech_hinweise, paste0(
"Als Ausfuelldatum wurde die Spalte '", zt$spalte, "' verwendet (zur Laufzeit ",
"aus den vorhandenen Zeitstempel-Spalten gewaehlt)."))
tech_hinweise = c(tech_hinweise, IST_HINWEIS_A13_UNTERTESTS, IST_HINWEIS_SW_UNTER_70)
list(
typ = "ok",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
ausfuelldatum_ymd = ausfuelldatum_ymd,
alter = alter,
schulform_text = schulform_text,
schulform_gruppe = schulform_gruppe,
mehrfach_warnung = mehrfach_warnung,
rw_analogien = rw_analogien,
rw_zahlenreihen = rw_zahlenreihen,
rw_matrizen = rw_matrizen,
rw_gesamt = rw_gesamt,
z_ana = z_ana,
z_zr = z_zr,
z_mat = z_mat,
norm_primaer = norm_primaer,
keine_norm_grund = keine_norm_grund,
norm_gesamtgruppe = norm_gesamtgruppe,
norm_a12 = norm_a12,
rohdaten_check = rohdaten_check,
details_ana = details_ana,
details_zr = details_zr,
details_mat = details_mat,
tech_hinweise = tech_hinweise
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) div(class = "alert-fehler", d$meldung)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) return(NULL)
meldungen = list()
if (!is.null(d$mehrfach_warnung)) {
meldungen = c(meldungen, paste0(
"Mehrere Ausfuellungen fuer diese Chiffre gefunden (", d$mehrfach_warnung$n,
" Eintraege). Angezeigt wird die neueste (nach Zeitstempel)."))
}
if (is.null(d$norm_primaer)) {
meldungen = c(meldungen, d$keine_norm_grund)
}
kurz = function(x) if (length(x) <= 8) paste(x, collapse = ", ") else
paste0(paste(range(x), collapse = "-"), " (", length(x), " Items)")
na_gesamt = d$z_ana$n_nicht_auswertbar + d$z_zr$n_nicht_auswertbar + d$z_mat$n_nicht_auswertbar
warn_technisch = NULL
if (na_gesamt > 0) {
teile = c(
if (d$z_ana$n_nicht_auswertbar > 0)
paste0("Analogien ", paste(d$z_ana$nicht_auswertbar_items, collapse = ", ")),
if (d$z_zr$n_nicht_auswertbar > 0)
paste0("Zahlenreihen ", paste(d$z_zr$nicht_auswertbar_items, collapse = ", ")),
if (d$z_mat$n_nicht_auswertbar > 0)
paste0("Matrizen ", paste(d$z_mat$nicht_auswertbar_items, collapse = ", "))
)
warn_technisch = paste0(
"TECHNISCH nicht auswertbare Items (Antwort vorhanden, aber Zuordnung ueber das ",
"labels-Attribut fehlgeschlagen): ", paste(teile, collapse = "; "),
". Diese zaehlen als falsch, sind aber keine gesicherten Falschantworten ",
"bitte den Rohdaten-Check unten pruefen.")
}
nb_gesamt = d$z_ana$n_nicht_beantwortet + d$z_zr$n_nicht_beantwortet + d$z_mat$n_nicht_beantwortet
info_unbeantwortet = NULL
if (nb_gesamt > 0) {
teile = c(
if (d$z_ana$n_nicht_beantwortet > 0)
paste0("Analogien ", kurz(d$z_ana$nicht_beantwortet_items)),
if (d$z_zr$n_nicht_beantwortet > 0)
paste0("Zahlenreihen ", kurz(d$z_zr$nicht_beantwortet_items)),
if (d$z_mat$n_nicht_beantwortet > 0)
paste0("Matrizen ", kurz(d$z_mat$nicht_beantwortet_items))
)
info_unbeantwortet = paste0(
nb_gesamt, " Item(s) wurden nicht beantwortet und als falsch gewertet ",
"(Speed-Test-Charakter, kein technisches Problem): ", paste(teile, collapse = "; "), ".")
}
if (!is.null(warn_technisch)) meldungen = c(meldungen, warn_technisch)
if (length(meldungen) == 0 && is.null(info_unbeantwortet)) return(NULL)
tagList(
lapply(meldungen, function(m) div(class = "alert-warnung", m)),
if (!is.null(info_unbeantwortet)) div(class = "hinweis-block", info_unbeantwortet)
)
})
# Vier-Zeilen-Tabelle RW / SW / PR / IQ fuer einen Normblock.
norm_tabelle_ui = function(block) {
zeile_ui = function(name, comp, max_rw, gesamt = FALSE) {
tags$tr(class = if (gesamt) "gesamt-zeile" else NULL,
tags$td(name),
tags$td(paste0(comp$rw, " / ", max_rw)),
tags$td(ist_oder_strich(comp$sw)),
tags$td(ist_pr_text(comp)),
tags$td(ist_iq_text(comp))
)
}
tags$table(class = "norm-tabelle",
tags$thead(tags$tr(
tags$th("Bereich"), tags$th("Rohwert"), tags$th("Standardwert"),
tags$th("Prozentrang"), tags$th("IQ")
)),
tags$tbody(
zeile_ui("Analogien", block$analogien, 20),
zeile_ui("Zahlenreihen", block$zahlenreihen, 20),
zeile_ui("Matrizen", block$matrizen, 20),
zeile_ui("Gesamtwert", block$gesamt, 60, gesamt = TRUE)
)
)
}
rohwert_tabelle_ui = function(d) {
zeile_ui = function(name, rw, max_rw) tags$tr(
tags$td(name), tags$td(paste0(rw, " / ", max_rw)),
tags$td(""), tags$td(""), tags$td("")
)
tags$table(class = "norm-tabelle",
tags$thead(tags$tr(
tags$th("Bereich"), tags$th("Rohwert"), tags$th("Standardwert"),
tags$th("Prozentrang"), tags$th("IQ")
)),
tags$tbody(
zeile_ui("Analogien", d$rw_analogien, 20),
zeile_ui("Zahlenreihen", d$rw_zahlenreihen, 20),
zeile_ui("Matrizen", d$rw_matrizen, 20),
tags$tr(class = "gesamt-zeile",
tags$td("Gesamtwert"), tags$td(paste0(d$rw_gesamt, " / 60")),
tags$td(""), tags$td(""), tags$td(""))
)
)
}
detail_liste_ui = function(details) {
div(
lapply(details, function(it) {
if (identical(it$status, "nicht_auswertbar")) {
badge = span(style = "color:#BF360C; font-weight:700;", "nicht auswertbar")
info = paste0("Antwort-Code: ", ist_oder_strich(it$gegeben), " | Soll: ",
it$soll_text, " | ", it$grund)
} else if (isFALSE(it$beantwortet)) {
badge = span(style = "color:#9E9E9E; font-weight:700;", "nicht beantwortet")
info = paste0("Soll: ", it$soll_text, " | als falsch gewertet")
} else if (isTRUE(it$richtig)) {
badge = span(style = "color:#2E7D32; font-weight:700;", "richtig")
info = paste0("Antwort-Code: ", ist_oder_strich(it$gegeben), " | Soll: ", it$soll_text)
} else {
badge = span(style = "color:#B71C1C; font-weight:700;", "falsch")
info = paste0("Antwort-Code: ", ist_oder_strich(it$gegeben), " | Soll: ", it$soll_text)
}
div(class = "item-zeile",
span(class = "item-nr", paste0(it$nr, ".")),
span(class = "item-text", info),
badge
)
})
)
}
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) return(NULL)
kopf = div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Alter: "), if (is.na(d$alter)) "k. A." else d$alter,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Schulform: "), if (is.na(d$schulform_text)) "k. A." else d$schulform_text
)
primaer_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Primaere Norm (schulform- und altersspezifisch)"),
if (is.null(d$norm_primaer)) {
tagList(
div(class = "alert-warnung", d$keine_norm_grund),
rohwert_tabelle_ui(d)
)
} else {
tagList(
div(class = "meta-block", tags$strong("Normgruppe: "), d$norm_primaer$label),
norm_tabelle_ui(d$norm_primaer),
div(class = "hinweis-block", IST_HINWEIS_A13_UNTERTESTS),
if (any(vapply(d$norm_primaer[c("analogien", "zahlenreihen", "matrizen", "gesamt")],
function(comp) isTRUE(comp$unter_minimum), logical(1)))) {
div(class = "hinweis-block", IST_HINWEIS_SW_UNTER_70)
}
)
}
)
gesamtgruppe_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Zusatzangabe: Gesamtgruppe (ohne Schulform)"),
if (is.null(d$norm_gesamtgruppe)) {
div(class = "alert-warnung",
"Nicht verfuegbar: Alter ausserhalb 15-30 Jahre oder fehlend.")
} else {
tagList(
div(class = "meta-block", tags$strong("Normgruppe: "), d$norm_gesamtgruppe$label),
norm_tabelle_ui(d$norm_gesamtgruppe),
div(class = "hinweis-block", IST_HINWEIS_A13_UNTERTESTS),
if (any(vapply(d$norm_gesamtgruppe[c("analogien", "zahlenreihen", "matrizen", "gesamt")],
function(comp) isTRUE(comp$unter_minimum), logical(1)))) {
div(class = "hinweis-block", IST_HINWEIS_SW_UNTER_70)
}
)
}
)
g12 = d$norm_a12$gesamt
a12_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Zusatzangabe: Gesamtstichprobe (Tabelle A12, nur Gesamtwert)"),
div(class = "hinweis-block",
"Ohne Alters- und Schulformdifferenzierung, ausschliesslich fuer den Gesamtwert."),
tags$table(class = "norm-tabelle",
tags$thead(tags$tr(
tags$th("Bereich"), tags$th("Rohwert"), tags$th("Standardwert"),
tags$th("Prozentrang"), tags$th("IQ")
)),
tags$tbody(tags$tr(class = "gesamt-zeile",
tags$td("Gesamtwert"), tags$td(paste0(g12$rw, " / 60")),
tags$td(ist_oder_strich(g12$sw)), tags$td(ist_pr_text(g12)), tags$td(ist_iq_text(g12))
))
),
if (isTRUE(g12$unter_minimum)) div(class = "hinweis-block", IST_HINWEIS_SW_UNTER_70)
)
na_gesamt = d$z_ana$n_nicht_auswertbar + d$z_zr$n_nicht_auswertbar + d$z_mat$n_nicht_auswertbar
rohcheck_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Rohdaten-Check und Einzelitems"),
tags$details(class = "rohcheck", open = if (na_gesamt > 0) NA else NULL,
tags$summary("Rohdaten-Check: gespeicherte Werte und labels-Attribut"),
div(class = "hinweis-block",
"Feste Stichprobe (Analogien 1-3, Matrizen 41-43) zur manuellen Verifikation, dass ",
"das textbasierte Matching greift plus automatisch jedes technisch nicht ",
"auswertbare Item."),
lapply(d$rohdaten_check, function(rc) {
div(class = "rohcheck-box",
paste0(rc$item, " | gespeicherter Rohwert: ", rc$roh,
if (!is.na(rc$grund)) paste0("\ngrund: ", rc$grund) else "",
"\nlabels:\n", rc$labels))
})
),
tags$details(class = "rohcheck",
tags$summary("Einzelitems: Analogien (1-20)"),
detail_liste_ui(d$details_ana)
),
tags$details(class = "rohcheck",
tags$summary("Einzelitems: Zahlenreihen (21-40)"),
detail_liste_ui(d$details_zr)
),
tags$details(class = "rohcheck",
tags$summary("Einzelitems: Matrizen (41-60)"),
detail_liste_ui(d$details_mat)
)
)
hinweis_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Hinweise und Einschraenkungen"),
tags$ul(
tags$li("Die digitale Selbstausfuell-Version repliziert nicht die im Manual ",
"beschriebene testleiter-administrierte Gruppentestsituation."),
tags$li("Ob Tabelle A13 fuer Untertest-Standardwerte gedacht ist, ist eine ",
"Interpretationsfrage, keine gesicherte Manual-Aussage."),
tags$li("Standardwerte unter 70 sind in Tabelle A13 nicht direkt tabelliert; ",
"die App zeigt hier IQ \"", IST_IQ_UNTER_MINIMUM_TEXT, "\" und Prozentrang ",
IST_PR_UNTER_MINIMUM, " (Bodenwert) bewusste, vom Nutzer festgelegte Konvention."),
tags$li("Fehlende oder nicht beantwortete Items zaehlen als falsch (Speed-Test-Charakter, ",
"keine Mindestbeantwortungsquote).")
),
div(class = "hinweis-block", IST_DISCLAIMER)
)
tagList(kopf, primaer_karte, gesamtgruppe_karte, a12_karte, rohcheck_karte, hinweis_karte)
})
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_fn = if (is.list(d) && identical(d$typ, "ok") && nchar(d$chiffre) > 0) {
gsub("[^A-Za-z0-9_-]", "_", d$chiffre)
} else {
"export"
}
datum_fn = if (is.list(d) && identical(d$typ, "ok") && !is.na(d$ausfuelldatum_ymd)) {
d$ausfuelldatum_ymd
} else {
format(Sys.Date(), "%Y%m%d")
}
paste0("IST-Screening-B_", chiffre_fn, "_", datum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
if (!is.list(d) || !identical(d$typ, "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_ist_screening_b_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)