Companion-App: neue Stations-Choreografie (Brett antwortet, App fragt+loest auf)

- Aktivitaets-Station: 2 Takte "Handeln am Brett" (Figuren ins RACI-Feld,
  Artefaktkarte in die Akte) -> "Aufloesung & Abgleich". MC-Quiz aus dem Flow.
  "Zeig mir"-Option (einklappbare RACI/Artefakt-Hilfe) als On-Ramp.
- Gates interaktiv: Kriterien (pruef) pruefen -> Entscheidung (pfade) waehlen ->
  Konsequenz + Verzweigung (enterStation/GATE_FLOW: Gate1 Konfig springt zu tr_05,
  Zurueck/Ablehnung-Routing) + SOR->DPM->Mission-Board-Hinweis.
- enterStation()/gateGoto() steuern Stage je Stationstyp; Sidebar-Sprung + Start
  ueber enterStation. Debrief von MC-Statistik bereinigt.
- End-to-end im Browser verifiziert (Gate1 Konfig -> tr_05, Act->Reveal). README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
breitenbach76 2026-06-06 15:55:53 +02:00
parent 9ecb3d3dfc
commit a922300b96
2 changed files with 106 additions and 99 deletions

View file

@ -5,7 +5,7 @@
> **Umsetzungsstand:** Die App liegt unter [`app/`](app/) als statische **PWA**
> (offline-/kioskfähig). Flow: **Karten-Raster** (Action Card ziehen) → **Change-Art
> bestimmen** (mit Legende, „nochmal versuchen" bis richtig) → **Phasen-Einstieg**
> (Lebenszyklus-Phase anklicken) → **Stationen** (Diskussion/Quiz/Auflösung) →
> (Lebenszyklus-Phase anklicken) → **Stationen** (Handeln am Brett → Auflösung; Gates als Entscheidung) →
> **Debrief** mit **Markdown-/JSON-Export**. Inhalte (Stationen, Quizfragen, Use-Cases)
> sind derzeit in `app/index.html` eingebettet. Die **finalen Action-Card-Grafiken**
> (Freiburg-digital-Layout) liegen in `app/cards/` (`s<service>-c<change>.png`, **24** = 6 Services × 4 Change-Arten; Major/Normal/Standard/Emergency).
@ -68,14 +68,16 @@ steht, sondern in der App liegt.
[4] Einstieg finden → Lebenszyklus groß: Phase anklicken (falsch = "nochmal")
[5] Los geht's → App nennt Start-Station + Begründung
→ App führt ab Start-Station durch die Stationen (Fortschritt sichtbar)
→ Station:
→ Gruppe diskutiert am Board anhand der Kurzbezeichnung (App noch zu)
→ Quiz (vermittelnd): Frage(n) → Gruppentipp → "Auflösen" → richtig/falsch
→ ausführliche Auflösung der Station (Erklärung + RACI + Artefakt)
→ Gruppe reflektiert; optional "war unklar" markieren
→ Aktivitäts-Station (2 Takte):
→ Handeln am Brett: Figuren ins RACI-Feld, Artefaktkarte in die Service-Akte
(App fragt; "Zeig mir" als Hilfe, wenn die Gruppe nicht weiterkommt)
→ Auflösung & Abgleich: App zeigt RACI/Artefakt → Brett korrigieren, "unklar" markieren
→ "Nächste Station"
→ an Gates: Gate-Frage + Rollen-Check
→ [Ende] → Debrief-Export (unklare Aktivitäten, Quote, Pfad)
→ Gate-Station: Kriterien prüfen (Artefakte in der Akte? Pflicht-Figuren?)
→ Entscheidung wählen (Go/Auflagen/Zurück/Ablehnung bzw. Entwicklung/Konfiguration)
→ Konsequenz + Verzweigung (z.B. Konfiguration überspringt den Build;
reicht die SOR-Hoheit nicht → Demand via DPM ans Mission Board)
→ [Ende] → Debrief-Export (bearbeitete Stationen, unklare, Pfad)
```
## 5. Funktionsumfang (MVP)

View file

@ -894,7 +894,7 @@ function renderList(){
}
$("#stationList").innerHTML = html;
$("#stationList").querySelectorAll(".stationItem").forEach(el=>{
el.onclick = ()=>{ S.index = +el.dataset.i; S.stage="discuss"; S.quizIndex=0; save(); draw(); };
el.onclick = ()=>{ enterStation(+el.dataset.i); save(); draw(); };
});
const pct = Math.round(Object.keys(S.done).length / STATIONEN.length * 100);
$("#progressBar").style.width = pct+"%";
@ -1024,11 +1024,36 @@ function renderEntry(){
<button class="primary" id="startRun">Los geht's →</button>
</div>`;
$("#backClassify").onclick=()=>{ S.view="classify"; save(); draw(); };
$("#startRun").onclick=()=>{ S.index=recIndex; S.view="run"; S.stage="discuss"; S.quizIndex=0; save(); draw(); };
$("#startRun").onclick=()=>{ enterStation(recIndex); S.view="run"; save(); draw(); };
}
}
/* ====================== RENDER: RUN (Station) ====================== */
const GATE_FLOW = {
tr_01: ["next","tr_05"], // Entwicklung / Konfiguration (Konfig ueberspringt Build)
tr_09: ["next","next","tr_02","end"], // Freigabe / m. Auflagen / Zurueck an Build / Ablehnung
tr_12: ["next","next","tr_10","end"] // Go-Live / m. Auflagen / Zurueck / Ablehnung
};
function enterStation(idx){
idx = Math.max(0, Math.min(idx, STATIONEN.length-1));
S.index = idx;
S.stage = STATIONEN[idx].typ==="gate" ? "gate" : "act";
S.gatePick = null; S.quizIndex = 0;
}
function gateGoto(st, i){
S.done[st.id] = true;
const t = (GATE_FLOW[st.id] || [])[i] || "next";
if(t==="end"){ save(); openDebrief(); return; }
if(t==="next"){ enterStation(S.index+1); }
else { const j = STATIONEN.findIndex(s=>s.id===t); enterStation(j>=0 ? j : S.index+1); }
save(); draw();
}
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("");
return `<table class="raci"><thead><tr><th>Rolle</th><th>RACI</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function renderRun(){
renderList();
const st = cur();
@ -1036,7 +1061,6 @@ function renderRun(){
const chip = st.typ==="gate"
? `<span class="phaseChip gateChip">⛩ Gate ${st.gateNr}</span>`
: `<span class="phaseChip" style="background:${ph.color}">${ph.label}</span>`;
let body = `
${chip}
<div class="stationName">${st.name}</div>
@ -1044,89 +1068,47 @@ function renderRun(){
<div class="token">Action Card: <b>${USE_CASES[S.service].service}</b>
<span class="ctChip">${CHANGE_TYPES[S.change]}</span>
<div class="ctText"><b>${acard(S.service,S.change).titel}</b> — ${acard(S.service,S.change).text}</div>
</div>
`;
if(S.stage==="discuss") body += renderDiscuss(st);
else if(S.stage==="quiz") body += renderQuiz(st);
else body += renderReveal(st);
</div>`;
if(st.typ==="gate") body += (S.stage==="gateDone") ? renderGateDone(st) : renderGate(st);
else body += (S.stage==="reveal") ? renderReveal(st) : renderAct(st);
$("#panel").innerHTML = body;
wire(st);
}
function renderDiscuss(st){
/* Aktivitaet — Takt 1: Handeln am Brett (mit "Zeig mir") */
function renderAct(st){
return `
<div class="step">
<div class="stepHead"><span class="n">1</span> Diskussion · App noch zu</div>
<div class="stepHead"><span class="n">1</span> Handeln am Brett</div>
<div class="discuss">
<strong>Besprecht in der Gruppe — anhand der Kurzbezeichnung:</strong>
<strong>Besprecht und legt am Brett ab:</strong>
<ul>
<li>Was passiert hier konkret für euer Szenario?</li>
<li>Wer macht es? Steckt die Rollen-Figuren ins <b>Aktiv-Feld (R/A/C/I)</b>.</li>
<li>Welches Artefakt entsteht?</li>
<li><b>Wer?</b> Rollen-Figuren ins <b>Aktiv-Feld (R · A · C · I)</b> stellen.</li>
<li><b>Was passiert?</b> Kurz klären, was hier für euren Change getan wird.</li>
<li><b>Artefakt?</b> Passende <b>Artefaktkarte in die Service-Akte</b> legen (oder Status weiterschieben).</li>
</ul>
<details style="margin-top:10px">
<summary style="cursor:pointer;color:var(--muted);font-weight:600">Zeig mir (wenn ihr nicht weiterkommt)</summary>
<div style="margin-top:8px">${raciTable(st)}<p style="margin:6px 0 0"><b>Artefakt:</b> ${st.artefakt}</p></div>
</details>
</div>
</div>
<div class="actions">
<div class="spacer"></div>
<button class="primary" id="toQuiz">Quiz starten →</button>
</div>`;
}
function renderQuiz(st){
const qi = S.quizIndex, q = st.quiz[qi];
const picked = S.picks[pkey(st.id,qi)];
const answered = picked !== undefined;
const opts = q.optionen.map((o,i)=>{
let cls = "opt";
if(answered){
if(i===q.richtig) cls+=" correct";
else if(i===picked) cls+=" wrong";
} else if(i===picked) cls+=" sel";
const mark = answered ? (i===q.richtig?'<span class="mark" style="color:var(--ok)"></span>': (i===picked?'<span class="mark" style="color:var(--bad)"></span>':'')) : '';
return `<button class="${cls}" data-opt="${i}" ${answered?'disabled':''}>${o}${mark}</button>`;
}).join("");
return `
<div class="step">
<div class="stepHead"><span class="n">2</span> Quiz · Frage ${qi+1} / ${st.quiz.length}</div>
<div class="q">
<div class="frage">${q.frage}</div>
<div class="opts">${opts}</div>
${answered ? `<div class="qExpl">${q.expl}</div>` : ``}
</div>
</div>
<div class="actions">
<button class="ghost" id="backDiscuss">← Diskussion</button>
<div class="spacer"></div>
${answered
? (qi < st.quiz.length-1
? `<button class="primary" id="nextQ">Nächste Frage →</button>`
: `<button class="primary" id="toReveal">Auflösung anzeigen →</button>`)
: `<button class="primary" disabled>Antwort wählen…</button>`}
<button class="primary" id="toReveal">Auflösen →</button>
</div>`;
}
/* Aktivitaet — Takt 2: Aufloesung & Abgleich */
function renderReveal(st){
const raci = st.raci.map(([r,c])=>`<tr><td>${roleLabel(r)}</td><td><span class="raciBadge raci-${c}">${c}</span></td></tr>`).join("");
let extra = "";
if(st.pfade){
extra += `<h4>Entscheidungspfade</h4><div class="pfade">` +
st.pfade.map(([n,d])=>`<div class="pfad"><b>${n}</b><span style="color:var(--muted)">${d}</span></div>`).join("") + `</div>`;
}
if(st.pruef){
extra += `<h4>Prüf-Dimensionen</h4><ul>` + st.pruef.map(([n,d])=>`<li><b>${n}</b> — ${d}</li>`).join("") + `</ul>`;
}
return `
<div class="step reveal">
<div class="stepHead"><span class="n">3</span> Auflösung & Reflexion</div>
<div class="stepHead"><span class="n">2</span> Auflösung & Abgleich</div>
<p>${st.beschreibung}</p>
<h4>Umfasst</h4>
<ul>${st.umfasst.map(u=>`<li>${u}</li>`).join("")}</ul>
<h4>Rollen / RACI</h4>
<table class="raci"><thead><tr><th>Rolle</th><th>RACI</th></tr></thead><tbody>${raci}</tbody></table>
<h4>Umfasst</h4><ul>${st.umfasst.map(u=>`<li>${u}</li>`).join("")}</ul>
<h4>Rollen / RACI</h4>${raciTable(st)}
<h4>Artefakt</h4><p>${st.artefakt}</p>
${extra}
<p class="muted">Gleicht euer Brett ab — Figuren/Artefakt korrigieren, falls nötig.</p>
</div>
<div class="actions">
<label class="unclear"><input type="checkbox" id="unclear" ${S.unclear[st.id]?'checked':''}/> War unklar</label>
@ -1137,19 +1119,58 @@ function renderReveal(st){
</div>`;
}
/* Gate — Entscheidung nach Kriterien */
function renderGate(st){
const keeper = (st.raci.find(([r,c])=>c==="A")||[])[0];
const pruef = (st.pruef||[]).map(([n,d])=>`<li><b>${n}</b> — ${d}</li>`).join("");
const opts = (st.pfade||[]).map(([n,d],i)=>
`<button class="choice" data-i="${i}"><b>${n}</b><br><span style="color:var(--muted);font-weight:400">${d}</span></button>`).join("");
return `
<div class="step">
<div class="stepHead"><span class="n"></span> Gate-Entscheidung</div>
<p>${st.beschreibung}</p>
<p><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
<h4>Prüft am Brett (Kriterien)</h4>
<ul>${pruef}</ul>
<p class="muted">Liegen die geforderten <b>Artefaktkarten</b> in der Service-Akte? Stehen die <b>Pflicht-Figuren</b> am Gate-Puck? Dann entscheidet:</p>
<div class="choiceGrid">${opts}</div>
</div>`;
}
/* Gate — Konsequenz der Entscheidung */
function renderGateDone(st){
const i = S.gatePick || 0;
const pf = st.pfade[i] || st.pfade[0];
const keeper = (st.raci.find(([r,c])=>c==="A")||[])[0];
const sorNote = (keeper==="sor")
? `<p class="muted">Reicht die <b>Ressourcen-/Entscheidungshoheit der SOR</b> nicht (zusätzliche Mittel nötig), wird der Change zum <b>Demand</b> → über DPM ans <b>Mission Board</b>.</p>` : ``;
return `
<div class="step reveal">
<div class="stepHead"><span class="n"></span> Entscheidung getroffen</div>
<div class="recBox"><h4>${pf[0]}</h4><p style="margin:0;color:var(--muted)">${pf[1]}</p></div>
${sorNote}
<p class="muted">Legt den passenden <b>Entscheidungs-Chip</b> ans Gate.</p>
</div>
<div class="actions">
<button class="ghost" id="gateBack">← andere Entscheidung</button>
<div class="spacer"></div>
<button class="primary" id="gateNext">Weiter →</button>
</div>`;
}
/* ====================== WIRING ====================== */
function wire(st){
const b = id => $("#"+id);
if(b("toQuiz")) b("toQuiz").onclick = ()=>{ S.stage="quiz"; S.quizIndex=0; save(); draw(); };
if(b("backDiscuss")) b("backDiscuss").onclick = ()=>{ S.stage="discuss"; save(); draw(); };
$("#panel").querySelectorAll(".opt[data-opt]").forEach(el=>{
el.onclick = ()=>{ S.picks[pkey(st.id,S.quizIndex)] = +el.dataset.opt; save(); draw(); };
});
if(b("nextQ")) b("nextQ").onclick = ()=>{ S.quizIndex++; save(); draw(); };
if(b("toReveal")) b("toReveal").onclick = ()=>{ S.stage="reveal"; save(); draw(); };
if(b("unclear")) b("unclear").onchange = e=>{ if(e.target.checked) S.unclear[st.id]=true; else delete S.unclear[st.id]; save(); renderList(); };
if(b("nextStation")) b("nextStation").onclick = ()=>{ S.done[st.id]=true; S.index++; S.stage="discuss"; S.quizIndex=0; save(); draw(); };
if(b("nextStation")) b("nextStation").onclick = ()=>{ S.done[st.id]=true; enterStation(S.index+1); save(); draw(); };
if(b("finish")) b("finish").onclick = ()=>{ S.done[st.id]=true; save(); openDebrief(); };
// Gate
$("#panel").querySelectorAll(".choiceGrid .choice[data-i]").forEach(el=>{
el.onclick = ()=>{ S.gatePick=+el.dataset.i; S.stage="gateDone"; save(); draw(); };
});
if(b("gateBack")) b("gateBack").onclick = ()=>{ S.stage="gate"; save(); draw(); };
if(b("gateNext")) b("gateNext").onclick = ()=>{ gateGoto(st, S.gatePick||0); };
}
/* ====================== DEBRIEF ====================== */
@ -1170,27 +1191,18 @@ function download(name, text, mime){
a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 1000);
}
function openDebrief(){
const {correct,total} = quizScore();
const doneN = Object.keys(S.done).length;
const unclearList = STATIONEN.filter(st=>S.unclear[st.id]);
$("#debriefStat").innerHTML = `
<div><b>${doneN}/${STATIONEN.length}</b><span>Stationen</span></div>
<div><b>${total?Math.round(correct/total*100):0}%</b><span>Quiz richtig (${correct}/${total})</span></div>
<div><b>${doneN}/${STATIONEN.length}</b><span>Stationen bearbeitet</span></div>
<div><b>${unclearList.length}</b><span>als unklar markiert</span></div>`;
let md = `# SLC-Workshop — Debrief\n\n`;
md += `**Service:** ${USE_CASES[S.service].service}\n`;
md += `**Change-Typ:** ${CHANGE_TYPES[S.change]}\n`;
md += `**Action Card:** „${acard(S.service,S.change).titel}“ — ${acard(S.service,S.change).text}\n`;
md += `**Stationen bearbeitet:** ${doneN}/${STATIONEN.length}\n`;
md += `**Quiz:** ${correct}/${total} richtig\n\n`;
md += `**Stationen bearbeitet:** ${doneN}/${STATIONEN.length}\n\n`;
md += `## Als unklar markiert\n`;
md += unclearList.length ? unclearList.map(st=>`- ${st.id} — ${st.name}`).join("\n") : "_keine_";
md += `\n\n## Quiz-Detail\n`;
STATIONEN.forEach(st=> st.quiz.forEach((q,qi)=>{
const p = S.picks[pkey(st.id,qi)];
if(p===undefined) return;
md += `- [${p===q.richtig?"✓":"✗"}] ${st.id}: ${q.frage} → „${q.optionen[p]}"\n`;
}));
const stamp = new Date().toISOString().slice(0,16).replace("T"," ");
const json = {
erzeugt: stamp,
@ -1200,15 +1212,8 @@ function openDebrief(){
ausloeser: acard(S.service,S.change).text,
stationenBearbeitet: doneN,
stationenGesamt: STATIONEN.length,
quiz: { richtig: correct, gesamt: total },
unklar: unclearList.map(st=>({id:st.id, name:st.name})),
quizDetail: []
unklar: unclearList.map(st=>({id:st.id, name:st.name}))
};
STATIONEN.forEach(st=> st.quiz.forEach((q,qi)=>{
const p = S.picks[pkey(st.id,qi)];
if(p===undefined) return;
json.quizDetail.push({station:st.id, frage:q.frage, gewaehlt:q.optionen[p], richtig:p===q.richtig});
}));
DEBRIEF = { md, json };
$("#debriefText").textContent = md;
$("#debriefDlg").showModal();