This commit is contained in:
breitenbach76 2026-06-06 16:38:46 +02:00
parent 36471297f5
commit 917bf1d613
3 changed files with 72 additions and 91 deletions

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>SLC-Workshop — Companion-App</title>
<meta name="description" content="Begleit-App zum SLC-Workshop-Tabletop: führt durch die Stationen, vermittelndes Quiz, Auflösung und Debrief. 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="#1d2430" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="icon.svg" />
@ -120,16 +120,6 @@
.actions .spacer { flex:1; }
.unclear { display:flex; align-items:center; gap:8px; color:var(--muted); font-size:14px; }
dialog { border:none; border-radius:16px; padding:0; box-shadow: var(--shadow); max-width: 640px; width: 92%; }
dialog::backdrop { background: rgba(15,22,35,.45); }
.modal { padding: 24px; }
.modal h2 { margin:0 0 6px; }
pre.export { background:#0f1623; color:#d6e2f0; padding:14px; border-radius:10px; overflow:auto; max-height:320px; font-size:13px; }
.stat { display:flex; gap:20px; margin:14px 0; }
.stat div b { display:block; font-size:24px; }
.stat div span { color:var(--muted); font-size:12px; }
.note { font-size:12px; color:var(--muted); margin-top:18px; text-align:center; }
/* Setup-Screens (Action Card / Startpunkt) */
body:not(.runMode) aside { display:none; }
body:not(.runMode) .layout { grid-template-columns: 1fr; }
@ -193,7 +183,6 @@
<div id="cardBadge" class="cardBadge"></div>
<div class="spacer"></div>
<button class="ghost" id="resetBtn" title="Neue Action Card / Durchlauf zurücksetzen">Neu starten</button>
<button class="primary" id="debriefBtn">Debrief</button>
</header>
<div class="progress"><div id="progressBar" style="width:0%"></div></div>
@ -202,22 +191,6 @@
<main><div class="card" id="panel"></div></main>
</div>
<dialog id="debriefDlg">
<div class="modal">
<h2>Debrief-Export</h2>
<p style="color:var(--muted);margin-top:0">Lokale Auswertung dieses Durchlaufs — nichts wird hochgeladen.</p>
<div class="stat" id="debriefStat"></div>
<pre class="export" id="debriefText"></pre>
<div class="actions">
<button class="ghost" id="copyBtn">In Zwischenablage</button>
<button class="ghost" id="dlMd">↓ Markdown</button>
<button class="ghost" id="dlJson">↓ JSON</button>
<div class="spacer"></div>
<button class="primary" id="closeDebrief">Schließen</button>
</div>
</div>
</dialog>
<script>
/* Empfohlener Einstiegspunkt je Change-Typ (didaktische Auflösung in Schritt 2).
Reihenfolge entspricht CHANGE_TYPES. */
@ -851,7 +824,8 @@ function defaultState(){
classifyDone:false, classifyWrong:null,
entryDone:false, entryWrong:null,
index:0, stage:"discuss", quizIndex:0,
picks:{}, done:{}, unclear:{} };
picks:{}, done:{}, unclear:{},
loopback:null, revisit:{}, endReason:null, endGate:null };
}
let S = load();
function load(){ try{ return Object.assign(defaultState(), JSON.parse(localStorage.getItem(LS_KEY)||"{}")); }catch(e){ return defaultState(); } }
@ -907,6 +881,7 @@ function draw(){
if(S.view==="deck") return renderDeck();
if(S.view==="classify") return renderClassify();
if(S.view==="entry") return renderEntry();
if(S.view==="end") return renderEnd();
renderRun();
}
@ -1036,6 +1011,11 @@ const GATE_FLOW = {
};
function enterStation(idx){
idx = Math.max(0, Math.min(idx, STATIONEN.length-1));
// Echte Rueckschleife: erreicht man das Gate wieder (oder ueberholt es), Schleife schliessen.
if(S.loopback && idx >= S.loopback.untilIdx){
if(idx === S.loopback.untilIdx) (S.revisit = S.revisit || {})[STATIONEN[idx].id] = true;
S.loopback = null;
}
S.index = idx;
S.stage = STATIONEN[idx].typ==="gate" ? "gate" : "act";
S.gatePick = null; S.quizIndex = 0;
@ -1043,9 +1023,15 @@ function enterStation(idx){
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==="end"){ S.view="end"; S.endReason="rejected"; S.endGate=st.id; save(); draw(); return; }
if(t==="next"){ enterStation(S.index+1); }
else { const j = STATIONEN.findIndex(s=>s.id===t); enterStation(j>=0 ? j : S.index+1); }
else {
const j = STATIONEN.findIndex(s=>s.id===t);
const target = j>=0 ? j : S.index+1;
// Sprung nach hinten = echte Rueckschleife: Banner setzen, Gate wird danach erneut vorgelegt.
if(target < S.index){ S.loopback = { gateId: st.id, gateNr: st.gateNr, untilIdx: S.index }; }
enterStation(target);
}
save(); draw();
}
@ -1061,7 +1047,11 @@ 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>`;
const loopBanner = (S.loopback && S.index < S.loopback.untilIdx)
? `<div class="tourBanner"><b>Nacharbeit nach Gate ${S.loopback.gateNr}</b> — überarbeitet die folgenden Stationen; danach wird das Gate erneut vorgelegt und entscheidet neu.</div>`
: ``;
let body = `
${loopBanner}
${chip}
<div class="stationName">${st.name}</div>
<div class="stationId">${st.id}</div>
@ -1083,7 +1073,8 @@ function renderAct(st){
<div class="discuss">
<strong>Besprecht und legt am Brett ab:</strong>
<ul>
<li><b>Wer?</b> Rollen-Figuren ins <b>Aktiv-Feld (R · A · C · I)</b> stellen.</li>
<li><b>1 · Wer ist beteiligt?</b> Die Figuren der beteiligten Rollen auf die <b>Mulden des Station-Pucks</b> stellen.</li>
<li><b>2 · Wie ist die RACI?</b> Dieselben Figuren ins <b>Aktiv-Feld (R · A · C · I)</b> einsortieren.</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>
@ -1125,10 +1116,13 @@ function renderGate(st){
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("");
const revisitNote = (S.revisit && S.revisit[st.id])
? `<div class="hint ok">↩ Erneute Vorlage nach Nacharbeit — prüft die Kriterien erneut und entscheidet neu.</div>` : ``;
return `
<div class="step">
<div class="stepHead"><span class="n"></span> Gate-Entscheidung</div>
<p>${st.beschreibung}</p>
${revisitNote}
<p><b>Entscheidet:</b> ${roleLabel(keeper)}</p>
<h4>Prüft am Brett (Kriterien)</h4>
<ul>${pruef}</ul>
@ -1164,7 +1158,7 @@ function wire(st){
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; enterStation(S.index+1); save(); draw(); };
if(b("finish")) b("finish").onclick = ()=>{ S.done[st.id]=true; save(); openDebrief(); };
if(b("finish")) b("finish").onclick = ()=>{ S.done[st.id]=true; S.view="end"; S.endReason="done"; save(); draw(); };
// Gate
$("#panel").querySelectorAll(".choiceGrid .choice[data-i]").forEach(el=>{
el.onclick = ()=>{ S.gatePick=+el.dataset.i; S.stage="gateDone"; save(); draw(); };
@ -1173,59 +1167,33 @@ function wire(st){
if(b("gateNext")) b("gateNext").onclick = ()=>{ gateGoto(st, S.gatePick||0); };
}
/* ====================== DEBRIEF ====================== */
function quizScore(){
let correct=0, total=0;
STATIONEN.forEach(st=> st.quiz.forEach((q,qi)=>{
const p = S.picks[pkey(st.id,qi)];
if(p!==undefined){ total++; if(p===q.richtig) correct++; }
}));
return {correct,total};
}
let DEBRIEF = { md:"", json:null };
function download(name, text, mime){
const blob = new Blob([text], {type:mime});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = name; document.body.appendChild(a); a.click();
a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 1000);
}
function openDebrief(){
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 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\n`;
md += `## Als unklar markiert\n`;
md += unclearList.length ? unclearList.map(st=>`- ${st.id} — ${st.name}`).join("\n") : "_keine_";
const stamp = new Date().toISOString().slice(0,16).replace("T"," ");
const json = {
erzeugt: stamp,
service: USE_CASES[S.service].service,
changeTyp: CHANGE_TYPES[S.change],
actionCard: acard(S.service,S.change).titel,
ausloeser: acard(S.service,S.change).text,
stationenBearbeitet: doneN,
stationenGesamt: STATIONEN.length,
unklar: unclearList.map(st=>({id:st.id, name:st.name}))
};
DEBRIEF = { md, json };
$("#debriefText").textContent = md;
$("#debriefDlg").showModal();
/* ====================== ABSCHLUSS-SCREEN ====================== */
function renderEnd(){
const rejected = S.endReason==="rejected";
const gate = rejected ? STATIONEN.find(s=>s.id===S.endGate) : null;
const head = rejected ? "Change abgelehnt" : "Durchlauf abgeschlossen";
const icon = rejected ? "✗" : "✓";
const box = rejected
? `<div class="recBox" style="border-left-color:var(--bad)">
<h4>Abgelehnt an ${gate ? "Gate "+gate.gateNr : "einem Gate"}</h4>
<p style="margin:0;color:var(--muted)">Das Vorhaben wird in dieser Form nicht weiterverfolgt. Eine endgültige Ablehnung erfordert die <b>SOR-Eskalation</b>.</p></div>`
: `<div class="recBox">
<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>`;
$("#panel").innerHTML = `
<div class="setupHead">Abschluss</div>
<h2 class="setupTitle">${icon} ${head}</h2>
<div class="hint ${rejected?"bad":"ok"}">${USE_CASES[S.service].service} · ${CHANGE_TYPES[S.change]}</div>
${box}
<div class="actions">
<div class="spacer"></div>
<button class="primary" id="endRestart">Neue Action Card →</button>
</div>`;
$("#endRestart").onclick = ()=>{ S = defaultState(); save(); draw(); };
}
/* ====================== INIT ====================== */
(function init(){
$("#debriefBtn").onclick = openDebrief;
$("#closeDebrief").onclick = ()=> $("#debriefDlg").close();
$("#copyBtn").onclick = ()=> navigator.clipboard?.writeText($("#debriefText").textContent);
$("#dlMd").onclick = ()=> download("slc-debrief.md", DEBRIEF.md, "text/markdown");
$("#dlJson").onclick = ()=> download("slc-debrief.json", JSON.stringify(DEBRIEF.json, null, 2), "application/json");
$("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
draw();
})();

View file

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