app update final

This commit is contained in:
breitenbach76 2026-06-10 08:22:00 +02:00
parent e06a717e4b
commit b6500bebe1
6 changed files with 406 additions and 30 deletions

View file

@ -130,6 +130,8 @@ formcodiert. Figuren werden **gestellt, nicht gesteckt**; es gibt **zwei** Orte:
**R | A** (oben) und **C | I** (unten). Die beteiligten Rollen werden zusätzlich in **R | A** (oben) und **C | I** (unten). Die beteiligten Rollen werden zusätzlich in
die passende RACI-Zone gestellt — sichtbar wird nicht nur *wer*, sondern *in welcher die passende RACI-Zone gestellt — sichtbar wird nicht nur *wer*, sondern *in welcher
Verantwortung*. **A** hat genau einen Platz (genau eine Rolle accountable). Verantwortung*. **A** hat genau einen Platz (genau eine Rolle accountable).
Didaktische Gewichtung (Frank): **R und A** sind die Pflicht und unbedingt zu
durchdenken (obere Zeile R | A), **C und I** sind ergänzend / nice-to-have.
Alle Standfelder sind Ø 22 (gleich wie die Puck-Mulden — dieselben Ø-20-Figuren). Alle Standfelder sind Ø 22 (gleich wie die Puck-Mulden — dieselben Ø-20-Figuren).
Details & Designvarianten: [`../02_Spielfiguren/`](../02_Spielfiguren/). Details & Designvarianten: [`../02_Spielfiguren/`](../02_Spielfiguren/).

View file

@ -1,7 +1,14 @@
# Deployment — SLC-Workshop Companion (App) # Deployment — SLC-Workshop Companion (App)
**Auftrag für die Server-Claude / Ops:** Diese App **statisch** ausliefern. Kein **Auftrag für die Server-Claude / Ops:** Diese App **statisch** ausliefern. Kein
Build-Schritt, kein Backend, keine Secrets, keine Datenbank. Build-Schritt, keine Secrets, keine Datenbank.
> **Neu (v0.10): kleiner Feedback-Endpoint.** Die App sammelt jetzt Workshop-Feedback
> (Phasen + Prüfschritte) und **speichert es auf dem Server** zur späteren Auswertung.
> Dafür braucht es **einen** schlanken POST-Sammelpunkt (Flat-File, **keine Datenbank**).
> Referenz liegt bei: [`feedback.php`](feedback.php). Ist der Endpoint (noch) nicht
> aktiv, geht **nichts verloren**: die App hält das Feedback lokal vor (Retry-Queue)
> und der Header-Button **„⇩ Feedback"** exportiert es als Backup-Datei.
## Was das ist ## Was das ist
- Eine **statische Single-Page-PWA**. Alle Inhalte (Stationen, Quiz, Use-Cases) - Eine **statische Single-Page-PWA**. Alle Inhalte (Stationen, Quiz, Use-Cases)
@ -11,6 +18,7 @@ Build-Schritt, kein Backend, keine Secrets, keine Datenbank.
- `manifest.webmanifest` — PWA-Manifest - `manifest.webmanifest` — PWA-Manifest
- `sw.js` — Service Worker (Offline-Cache der App-Shell) - `sw.js` — Service Worker (Offline-Cache der App-Shell)
- `icon.svg` — App-Icon - `icon.svg` — App-Icon
- `feedback.php`**optionaler** Feedback-Sammelpunkt (Flat-File, JSON Lines)
## Ziel ## Ziel
Den Ordner `04_Tablet-Quiz/app/` als **statisches Web-Root** über den vorhandenen Den Ordner `04_Tablet-Quiz/app/` als **statisches Web-Root** über den vorhandenen
@ -47,11 +55,44 @@ slc.example.intern {
} }
``` ```
## Feedback-Endpoint (empfohlen — für die Auswertung)
Die App schickt jedes gespeicherte Feedback per `POST` als JSON an den im
`<meta name="slc-feedback-endpoint">` hinterlegten Pfad (Default: `feedback.php`,
gleiche Domain). Der Service Worker fasst POSTs nicht an — der Offline-Cache stört
also nicht.
**Variante A — PHP (Referenz, einfachste Option).** `feedback.php` liegt im App-Ordner.
Voraussetzung: `php-fpm` aktiv und das App-Verzeichnis für PHP freigegeben; das
Datenverzeichnis `feedback-data/` muss vom Webserver-User beschreibbar sein.
```nginx
# innerhalb des server{}-Blocks von oben:
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php-fpm.sock; # Pfad anpassen
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
```
Daten landen in `feedback-data/feedback.jsonl` (eine JSON-Zeile pro Feedback) →
später für die Auswertung einlesen/zu CSV konvertieren.
**Variante B — kein PHP verfügbar.** Endpoint auf einen beliebigen JSON-POST-Empfänger
zeigen lassen (z. B. ein kleines Node-/Worker-Skript, das den Body an eine Datei
anhängt) und im `<meta name="slc-feedback-endpoint">` dessen URL eintragen.
**Variante C — gar kein Server-Save.** Auch ohne Endpoint funktioniert alles: das
Feedback bleibt in der Retry-Queue und kann über den Header-Button **„⇩ Feedback"**
als JSON-Datei exportiert werden.
## Verifikation nach dem Deploy ## Verifikation nach dem Deploy
1. URL öffnen → Startbildschirm „SLC Companion · Schritt 1 · Action Card". 1. URL öffnen → Startbildschirm „SLC Companion".
2. DevTools → Application → **Service Workers**: `sw.js` ist *activated*. 2. DevTools → Application → **Service Workers**: `sw.js` ist *activated*.
3. Flugmodus/Offline → Seite neu laden → App lädt weiterhin (Offline-Cache). 3. Flugmodus/Offline → Seite neu laden → App lädt weiterhin (Offline-Cache).
4. Einen Durchlauf spielen → „Debrief" → **↓ Markdown / ↓ JSON** lädt eine Datei. 4. Einen Major-Durchlauf spielen, an einem Gate Feedback eintragen → **„💾 Feedback
speichern"**. Bei aktivem Endpoint erscheint kein Fehler in der Konsole; in
`feedback-data/feedback.jsonl` taucht eine neue Zeile auf. Ohne Endpoint: Header
**„⇩ Feedback"** lädt die Backup-Datei.
## Updates ## Updates
- Bei neuem Stand: `git pull`. Wenn sich App-Assets geändert haben, in `sw.js` - Bei neuem Stand: `git pull`. Wenn sich App-Assets geändert haben, in `sw.js`

View file

@ -0,0 +1,58 @@
<?php
/**
* SLC-Workshop minimaler Feedback-Sammelpunkt (Referenz-Implementierung).
*
* Nimmt einen JSON-POST der Companion-App entgegen und hängt ihn als eine Zeile
* (JSON Lines) an eine Datei an. Kein Framework, keine Datenbank, keine Secrets.
*
* Auswertung später: die Datei `feedback-data/feedback.jsonl` einlesen (jede Zeile
* ein JSON-Objekt) z. B. nach JSON/CSV konvertieren.
*
* Voraussetzung: PHP (php-fpm) ist auf dem Webserver aktiv und dieser Pfad wird
* von der App aus erreicht (gleiche Domain). Schreibrechte aufs Datenverzeichnis
* sind nötig. Alternativen (Node/Caddy) siehe DEPLOY.md.
*/
header('Content-Type: application/json; charset=utf-8');
// Nur POST erlaubt (GET nur als simpler Health-Check).
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
echo json_encode(['ok' => true, 'service' => 'slc-feedback', 'hint' => 'POST JSON to store feedback']);
exit;
}
$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) === 0 || strlen($raw) > 65536) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'empty or oversized body']);
exit;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'invalid json']);
exit;
}
// Serverseitigen Eingangszeitstempel + Roh-Metadaten ergänzen (nicht überschreibend).
$data['_received'] = gmdate('c');
$data['_ip'] = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null;
$dir = __DIR__ . '/feedback-data';
$file = $dir . '/feedback.jsonl';
if (!is_dir($dir)) { @mkdir($dir, 0775, true); }
$line = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
$fp = @fopen($file, 'ab');
if ($fp === false) {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'cannot open data file (Schreibrechte prüfen)']);
exit;
}
flock($fp, LOCK_EX);
fwrite($fp, $line);
flock($fp, LOCK_UN);
fclose($fp);
echo json_encode(['ok' => true]);

View file

@ -6,6 +6,8 @@
<title>SLC-Workshop — Companion-App</title> <title>SLC-Workshop — Companion-App</title>
<meta name="description" content="Begleit-App zum SLC-Workshop-Tabletop: führt durch die Stationen, vermittelndes Quiz und Auflösung. Offline lauffähig." /> <meta name="description" content="Begleit-App zum SLC-Workshop-Tabletop: führt durch die Stationen, vermittelndes Quiz und Auflösung. Offline lauffähig." />
<meta name="theme-color" content="#161b24" /> <meta name="theme-color" content="#161b24" />
<!-- Feedback-Sammelpunkt (Server). Bei Bedarf anpassen; Default: gleiche Domain, feedback.php -->
<meta name="slc-feedback-endpoint" content="feedback.php" />
<link rel="manifest" href="manifest.webmanifest" /> <link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="icon.svg" /> <link rel="apple-touch-icon" href="icon.svg" />
<link rel="icon" href="icon.svg" type="image/svg+xml" /> <link rel="icon" href="icon.svg" type="image/svg+xml" />
@ -176,6 +178,10 @@
.sHead{display:flex;align-items:center;gap:12px;flex-wrap:wrap} .sHead{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.sHead .phaseChip{font-size:15px;padding:8px 18px;letter-spacing:.8px} .sHead .phaseChip{font-size:15px;padding:8px 18px;letter-spacing:.8px}
.sHead .sId{color:var(--ink);font-size:18px;font-weight:800;font-variant-numeric:tabular-nums} .sHead .sId{color:var(--ink);font-size:18px;font-weight:800;font-variant-numeric:tabular-nums}
.tiefeBadge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;cursor:help;white-space:nowrap}
.tiefeBadge.bearbeitet{background:#e7f5ec;color:#177a44;border:1px solid #b8e0c7}
.tiefeBadge.entwurf{background:#f0f1f4;color:#6b7280;border:1px solid #dfe2e8}
.sHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.sTitle{font-size:22px;font-weight:700;line-height:1.25;margin:10px 0 4px} .sTitle{font-size:22px;font-weight:700;line-height:1.25;margin:10px 0 4px}
.caseLine{color:var(--muted);font-size:13px;margin:0 0 22px} .caseLine{color:var(--muted);font-size:13px;margin:0 0 22px}
.lead{font-size:16px;margin:0 0 16px} .lead{font-size:16px;margin:0 0 16px}
@ -184,6 +190,7 @@
.crit{margin:8px 0 0;padding-left:20px;color:var(--muted)} .crit{margin:8px 0 0;padding-left:20px;color:var(--muted)}
.crit li{margin:4px 0} .crit li{margin:4px 0}
.critHead{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px} .critHead{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px}
.gateZiel{font-size:14px;line-height:1.5;background:var(--soft);border-left:3px solid var(--accent);border-radius:8px;padding:10px 12px;margin:0 0 12px}
.gateCrit{display:flex;flex-direction:column;gap:8px;margin:0 0 16px} .gateCrit{display:flex;flex-direction:column;gap:8px;margin:0 0 16px}
.critItem{display:flex;align-items:flex-start;gap:10px;text-align:left;padding:11px 14px;border:1px solid var(--line);border-radius:10px;background:var(--soft);color:var(--ink);cursor:pointer;font-size:14px;line-height:1.45;animation:critIn .22s ease} .critItem{display:flex;align-items:flex-start;gap:10px;text-align:left;padding:11px 14px;border:1px solid var(--line);border-radius:10px;background:var(--soft);color:var(--ink);cursor:pointer;font-size:14px;line-height:1.45;animation:critIn .22s ease}
.critItem:hover{border-color:#c9d2dd} .critItem:hover{border-color:#c9d2dd}
@ -208,6 +215,10 @@
.raciLegend .rlHead{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700;margin-bottom:8px} .raciLegend .rlHead{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700;margin-bottom:8px}
.raciLegend .rlRow{display:flex;align-items:flex-start;gap:10px;margin:5px 0;font-size:14px;line-height:1.45} .raciLegend .rlRow{display:flex;align-items:flex-start;gap:10px;margin:5px 0;font-size:14px;line-height:1.45}
.raciLegend .rlRow .raciBadge{flex:none;margin-top:1px} .raciLegend .rlRow .raciBadge{flex:none;margin-top:1px}
.raciLegend .rlGroup{margin-top:8px;padding-top:6px;border-top:1px dashed var(--line)}
.raciLegend .rlGroup:first-of-type{border-top:0;padding-top:0}
.raciLegend .rlGroupHead{font-size:11px;font-weight:700;color:var(--muted);margin:2px 0 4px}
.raciLegend .rlGroupHead.kern{color:var(--ink)}
/* Aufgaben-Kasten (Anweisung hervorgehoben) */ /* Aufgaben-Kasten (Anweisung hervorgehoben) */
.frageBox{background:#f7f9fc;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:12px;padding:14px 18px;margin:6px 0;font-size:16px;line-height:1.55} .frageBox{background:#f7f9fc;border:1px solid var(--line);border-left:4px solid var(--accent);border-radius:12px;padding:14px 18px;margin:6px 0;font-size:16px;line-height:1.55}
.frageBox .frageLabel{font-size:11px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-weight:700;margin-bottom:5px} .frageBox .frageLabel{font-size:11px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);font-weight:700;margin-bottom:5px}
@ -288,6 +299,24 @@
.slcDonut.clickable .donutSeg{cursor:pointer;transition:opacity .12s} .slcDonut.clickable .donutSeg{cursor:pointer;transition:opacity .12s}
.slcDonut.clickable .donutSeg:hover{opacity:.82} .slcDonut.clickable .donutSeg:hover{opacity:.82}
.slcDonut .donutSeg.bad{stroke:var(--bad);stroke-width:4} .slcDonut .donutSeg.bad{stroke:var(--bad);stroke-width:4}
.slcDonut path.dim{opacity:.32}
.slcDonut path.current{stroke:var(--ink);stroke-width:3;filter:drop-shadow(0 1px 4px rgba(0,0,0,.28))}
.slcOverview{margin:4px 0 14px;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:#fbfcfe}
.slcOverview .slcOvHead{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700;text-align:center;margin-bottom:4px}
.slcOverview .slcDonut{width:180px;height:180px}
/* Feedback-Cards (Phase / Dokument) */
.fbCard{margin:14px 0 4px;padding:14px 16px;border:1px solid var(--line);border-radius:12px;background:var(--soft)}
.fbCard .fbHead{font-size:15px;font-weight:800;margin-bottom:10px}
.fbCard .fbOpt{font-weight:600;color:var(--muted);font-size:12px}
.fbCard .fbQ{display:block;font-size:13.5px;font-weight:600;margin:10px 0 5px;line-height:1.4}
.fbCard textarea.fbInput{width:100%;box-sizing:border-box;font:inherit;font-size:14px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);resize:vertical}
.fbCard textarea.fbInput:focus{outline:2px solid var(--accent);outline-offset:0;border-color:var(--accent)}
.fbScaleRow{display:flex;gap:8px;margin:2px 0 4px}
.fbScale{width:42px;height:42px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);font-weight:800;font-size:16px;cursor:pointer}
.fbScale:hover{border-color:#9fb0c4}
.fbScale.sel{background:var(--accent);color:#fff;border-color:var(--accent)}
.fbActions{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
.fbSaved{color:#177a44;font-weight:700;font-size:13px}
.akteItem.flash{animation:akteFlash 1.6s ease-out} .akteItem.flash{animation:akteFlash 1.6s ease-out}
@keyframes akteFlash{0%,55%{background:#fff3bf}100%{background:transparent}} @keyframes akteFlash{0%,55%{background:#fff3bf}100%{background:transparent}}
.slcCap{font-size:11px;color:var(--muted);margin-top:4px;text-align:center} .slcCap{font-size:11px;color:var(--muted);margin-top:4px;text-align:center}
@ -357,13 +386,14 @@
<body> <body>
<header> <header>
<div class="brand">SLC&nbsp;Workshop&nbsp;<b>Companion</b></div> <div class="brand">SLC&nbsp;Workshop&nbsp;<b>Companion</b></div>
<span class="tag">v0.9</span> <span class="tag">v0.10</span>
<div id="cardBadge" class="cardBadge"></div> <div id="cardBadge" class="cardBadge"></div>
<div class="spacer"></div> <div class="spacer"></div>
<button class="ghost" id="svcBtn" title="Ausführliche Service-Beschreibungen">&nbsp;Service-Info</button> <button class="ghost" id="svcBtn" title="Ausführliche Service-Beschreibungen">&nbsp;Service-Info</button>
<button class="ghost" id="akteBtn" title="Service-Akte (gesammelte Artefakte)">📁&nbsp;Service-Akte</button> <button class="ghost" id="akteBtn" title="Service-Akte (gesammelte Artefakte)">📁&nbsp;Service-Akte</button>
<button class="ghost" id="rollenBtn" title="Rollen-Glossar (RACI)">👥&nbsp;Rollen</button> <button class="ghost" id="rollenBtn" title="Rollen-Glossar (RACI)">👥&nbsp;Rollen</button>
<button class="ghost" id="stationsBtn" title="Stationsübersicht">&nbsp;Stationen</button> <button class="ghost" id="stationsBtn" title="Stationsübersicht">&nbsp;Stationen</button>
<button class="ghost" id="fbExportBtn" title="Gesammeltes Feedback als Backup-Datei exportieren">&nbsp;Feedback</button>
<button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button> <button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button>
</header> </header>
<div class="progress"><div id="progressBar" style="width:0%"></div></div> <div class="progress"><div id="progressBar" style="width:0%"></div></div>
@ -752,13 +782,21 @@ const STATIONEN = [
beschreibung:"Entry-Gate der Transition: Die SOR entscheidet zuerst, ob das Vorhaben überhaupt in die Transition darf — und wenn ja, ob die Service-Komponenten neu entwickelt oder nur konfiguriert werden. Erfordert SOR-Approval (Budget- und Ressourcenimplikationen).", beschreibung:"Entry-Gate der Transition: Die SOR entscheidet zuerst, ob das Vorhaben überhaupt in die Transition darf — und wenn ja, ob die Service-Komponenten neu entwickelt oder nur konfiguriert werden. Erfordert SOR-Approval (Budget- und Ressourcenimplikationen).",
umfasst:["Aufwandsschätzung (Build vs. Configure)","Technische Risiken","Budget-Abgleich","ggf. Lieferanten-Einbindung","SOR-Vorlage für Freigabe"], umfasst:["Aufwandsschätzung (Build vs. Configure)","Technische Risiken","Budget-Abgleich","ggf. Lieferanten-Einbindung","SOR-Vorlage für Freigabe"],
artefakt:"Gate-Entscheidung + SOR-Vorlage", artefakt:"Gate-Entscheidung + SOR-Vorlage",
ziel:"Sicherstellen, dass nur Vorhaben in die weitere Entwicklung gehen, die ausreichend geplant sind und für die ein grundsätzliches Commitment bezüglich Ressourcen besteht (Budget, Betrieb & Support, Projektressourcen).",
// Schritt A: Freigabe-Entscheidung (Frank-Template). Schritt B: Routing (pfade).
freigabe:[
["Freigabe","Ausreichend geplant, Ressourcen-Commitment liegt vor → weiter zum Build-/Konfigurations-Routing"],
["Freigabe mit Auflagen","Weiter zum Routing; definierte Auflagen müssen nachgearbeitet werden"],
["Zurück an Design","Planung oder Commitment unzureichend → zurück in die Design-Phase"],
["Ablehnung","Vorhaben wird nicht weiterverfolgt — erfordert SOR-Eskalation"]
],
pfade:[ pfade:[
["Entwicklung","Neuentwicklung/wesentliche Anpassung → weiter zu tr_02"], ["Entwicklung","Neuentwicklung/wesentliche Anpassung → weiter zu tr_02"],
["Konfiguration","Bestehende Komponenten konfigurieren → springt zu tr_05"] ["Konfiguration","Bestehende Komponenten konfigurieren → springt zu tr_05"]
], ],
pruef:[ pruef:[
["Design-Vollständigkeit","Ist das Service Design Document vollständig?"], ["Design-Vollständigkeit","Ist das Service Design Document vollständig?"],
["Budget-Verfügbarkeit","Ist Budget für die Strategie verfügbar?"], ["Budget-Verfügbarkeit","Ist Budget für die Implementierung verfügbar?"],
["Projektressourcen","Können Ressourcen mobilisiert werden?"], ["Projektressourcen","Können Ressourcen mobilisiert werden?"],
["Betriebs-/Support-Bereitschaft","Grundsätzliches Commitment vorhanden?"] ["Betriebs-/Support-Bereitschaft","Grundsätzliches Commitment vorhanden?"]
], ],
@ -851,6 +889,7 @@ const STATIONEN = [
{ id:"tr_09", phase:"transition", typ:"gate", gateNr:2, { id:"tr_09", phase:"transition", typ:"gate", gateNr:2,
name:"Gate 2: Entry-Prüfung nach Build", name:"Gate 2: Entry-Prüfung nach Build",
beschreibung:"Validierung der Übergabefähigkeit nach Abschluss des Builds. Dies ist eine SO-Einzelentscheidung (keine Gremienentscheidung).", beschreibung:"Validierung der Übergabefähigkeit nach Abschluss des Builds. Dies ist eine SO-Einzelentscheidung (keine Gremienentscheidung).",
ziel:"Sicherstellen, dass die grundlegenden Voraussetzungen für das Ausrollen eines Service gegeben sind.",
umfasst:["Prüfung der Übergabe-Vollständigkeit","Prüfung des Abnahme-Status","Ressourcen-Outlook (Betrieb/Support vorbereitet)","Pilot-Entscheidung (falls erforderlich)","Dokumentation im Transition-Steckbrief"], umfasst:["Prüfung der Übergabe-Vollständigkeit","Prüfung des Abnahme-Status","Ressourcen-Outlook (Betrieb/Support vorbereitet)","Pilot-Entscheidung (falls erforderlich)","Dokumentation im Transition-Steckbrief"],
artefakt:"Gate-2-Entscheidung (Transition-Steckbrief)", artefakt:"Gate-2-Entscheidung (Transition-Steckbrief)",
pfade:[ pfade:[
@ -899,6 +938,7 @@ const STATIONEN = [
{ id:"tr_12", phase:"transition", typ:"gate", gateNr:3, { id:"tr_12", phase:"transition", typ:"gate", gateNr:3,
name:"Gate 3: Go-Live-Freigabe", name:"Gate 3: Go-Live-Freigabe",
beschreibung:"Portfolio-Freigabe und formale Aktivierung des Services. Exit-Gate der Transition — SOR-Entscheidung nach dem Konsent-Prinzip.", beschreibung:"Portfolio-Freigabe und formale Aktivierung des Services. Exit-Gate der Transition — SOR-Entscheidung nach dem Konsent-Prinzip.",
ziel:"Sicherstellen, dass nur Services für den produktiven Betrieb aktiviert werden, die ausreichend geprüft und qualitätsgesichert sind und für die sowohl im Betrieb als auch im Support genügend Ressourcen zur Verfügung stehen.",
umfasst:["Prüfung der Portfolio-Konsistenz","Prüfung der Betriebsreife (SO-Bestätigung)","Prüfung der Support-Bereitschaft","Prüfung der SLA/SLO-Vereinbarung","Prüfung der Auflagen-Erfüllung aus Gate 2","Bewertung der Geschäftskritikalität","Formale Aufnahme ins Service-Portfolio"], umfasst:["Prüfung der Portfolio-Konsistenz","Prüfung der Betriebsreife (SO-Bestätigung)","Prüfung der Support-Bereitschaft","Prüfung der SLA/SLO-Vereinbarung","Prüfung der Auflagen-Erfüllung aus Gate 2","Bewertung der Geschäftskritikalität","Formale Aufnahme ins Service-Portfolio"],
artefakt:"Gate-3-Entscheidung + Portfolio-Aufnahme", artefakt:"Gate-3-Entscheidung + Portfolio-Aufnahme",
pfade:[ pfade:[
@ -1119,8 +1159,9 @@ const STATIONEN = [
RACI + Quiz hier abgeleitet (Franks Entwurf nennt nur die Aktivitaeten). */ RACI + Quiz hier abgeleitet (Franks Entwurf nennt nur die Aktivitaeten). */
{ id:"rv_01", phase:"review", typ:"aktivitaet", { id:"rv_01", phase:"review", typ:"aktivitaet",
name:"Durchführen von Service-Reviews", name:"Durchführen von Service-Reviews",
beschreibung:"Den Service systematisch auswerten und die Ergebnisse im Service-Review-Dokument festhalten.", beschreibung:"Den Service systematisch auswerten und die Ergebnisse im Service-Review-Dokument festhalten (4 Dimensionen → Handlungsempfehlung).",
umfasst:["KPIs & Monitoring auswerten","Problems & Incidents auswerten","Kundenfeedback sammeln/einholen","zugrunde liegende Infrastruktur bewerten","Service-Review-Dokument ausfüllen"], ziel:"Eine fundierte Entscheidung ermöglichen, ob der Service unverändert weiterbetrieben werden kann oder ob Änderungen erforderlich sind.",
umfasst:["KPIs & Monitoring auswerten","Problems & Incidents auswerten","Kundenfeedback sammeln/einholen","zugrunde liegende Infrastruktur bewerten","Bewertung über 4 Dimensionen: Leistungserbringung · Betriebsstabilität · Nutzerzufriedenheit · Zukunftsfähigkeit","Handlungsempfehlung ableiten: Weiterbetrieb · Normal-Change · Major-Change"],
artefakt:"Service-Review-Dokument", artefakt:"Service-Review-Dokument",
raci:[["service_owner","A"],["betriebsteam","R"],["service_support_team","C"],["problem_manager","C"]], raci:[["service_owner","A"],["betriebsteam","R"],["service_support_team","C"],["problem_manager","C"]],
quiz:[ quiz:[
@ -1221,9 +1262,9 @@ const ARTEFAKTE = {
A13:{name:"Wissensdatenbank-Eintrag", phase:"support", live:true, A13:{name:"Wissensdatenbank-Eintrag", phase:"support", live:true,
was:"Standardlösungen, Known Errors, Workarounds, FAQ und Anleitungen — das zentrale Arbeitsmittel für 1st und 2nd Level Support.", was:"Standardlösungen, Known Errors, Workarounds, FAQ und Anleitungen — das zentrale Arbeitsmittel für 1st und 2nd Level Support.",
warum:"Es beschleunigt den Support, sichert konsistente Antworten und entlastet die höheren Support-Level."}, warum:"Es beschleunigt den Support, sichert konsistente Antworten und entlastet die höheren Support-Level."},
A14:{name:"Service-Review-Bericht", phase:"review", A14:{name:"Service-Review-Dokument", phase:"review",
was:"Das „Service Performance & Improvement Review“: Bewertung über 4 Dimensionen (Leistung, Stabilität, Nutzerzufriedenheit, Zukunftsfähigkeit) per Ampel und eine Handlungsempfehlung (CONTINUE / IMPROVEMENT / REDESIGN / RETIRE).", was:"Verantwortlich: Service-Owner. Bewertung über 4 Dimensionen mit Leitfrage — Leistungserbringung (liefert der Service den erwarteten Nutzen?), Betriebsstabilität (läuft er störungsarm und beherrschbar?), Nutzerzufriedenheit (wie bewerten die Nutzer den Service?) und Zukunftsfähigkeit (ist er mittelfristig tragfähig?). Daraus eine Handlungsempfehlung: Weiterbetrieb (ggf. mit Monitoring-Fokus) · Änderung als Normal-Change · Änderung als Major-Change.",
warum:"Es liefert die strukturierte Grundlage für die SOR-Entscheidung über die Zukunft des Service."}, warum:"Es ermöglicht eine fundierte Entscheidung, ob der Service unverändert weiterbetrieben werden kann oder ob Änderungen erforderlich sind — die Grundlage für die SOR-Bewertung."},
A15:{name:"DPM-Rücklauf", phase:"review", A15:{name:"DPM-Rücklauf", phase:"review",
was:"Die Übergabe an den Demand-Lifecycle — Variante A: Neuer Demand (Redesign/Erweiterung) oder Variante B: Retirement-Plan / Decommissioning-Auftrag (Stilllegung).", was:"Die Übergabe an den Demand-Lifecycle — Variante A: Neuer Demand (Redesign/Erweiterung) oder Variante B: Retirement-Plan / Decommissioning-Auftrag (Stilllegung).",
warum:"Es schließt den Lebenszyklus: größere Änderungen oder das Ende eines Service laufen kontrolliert über den Demand-Lifecycle, nicht „nebenbei“."} warum:"Es schließt den Lebenszyklus: größere Änderungen oder das Ende eines Service laufen kontrolliert über den Demand-Lifecycle, nicht „nebenbei“."}
@ -1246,6 +1287,20 @@ const FOLDS_INTO = {
op_02:"A6", op_05:"A9", op_07:"A11", op_02:"A6", op_05:"A9", op_07:"A11",
sp_03:"A10", sp_04:"A10", sp_05:"A10", sp_06:"A10", sp_08:"A10", sp_10:"A11" sp_03:"A10", sp_04:"A10", sp_05:"A10", sp_06:"A10", sp_08:"A10", sp_10:"A11"
}; };
/* Bearbeitungstiefe (Frank): "bearbeitet" = eigene Arbeitsergebnisse/Templates,
alles andere "entwurf" (konzeptioneller Best-Practice-Entwurf). Fuer Spieler sichtbar. */
const BEARBEITET = new Set(["ds_01","tr_01","tr_09","tr_12","rv_01"]);
function stationTiefe(id){ return BEARBEITET.has(id) ? "bearbeitet" : "entwurf"; }
function tiefeBadge(id){
const t = stationTiefe(id);
return t==="bearbeitet"
? `<span class="tiefeBadge bearbeitet" title="Eigene Arbeitsergebnisse/Templates liegen vor — hier ist detailliertes Feedback besonders wertvoll.">● Bearbeitet</span>`
: `<span class="tiefeBadge entwurf" title="Konzeptioneller Entwurf nach Best Practice — noch keine substantiellen eigenen Arbeitsergebnisse.">○ Entwurf</span>`;
}
/* Dokumente mit eigenem Detail-Feedback (Frank): Gate 1/2/3 + Service Review. */
const FEEDBACK_DOCS = {
tr_01:"Gate 1", tr_09:"Gate 2", tr_12:"Gate 3", rv_01:"Service Review"
};
function addArtefakt(a){ if(a){ S.akte = S.akte || {}; S.akte[a] = true; } } function addArtefakt(a){ if(a){ S.akte = S.akte || {}; S.akte[a] = true; } }
// Beim Start nach Einstiegspunkt vorbefuellen: alles, was VOR der Einstiegs-Station // Beim Start nach Einstiegspunkt vorbefuellen: alles, was VOR der Einstiegs-Station
@ -1270,7 +1325,8 @@ function defaultState(){
freigabeDone:false, freigabeWrong:null, freigabeDone:false, freigabeWrong:null,
entryDone:false, entryWrong:null, entryDone:false, entryWrong:null,
bonusReveal:false, bonusDone:{}, servicesDone:{}, akteFlash:null, svcModalSel:null, bonusReveal:false, bonusDone:{}, servicesDone:{}, akteFlash:null, svcModalSel:null,
index:0, stage:"discuss", quizIndex:0, gateDeciderDone:false, gateCrit:0, index:0, stage:"discuss", quizIndex:0, gateDeciderDone:false, gateCrit:0, gate1Approval:null,
feedback:{}, feedbackSaved:{}, feedbackQueue:[],
actStep:0, actReveal:false, actDone:false, arteWrong:null, actStep:0, actReveal:false, actDone:false, arteWrong:null,
picks:{}, done:{}, akte:{}, picks:{}, done:{}, akte:{},
loopback:null, revisit:{}, endReason:null, endGate:null }; loopback:null, revisit:{}, endReason:null, endGate:null };
@ -1462,6 +1518,7 @@ function renderMainIntro(){
<p style="margin:0 0 8px">${card.text}</p> <p style="margin:0 0 8px">${card.text}</p>
<p style="margin:0;color:var(--muted)">Als <b>Major Change</b> wird der Service <b>neu eingeführt</b> und durchläuft den <b>kompletten Lifecycle ab Design</b>. Die Freigaben fallen unterwegs an den <b>Gates</b> (SOR bzw. Service Owner) an — hier müsst ihr nichts einordnen. Das Einordnen (Change-Art · Freigabe · Einstieg) kommt danach bei den <b>Bonus-Varianten</b>.</p></div> <p style="margin:0;color:var(--muted)">Als <b>Major Change</b> wird der Service <b>neu eingeführt</b> und durchläuft den <b>kompletten Lifecycle ab Design</b>. Die Freigaben fallen unterwegs an den <b>Gates</b> (SOR bzw. Service Owner) an — hier müsst ihr nichts einordnen. Das Einordnen (Change-Art · Freigabe · Einstieg) kommt danach bei den <b>Bonus-Varianten</b>.</p></div>
<div class="recBox"><h4>Service-Beschreibung</h4>${serviceDetailHtml(S.service)}</div> <div class="recBox"><h4>Service-Beschreibung</h4>${serviceDetailHtml(S.service)}</div>
${slcOverview("design","Start — ihr durchlauft den vollen Lifecycle ab <b>Design</b>")}
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
@ -1580,15 +1637,17 @@ function renderFreigabe(){
} }
} }
/* SLC-Orientierungs-Donut (5 Phasen, Farben = Phasenfarben der App) */ /* SLC-Orientierungs-Donut (5 Phasen, Farben = Phasenfarben der App).
function phaseDonut(wrongPh, clickable){ currentPh: hebt die aktuelle/nächste Phase hervor, dimmt die übrigen. */
function phaseDonut(wrongPh, clickable, currentPh){
const order=["design","transition","operation","support","review"]; const order=["design","transition","operation","support","review"];
const cx=100,cy=100,R=92,r=46,seg=72,start=-90-seg/2; const cx=100,cy=100,R=92,r=46,seg=72,start=-90-seg/2;
const P=(a,rad)=>[ (cx+rad*Math.cos(a*Math.PI/180)).toFixed(1), (cy+rad*Math.sin(a*Math.PI/180)).toFixed(1) ]; const P=(a,rad)=>[ (cx+rad*Math.cos(a*Math.PI/180)).toFixed(1), (cy+rad*Math.sin(a*Math.PI/180)).toFixed(1) ];
let segs="", labels=""; let segs="", labels="";
order.forEach((ph,i)=>{ order.forEach((ph,i)=>{
const a0=start+i*seg, a1=a0+seg, o0=P(a0,R), o1=P(a1,R), i1=P(a1,r), i0=P(a0,r); const a0=start+i*seg, a1=a0+seg, o0=P(a0,R), o1=P(a1,R), i1=P(a1,r), i0=P(a0,r);
const cls = (clickable?"donutSeg":"") + (ph===wrongPh?" bad":""); const cls = (clickable?"donutSeg":"") + (ph===wrongPh?" bad":"")
+ (currentPh ? (ph===currentPh?" current":" dim") : "");
segs+=`<path class="${cls}" data-ph="${ph}" d="M${o0[0]} ${o0[1]} A${R} ${R} 0 0 1 ${o1[0]} ${o1[1]} L${i1[0]} ${i1[1]} A${r} ${r} 0 0 0 ${i0[0]} ${i0[1]} Z" fill="var(--${ph})"/>`; segs+=`<path class="${cls}" data-ph="${ph}" d="M${o0[0]} ${o0[1]} A${R} ${R} 0 0 1 ${o1[0]} ${o1[1]} L${i1[0]} ${i1[1]} A${r} ${r} 0 0 0 ${i0[0]} ${i0[1]} Z" fill="var(--${ph})"/>`;
const mid=a0+seg/2, L=P(mid,(R+r)/2); const mid=a0+seg/2, L=P(mid,(R+r)/2);
labels+=`<text x="${L[0]}" y="${(+L[1]+3).toFixed(1)}" text-anchor="middle" font-size="9.5" font-weight="700" fill="#fff">${PHASEN[ph].label}</text>`; labels+=`<text x="${L[0]}" y="${(+L[1]+3).toFixed(1)}" text-anchor="middle" font-size="9.5" font-weight="700" fill="#fff">${PHASEN[ph].label}</text>`;
@ -1597,6 +1656,15 @@ function phaseDonut(wrongPh, clickable){
labels+=`<text x="100" y="108" text-anchor="middle" font-size="9" font-weight="700" fill="var(--muted)">Lifecycle</text>`; labels+=`<text x="100" y="108" text-anchor="middle" font-size="9" font-weight="700" fill="var(--muted)">Lifecycle</text>`;
return `<svg viewBox="0 0 200 200" class="slcDonut${clickable?' clickable':''}" role="img" aria-label="Service-Lifecycle: Design, Transition, Operation und Support (parallel), Review">${segs}<g pointer-events="none">${labels}</g></svg>`; return `<svg viewBox="0 0 200 200" class="slcDonut${clickable?' clickable':''}" role="img" aria-label="Service-Lifecycle: Design, Transition, Operation und Support (parallel), Review">${segs}<g pointer-events="none">${labels}</g></svg>`;
} }
/* Gesamtbild-Einblendung für den Major-Walk (Frank: Start, Phasenübergänge, Schluss).
phase = hervorzuhebende Phase (oder null am Schluss). Nur im Major-Modus genutzt. */
function slcOverview(phase, caption){
const cap = caption || (phase ? `Ihr seid in der Phase <b style="color:var(--${phase})">${PHASEN[phase].label}</b>` : `Alle Phasen durchlaufen`);
return `<div class="slcOverview">
<div class="slcOvHead">Service-Lifecycle — Gesamtbild</div>
<div class="slcOrient">${phaseDonut(null, false, phase)}<div class="slcCap">${cap}</div></div>
</div>`;
}
/* ---------- Aufgabe 3: Einstieg finden (Phase anklicken) ---------------- */ /* ---------- Aufgabe 3: Einstieg finden (Phase anklicken) ---------------- */
function renderEntry(){ function renderEntry(){
@ -1752,7 +1820,7 @@ function enterStation(idx){
} }
S.index = idx; S.index = idx;
S.stage = STATIONEN[idx].typ==="gate" ? "gate" : "act"; S.stage = STATIONEN[idx].typ==="gate" ? "gate" : "act";
S.gatePick = null; S.quizIndex = 0; S.gateDeciderDone = false; S.gateCrit = 0; S.gatePick = null; S.quizIndex = 0; S.gateDeciderDone = false; S.gateCrit = 0; S.gate1Approval = null;
S.actStep = 0; S.actReveal = false; S.actDone = false; S.arteWrong = null; S.actStep = 0; S.actReveal = false; S.actDone = false; S.arteWrong = null;
S.akteFlash = null; document.body.classList.remove("akteOpen"); S.akteFlash = null; document.body.classList.remove("akteOpen");
} }
@ -1784,16 +1852,21 @@ function raciTable(st){
const rows = st.raci.map(([r,c])=>`<tr><td>${roleLabel(r)}</td><td><span class="raciBadge raci-${c}">${c}</span></td></tr>`).join(""); const rows = st.raci.map(([r,c])=>`<tr><td>${roleLabel(r)}</td><td><span class="raciBadge raci-${c}">${c}</span></td></tr>`).join("");
return `<table class="raci"><thead><tr><th>Rolle</th><th>RACI</th></tr></thead><tbody>${rows}</tbody></table>`; return `<table class="raci"><thead><tr><th>Rolle</th><th>RACI</th></tr></thead><tbody>${rows}</tbody></table>`;
} }
/* RACI-Legende (deutsch) — wird bei der RACI-Frage immer mit angezeigt. */ /* RACI-Legende (deutsch) — wird bei der RACI-Frage immer mit angezeigt.
Frank: R + A sind die Pflicht (durchdenken!), C + I ergänzend (nice-to-have). */
function raciLegendHtml(){ function raciLegendHtml(){
const items = [ const kern = [
["R","Responsible","verantwortlich für die Durchführung — erledigt die Aufgabe operativ"], ["R","Responsible","verantwortlich für die Durchführung — erledigt die Aufgabe operativ"],
["A","Accountable","rechenschaftspflichtig — trägt die Ergebnisverantwortung (genau eine Rolle)"], ["A","Accountable","rechenschaftspflichtig — trägt die Ergebnisverantwortung (genau eine Rolle)"]
];
const erg = [
["C","Consulted","konsultiert — wird vorab um Rat/Beitrag gefragt"], ["C","Consulted","konsultiert — wird vorab um Rat/Beitrag gefragt"],
["I","Informed","informiert — wird über das Ergebnis in Kenntnis gesetzt"] ["I","Informed","informiert — wird über das Ergebnis in Kenntnis gesetzt"]
]; ];
const row = ([l,en,de])=>`<div class="rlRow"><span class="raciBadge raci-${l}">${l}</span><span><b>${en}</b> — ${de}</span></div>`;
return `<div class="raciLegend"><div class="rlHead">Wofür RACI steht</div>` + return `<div class="raciLegend"><div class="rlHead">Wofür RACI steht</div>` +
items.map(([l,en,de])=>`<div class="rlRow"><span class="raciBadge raci-${l}">${l}</span><span><b>${en}</b> — ${de}</span></div>`).join("") + `<div class="rlGroup"><div class="rlGroupHead kern">★ Pflicht — unbedingt durchdenken</div>${kern.map(row).join("")}</div>` +
`<div class="rlGroup"><div class="rlGroupHead">ergänzend — nice-to-have</div>${erg.map(row).join("")}</div>` +
`</div>`; `</div>`;
} }
@ -1821,7 +1894,7 @@ function renderRun(){
<div class="classifyTop"> <div class="classifyTop">
${cardBig} ${cardBig}
<div class="classifyMain"> <div class="classifyMain">
<div class="sHead">${chip}<span class="sId">${st.id}</span></div> <div class="sHead">${chip}<span class="sId">${st.id}</span>${tiefeBadge(st.id)}</div>
<h2 class="sTitle" style="margin-top:8px">${st.name}</h2> <h2 class="sTitle" style="margin-top:8px">${st.name}</h2>
${stepBody} ${stepBody}
</div> </div>
@ -1841,7 +1914,7 @@ function activitySteps(st){
frage:`Welche Rollen setzen diese Aktivität <b>operativ</b> um — wer sorgt für die <b>Umsetzung</b> (Responsible)? Stellt deren Figuren auf die <b>Mulden des Station-Pucks</b>.`, frage:`Welche Rollen setzen diese Aktivität <b>operativ</b> um — wer sorgt für die <b>Umsetzung</b> (Responsible)? Stellt deren Figuren auf die <b>Mulden des Station-Pucks</b>.`,
auf:`<h4 class="aufH">Operativ verantwortlich (R)</h4><div class="roleChips">${(st.raci.filter(([r,c])=>c.includes("R")).map(([r,c])=>`<span class="roleChip">${roleLabel(r)}${c==="A/R"?" (zugleich A)":""}</span>`).join("")) || '<span class="roleChip">— (keine eigene R-Rolle)</span>'}</div><p class="muted" style="margin:10px 0 0;font-size:13px">Das sind die „Macher" der Aktivität. <b>Wer</b> dafür geradesteht (A) sowie beratend (C) bzw. informiert (I) ist, klärt Schritt 3.</p>` }, auf:`<h4 class="aufH">Operativ verantwortlich (R)</h4><div class="roleChips">${(st.raci.filter(([r,c])=>c.includes("R")).map(([r,c])=>`<span class="roleChip">${roleLabel(r)}${c==="A/R"?" (zugleich A)":""}</span>`).join("")) || '<span class="roleChip">— (keine eigene R-Rolle)</span>'}</div><p class="muted" style="margin:10px 0 0;font-size:13px">Das sind die „Macher" der Aktivität. <b>Wer</b> dafür geradesteht (A) sowie beratend (C) bzw. informiert (I) ist, klärt Schritt 3.</p>` },
{ label:"RACI", { label:"RACI",
frage:`Ergänzt nun die <b>vollständige RACI</b>: Wer ist <b>A</b>ccountable (trägt die Verantwortung), wer <b>C</b>onsulted, wer <b>I</b>nformed — zusätzlich zu den Responsible aus Schritt 2? Sortiert die Figuren ins <b>Aktiv-Feld (R·A·C·I)</b>.`, frage:`Vervollständigt die RACI. <b>Wichtig zuerst:</b> Wer ist <b>A</b>ccountable (trägt die Ergebnisverantwortung — genau eine Rolle)? Zusammen mit den Responsible aus Schritt 2 sind <b>R + A die Pflicht</b>. <b>C</b>onsulted und <b>I</b>nformed danach ergänzen (nice-to-have). Sortiert die Figuren ins <b>Aktiv-Feld (R·A·C·I)</b>.`,
legend: raciLegendHtml(), legend: raciLegendHtml(),
auf:`<h4 class="aufH">RACI (vollständig)</h4>${raciTable(st)}` }, auf:`<h4 class="aufH">RACI (vollständig)</h4>${raciTable(st)}` },
{ label:"Artefakt", artefakt:true, { label:"Artefakt", artefakt:true,
@ -1878,6 +1951,9 @@ function renderActivity(st){
<p style="margin:6px 0">Gut gemacht! Ihr habt <b>${st.id} — ${st.name}</b> durchgespielt.</p> <p style="margin:6px 0">Gut gemacht! Ihr habt <b>${st.id} — ${st.name}</b> durchgespielt.</p>
${phaseLine} ${phaseLine}
</div> </div>
${phaseEnd && S.mode==="main" ? slcOverview(next.phase, `Weiter in der Phase <b style="color:${PHASEN[next.phase].color}">${PHASEN[next.phase].label}</b>`) : ``}
${FEEDBACK_DOCS[st.id] ? docFeedbackBlock(st.id) : ``}
${phaseEnd ? phaseFeedbackBlock(st.phase) : ``}
<div class="actions"> <div class="actions">
<button class="ghost" id="actBack">← zurück</button> <button class="ghost" id="actBack">← zurück</button>
<div class="spacer"></div> <div class="spacer"></div>
@ -1975,6 +2051,7 @@ function renderGate(st){
const reqLine = req.length === 0 ? `` : blocked const reqLine = req.length === 0 ? `` : blocked
? `<div class="gateReq bad">🔒 Gate öffnet nicht — es fehlt in der Akte: ${missing.map(a=>a+" "+ARTEFAKTE[a].name).join(" · ")}. Erzeugt diese Artefakte zuerst.</div>` ? `<div class="gateReq bad">🔒 Gate öffnet nicht — es fehlt in der Akte: ${missing.map(a=>a+" "+ARTEFAKTE[a].name).join(" · ")}. Erzeugt diese Artefakte zuerst.</div>`
: `<div class="gateReq ok">✓ Alle geforderten Artefakte liegen in der Akte (${req.join(" · ")}).</div>`; : `<div class="gateReq ok">✓ Alle geforderten Artefakte liegen in der Akte (${req.join(" · ")}).</div>`;
const zielLine = st.ziel ? `<div class="gateZiel"><b>Ziel:</b> ${st.ziel}</div>` : ``;
// Prüf-Kriterien als nacheinander erscheinende Checkboxen (nur Anzeige, nicht blockierend) // Prüf-Kriterien als nacheinander erscheinende Checkboxen (nur Anzeige, nicht blockierend)
const pruef = st.pruef || []; const pruef = st.pruef || [];
@ -1994,8 +2071,34 @@ function renderGate(st){
<div class="gateCrit">${critItems}</div> <div class="gateCrit">${critItems}</div>
${allChecked?`<p class="muted" style="margin:0 0 16px">Alle Kriterien geprüft. Geforderte Artefakte in der Akte (siehe oben)? Pflicht-Figuren am Gate-Puck?</p>`:``}` : ``; ${allChecked?`<p class="muted" style="margin:0 0 16px">Alle Kriterien geprüft. Geforderte Artefakte in der Akte (siehe oben)? Pflicht-Figuren am Gate-Puck?</p>`:``}` : ``;
// Zweistufiges Gate (Gate 1): erst Freigabe-Entscheidung, dann Build-/Konfig-Routing.
if(st.freigabe){
if(S.gate1Approval==null){
const fopts = st.freigabe.map(([n,d],i)=>
`<button class="choice freigabeOpt" data-fi="${i}" ${blocked?"disabled":""}><b>${n}</b><br><span style="color:var(--muted);font-weight:400">${d}</span></button>`).join("");
return `
${revisitNote}
${zielLine}
<p class="lead"><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
${reqLine}
${critWrap}
<div class="critHead">Freigabe-Entscheidung</div>
<div class="choiceGrid">${fopts}</div>`;
}
// Freigabe erteilt (0/1) → Routing-Schritt
const fa = st.freigabe[S.gate1Approval];
return `
${revisitNote}
<div class="hint ok">✓ Freigabe-Entscheidung: <b>${fa[0]}</b></div>
<div class="critHead">Build-/Konfigurations-Routing</div>
<p class="muted" style="margin:0 0 10px">Da freigegeben: Werden die Service-Komponenten <b>neu entwickelt</b> oder bestehende nur <b>konfiguriert</b>?</p>
<div class="choiceGrid">${opts}</div>
<div class="actions"><button class="ghost" id="gateApprovalBack">← andere Freigabe-Entscheidung</button><div class="spacer"></div></div>`;
}
return ` return `
${revisitNote} ${revisitNote}
${zielLine}
<p class="lead"><b>Entscheidet:</b> ${roleLabel(keeper)}</p> <p class="lead"><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
${reqLine} ${reqLine}
${critWrap} ${critWrap}
@ -2023,6 +2126,8 @@ function renderGateDone(st){
<p style="margin:4px 0">Weiter geht's mit der Phase <b style="color:${nextColor}">${PHASEN[target.phase].label}</b>.</p> <p style="margin:4px 0">Weiter geht's mit der Phase <b style="color:${nextColor}">${PHASEN[target.phase].label}</b>.</p>
</div>`; </div>`;
} }
const overview = (phaseEnd && S.mode==="main")
? slcOverview(target.phase, `Weiter in der Phase <b style="color:${PHASEN[target.phase].color}">${PHASEN[target.phase].label}</b>`) : ``;
return ` return `
<div class="step reveal"> <div class="step reveal">
<div class="stepHead"><span class="n"></span> Entscheidung getroffen</div> <div class="stepHead"><span class="n"></span> Entscheidung getroffen</div>
@ -2030,6 +2135,9 @@ function renderGateDone(st){
${sorNote} ${sorNote}
${feedback} ${feedback}
</div> </div>
${overview}
${FEEDBACK_DOCS[st.id] ? docFeedbackBlock(st.id) : ``}
${phaseEnd ? phaseFeedbackBlock(st.phase) : ``}
<div class="actions"> <div class="actions">
<button class="ghost" id="gateBack">← andere Entscheidung</button> <button class="ghost" id="gateBack">← andere Entscheidung</button>
<div class="spacer"></div> <div class="spacer"></div>
@ -2037,6 +2145,117 @@ function renderGateDone(st){
</div>`; </div>`;
} }
/* ====================== FEEDBACK (Frank: Phase + Dokument, Server-Save) ======================
Erfasst zwei Arten von Feedback und speichert sie auf dem Server (POST an
FEEDBACK_ENDPOINT) — mit Retry-Queue (offline-fest) und localStorage-Archiv als
Backup für den manuellen Export. Die Erfassung blockt den Spielfluss nie (optional). */
const FEEDBACK_ENDPOINT = ((document.querySelector('meta[name="slc-feedback-endpoint"]')||{}).content) || "feedback.php";
const FB_QUEUE = "slc-fb-queue", FB_ARCHIVE = "slc-fb-archive", FB_SESSION = "slc-fb-session";
function fbReadArr(k){ try{ return JSON.parse(localStorage.getItem(k)||"[]"); }catch(e){ return []; } }
function fbWriteArr(k,a){ localStorage.setItem(k, JSON.stringify(a)); }
function escapeHtml(s){ return (s==null?"":""+s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function feedbackSessionId(){
let id = localStorage.getItem(FB_SESSION);
if(!id){ id = "s-"+Math.random().toString(36).slice(2,10)+"-"+Date.now().toString(36); localStorage.setItem(FB_SESSION, id); }
return id;
}
function fbCtx(){
return { service: S.service!=null ? USE_CASES[S.service].service : null,
serviceIdx: S.service,
change: S.change!=null ? CHANGE_TYPES[S.change] : null,
mode: S.mode };
}
/* Phasen-Feedback (2 Freitexte, am Phasenende) */
function phaseFeedbackBlock(phase){
const key = "phase:"+phase;
const fb = (S.feedback && S.feedback[key]) || {};
const saved = S.feedbackSaved && S.feedbackSaved[key];
return `<div class="fbCard" data-fbkey="${key}" data-fbtype="phase" data-target="${phase}">
<div class="fbHead">📝 Feedback zur Phase <b>${PHASEN[phase].label}</b> <span class="fbOpt">· optional</span></div>
<label class="fbQ">Welche Elemente oder Aktivitäten dieser Phase sind besonders relevant oder hilfreich?</label>
<textarea class="fbInput" data-f="relevant" rows="2" placeholder="Freitext …">${escapeHtml(fb.relevant)}</textarea>
<label class="fbQ">Welche Elemente oder Aktivitäten erscheinen überflüssig? Warum?</label>
<textarea class="fbInput" data-f="ueberfluessig" rows="2" placeholder="Freitext …">${escapeHtml(fb.ueberfluessig)}</textarea>
<div class="fbActions">${saved?`<span class="fbSaved">✓ gespeichert</span>`:`<span></span>`}<button class="ghost fbSaveBtn" type="button">💾 Feedback speichern</button></div>
</div>`;
}
/* Dokument-Feedback (Skala 15 + 2 Freitexte) für Gate 1/2/3 + Service Review */
function docFeedbackBlock(sid){
const key = "doc:"+sid;
const fb = (S.feedback && S.feedback[key]) || {};
const saved = S.feedbackSaved && S.feedbackSaved[key];
const label = FEEDBACK_DOCS[sid] || sid;
const scale = [1,2,3,4,5].map(n=>`<button type="button" class="fbScale ${(+fb.skala===n)?'sel':''}" data-n="${n}">${n}</button>`).join("");
return `<div class="fbCard" data-fbkey="${key}" data-fbtype="doc" data-target="${sid}">
<div class="fbHead">📝 Feedback zum Prüfschritt <b>„${label}"</b> <span class="fbOpt">· optional</span></div>
<label class="fbQ">Wie relevant oder hilfreich ist dieser Prüfschritt? <span class="muted">(1 = gar nicht … 5 = sehr)</span></label>
<div class="fbScaleRow">${scale}</div>
<label class="fbQ">Fehlen noch Themen oder Punkte, die auch geprüft werden sollten? Wenn ja, welche?</label>
<textarea class="fbInput" data-f="fehlt" rows="2" placeholder="Freitext …">${escapeHtml(fb.fehlt)}</textarea>
<label class="fbQ">Weiteres Feedback zu diesem Prüfschritt</label>
<textarea class="fbInput" data-f="weiteres" rows="2" placeholder="Freitext …">${escapeHtml(fb.weiteres)}</textarea>
<div class="fbActions">${saved?`<span class="fbSaved">✓ gespeichert</span>`:`<span></span>`}<button class="ghost fbSaveBtn" type="button">💾 Feedback speichern</button></div>
</div>`;
}
/* Ein Feedback-Card aus dem DOM lesen; nur committen, wenn Inhalt vorhanden. */
function commitFeedbackCard(card){
const key = card.dataset.fbkey;
const rec = (S.feedback && S.feedback[key]) || {};
card.querySelectorAll('.fbInput[data-f]').forEach(t=>{ rec[t.dataset.f] = t.value; });
const sel = card.querySelector('.fbScale.sel'); if(sel) rec.skala = +sel.dataset.n;
S.feedback = S.feedback || {}; S.feedback[key] = rec;
const hasContent = (rec.relevant||rec.ueberfluessig||rec.fehlt||rec.weiteres||rec.skala);
if(!hasContent) return false;
S.feedbackSaved = S.feedbackSaved || {}; S.feedbackSaved[key] = true;
const record = Object.assign({
id: key+"@"+feedbackSessionId(),
key, type: card.dataset.fbtype, target: card.dataset.target,
ts: new Date().toISOString(), session: feedbackSessionId()
}, fbCtx(), rec);
// Archiv + Queue (außerhalb des Spielstands, übersteht „Neu starten")
const arch = fbReadArr(FB_ARCHIVE).filter(r=>r.id!==record.id); arch.push(record); fbWriteArr(FB_ARCHIVE, arch);
const q = fbReadArr(FB_QUEUE).filter(r=>r.id!==record.id); q.push(record); fbWriteArr(FB_QUEUE, q);
return true;
}
/* Alle aktuell sichtbaren Feedback-Cards committen (z. B. beim Weiterblättern). */
function captureFeedbackInputs(){
let any=false;
document.querySelectorAll('.fbCard').forEach(card=>{ if(commitFeedbackCard(card)) any=true; });
if(any){ save(); flushFeedbackQueue(); }
return any;
}
/* Offene Server-Submissions abarbeiten (Retry). Fehlt der Endpoint, bleibt alles in der Queue. */
function flushFeedbackQueue(){
const q = fbReadArr(FB_QUEUE);
if(!q.length || !navigator.onLine) return;
q.slice().forEach(record=>{
fetch(FEEDBACK_ENDPOINT, { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(record) })
.then(r=>{ if(r.ok){ fbWriteArr(FB_QUEUE, fbReadArr(FB_QUEUE).filter(x=>x.id!==record.id)); } })
.catch(()=>{ /* offline / kein Endpoint → bleibt für späteren Retry in der Queue */ });
});
}
/* Manueller Export (Backup) — lädt das gesammelte Feedback als JSON herunter. */
function exportFeedback(){
const data = fbReadArr(FB_ARCHIVE);
if(!data.length){ alert("Noch kein Feedback erfasst."); return; }
const pending = fbReadArr(FB_QUEUE).length;
const blob = new Blob([JSON.stringify({exported:new Date().toISOString(), pendingUpload:pending, items:data}, null, 2)], {type:"application/json"});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "slc-feedback-"+new Date().toISOString().slice(0,10)+".json";
document.body.appendChild(a); a.click(); a.remove();
}
function wireFeedback(){
document.querySelectorAll('.fbCard .fbScale[data-n]').forEach(btn=>{
btn.onclick = ()=>{ btn.parentNode.querySelectorAll('.fbScale').forEach(x=>x.classList.remove('sel')); btn.classList.add('sel'); };
});
document.querySelectorAll('.fbCard .fbSaveBtn').forEach(btn=>{
btn.onclick = ()=>{ const card = btn.closest('.fbCard'); commitFeedbackCard(card); save(); flushFeedbackQueue(); draw(); };
});
}
/* ====================== WIRING ====================== */ /* ====================== WIRING ====================== */
function wire(st){ function wire(st){
const b = id => $("#"+id); const b = id => $("#"+id);
@ -2055,12 +2274,13 @@ function wire(st){
}; };
}); });
if(b("actBack")) b("actBack").onclick = ()=>{ if(b("actBack")) b("actBack").onclick = ()=>{
captureFeedbackInputs();
if(S.actDone){ S.actDone=false; } if(S.actDone){ S.actDone=false; }
else if(S.actReveal){ S.actReveal=false; } else if(S.actReveal){ S.actReveal=false; }
else if((S.actStep||0)>0){ S.actStep--; S.actReveal=true; } else if((S.actStep||0)>0){ S.actStep--; S.actReveal=true; }
save(); draw(); }; save(); draw(); };
if(b("nextStation")) b("nextStation").onclick = ()=>{ S.done[st.id]=true; enterStation(S.index+1); save(); draw(); }; if(b("nextStation")) b("nextStation").onclick = ()=>{ captureFeedbackInputs(); S.done[st.id]=true; enterStation(S.index+1); save(); draw(); };
if(b("finish")) b("finish").onclick = ()=>{ S.done[st.id]=true; S.view="end"; S.endReason="done"; save(); draw(); }; if(b("finish")) b("finish").onclick = ()=>{ captureFeedbackInputs(); S.done[st.id]=true; S.view="end"; S.endReason="done"; save(); draw(); };
// Gate // Gate
if(b("gateDeciderNext")) b("gateDeciderNext").onclick = ()=>{ S.gateDeciderDone=true; save(); draw(); }; if(b("gateDeciderNext")) b("gateDeciderNext").onclick = ()=>{ S.gateDeciderDone=true; save(); draw(); };
$("#panel").querySelectorAll(".critItem[data-ci]").forEach(el=>{ $("#panel").querySelectorAll(".critItem[data-ci]").forEach(el=>{
@ -2071,8 +2291,23 @@ function wire(st){
$("#panel").querySelectorAll(".choiceGrid .choice[data-i]").forEach(el=>{ $("#panel").querySelectorAll(".choiceGrid .choice[data-i]").forEach(el=>{
el.onclick = ()=>{ S.gatePick=+el.dataset.i; S.stage="gateDone"; save(); draw(); }; el.onclick = ()=>{ S.gatePick=+el.dataset.i; S.stage="gateDone"; save(); draw(); };
}); });
if(b("gateBack")) b("gateBack").onclick = ()=>{ S.stage="gate"; save(); draw(); }; // Gate 1 — Freigabe-Entscheidung (Frank-Template): 0/1 → Routing, 2 → zurück an Design, 3 → Ablehnung.
if(b("gateNext")) b("gateNext").onclick = ()=>{ gateGoto(st, S.gatePick||0); }; $("#panel").querySelectorAll(".freigabeOpt[data-fi]").forEach(el=>{
el.onclick = ()=>{ const fi=+el.dataset.fi;
if(fi<=1){ S.gate1Approval=fi; save(); draw(); return; }
S.done[st.id]=true;
if(fi===2){ // Zurück an Design — echte Rückschleife, Gate wird danach erneut vorgelegt
S.loopback = { gateId: st.id, gateNr: st.gateNr, untilIdx: S.index };
enterStation(0);
} else { // Ablehnung
S.view="end"; S.endReason="rejected"; S.endGate=st.id;
}
save(); draw(); };
});
if(b("gateApprovalBack")) b("gateApprovalBack").onclick = ()=>{ S.gate1Approval=null; save(); draw(); };
if(b("gateBack")) b("gateBack").onclick = ()=>{ captureFeedbackInputs(); S.stage="gate"; save(); draw(); };
if(b("gateNext")) b("gateNext").onclick = ()=>{ captureFeedbackInputs(); gateGoto(st, S.gatePick||0); };
wireFeedback();
} }
/* ====================== ABSCHLUSS-SCREEN ====================== */ /* ====================== ABSCHLUSS-SCREEN ====================== */
@ -2088,20 +2323,28 @@ function renderEnd(){
: `<div class="recBox"> : `<div class="recBox">
<h4>Service-Lifecycle durchlaufen</h4> <h4>Service-Lifecycle durchlaufen</h4>
<p style="margin:0;color:var(--muted)">Ihr habt den Change von der Einordnung bis zur Umsetzung begleitet. Im Workshop schließt hier das gemeinsame <b>Debriefing am Tisch</b> an (Reflexion der Stationen, offene Fragen).</p></div>`; <p style="margin:0;color:var(--muted)">Ihr habt den Change von der Einordnung bis zur Umsetzung begleitet. Im Workshop schließt hier das gemeinsame <b>Debriefing am Tisch</b> an (Reflexion der Stationen, offene Fragen).</p></div>`;
const ending = (!rejected && S.mode==="main")
? slcOverview(null, `Alle Phasen durchlaufen — der Lifecycle ist komplett`) + phaseFeedbackBlock("review")
: ``;
$("#panel").innerHTML = ` $("#panel").innerHTML = `
<div class="setupHead">Abschluss</div> <div class="setupHead">Abschluss</div>
<h2 class="setupTitle">${icon} ${head}</h2> <h2 class="setupTitle">${icon} ${head}</h2>
<div class="hint ${rejected?"bad":"ok"}">${USE_CASES[S.service].service} · ${CHANGE_TYPES[S.change]}</div> <div class="hint ${rejected?"bad":"ok"}">${USE_CASES[S.service].service} · ${CHANGE_TYPES[S.change]}</div>
${box} ${box}
${ending}
<div class="actions"> <div class="actions">
<div class="spacer"></div> <div class="spacer"></div>
<button class="primary" id="toBonus">Weiter → Varianten dieses Service →</button> <button class="primary" id="toBonus">Weiter → Varianten dieses Service →</button>
</div>`; </div>`;
$("#toBonus").onclick = ()=>{ S.view="bonusPick"; save(); draw(); }; wireFeedback();
$("#toBonus").onclick = ()=>{ captureFeedbackInputs(); S.view="bonusPick"; save(); draw(); };
} }
/* ====================== INIT ====================== */ /* ====================== INIT ====================== */
(function init(){ (function init(){
flushFeedbackQueue(); // offene Feedback-Submissions nachreichen
window.addEventListener("online", flushFeedbackQueue); // bei Reconnect erneut versuchen
$("#fbExportBtn").onclick = exportFeedback;
$("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } }; $("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
const closeOverlays = ()=> document.body.classList.remove("navOpen","akteOpen","rollenOpen"); const closeOverlays = ()=> document.body.classList.remove("navOpen","akteOpen","rollenOpen");
$("#stationsBtn").onclick = ()=>{ const o=document.body.classList.contains("navOpen"); closeOverlays(); if(!o) document.body.classList.add("navOpen"); }; $("#stationsBtn").onclick = ()=>{ const o=document.body.classList.contains("navOpen"); closeOverlays(); if(!o) document.body.classList.add("navOpen"); };

View file

@ -1,5 +1,5 @@
/* Service Worker — SLC-Workshop Companion (App-Shell, offline-first) */ /* Service Worker — SLC-Workshop Companion (App-Shell, offline-first) */
const CACHE = "slc-companion-v31"; const CACHE = "slc-companion-v32";
const SHELL = ["./", "index.html", "manifest.webmanifest", "icon.svg"]; const SHELL = ["./", "index.html", "manifest.webmanifest", "icon.svg"];
// Action-Card-Grafiken (cards/s<service>-c<change>.png) fuer Offline vorab cachen (alle 24). // Action-Card-Grafiken (cards/s<service>-c<change>.png) fuer Offline vorab cachen (alle 24).
const CARDS = []; const CARDS = [];

View file

@ -4,7 +4,7 @@
Chat). Hier steht, **wo wir stehen, was entschieden wurde, was offen ist** und wie man Chat). Hier steht, **wo wir stehen, was entschieden wurde, was offen ist** und wie man
App lokal startet/deployt. App lokal startet/deployt.
**Stand:** 2026-06-09 · **Branch:** `feat/redesign-und-companion-app` · App **v0.7** **Stand:** 2026-06-10 · **Branch:** `feat/redesign-und-companion-app` · App **v0.10**
**Remote:** `https://git.1789.cloud/patrick/SLC_Game.git` **Remote:** `https://git.1789.cloud/patrick/SLC_Game.git`
--- ---
@ -76,8 +76,11 @@ Service gilt als bereits eingeführt) → zurück zur Bonus-Auswahl → „Servi
zurück, blendet einen Nacharbeits-Banner ein und legt das Gate danach **erneut** zurück, blendet einen Nacharbeits-Banner ein und legt das Gate danach **erneut**
vor (Hinweis „Erneute Vorlage"). „Ablehnung" → eigener **End-Screen** (SOR-Eskalation). vor (Hinweis „Erneute Vorlage"). „Ablehnung" → eigener **End-Screen** (SOR-Eskalation).
(z. B. Gate 1 „Konfiguration" → `tr_05`; SOR→DPM→Mission Board.) (z. B. Gate 1 „Konfiguration" → `tr_05`; SOR→DPM→Mission Board.)
- **Kein App-Debrief mehr:** Der Export-Dialog (Markdown/JSON) wurde entfernt; das - **Kein Lauf-Debrief mehr** (Markdown/JSON-Export des Durchlaufs raus); Workshop-**Debriefing
Workshop-**Debriefing am Tisch** bleibt (im Done-Screen erwähnt). am Tisch** bleibt (im Done-Screen erwähnt).
- **Strukturiertes Feedback (v0.10, Frank):** Phasen-Feedback (2 Freitexte) an Phasenenden +
Dokument-Feedback (Skala 15 + 2 Freitexte) an Gate 1/2/3 + Service Review. **Server-Save**
via POST an `feedback.php` (Retry-Queue + localStorage-Archiv + manueller „⇩ Feedback"-Export).
- **4 Change-Arten** (Major/Normal/Standard/Emergency), 24 Karten. Multiple-Choice - **4 Change-Arten** (Major/Normal/Standard/Emergency), 24 Karten. Multiple-Choice
ist aus dem Hauptfluss raus (Daten liegen noch in `index.html`, ungenutzt). ist aus dem Hauptfluss raus (Daten liegen noch in `index.html`, ungenutzt).
- **Inhalte sind in `index.html` eingebettet** (noch keine YAML-Pipeline). - **Inhalte sind in `index.html` eingebettet** (noch keine YAML-Pipeline).
@ -108,6 +111,35 @@ Konzept (`00_Konzept/README_konzept.md`), bis Rückkopplung mit Michael:**
## 5. Offene Punkte / nächste Schritte ## 5. Offene Punkte / nächste Schritte
**Frank-Feedback 2026-06-10 (Workshop-/Spiel-Ebene, umgesetzt in App v0.10 — Entscheidungen Frank + Patrick):**
- [x] **RACI-Betonung:** Volle RACI bleibt; **R + A** sind als „Pflicht — unbedingt durchdenken"
hervorgehoben, **C + I** als „ergänzend / nice-to-have". Legende zweigeteilt, Schritt-3-Text
umformuliert (`activitySteps`, `raciLegendHtml`).
- [x] **GRAFIK SLC (Gesamtbild) 6×** im **Major-Walk**: Start (Main-Intro), an den 4 Phasenübergängen
(`renderActivity`/`renderGateDone`) und am Schluss (`renderEnd`) — jeweils mit Highlight der
aktuellen/nächsten Phase (`slcOverview` auf Basis von `phaseDonut`). **Bonus unberührt.**
- [x] **„Bearbeitet"/„Entwurf" sichtbar:** Badge im Stations-Header (`stationTiefe`/`tiefeBadge`).
Bearbeitet = `ds_01, tr_01, tr_09, tr_12, rv_01`; alles andere Entwurf.
- [x] **Gate 1 zweistufig:** erst **Freigabe-Entscheidung** (Freigabe / mit Auflagen / **Zurück an
Design** [Rückschleife] / **Ablehnung** [End-Screen]), dann **Routing** Entwicklung/Konfiguration.
Gate-Templates: `ziel` an Gate 1/2/3 ergänzt; Gate-1-Prüfdim. „Budget für die **Implementierung**".
- [x] **Service-Review-Template (Frank):** A14 + rv_01 auf 4 Dimensionen (Leistungserbringung,
Betriebsstabilität, Nutzerzufriedenheit, Zukunftsfähigkeit) + Empfehlung **Weiterbetrieb /
Normal-Change / Major-Change** umgestellt (**ersetzt** CONTINUE/IMPROVEMENT/REDESIGN/RETIRE).
- [x] **Feedback-Erfassung + Server-Save** (re-aktiviert nach v0.7-Entfernung des Exports):
- **Phasen-Feedback** (2 Freitexte) an jedem Phasenende; **Dokument-Feedback** (Skala 15 +
2 Freitexte) an **Gate 1/2/3 + Service Review** (`FEEDBACK_DOCS`).
- **POST** an `<meta name="slc-feedback-endpoint">` (Default `feedback.php`), **Retry-Queue +
localStorage-Archiv** (offline-fest, übersteht „Neu starten"), Header-Button **„⇩ Feedback"**
für manuellen JSON-Export als Backup.
- **Server:** Referenz-Collector `04_Tablet-Quiz/app/feedback.php` (Flat-File `feedback-data/feedback.jsonl`,
keine DB). **DEPLOY.md** angepasst (statisch **+ ein** kleiner POST-Endpoint; PHP/Node/„ohne"-Varianten).
- **Offen / fachlich mit Frank zu bestätigen:**
- **Retirement/Außerbetriebnahme:** in Franks Review-Empfehlung bewusst nicht mehr enthalten —
bestätigen, dass das so gewollt ist (vgl. `review-phase_arbeitsstand-frank.md`).
- **Server-Endpoint provisionieren:** PHP (php-fpm) am Webserver aktivieren **oder** Endpoint-URL
im Meta-Tag umbiegen. Bis dahin greift Queue + Export (kein Datenverlust).
**Frank-Feedback 2026-06-08 (Workshop-Ebene, entscheiden Frank + Patrick):** **Frank-Feedback 2026-06-08 (Workshop-Ebene, entscheiden Frank + Patrick):**
- [x] **Main/Bonus-Flow** umgesetzt (nur Major = voller Walk; Bonus = einordnen + Kurz-Auflösung). - [x] **Main/Bonus-Flow** umgesetzt (nur Major = voller Walk; Bonus = einordnen + Kurz-Auflösung).
- [x] **Aufgabe „Freigabe-Stelle"** ergänzt (SOR/DPM/MB · SO · keine[Standard/Emergency]). - [x] **Aufgabe „Freigabe-Stelle"** ergänzt (SOR/DPM/MB · SO · keine[Standard/Emergency]).