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.

+
+
+ +
+
4
+
+

Tipp-Modal verschlanken

+

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.

+
+
+ +
+ +
+

Zusammenfassung Phase 1

+
+
+

Neu

+
    +
  • Dashboard als Startseite (Hero + Stats + Nudges)
  • +
  • Bottom Navigation Bar
  • +
  • Smart Sections im Spielplan
  • +
  • 5 emotionale Momente (Animationen, Konfetti, Streaks)
  • +
  • Zustandsbasierte Match-Cards
  • +
  • Reiches Profil mit Stats-Ring + Historie
  • +
+
+
+

Phase 2 & 3 (später)

+
    +
  • Badges & Achievements
  • +
  • Wochenwertung
  • +
  • Tipps anderer sehen
  • +
  • Reaktionen / Emojis
  • +
  • Abteilungs-Challenge
  • +
+
+
+
+ +

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() { )}