diff --git a/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html b/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html
new file mode 100644
index 0000000..809ff8d
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html
@@ -0,0 +1,87 @@
+
Neue Startseite: Dashboard statt Spielliste
+
Aktuell sieht man 104 Spiele als endlose Liste. Die neue Startseite soll sofort zeigen: Was ist relevant für MICH, JETZT?
+
+
+
+
+
+
+
+
Nächstes Spiel · in 2h 14min
+
+
🇲🇽
Mexico
+
vs
+
🇿🇦
S. Africa
+
+
+ Dein Tipp: 2:1 ✓
+
+
+
+
+
5.
+
Dein Rang
+
+
+
12
+
Punkte
+
+
+
3🔥
+
Streak
+
+
+
+
📅 Heute noch 2 Spiele ohne Tipp
+
🏆 Max führt mit 15 Punkten
+
+
+
+
+
A: Hero + Stats + Nudges
+
Großes "Nächstes Spiel" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.
+
+
+
+
+
+
+
+
Hallo Ronny! Platz 5 · 12 Punkte
+
3🔥
+
+
⏰ Jetzt tippen
+
+
+ 🇲🇽 Mexico vs S. Africa 🇿🇦
+ 2h 14m
+
+
+
+
+ 🇰🇷 S. Korea vs Croatia 🇭🇷
+ 5h 14m
+
+
+
📊 Ergebnisse
+
+
+ 🇧🇷 Brazil 3:1 Serbia 🇷🇸
+ +3 🎯
+
+
+
+
+
+
B: Feed-Style Timeline
+
Persönliche Begrüßung oben, dann chronologisch: erst offene Tipps (dringendste zuerst mit Countdown), dann letzte Ergebnisse mit Punkte-Badges. Wie ein persönlicher WM-Feed.
+
+
+
+
+
+
+
Beide Varianten ersetzen die 104-Spiele-Liste als Startseite
+
Der komplette Spielplan bleibt erreichbar über "Alle Spiele" in der Navigation. Die Startseite wird zum persönlichen WM-Cockpit.
+
+
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html b/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html
new file mode 100644
index 0000000..06c3efa
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html
@@ -0,0 +1,50 @@
+
Emotionale Momente: Wo die App "lebendig" wird
+
5 Schlüsselmomente, in denen die App ein Lächeln erzeugen soll. Welche sind dir am wichtigsten?
+
+
+
+
+
1
+
+
Tipp-Bestätigung
+
Nach "Tipp bestätigen" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → "Dein Tipp ist drin! 🎯" mit sanfter Animation.
+
+
+
+
+
2
+
+
Live-Countdown vor Anpfiff
+
Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. "Noch 12 Minuten zum Tippen!" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: "Tippfenster geschlossen" mit Schloss-Icon.
+
+
+
+
+
3
+
+
Punkte-Reveal nach Spielende
+
Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → "Dein Tipp war..." → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener "EXAKT!"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.
+
+
+
+
+
4
+
+
Ranglistenveränderung
+
Wenn sich dein Rang ändert: "Du bist auf Platz 3 aufgestiegen! ⬆️" als Toast-Notification beim nächsten App-Öffnen. Oder: "Achtung, Anna ist nur noch 1 Punkt hinter dir!" — erzeugt freundschaftliche Rivalität.
+
+
+
+
+
5
+
+
Streak-Tracker
+
Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: "Deine 7er-Serie ist gerissen! Starte eine neue."
+
+
+
+
+
+
+
Klicke auf alle Momente, die du umsetzen möchtest (Mehrfachauswahl möglich). Oder sag mir im Terminal, wenn du alle willst oder Änderungswünsche hast.
+
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html b/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html
new file mode 100644
index 0000000..b0164a8
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html
@@ -0,0 +1,61 @@
+
Spielplan & Navigation: 104 Spiele im Griff
+
Aktuell: endlose Liste mit Stage-Filter oben. Wie machen wir das besser für Mobile?
+
+
+
Spielplan-Struktur
+
+
+
+
+
+
A
+
+
Smart Sections (Empfehlung)
+
Spielplan automatisch in Abschnitte unterteilt:
+ "Heute" (expandiert, farbig hervorgehoben)
+ "Morgen" (expandiert)
+ "Diese Woche" (kollapsiert, Anzahl angezeigt)
+ "Vergangene Spiele" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)
+
Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.
+
+
+
+
+
B
+
+
Tab-basiert
+
Drei Haupt-Tabs am oberen Rand:
+ "Offen" — Spiele, die noch getippt werden können (nach Kick-off sortiert)
+ "Live" — Laufende Spiele mit Echtzeit-Status
+ "Ergebnisse" — Abgeschlossene Spiele mit Punkten
+
Klar getrennt, aber weniger zeitlicher Kontext ("heute/morgen").
+
+
+
+
+
+
+
Mobile Navigation
+
+
+
+
+
+
C
+
+
Bottom Navigation Bar (Empfehlung)
+
Feste Leiste am unteren Bildschirmrand mit Icons + Label:
+ 🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil
+
Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.
+
+
+
+
+
D
+
+
Header-Nav beibehalten
+
Bestehende Navigation oben lassen, aber scrollbar machen auf Mobile (horizontal swipe). Weniger Aufwand, aber der Header nimmt auf kleinen Screens viel Platz ein und ist weniger daumenfreundlich.
+
+
+
+
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html b/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html
new file mode 100644
index 0000000..7798ec3
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html
@@ -0,0 +1,64 @@
+
Profil-Seite & Match-Cards aufwerten
+
Zwei Bereiche, die aktuell etwas leblos wirken
+
+
+
Profil-Seite: Von Nullen zu Persönlichkeit
+
Aktuell: Name, Rang, 4 Stat-Boxen mit Nullen. Kein Grund hier wiederzukommen.
+
+
+
+
+
+
A
+
+
Reiches Profil (Empfehlung)
+
+ Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge
+ Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen
+ Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — "Deine letzten 10 Tipps"
+ Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2
+ Fun-Stat: "Dein Lieblings-Tipp: 1:0 (5x getippt)" oder "Du tippst 70% Heimsiege"
+
+
+
+
+
+
B
+
+
Minimal-Upgrade
+
Bestehende Struktur beibehalten, aber: Stat-Boxen mit Animationen beim Laden, Tipp-Historie als einfache Liste darunter, Lieblingsteam-Auswahl prominenter platzieren. Weniger Aufwand, aber auch weniger Wow.
+
+
+
+
+
+
+
Match-Cards: Mehr Status, mehr Emotion
+
Aktuell: Team-Flaggen + Uhrzeit + "Tipp abgeben"-Button. Funktional, aber alle Karten sehen gleich aus.
+
+
+
+
+
+
C
+
+
Zustandsbasierte Cards (Empfehlung)
+
Jede Card sieht anders aus je nach Zustand:
+ ⏳ Offen: "Tipp abgeben"-Button, Countdown wenn <24h
+ ✅ Getippt: Dein Tipp prominent angezeigt, grüner Rand, "Ändern"-Link
+ 🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)
+ 🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer
+ 🔒 Verpasst: Ausgegraut, "Nicht getippt" — leichter Shame-Effekt als Motivation
+
+
+
+
+
+
D
+
+
Einfache Verbesserung
+
Bestehende Cards beibehalten, nur Farb-Akzente für Status hinzufügen (grüner Rand = getippt, grau = verpasst). Kein grundlegender Redesign. Weniger Aufwand, aber auch weniger visuell differenziert.
+
+
+
+
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html b/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html
new file mode 100644
index 0000000..0209200
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html
@@ -0,0 +1,67 @@
+
Aufräumen: Was fällt weg, was ändert sich?
+
Damit die App schlanker und fokussierter wird
+
+
+
+
+
1
+
+
KI-Agent / Expertenblick entfernen
+
Der Chat-Widget (Fußball-Icon unten rechts) und "Expertenblick" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).
+
+
+
+
+
2
+
+
Stage-Filter vereinfachen
+
Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.
+
+
+
+
+
3
+
+
Admin aus der Hauptnav entfernen
+
Aktuell sieht jeder "Admin" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.
Wähle aus, was wegfallen soll. Dann schreibe ich das komplette Design-Dokument.
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/content/waiting.html b/.superpowers/brainstorm/4751-1775923699/content/waiting.html
new file mode 100644
index 0000000..d990a0e
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/content/waiting.html
@@ -0,0 +1,3 @@
+
+
Design-Dokument wird geschrieben... weiter im Terminal.
+
\ No newline at end of file
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server-stopped b/.superpowers/brainstorm/4751-1775923699/state/server-stopped
new file mode 100644
index 0000000..795ce5c
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1775926519574}
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.log b/.superpowers/brainstorm/4751-1775923699/state/server.log
new file mode 100644
index 0000000..108608f
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/state/server.log
@@ -0,0 +1,38 @@
+{"type":"server-started","port":52250,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:52250","screen_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content","state_dir":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/state"}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/01-dashboard-concept.html"}
+{"source":"user-event","type":"click","text":"Nächstes Spiel · in 2h 14min\n \n 🇲🇽Mexico\n vs\n 🇿🇦S. Africa\n \n \n Dein Tipp: 2:1 ✓\n \n \n \n \n 5.\n Dein Rang\n \n \n 12\n Punkte\n \n \n 3🔥\n Streak\n \n \n \n 📅 Heute noch 2 Spiele ohne Tipp\n 🏆 Max führt mit 15 Punkten\n \n \n \n \n A: Hero + Stats + Nudges\n Großes \"Nächstes Spiel\" oben, darunter persönliche Stats (Rang, Punkte, Streak), unten Handlungsaufforderungen und Social-Info. Kompakt, alles Wichtige auf einen Blick.","choice":"a","id":null,"timestamp":1775924301735}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/02-emotional-moments.html"}
+{"source":"user-event","type":"click","text":"1\n \n Tipp-Bestätigung\n Nach \"Tipp bestätigen\" kurze Erfolgsanimation: Card pulsiert grün, Häkchen fliegt rein, subtiles Haptic-Feedback (Vibration auf Mobile). Statt nur Modal schließen → \"Dein Tipp ist drin! 🎯\" mit sanfter Animation.","choice":"tipp-confirm","id":null,"timestamp":1775924389765}
+{"source":"user-event","type":"click","text":"2\n \n Live-Countdown vor Anpfiff\n Wenn ein Spiel in <1 Stunde startet: pulsierender roter Countdown auf der Match-Card. \"Noch 12 Minuten zum Tippen!\" — erzeugt Dringlichkeit und FOMO. Nach Anpfiff: \"Tippfenster geschlossen\" mit Schloss-Icon.","choice":"countdown","id":null,"timestamp":1775924391183}
+{"source":"user-event","type":"click","text":"3\n \n Punkte-Reveal nach Spielende\n Der stärkste Moment: Wenn ein Spiel auf FINISHED geht, Punkte nicht einfach anzeigen — enthüllen. Kurzsequenz: Ergebnis einblenden → \"Dein Tipp war...\" → Punkte-Zähler animiert hochzählen. Bei exaktem Treffer: 🎉 Konfetti-Explosion + goldener \"EXAKT!\"-Badge. Bei Tendenz: 👏 grüner Puls. Bei falsch: 😅 kurzes Kopfschütteln-Emoji.","choice":"punkte-reveal","id":null,"timestamp":1775924392631}
+{"source":"user-event","type":"click","text":"4\n \n Ranglistenveränderung\n Wenn sich dein Rang ändert: \"Du bist auf Platz 3 aufgestiegen! ⬆️\" als Toast-Notification beim nächsten App-Öffnen. Oder: \"Achtung, Anna ist nur noch 1 Punkt hinter dir!\" — erzeugt freundschaftliche Rivalität.","choice":"rang-change","id":null,"timestamp":1775924394132}
+{"source":"user-event","type":"click","text":"5\n \n Streak-Tracker\n Visueller Feuer-Counter: 🔥3 wenn du 3 Spiele in Folge getippt hast (nicht korrekt, nur abgegeben). Motiviert zum Dranbleiben. Bei 10er-Streak: besonderes Icon. Wenn Streak bricht: \"Deine 7er-Serie ist gerissen! Starte eine neue.\"","choice":"streak","id":null,"timestamp":1775924395415}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/03-spielplan-navigation.html"}
+{"source":"user-event","type":"click","text":"A\n \n Smart Sections (Empfehlung)\n Spielplan automatisch in Abschnitte unterteilt:\n \"Heute\" (expandiert, farbig hervorgehoben)\n \"Morgen\" (expandiert)\n \"Diese Woche\" (kollapsiert, Anzahl angezeigt)\n \"Vergangene Spiele\" (kollapsiert, zeigt letzte Ergebnisse + deine Punkte)\n Kein manuelles Filtern nötig — die App weiß, was jetzt relevant ist. Stage-Filter (Gruppenphase, Achtelfinale...) bleiben als optionaler Zweitmodus.","choice":"smart-sections","id":null,"timestamp":1775924457903}
+{"source":"user-event","type":"click","text":"C\n \n Bottom Navigation Bar (Empfehlung)\n Feste Leiste am unteren Bildschirmrand mit Icons + Label:\n 🏠 Home (Dashboard) · ⚽ Spiele · 🏆 Rangliste · 👤 Profil\n Standard-Pattern für Mobile-Apps (Staffbase, Instagram, etc.). Header wird schlank — nur Logo + evtl. Notification-Badge. Admin nur für Editoren sichtbar als Extra-Icon.","choice":"bottom-nav","id":null,"timestamp":1775924462017}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/04-profil-matchcards.html"}
+{"source":"user-event","type":"click","text":"A\n \n Reiches Profil (Empfehlung)\n \n Header: Avatar (Initialen-Circle wie jetzt), Name, Rang-Badge, Lieblingsteam mit Flagge\n Stats-Ring: Kreisdiagramm mit Exakt/Tendenz/Falsch-Verteilung statt 4 separate Boxen\n Tipp-Historie: Scrollbare Liste der letzten Tipps mit Ergebnis + Punkte — \"Deine letzten 10 Tipps\"\n Achievements: Badge-Leiste (grau wenn noch nicht erreicht, farbig wenn freigeschaltet) — Vorgriff auf Phase 2\n Fun-Stat: \"Dein Lieblings-Tipp: 1:0 (5x getippt)\" oder \"Du tippst 70% Heimsiege\"","choice":"profil-rich","id":null,"timestamp":1775924529062}
+{"source":"user-event","type":"click","text":"C\n \n Zustandsbasierte Cards (Empfehlung)\n Jede Card sieht anders aus je nach Zustand:\n ⏳ Offen: \"Tipp abgeben\"-Button, Countdown wenn <24h\n ✅ Getippt: Dein Tipp prominent angezeigt, grüner Rand, \"Ändern\"-Link\n 🔴 Live: Pulsierender roter Punkt, aktueller Spielstand (wenn verfügbar)\n 🏁 Beendet: Ergebnis + dein Tipp + Punkte-Badge (Gold/Grün/Grau), bei exaktem Treffer: goldener Schimmer\n 🔒 Verpasst: Ausgegraut, \"Nicht getippt\" — leichter Shame-Effekt als Motivation","choice":"cards-states","id":null,"timestamp":1775924530413}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/05-cleanup-scope.html"}
+{"source":"user-event","type":"click","text":"4\n \n Tipp-Modal verschlanken\n Aktuell enthält das Modal: Gruppeninfo, Kick-off-Datum, Picker, Tendenz-Anzeige, Expertenblick-Accordion, Bestätigen, Abbrechen. Vereinfachen auf: Teams + Flaggen, Picker, Tendenz, Bestätigen. Kompakter, schnellerer Flow.","choice":"simplify-tip-modal","id":null,"timestamp":1775924611669}
+{"source":"user-event","type":"click","text":"3\n \n Admin aus der Hauptnav entfernen\n Aktuell sieht jeder \"Admin\" in der Navigation (auch Viewer). Besser: Admin nur für Editoren sichtbar, und zwar als kleines Zahnrad-Icon — nicht als vollwertiger Nav-Punkt. Hält die Navigation sauber für normale User.","choice":"admin-hidden","id":null,"timestamp":1775924612378}
+{"source":"user-event","type":"click","text":"2\n \n Stage-Filter vereinfachen\n Aktuell: 8 Filter-Buttons (Alle, Gruppenphase, Runde der 32, Achtelfinale...). Wird durch Smart Sections ersetzt. Stage-Filter nur noch als Dropdown im Spielplan-View, nicht mehr als Button-Leiste. Weniger visueller Lärm.","choice":"remove-stage-filter","id":null,"timestamp":1775924613128}
+{"source":"user-event","type":"click","text":"1\n \n KI-Agent / Expertenblick entfernen\n Der Chat-Widget (Fußball-Icon unten rechts) und \"Expertenblick\" im Tipp-Modal. Wurde als Nice-to-have eingestuft. Spart Platz auf Mobile und reduziert Ablenkung vom Kernflow. Der Platz unten rechts wird frei für den Dev-Button (nur Dev-Mode).","choice":"remove-agent","id":null,"timestamp":1775924613861}
+{"type":"screen-added","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
+{"type":"screen-updated","file":"/Users/todi975/Documents/Claude/Projects/Tippspiel/.superpowers/brainstorm/4751-1775923699/content/waiting.html"}
+{"type":"server-stopped","reason":"idle timeout"}
diff --git a/.superpowers/brainstorm/4751-1775923699/state/server.pid b/.superpowers/brainstorm/4751-1775923699/state/server.pid
new file mode 100644
index 0000000..f9aa984
--- /dev/null
+++ b/.superpowers/brainstorm/4751-1775923699/state/server.pid
@@ -0,0 +1 @@
+4760
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 6a6dcb5..71eb8fd 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -14,7 +14,7 @@ import leaderboardRouter from './routes/leaderboard';
import adminRouter from './routes/admin';
import profileRouter from './routes/profile';
import devRouter from './routes/dev';
-import agentRouter from './routes/agent';
+import dashboardRouter from './routes/dashboard';
const app = express();
const PORT = parseInt(process.env.PORT ?? '3001');
@@ -96,16 +96,6 @@ app.use(
})
);
-// Rate limit für Agent-Chat (Claude API Calls sind kostenpflichtig)
-app.use(
- '/api/agent',
- rateLimit({
- windowMs: 60 * 1000, // 1 Minute
- max: 10, // 10 Chat-Nachrichten pro User/Minute
- message: { error: 'Too many agent requests, bitte kurz warten.' },
- })
-);
-
// ============================================================
// Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring)
// ============================================================
@@ -137,7 +127,7 @@ app.use('/api/tips', tipsRouter);
app.use('/api/leaderboard', leaderboardRouter);
app.use('/api/admin', adminRouter);
app.use('/api/profile', profileRouter);
-app.use('/api/agent', agentRouter);
+app.use('/api/dashboard', dashboardRouter);
if (process.env.NODE_ENV === 'development') {
app.use('/api/dev', devRouter);
}
diff --git a/backend/src/routes/agent.ts b/backend/src/routes/agent.ts
deleted file mode 100644
index 2481d06..0000000
--- a/backend/src/routes/agent.ts
+++ /dev/null
@@ -1,411 +0,0 @@
-import { Router, Request, Response } from 'express';
-import Anthropic from '@anthropic-ai/sdk';
-import { query } from '../db/client';
-import { logger } from '../services/logger';
-
-const router = Router();
-
-// ============================================================
-// Anthropic Client (lazy-initialized, Key aus .env)
-// ============================================================
-let anthropic: Anthropic | null = null;
-function getClient(): Anthropic {
- if (!anthropic) {
- const apiKey = process.env.ANTHROPIC_API_KEY;
- if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
- anthropic = new Anthropic({ apiKey });
- }
- return anthropic;
-}
-
-// ============================================================
-// Kontext: aktuelle Spiele aus der DB holen
-// ============================================================
-async function getMatchContext(): Promise {
- try {
- const rows = await query<{
- utc_date: Date;
- status: string;
- stage: string;
- group_name: string | null;
- home_team_name: string;
- away_team_name: string;
- score_home: number | null;
- score_away: number | null;
- }>(
- `SELECT utc_date, status, stage, group_name,
- home_team_name, away_team_name, score_home, score_away
- FROM matches
- ORDER BY utc_date ASC
- LIMIT 200`
- );
-
- const now = new Date();
- const upcoming = rows.filter(
- (r) => new Date(r.utc_date) > now && (r.status === 'SCHEDULED' || r.status === 'TIMED')
- );
- const finished = rows.filter((r) => r.status === 'FINISHED');
- const inPlay = rows.filter((r) => r.status === 'IN_PLAY' || r.status === 'PAUSED');
-
- const lines: string[] = [];
-
- if (inPlay.length > 0) {
- lines.push('=== LIVE JETZT ===');
- inPlay.forEach((m) => {
- lines.push(
- `${m.home_team_name} ${m.score_home ?? '?'} : ${m.score_away ?? '?'} ${m.away_team_name} (LIVE)`
- );
- });
- }
-
- if (upcoming.length > 0) {
- lines.push('\n=== NÄCHSTE SPIELE (WM 2026) ===');
- upcoming.slice(0, 20).forEach((m) => {
- const d = new Date(m.utc_date);
- const dateStr = d.toLocaleDateString('de-DE', {
- weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
- });
- const groupStr = m.group_name ? ` [${m.group_name}]` : ` [${m.stage}]`;
- lines.push(`${dateStr}${groupStr}: ${m.home_team_name} vs. ${m.away_team_name}`);
- });
- }
-
- if (finished.length > 0) {
- lines.push('\n=== ZULETZT GESPIELT ===');
- finished.slice(-10).forEach((m) => {
- lines.push(
- `${m.home_team_name} ${m.score_home} : ${m.score_away} ${m.away_team_name} (Ergebnis)`
- );
- });
- }
-
- return lines.join('\n');
- } catch (err) {
- logger.error('Agent: Fehler beim Laden des Match-Kontexts', { err });
- return '(Keine Spieldaten verfügbar)';
- }
-}
-
-// ============================================================
-// System-Prompt: Der Fußball-Experte
-// ============================================================
-const NETZER_STYLE =
- 'DEIN STIL - Günther Netzer:\n' +
- 'Du bist Günther Netzer, ARD-Fußballexperte von 1997-2010. Trocken. Direkt. Elitär. Nostalgisch.\n' +
- 'Du maßt das aktuelle Geschehen stets an idealistischen Maßstäben - und an deiner eigenen Karriere.\n\n' +
- 'TYPISCHE PHRASEN die du verwendest:\n' +
- '- "Aus der Tiefe des Raumes"\n' +
- '- "Das sind fundamentale Dinge"\n' +
- '- "Das ist ein Minimalisten-Dasein"\n' +
- '- "Mir hat hier heute noch gar nichts gefallen"\n' +
- '- "Das hat mit Spitzenfußball nichts zu tun"\n' +
- '- "Das war dezent" (wenn eine Leistung mäßig war)\n' +
- '- "Was bleibt mir noch übrig jetzt zu sagen..."\n' +
- '- Gelegentlich ironisches Lob: "Das ist wirklich eine sehr kluge Beobachtung..."\n\n' +
- 'EIGENHEITEN:\n' +
- '- Du vergleichst fast alles mit Beckenbauer, Müller, Cruyff oder deiner eigenen Zeit\n' +
- '- Taktik-Geschwafel lehnst du ab: "Das nennen die heutzutage Ballbesitzfußball. Früher nannte man das Angst."\n' +
- '- Du bist von Mannschaften prinzipiell enttäuscht, außer die Leistung ist absolut unstrittig\n' +
- '- Kurze Sätze. Kein "mega", kein "Wahnsinn". Kein übertriebenes Lob.\n\n';
-
-const DELLING_STYLE =
- 'Die Rolle von Gerhard Delling (dein Moderator-Pendant, NUR im Dialog-Modus):\n' +
- '- Trocken, skeptisch, stichelt gerne\n' +
- '- Typische Phrasen: "Nun könnte man sagen, seien wir doch mal großzügig...", "Fanden Sie nicht, dass immerhin..."\n' +
- '- Verteidigt absichtlich die schwächere Mannschaft um Netzer zu provozieren\n' +
- '- Stichelt gegen Netzers Vergangenheit (Laufbereitschaft, Frisur)\n' +
- '- Bleibt immer ruhig, lässt sich von Netzers Arroganz nicht erschüttern\n' +
- '- Er und Netzer siezen sich stets, obwohl sie Freunde sind\n\n';
-
-const SYSTEM_PROMPT_BASE =
- 'Du bist der Fußball-Experte im WM 2026 Tippspiel von GEALAN. Du kennst WM und EM in- und auswendig: Ergebnisse, Rekorde, Taktiken, Legenden - von 1930 bis heute.\n\n' +
- NETZER_STYLE +
- DELLING_STYLE +
- 'GESPRÄCHSMODUS: Wenn der Nutzer dich direkt anschreibt, antwortest du als Netzer allein. Wenn du eine Analyse oder Einschätzung gibst, kannst du gelegentlich einen kurzen Einwurf von Delling einfließen lassen - im Format:\n' +
- '**Delling:** "..."\n' +
- '**Netzer:** "..."\n\n' +
- 'TIPP-EMPFEHLUNGEN: Wenn jemand allgemein fragt, stelle zuerst eine Rückfrage. Haenge einen CHOICES-Block an mit den naechsten 5 Spielen. Erst nach Auswahl gibst du Empfehlungen - maximal 3 auf einmal.\n\n' +
- 'CHOICES-FORMAT (nur fuer Tipp-Rückfragen):\n' +
- '[CHOICES]\nHeimteam vs. Gastteam\n[/CHOICES]\n' +
- 'Exakte Teamnamen aus dem Kontext. Kein Datum, keine Emojis im Block.\n\n' +
- 'FORMATIERUNG: Kompakt (max. 4 Absaetze). **fett** fuer Namen/Scores. ## fuer Überschriften. --- als Trennlinie. Kein # H1. Kein Emoji-Overload.\n\n' +
- 'SPIELPLAN-KONTEXT:\n';
-
-// ============================================================
-// POST /api/agent/chat
-// Body: { messages: [{role, content}][], quickAction?: string }
-// ============================================================
-router.post('/chat', async (req: Request, res: Response): Promise => {
- const { messages, quickAction } = req.body as {
- messages?: Array<{ role: 'user' | 'assistant'; content: string }>;
- quickAction?: string;
- };
-
- // Validierung
- if (!messages && !quickAction) {
- res.status(400).json({ error: 'messages oder quickAction erforderlich' });
- return;
- }
-
- const chatMessages: Array<{ role: 'user' | 'assistant'; content: string }> =
- messages ?? [];
-
- // Quick-Action als User-Nachricht hinzufügen
- if (quickAction) {
- chatMessages.push({ role: 'user', content: quickAction });
- }
-
- if (chatMessages.length === 0) {
- res.status(400).json({ error: 'Keine Nachrichten vorhanden' });
- return;
- }
-
- try {
- const client = getClient();
- const matchContext = await getMatchContext();
- const systemPrompt = SYSTEM_PROMPT_BASE + matchContext;
-
- // Streaming-Antwort via SSE
- res.setHeader('Content-Type', 'text/event-stream');
- res.setHeader('Cache-Control', 'no-cache');
- res.setHeader('Connection', 'keep-alive');
- res.flushHeaders();
-
- const stream = await client.messages.stream({
- model: 'claude-sonnet-4-6',
- max_tokens: 1024,
- system: systemPrompt,
- messages: chatMessages.slice(-10), // max. 10 Nachrichten Kontext
- });
-
- for await (const chunk of stream) {
- if (
- chunk.type === 'content_block_delta' &&
- chunk.delta.type === 'text_delta'
- ) {
- const data = JSON.stringify({ text: chunk.delta.text });
- res.write(`data: ${data}\n\n`);
- }
- }
-
- res.write('data: [DONE]\n\n');
- res.end();
-
- logger.info('Agent: Chat-Anfrage beantwortet', {
- userId: req.staffbaseUser?.sub,
- messageCount: chatMessages.length,
- });
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : 'Unknown error';
- logger.error('Agent: Fehler', { error: message });
-
- if (!res.headersSent) {
- res.status(500).json({ error: 'Agent nicht verfügbar', detail: message });
- } else {
- res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
- res.end();
- }
- }
-});
-
-// ============================================================
-// POST /api/agent/insight
-// Kompakte Einschätzung für genau ein Spiel (für TipModal)
-// Body: { homeTeam: string, awayTeam: string, stage: string, group?: string }
-// ============================================================
-router.post('/insight', async (req: Request, res: Response): Promise => {
- const { homeTeam, awayTeam, stage, group } = req.body as {
- homeTeam: string;
- awayTeam: string;
- stage?: string;
- group?: string;
- };
-
- if (!homeTeam || !awayTeam) {
- res.status(400).json({ error: 'homeTeam und awayTeam erforderlich' });
- return;
- }
-
- const stageLabel = group
- ? group.replace('GROUP_', 'Gruppe ')
- : stage ?? 'WM 2026';
-
- const insightPrompt =
- NETZER_STYLE +
- DELLING_STYLE +
- 'Schreibe einen kurzen Expertenblick als Dialog zwischen Delling und Netzer über das folgende Spiel.\n\n' +
- 'WICHTIG: Das Spiel hat noch NICHT stattgefunden. Es ist eine Vorschau, keine Nachbetrachtung.\n' +
- 'Verwende ausschließlich Zukunftsformen und Konjunktiv: "wird", "könnte", "dürfte", "ist zu erwarten".\n' +
- 'Du darfst auf vergangene Begegnungen, Qualifikation oder historische Statistiken referenzieren - aber nur als Argument für die Prognose.\n' +
- 'VERBOTEN: Phrasen wie "hat mir nicht gefallen", "da war nichts", "das war" bezogen auf das aktuelle Spiel.\n\n' +
- 'BEISPIELE (Vorschau-Ton - diese Authentizität ist entscheidend):\n\n' +
- 'Beispiel 1 (Gruppenspiel mit klarem Favoriten):\n' +
- '**Delling:** "Nun, Herr Netzer, wir haben hier ja doch einen veritablen Favoriten. Könnte der Außenseiter nicht von der Qualifikationsform profitieren?"\n' +
- '**Netzer:** "Nein. Das waren Qualifikationsspiele. Das wird mit dem hier nichts zu tun haben. Das sind fundamentale Dinge."\n' +
- '**Delling:** "Seien wir doch mal großzügig - auch der Außenseiter hat Qualitäten, die sich zeigen könnten."\n' +
- '**Netzer:** "Das nennen Sie Qualitäten. Ich nenne das ein Minimalisten-Dasein. Ich tippe auf einen klaren Sieg des Favoriten."\n\n' +
- 'Beispiel 2 (Ausgeglichenes Spiel):\n' +
- '**Delling:** "Herr Netzer, das könnte ja ein enges Spiel werden. Beide Mannschaften liegen nah beieinander."\n' +
- '**Netzer:** "Das ist dezent ausgedrückt. Beiden fehlt, was Beckenbauer damals selbstverständlich war - diese Überlegenheit. Aus der Tiefe des Raumes heraus, verstehen Sie?"\n' +
- '**Delling:** "Ich glaube, die Spieler würden sich bedanken, wenn Sie ihnen das vor dem Anpfiff erläutern könnten."\n' +
- '**Netzer:** "Was bleibt mir noch übrig jetzt zu sagen. Ich tippe auf ein 1:1."\n\n' +
- 'JETZT das echte Spiel:\n' +
- 'Spiel: **' + homeTeam + '** vs. **' + awayTeam + '** (' + stageLabel + ')\n\n' +
- 'Schreibe genau 4 Wechselreden (Delling, Netzer, Delling, Netzer). Netzer gibt am Ende seinen konkreten Tipp mit Score. Kein Emoji. Siezen. Kurze Sätze bei Netzer.';
-
- try {
- const client = getClient();
-
- res.setHeader('Content-Type', 'text/event-stream');
- res.setHeader('Cache-Control', 'no-cache');
- res.setHeader('Connection', 'keep-alive');
- res.flushHeaders();
-
- const stream = await client.messages.stream({
- model: 'claude-haiku-4-5-20251001',
- max_tokens: 512,
- messages: [{ role: 'user', content: insightPrompt }],
- });
-
- for await (const chunk of stream) {
- if (
- chunk.type === 'content_block_delta' &&
- chunk.delta.type === 'text_delta'
- ) {
- res.write('data: ' + JSON.stringify({ text: chunk.delta.text }) + '\n\n');
- }
- }
-
- res.write('data: [DONE]\n\n');
- res.end();
-
- logger.info('Agent: Insight geliefert', {
- userId: req.staffbaseUser?.sub,
- match: homeTeam + ' vs ' + awayTeam,
- });
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : 'Unknown error';
- logger.error('Agent: Insight-Fehler', { error: message });
- if (!res.headersSent) {
- res.status(500).json({ error: 'Insight nicht verfügbar' });
- } else {
- res.write('data: ' + JSON.stringify({ error: message }) + '\n\n');
- res.end();
- }
- }
-});
-
-// ============================================================
-// POST /api/agent/insight-audio
-// Body: { dialogText: string }
-// Gibt eine MP3 zurück (Delling + Netzer als Dialog, 2 Stimmen)
-// ============================================================
-
-// ElevenLabs Voice-IDs (kostenlose Standard-Voices)
-// Netzer: "Adam" – tief, ruhig, autoritär
-// Delling: "Antoni" – etwas heller, sachlicher
-const ELEVENLABS_VOICE_NETZER = process.env.ELEVENLABS_VOICE_NETZER ?? 'pNInz6obpgDQGcFmaJgB'; // Adam
-const ELEVENLABS_VOICE_DELLING = process.env.ELEVENLABS_VOICE_DELLING ?? 'ErXwobaYiN019PkySvjV'; // Antoni
-
-async function synthesizeTurn(
- text: string,
- voiceId: string,
- apiKey: string
-): Promise {
- const res = await fetch(
- `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
- {
- method: 'POST',
- headers: {
- 'xi-api-key': apiKey,
- 'Content-Type': 'application/json',
- Accept: 'audio/mpeg',
- },
- body: JSON.stringify({
- text,
- model_id: 'eleven_multilingual_v2',
- voice_settings: { stability: 0.55, similarity_boost: 0.75 },
- }),
- }
- );
- if (!res.ok) {
- const err = await res.text();
- throw new Error(`ElevenLabs error ${res.status}: ${err}`);
- }
- const arrayBuf = await res.arrayBuffer();
- return Buffer.from(arrayBuf);
-}
-
-// Parst **Delling:** "..." / **Netzer:** "..." Zeilen aus dem Dialog-Text
-function parseDialogTurns(
- dialogText: string
-): Array<{ speaker: 'Delling' | 'Netzer'; text: string }> {
- const turns: Array<{ speaker: 'Delling' | 'Netzer'; text: string }> = [];
- const lines = dialogText.split('\n');
- for (const line of lines) {
- const m = line.match(/^\*\*(Delling|Netzer):\*\*\s*[„""]?(.+?)["""]?\s*$/);
- if (m) {
- turns.push({
- speaker: m[1] as 'Delling' | 'Netzer',
- text: m[2].trim(),
- });
- }
- }
- return turns;
-}
-
-router.post('/insight-audio', async (req: Request, res: Response): Promise => {
- const { dialogText } = req.body as { dialogText?: string };
-
- if (!dialogText) {
- res.status(400).json({ error: 'dialogText erforderlich' });
- return;
- }
-
- const apiKey = process.env.ELEVENLABS_API_KEY;
- if (!apiKey) {
- res.status(503).json({ error: 'ELEVENLABS_API_KEY nicht konfiguriert' });
- return;
- }
-
- const turns = parseDialogTurns(dialogText);
- logger.info('Audio: Dialog geparst', { turns: turns.length, preview: dialogText.slice(0, 200) });
- if (turns.length === 0) {
- res.status(400).json({ error: 'Kein Dialog-Format erkannt' });
- return;
- }
-
- try {
- // Turns sequenziell synthetisieren (Free Tier: max 2 concurrent)
- const audioBuffers: Buffer[] = [];
- for (const turn of turns) {
- const buf = await synthesizeTurn(
- turn.text,
- turn.speaker === 'Netzer' ? ELEVENLABS_VOICE_NETZER : ELEVENLABS_VOICE_DELLING,
- apiKey
- );
- audioBuffers.push(buf);
- }
-
- // MP3-Chunks zusammenführen (einfaches Aneinanderhängen reicht für MP3)
- const combined = Buffer.concat(audioBuffers);
-
- res.setHeader('Content-Type', 'audio/mpeg');
- res.setHeader('Content-Length', combined.length);
- res.setHeader('Cache-Control', 'no-store');
- res.send(combined);
-
- logger.info('Agent: Insight-Audio generiert', {
- userId: req.staffbaseUser?.sub,
- turns: turns.length,
- });
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : 'Unknown error';
- logger.error('Agent: Insight-Audio-Fehler', { error: message });
- if (!res.headersSent) {
- res.status(500).json({ error: 'Audio-Generierung fehlgeschlagen' });
- }
- }
-});
-
-export default router;
diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts
new file mode 100644
index 0000000..5ba8a94
--- /dev/null
+++ b/backend/src/routes/dashboard.ts
@@ -0,0 +1,123 @@
+import { Router, Request, Response } from 'express';
+import { query } from '../db/client';
+import { logger } from '../services/logger';
+
+const router = Router();
+
+router.get('/', async (req: Request, res: Response): Promise => {
+ const userId = req.staffbaseUser?.sub;
+ if (!userId) {
+ res.status(401).json({ error: 'Unauthorized' });
+ return;
+ }
+
+ try {
+ // 1. Hero: next upcoming match
+ const heroResult = await query(
+ `SELECT m.id, m.utc_date, m.status,
+ m.home_team_name, m.home_team_short, m.home_team_crest,
+ m.away_team_name, m.away_team_short, m.away_team_crest,
+ t.tip_home, t.tip_away,
+ EXTRACT(EPOCH FROM (m.utc_date - NOW())) / 60 AS minutes_until
+ FROM matches m
+ LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
+ WHERE m.status IN ('SCHEDULED', 'TIMED')
+ ORDER BY m.utc_date ASC
+ LIMIT 1`,
+ [userId]
+ );
+
+ const h = heroResult[0];
+ const hero = h ? {
+ match: {
+ id: h.id,
+ homeTeam: { name: h.home_team_name, shortName: h.home_team_short, crest: h.home_team_crest },
+ awayTeam: { name: h.away_team_name, shortName: h.away_team_short, crest: h.away_team_crest },
+ utcDate: h.utc_date,
+ status: h.status,
+ minutesUntilKickoff: Math.round(parseFloat(h.minutes_until)),
+ },
+ userTip: h.tip_home != null ? { home: h.tip_home, away: h.tip_away } : null,
+ tippable: parseFloat(h.minutes_until) > 5,
+ } : null;
+
+ // 2. Stats from leaderboard
+ const statsResult = await query(
+ `SELECT rank, total_points FROM leaderboard WHERE user_id = $1`,
+ [userId]
+ );
+ const s = statsResult[0];
+
+ // 3. Streak: consecutive tipped matches (most recent backward)
+ const pastMatches = await query<{ has_tip: boolean }>(
+ `SELECT CASE WHEN t.id IS NOT NULL THEN true ELSE false END AS has_tip
+ FROM matches m
+ LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
+ WHERE m.utc_date <= NOW() AND m.status IN ('FINISHED', 'IN_PLAY')
+ ORDER BY m.utc_date DESC`,
+ [userId]
+ );
+ let streak = 0;
+ for (const m of pastMatches) {
+ if (m.has_tip) streak++;
+ else break;
+ }
+
+ // 4. Nudges
+ const nudges: Array<{ type: string; text: string; matchId?: number }> = [];
+
+ const untipped = await query<{ count: string }>(
+ `SELECT COUNT(*) AS count FROM matches m
+ LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
+ WHERE m.utc_date::date = CURRENT_DATE
+ AND m.status IN ('SCHEDULED', 'TIMED')
+ AND t.id IS NULL`,
+ [userId]
+ );
+ const untippedCount = parseInt(untipped[0]?.count || '0');
+ if (untippedCount > 0) {
+ nudges.push({
+ type: 'untipped',
+ text: `📅 Heute noch ${untippedCount} ${untippedCount === 1 ? 'Spiel' : 'Spiele'} ohne Tipp`,
+ });
+ }
+
+ const leader = await query<{ full_name: string; total_points: string }>(
+ `SELECT full_name, total_points FROM leaderboard ORDER BY rank ASC LIMIT 1`
+ );
+ if (leader[0]) {
+ nudges.push({ type: 'leader', text: `🏆 ${leader[0].full_name} führt mit ${leader[0].total_points} Punkten` });
+ }
+
+ const latest = await query(
+ `SELECT m.home_team_short, m.away_team_short, m.score_home, m.score_away, t.points, m.id AS match_id
+ FROM tips t JOIN matches m ON m.id = t.match_id
+ WHERE t.user_id = $1 AND t.points IS NOT NULL
+ ORDER BY m.utc_date DESC LIMIT 1`,
+ [userId]
+ );
+ if (latest[0]) {
+ const r = latest[0];
+ nudges.push({
+ type: 'result',
+ text: `🎯 Letzte Auswertung: ${r.points} Punkte für ${r.home_team_short} ${r.score_home}:${r.score_away} ${r.away_team_short}`,
+ matchId: r.match_id,
+ });
+ }
+
+ res.json({
+ hero,
+ stats: {
+ rank: s ? parseInt(s.rank) : null,
+ totalPoints: s ? parseInt(s.total_points) : 0,
+ streak,
+ },
+ nudges,
+ });
+ } catch (error) {
+ logger.error('Dashboard failed', { error });
+ res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+export default router;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 560e157..1a9570e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,12 +8,14 @@
"name": "wm2026-tippspiel-frontend",
"version": "1.0.0",
"dependencies": {
+ "canvas-confetti": "^1.9.4",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
+ "@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -1155,6 +1157,13 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/canvas-confetti": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
+ "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1279,6 +1288,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/canvas-confetti": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
+ "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
+ "license": "ISC",
+ "funding": {
+ "type": "donate",
+ "url": "https://www.paypal.me/kirilvatev"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 521c4ac..9460d50 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,12 +8,14 @@
"preview": "vite preview"
},
"dependencies": {
+ "canvas-confetti": "^1.9.4",
"lucide-react": "^1.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
+ "@types/canvas-confetti": "^1.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
diff --git a/frontend/src/App.js b/frontend/src/App.js
new file mode 100644
index 0000000..d9a5ead
--- /dev/null
+++ b/frontend/src/App.js
@@ -0,0 +1,72 @@
+import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
+import { useState, useEffect } from 'react';
+import { Routes, Route, NavLink } from 'react-router-dom';
+import { Sun, Moon, Settings } from 'lucide-react';
+import DashboardPage from './pages/DashboardPage';
+import MatchesPage from './pages/MatchesPage';
+import LeaderboardPage from './pages/LeaderboardPage';
+import ProfilePage from './pages/ProfilePage';
+import AdminPage from './pages/AdminPage';
+import BottomNav from './components/BottomNav';
+import Toast from './components/Toast';
+import { useRankChange } from './hooks/useRankChange';
+import styles from './App.module.css';
+const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true';
+// Lazy-load DevPanel in Development/Test-Mode
+let DevPanel = null;
+// VITE_TEST_MODE wird erst zur Laufzeit geprüft, daher Import immer einbinden
+import('./components/DevPanel').then(m => { DevPanel = m.default; }).catch(() => { });
+function getInitialTheme() {
+ try {
+ const stored = localStorage.getItem('theme');
+ if (stored === 'light' || stored === 'dark')
+ return stored;
+ }
+ catch { }
+ return 'dark';
+}
+export default function App() {
+ const [theme, setTheme] = useState(getInitialTheme);
+ const { message: rankMsg, dismiss: dismissRank } = useRankChange();
+ const [devUser, setDevUser] = useState(1);
+ const [devMatches, setDevMatches] = useState([]);
+ const [refreshKey, setRefreshKey] = useState(0);
+ // Theme auf setzen und in localStorage speichern
+ useEffect(() => {
+ document.documentElement.setAttribute('data-theme', theme);
+ try {
+ localStorage.setItem('theme', theme);
+ }
+ catch { }
+ }, [theme]);
+ function toggleTheme() {
+ setTheme(t => t === 'dark' ? 'light' : 'dark');
+ }
+ // DevUser als Query-Parameter im API-Fetch setzen
+ useEffect(() => {
+ if (!IS_DEV)
+ return;
+ // Patch fetch für Dev-Mode: devUser Query-Param anhängen
+ const origFetch = window.fetch;
+ window._devUser = devUser;
+ window.fetch = (input, init) => {
+ if (typeof input === 'string' && input.startsWith('/api')) {
+ const url = new URL(input, window.location.origin);
+ url.searchParams.set('devUser', String(window._devUser ?? 1));
+ return origFetch(url.toString(), init);
+ }
+ return origFetch(input, init);
+ };
+ return () => { window.fetch = origFetch; };
+ }, [devUser]);
+ // Matches für DevPanel laden
+ useEffect(() => {
+ if (!IS_DEV)
+ return;
+ fetch('/api/matches').then(r => r.json()).then(d => setDevMatches(d.matches ?? [])).catch(() => { });
+ }, [refreshKey, devUser]);
+ function handleDevRefresh() {
+ setRefreshKey(k => k + 1);
+ }
+ return (_jsxs("div", { className: styles.app, children: [_jsx("header", { className: styles.header, children: _jsxs("div", { className: styles.headerInner, children: [_jsxs("div", { className: styles.logo, children: [_jsx("span", { className: styles.logoFlag, children: "\uD83C\uDFC6" }), _jsx("span", { className: styles.logoText, children: "WM 2026 Tippspiel" }), IS_DEV && (_jsxs("span", { className: styles.devBadge, children: ["DEV \u00B7 User ", devUser] }))] }), _jsxs("nav", { className: styles.nav, children: [_jsx(NavLink, { to: "/spiele", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Spielplan" }), _jsx(NavLink, { to: "/rangliste", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Rangliste" }), _jsx(NavLink, { to: "/profil", className: ({ isActive }) => isActive ? styles.navLinkActive : styles.navLink, children: "Mein Profil" }), _jsx(NavLink, { to: "/admin", className: styles.adminLink, title: "Admin", children: _jsx(Settings, { size: 16 }) }), _jsx("button", { className: styles.themeToggle, onClick: toggleTheme, title: theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren', "aria-label": "Theme wechseln", children: theme === 'dark' ? _jsx(Sun, { size: 16 }) : _jsx(Moon, { size: 16 }) })] })] }) }), _jsx("main", { className: styles.main, children: _jsxs(Routes, { children: [_jsx(Route, { path: "/", element: _jsx(DashboardPage, {}, refreshKey) }), _jsx(Route, { path: "/spiele", element: _jsx(MatchesPage, {}, refreshKey) }), _jsx(Route, { path: "/rangliste", element: _jsx(LeaderboardPage, {}, refreshKey) }), _jsx(Route, { path: "/profil", element: _jsx(ProfilePage, {}, refreshKey) }), _jsx(Route, { path: "/admin", element: _jsx(AdminPage, {}) })] }) }), rankMsg && _jsx(Toast, { message: rankMsg, onDismiss: dismissRank }), _jsx(BottomNav, {}), IS_DEV && DevPanel && (_jsx(DevPanel, { currentUser: devUser, onUserChange: (u) => { setDevUser(u); setRefreshKey(k => k + 1); }, matches: devMatches, onRefresh: handleDevRefresh }))] }));
+}
diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css
index d301e70..4b91282 100644
--- a/frontend/src/App.module.css
+++ b/frontend/src/App.module.css
@@ -96,5 +96,32 @@
max-width: 1100px;
margin: 0 auto;
padding: 32px 24px;
+ padding-bottom: 70px;
width: 100%;
}
+
+@media (min-width: 768px) {
+ .main {
+ padding-bottom: 32px;
+ }
+}
+
+/* Hide header nav on mobile */
+@media (max-width: 767px) {
+ .nav {
+ display: none;
+ }
+}
+
+/* Admin link: icon only, subtle */
+.adminLink {
+ display: flex;
+ align-items: center;
+ padding: 4px;
+ color: var(--text-muted);
+ text-decoration: none;
+ transition: color 0.2s;
+}
+.adminLink:hover {
+ color: var(--text-secondary);
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5a9ea8d..cfb7329 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,11 +1,14 @@
import { useState, useEffect } from 'react';
import { Routes, Route, NavLink } from 'react-router-dom';
-import { Sun, Moon } from 'lucide-react';
+import { Sun, Moon, Settings } from 'lucide-react';
+import DashboardPage from './pages/DashboardPage';
import MatchesPage from './pages/MatchesPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProfilePage from './pages/ProfilePage';
import AdminPage from './pages/AdminPage';
-import AgentChat from './components/AgentChat';
+import BottomNav from './components/BottomNav';
+import Toast from './components/Toast';
+import { useRankChange } from './hooks/useRankChange';
import styles from './App.module.css';
const IS_DEV = import.meta.env.DEV || import.meta.env.VITE_TEST_MODE === 'true';
@@ -27,6 +30,7 @@ function getInitialTheme(): Theme {
export default function App() {
const [theme, setTheme] = useState(getInitialTheme);
+ const { message: rankMsg, dismiss: dismissRank } = useRankChange();
const [devUser, setDevUser] = useState(1);
const [devMatches, setDevMatches] = useState([]);
const [refreshKey, setRefreshKey] = useState(0);
@@ -80,7 +84,7 @@ export default function App() {
)}