Companion-App: zur deploybaren PWA ausgebaut (statisch) #1

Merged
patrick merged 1 commit from feat/redesign-und-companion-app into main 2026-06-05 13:34:32 +00:00
8 changed files with 196 additions and 9 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "slc-companion",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8099", "--bind", "127.0.0.1", "--directory", "04_Tablet-Quiz/app"],
"port": 8099
}
]
}

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ _*.png
*.stl *.stl
*.bak *.bak
*.tmp *.tmp
.claude/settings.local.json

View file

@ -1,6 +1,14 @@
# Tablet-Quiz — Begleit-App (Teilprojekt) # Tablet-Quiz — Begleit-App (Teilprojekt)
**Status:** Konzept · **Typ:** eigenständiges Software-Teilprojekt des SLC-Workshops **Status:** App lauffähig (PWA) · Deploy vorbereitet · **Typ:** eigenständiges Software-Teilprojekt des SLC-Workshops
> **Umsetzungsstand:** Die App liegt unter [`app/`](app/) als statische **PWA**
> (offline-/kioskfähig). Sie führt den kompletten Flow durch (Action Card →
> Startpunkt → optionale Tour → Station: Diskussion/Quiz/Auflösung → Debrief mit
> **Markdown-/JSON-Export**). Inhalte (40 Stationen, 45 Quizfragen, 6 Use-Cases)
> sind derzeit in `app/index.html` eingebettet. **Deployment:** statisch, siehe
> [`app/DEPLOY.md`](app/DEPLOY.md). **Lokal testen:** `python -m http.server 8099
> --directory 04_Tablet-Quiz/app` (oder Preview-Config `.claude/launch.json`).
Das Tablet-Quiz ist der **digitale Begleiter** des Tabletops — kein Ersatz fürs Das Tablet-Quiz ist der **digitale Begleiter** des Tabletops — kein Ersatz fürs
Brett. Es ist der **erklärende Gegenpart** zu den Pucks: Die Pucks tragen nur die Brett. Es ist der **erklärende Gegenpart** zu den Pucks: Die Pucks tragen nur die
@ -65,12 +73,13 @@ steht, sondern in der App liegt.
## 5. Funktionsumfang (MVP) ## 5. Funktionsumfang (MVP)
- [ ] `questions.json` + Stations-Inhalte aus YAMLs generieren (Build-Skript). - [x] Stationsführung: linearer Durchlauf mit „Nächste Station" + Fortschritt/Phasen-Farben.
- [ ] Stationsführung: linearer Durchlauf mit „Nächste Station" + Fortschritt/Phasen-Farben. - [x] Fragetypen 13 (vermittelndes Quiz).
- [ ] Fragetypen 13 (vermittelndes Quiz). - [x] „Auflösen"-Mechanik (Antwort erst auf Klick) **+ ausführliche Stationsauflösung** (Erklärung/RACI/Artefakt) nach dem Quiz.
- [ ] „Auflösen"-Mechanik (Antwort erst auf Klick) **+ ausführliche Stationsauflösung** (Erklärung/RACI/Artefakt) nach dem Quiz. - [x] „Unklar"-Markierung je Aktivität.
- [ ] „Unklar"-Markierung je Aktivität. - [x] Debrief-Export (Markdown **und** JSON, lokaler Download).
- [ ] Debrief-Export (Markdown/JSON, lokal). - [x] PWA / offline lauffähig (Manifest + Service Worker).
- [ ] `questions.json` + Stations-Inhalte aus YAMLs generieren (Build-Skript) — Inhalte aktuell in `app/index.html` eingebettet (braucht Blueprint-Repo-Zugriff).
### Später (Ausbau) ### Später (Ausbau)
- Gate-Fragen mit Rollen-Check (Typ 45). - Gate-Fragen mit Rollen-Check (Typ 45).

View file

@ -0,0 +1,66 @@
# Deployment — SLC-Workshop Companion (App)
**Auftrag für die Server-Claude / Ops:** Diese App **statisch** ausliefern. Kein
Build-Schritt, kein Backend, keine Secrets, keine Datenbank.
## Was das ist
- Eine **statische Single-Page-PWA**. Alle Inhalte (Stationen, Quiz, Use-Cases)
sind in `index.html` eingebettet — keine Laufzeit-API nötig.
- Dateien im Ordner `04_Tablet-Quiz/app/`:
- `index.html` — die App
- `manifest.webmanifest` — PWA-Manifest
- `sw.js` — Service Worker (Offline-Cache der App-Shell)
- `icon.svg` — App-Icon
## Ziel
Den Ordner `04_Tablet-Quiz/app/` als **statisches Web-Root** über den vorhandenen
Webserver bereitstellen, erreichbar per **HTTPS** (oder localhost).
> **Wichtig:** Der Service Worker (Offline/Installierbar) läuft **nur über HTTPS
> oder localhost**. Über reines `http://` ohne TLS registriert er sich nicht —
> die App funktioniert dann trotzdem, nur ohne Offline-Cache.
## Schritte
1. Repo auf dem Server bereitstellen/aktualisieren:
`git clone https://git.1789.cloud/patrick/SLC_Game.git` (bzw. `git pull`).
2. Den Ordner `SLC_Game/04_Tablet-Quiz/app/` als statisches Root einbinden.
### Beispiel nginx
```nginx
server {
listen 443 ssl;
server_name slc.example.intern; # anpassen
# ssl_certificate ... ; ssl_certificate_key ... ;
root /srv/SLC_Game/04_Tablet-Quiz/app; # Pfad anpassen
index index.html;
location = /sw.js { add_header Cache-Control "no-cache"; } # SW immer frisch
location / { try_files $uri $uri/ /index.html; }
}
```
### Beispiel Caddy (TLS automatisch)
```caddy
slc.example.intern {
root * /srv/SLC_Game/04_Tablet-Quiz/app
file_server
header /sw.js Cache-Control "no-cache"
}
```
## Verifikation nach dem Deploy
1. URL öffnen → Startbildschirm „SLC Companion · Schritt 1 · Action Card".
2. DevTools → Application → **Service Workers**: `sw.js` ist *activated*.
3. Flugmodus/Offline → Seite neu laden → App lädt weiterhin (Offline-Cache).
4. Einen Durchlauf spielen → „Debrief" → **↓ Markdown / ↓ JSON** lädt eine Datei.
## Updates
- Bei neuem Stand: `git pull`. Wenn sich App-Assets geändert haben, in `sw.js`
die Konstante `CACHE` hochzählen (z. B. `slc-companion-v1``-v2`), damit der
Service Worker den Cache erneuert.
## Noch offen (nicht Teil dieses Deploys)
- **YAML→Inhaltspipeline:** Inhalte sind aktuell in `index.html` eingebettet.
Später aus den Blueprint-`service-lifecycle_*.yaml` generieren (braucht Zugriff
aufs Blueprint-Repo). Bis dahin werden Inhalte direkt in `index.html` gepflegt.
- **Companion-Chatbot** (optionaler Nachschlage-Bot) — siehe `../README.md` §8;
*braucht* ein LLM-Backend und ist daher **nicht** Teil der statischen App.

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<!-- maskable: vollflaechiger Hintergrund, Inhalt im sicheren Mittelbereich -->
<rect width="512" height="512" fill="#1d2430"/>
<!-- Bahn-Linie -->
<line x1="150" y1="256" x2="362" y2="256" stroke="#3a4658" stroke-width="10" stroke-linecap="round"/>
<!-- 5 Phasen-Pucks (blau/orange/gruen/teal/lila) -->
<circle cx="160" cy="256" r="24" fill="#2F80C9"/>
<circle cx="208" cy="256" r="24" fill="#E8893B"/>
<circle cx="256" cy="256" r="24" fill="#5BAE5B"/>
<circle cx="304" cy="256" r="24" fill="#3FB5B5"/>
<circle cx="352" cy="256" r="24" fill="#8E63B5"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

View file

@ -3,7 +3,12 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>SLC-Workshop — Companion-App (Prototyp)</title> <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="theme-color" content="#1d2430" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="apple-touch-icon" href="icon.svg" />
<link rel="icon" href="icon.svg" type="image/svg+xml" />
<style> <style>
:root { :root {
--bg: #f4f5f7; --bg: #f4f5f7;
@ -160,7 +165,7 @@
<body> <body>
<header> <header>
<div class="brand">SLC&nbsp;<b>Companion</b></div> <div class="brand">SLC&nbsp;<b>Companion</b></div>
<span class="tag">Prototyp · v0.5</span> <span class="tag">v0.5</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="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>
@ -181,6 +186,8 @@
<pre class="export" id="debriefText"></pre> <pre class="export" id="debriefText"></pre>
<div class="actions"> <div class="actions">
<button class="ghost" id="copyBtn">In Zwischenablage</button> <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> <div class="spacer"></div>
<button class="primary" id="closeDebrief">Schließen</button> <button class="primary" id="closeDebrief">Schließen</button>
</div> </div>
@ -1157,6 +1164,14 @@ function quizScore(){
})); }));
return {correct,total}; 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(){ function openDebrief(){
const {correct,total} = quizScore(); const {correct,total} = quizScore();
const doneN = Object.keys(S.done).length; const doneN = Object.keys(S.done).length;
@ -1179,6 +1194,24 @@ function openDebrief(){
if(p===undefined) return; if(p===undefined) return;
md += `- [${p===q.richtig?"✓":"✗"}] ${st.id}: ${q.frage} → „${q.optionen[p]}"\n`; 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,
service: USE_CASES[S.service].service,
changeTyp: CHANGE_TYPES[S.change],
ausloeser: USE_CASES[S.service].changes[S.change],
stationenBearbeitet: doneN,
stationenGesamt: STATIONEN.length,
quiz: { richtig: correct, gesamt: total },
unklar: unclearList.map(st=>({id:st.id, name:st.name})),
quizDetail: []
};
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; $("#debriefText").textContent = md;
$("#debriefDlg").showModal(); $("#debriefDlg").showModal();
} }
@ -1188,9 +1221,16 @@ function openDebrief(){
$("#debriefBtn").onclick = openDebrief; $("#debriefBtn").onclick = openDebrief;
$("#closeDebrief").onclick = ()=> $("#debriefDlg").close(); $("#closeDebrief").onclick = ()=> $("#debriefDlg").close();
$("#copyBtn").onclick = ()=> navigator.clipboard?.writeText($("#debriefText").textContent); $("#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(); } }; $("#resetBtn").onclick = ()=>{ if(confirm("Neue Action Card ziehen und Durchlauf zurücksetzen?")){ S=defaultState(); save(); draw(); } };
draw(); draw();
})(); })();
// PWA: Service Worker fuer Offline-/Kiosk-Betrieb (nur ueber http/https aktiv)
if ("serviceWorker" in navigator) {
window.addEventListener("load", ()=> navigator.serviceWorker.register("sw.js").catch(()=>{}));
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,15 @@
{
"name": "SLC-Workshop Companion",
"short_name": "SLC Companion",
"description": "Begleit-App zum SLC-Workshop-Tabletop: Stationsführung, vermittelndes Quiz, Auflösung und Debrief. Offline lauffähig.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "landscape",
"lang": "de",
"background_color": "#f4f5f7",
"theme_color": "#1d2430",
"icons": [
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}

33
04_Tablet-Quiz/app/sw.js Normal file
View file

@ -0,0 +1,33 @@
/* Service Worker — SLC-Workshop Companion (App-Shell, offline-first) */
const CACHE = "slc-companion-v1";
const ASSETS = ["./", "index.html", "manifest.webmanifest", "icon.svg"];
self.addEventListener("install", (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (e) => {
e.waitUntil(
caches.keys()
.then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (e) => {
if (e.request.method !== "GET") return;
e.respondWith(
caches.match(e.request).then((hit) =>
hit ||
fetch(e.request)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(e.request, copy));
return resp;
})
.catch(() => caches.match("index.html"))
)
);
});