diff --git a/docs/superpowers/plans/2026-04-11-phase1-engagement-ux.md b/docs/superpowers/plans/2026-04-11-phase1-engagement-ux.md new file mode 100644 index 0000000..5195894 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-phase1-engagement-ux.md @@ -0,0 +1,1720 @@ +# Phase 1: Engagement & UX-Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform the WM 2026 Tippspiel from functional to fun — new dashboard, bottom nav, smart spielplan, emotional animations, rich profile. + +**Architecture:** Mobile-first SPA. React + Vite frontend served as static files by Express backend. CSS Modules with CSS custom properties for theming. Raw PostgreSQL queries via `pg` pool. No test framework exists — tasks include build verification via `tsc` and manual smoke tests. + +**Tech Stack:** React 18, React Router v6, CSS Modules, Vite 5, Express 4, PostgreSQL (Supabase), `canvas-confetti` (new), `framer-motion` (new). + +**Design Spec:** `docs/superpowers/specs/2026-04-11-phase1-engagement-ux-design.md` + +--- + +## File Structure Overview + +### New Files +| File | Purpose | +|------|---------| +| `frontend/src/pages/DashboardPage.tsx` | New startseite with hero, stats, nudges | +| `frontend/src/pages/DashboardPage.module.css` | Dashboard styles | +| `frontend/src/components/BottomNav.tsx` | Mobile bottom navigation bar | +| `frontend/src/components/BottomNav.module.css` | Bottom nav styles | +| `frontend/src/components/ConfettiReveal.tsx` | Punkte-reveal overlay with confetti | +| `frontend/src/components/ConfettiReveal.module.css` | Reveal animation styles | +| `frontend/src/components/Toast.tsx` | Toast notification component | +| `frontend/src/components/Toast.module.css` | Toast styles | +| `frontend/src/components/StatsRing.tsx` | Donut chart SVG for profile | +| `frontend/src/components/StatsRing.module.css` | Stats ring styles | +| `frontend/src/hooks/useStreak.ts` | Hook to fetch/display streak | +| `frontend/src/hooks/useRankChange.ts` | Hook to detect rank changes (localStorage) | +| `frontend/src/hooks/useRevealQueue.ts` | Hook to queue unseen punkte-reveals | +| `backend/src/routes/dashboard.ts` | GET /api/dashboard — bundled dashboard data | + +### Modified Files +| File | Changes | +|------|---------| +| `frontend/src/App.tsx` | Remove AgentChat, add BottomNav, new routes, slim header | +| `frontend/src/App.module.css` | Slim header, remove old nav, add bottom-nav spacing | +| `frontend/src/index.css` | New animation tokens, streak colors | +| `frontend/src/api/client.ts` | Add `getDashboard()`, `getStreak()`, remove agent methods | +| `frontend/src/pages/MatchesPage.tsx` | Smart Sections (Heute/Morgen/Woche), remove stage filter buttons | +| `frontend/src/pages/MatchesPage.module.css` | Section headers, accordion styles | +| `frontend/src/components/MatchCard.tsx` | 5 visual states, countdown, streak badge | +| `frontend/src/components/MatchCard.module.css` | State-specific styles (glow, pulse, grayout) | +| `frontend/src/components/TipModal.tsx` | Remove Expertenblick, add success animation | +| `frontend/src/components/TipModal.module.css` | Slimmer modal, success pulse | +| `frontend/src/pages/ProfilePage.tsx` | Stats ring, tip history, fun stats | +| `frontend/src/pages/ProfilePage.module.css` | Rich profile layout | +| `frontend/src/pages/LeaderboardPage.tsx` | Minor: streak badge on entries | +| `backend/src/index.ts` | Mount dashboard route, remove agent route | +| `backend/src/routes/matches.ts` | Add streak count to response | +| `backend/src/routes/tips.ts` | Return streak after tip submission | + +### Removed Files +| File | Reason | +|------|--------| +| `frontend/src/components/AgentChat.tsx` | KI-Agent removed per spec | +| `frontend/src/components/AgentChat.module.css` | KI-Agent removed per spec | +| `backend/src/routes/agent.ts` | Agent endpoint removed per spec | + +--- + +## Task 1: Cleanup — Remove AgentChat & Agent Route + +Remove the KI-Agent chat widget and backend route. This is the safest first step — pure deletion with no dependencies. + +**Files:** +- Delete: `frontend/src/components/AgentChat.tsx` +- Delete: `frontend/src/components/AgentChat.module.css` +- Delete: `backend/src/routes/agent.ts` +- Modify: `frontend/src/App.tsx` +- Modify: `backend/src/index.ts` + +- [ ] **Step 1: Remove AgentChat from App.tsx** + +In `frontend/src/App.tsx`, remove the import and usage: + +```tsx +// DELETE these lines: +import AgentChat from './components/AgentChat'; +// ... +{/* Fußball-Experte Chat-Widget – immer sichtbar */} + +``` + +- [ ] **Step 2: Delete AgentChat component files** + +```bash +rm frontend/src/components/AgentChat.tsx frontend/src/components/AgentChat.module.css +``` + +- [ ] **Step 3: Remove agent route from backend** + +In `backend/src/index.ts`, find and remove: +```ts +// DELETE: import and route mount for agent +import agentRouter from './routes/agent'; +// ... +app.use('/api/agent', agentRouter); +``` + +Then delete the route file: +```bash +rm backend/src/routes/agent.ts +``` + +- [ ] **Step 4: Build and verify** + +```bash +cd frontend && npx tsc --noEmit && cd ../backend && npx tsc --noEmit +``` + +Expected: No type errors. The agent endpoint is not referenced anywhere else. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "refactor: remove KI-Agent chat widget and backend route + +Agent/Expertenblick was a nice-to-have that distracted from the core tipping flow. +Removes AgentChat.tsx, AgentChat.module.css, and /api/agent routes." +``` + +--- + +## Task 2: Simplify TipModal — Remove Expertenblick + +Strip the Expertenblick accordion from the TipModal. Keep: teams, picker, tendency, confirm button. + +**Files:** +- Modify: `frontend/src/components/TipModal.tsx` +- Modify: `frontend/src/components/TipModal.module.css` + +- [ ] **Step 1: Remove Expertenblick from TipModal.tsx** + +In `TipModal.tsx`, remove: +- All state related to insight (`insightOpen`, `insightText`, `insightLoading`, `insightAudio`, etc.) +- The `fetchInsight()` function and audio playback logic +- The entire `insightWrapper` / `insightToggleRow` JSX section +- Imports for Sparkles, ChevronDown icons if only used by insight + +Keep: +- Match header (teams + flags — but remove `groupBadge` and `kickoffBlock`) +- Score picker section (`pickerSection`) +- Tendency bar (`tendencyBar`) +- Save button (`saveBtn`) +- Cancel button + +- [ ] **Step 2: Slim down the modal header** + +Remove the group badge and kickoff time from the modal (already visible on the card): + +```tsx +// KEEP only teams row: +
+
+
+ {match.homeTeam.name} +
+ {match.homeTeam.shortName} +
+
+ vs +
+
+
+ {match.awayTeam.name} +
+ {match.awayTeam.shortName} +
+
+``` + +- [ ] **Step 3: Clean up unused CSS** + +In `TipModal.module.css`, remove all styles prefixed with `insight` (`insightWrapper`, `insightToggleRow`, `insightToggle`, `insightIcon`, `insightChevron`, `insightPanel`, `insightDialogue`, etc.). Also remove `matchHeader`, `groupBadge`, `kickoffBlock`, `kickoffDate`, `kickoffTime` if no longer referenced. + +- [ ] **Step 4: Build and verify** + +```bash +cd frontend && npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "refactor: simplify TipModal — remove Expertenblick and redundant header + +Modal now shows only: teams, score picker, tendency bar, confirm button. +Reduces modal from 367 to ~150 lines." +``` + +--- + +## Task 3: Bottom Navigation Bar + Slim Header + +Replace header-based navigation with a fixed bottom bar (mobile-first). Header becomes logo-only. + +**Files:** +- Create: `frontend/src/components/BottomNav.tsx` +- Create: `frontend/src/components/BottomNav.module.css` +- Modify: `frontend/src/App.tsx` +- Modify: `frontend/src/App.module.css` +- Modify: `frontend/src/index.css` (add safe-area variable) + +- [ ] **Step 1: Create BottomNav component** + +Create `frontend/src/components/BottomNav.tsx`: + +```tsx +import { NavLink } from 'react-router-dom'; +import { Home, Trophy, User } from 'lucide-react'; +import styles from './BottomNav.module.css'; + +// ⚽ is an emoji, not a lucide icon — used directly +export default function BottomNav() { + const linkClass = ({ isActive }: { isActive: boolean }) => + isActive ? styles.tabActive : styles.tab; + + return ( + + ); +} +``` + +- [ ] **Step 2: Create BottomNav styles** + +Create `frontend/src/components/BottomNav.module.css`: + +```css +.bottomNav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-around; + align-items: center; + height: 60px; + padding-bottom: env(safe-area-inset-bottom, 0px); + background: color-mix(in srgb, var(--bg-deep) 92%, transparent); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid rgba(var(--primary-rgb, 75, 183, 248), 0.15); + z-index: 100; +} + +.tab, .tabActive { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 6px 16px; + font-size: 11px; + text-decoration: none; + color: var(--text-muted); + transition: color 0.2s; +} + +.tabActive { + color: var(--primary); +} + +.tab:hover { + color: var(--text-secondary); +} + +.emojiIcon { + font-size: 20px; + line-height: 1; +} + +/* Desktop: hide bottom nav, use header nav */ +@media (min-width: 768px) { + .bottomNav { + display: none; + } +} +``` + +- [ ] **Step 3: Update App.tsx — slim header + bottom nav + new routes** + +Rewrite `frontend/src/App.tsx`: +- Import `BottomNav` +- Header: show only logo + dev badge + theme toggle on mobile; full nav on desktop +- Add new route `/spiele` → MatchesPage, `/` → DashboardPage (placeholder for now, use MatchesPage temporarily) +- Admin route: only render NavLink if user has editor role (for now, keep it in header desktop nav with a gear icon) +- Add `` before closing `` +- Add `padding-bottom: 70px` to main content area to account for bottom nav + +Key changes to `App.tsx`: +```tsx +import BottomNav from './components/BottomNav'; +import { Settings } from 'lucide-react'; + +// In header nav (desktop only): + + +// Routes: +} /> +} /> +} /> +} /> +} /> + +// After , before DevPanel: + +``` + +- [ ] **Step 4: Update App.module.css** + +```css +/* Hide desktop nav on mobile */ +.nav { + display: none; +} + +@media (min-width: 768px) { + .nav { + display: flex; + /* existing styles */ + } +} + +/* Add bottom padding for fixed bottom nav */ +.main { + padding-bottom: 70px; +} + +@media (min-width: 768px) { + .main { + padding-bottom: 0; + } +} + +/* Admin link: icon only */ +.adminLink { + display: flex; + align-items: center; + color: var(--text-muted); + text-decoration: none; +} +.adminLink:hover { color: var(--text-secondary); } +``` + +- [ ] **Step 5: Build and smoke test** + +```bash +cd frontend && npx tsc --noEmit && npm run build +``` + +Manual: open `http://192.168.1.60:3301?devUser=1` on mobile viewport — bottom nav should appear with 4 tabs. Desktop should still show header nav. + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add bottom navigation bar (mobile-first) + +Fixed bottom nav with Home/Spiele/Rangliste/Profil tabs. +Desktop keeps header nav. Admin hidden behind gear icon. +Main content padded to avoid overlap with bottom nav." +``` + +--- + +## Task 4: Backend — Dashboard Endpoint + Streak Calculation + +New `/api/dashboard` endpoint that returns hero match, user stats, streak, and nudges in a single request. + +**Files:** +- Create: `backend/src/routes/dashboard.ts` +- Modify: `backend/src/index.ts` (mount route) +- Modify: `frontend/src/api/client.ts` (add `getDashboard()`) +- Modify: `backend/src/types/index.ts` (add types) + +- [ ] **Step 1: Define dashboard types** + +Add to `backend/src/types/index.ts`: + +```ts +export interface DashboardResponse { + hero: { + match: { + id: number; + homeTeam: { name: string; shortName: string; crest: string | null }; + awayTeam: { name: string; shortName: string; crest: string | null }; + utcDate: string; + status: string; + minutesUntilKickoff: number; + }; + userTip: { home: number; away: number } | null; + tippable: boolean; + } | null; + stats: { + rank: number | null; + totalPoints: number; + streak: number; + }; + nudges: Array<{ + type: 'untipped' | 'leader' | 'result'; + text: string; + matchId?: number; + }>; +} +``` + +- [ ] **Step 2: Create dashboard route** + +Create `backend/src/routes/dashboard.ts`: + +```ts +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 match: next tippable or next upcoming + const heroResult = await query( + `SELECT m.id, m.utc_date, m.status, m.stage, m.group_name, + 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 heroMatch = heroResult[0] || null; + const hero = heroMatch ? { + match: { + id: heroMatch.id, + homeTeam: { name: heroMatch.home_team_name, shortName: heroMatch.home_team_short, crest: heroMatch.home_team_crest }, + awayTeam: { name: heroMatch.away_team_name, shortName: heroMatch.away_team_short, crest: heroMatch.away_team_crest }, + utcDate: heroMatch.utc_date, + status: heroMatch.status, + minutesUntilKickoff: Math.round(heroMatch.minutes_until), + }, + userTip: heroMatch.tip_home != null ? { home: heroMatch.tip_home, away: heroMatch.tip_away } : null, + tippable: heroMatch.minutes_until > 5, + } : null; + + // 2. User stats from leaderboard + const statsResult = await query( + `SELECT rank, total_points FROM leaderboard WHERE user_id = $1`, + [userId] + ); + const userStats = statsResult[0]; + + // 3. Streak: count consecutive tipped matches (by kickoff date, most recent first) + const streakResult = await query<{ streak: string }>( + `WITH ordered_matches AS ( + SELECT m.id, m.utc_date, + 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 + ) + SELECT COUNT(*) AS streak + FROM ( + SELECT has_tip, + ROW_NUMBER() OVER () AS rn + FROM ordered_matches + ) sub + WHERE has_tip = true + AND rn = (SELECT MIN(rn) FROM ( + SELECT has_tip, ROW_NUMBER() OVER () AS rn FROM ordered_matches + ) s WHERE s.rn <= sub.rn AND s.has_tip = true) + AND NOT EXISTS ( + SELECT 1 FROM ( + SELECT has_tip, ROW_NUMBER() OVER () AS rn FROM ordered_matches + ) s2 WHERE s2.rn < sub.rn AND s2.has_tip = false + )`, + [userId] + ); + + // Simpler streak approach: iterate from most recent backward + const allMatches = 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 allMatches) { + if (m.has_tip) streak++; + else break; + } + + // 4. Nudges + const nudges: Array<{ type: string; text: string; matchId?: number }> = []; + + // Untipped matches today + const untippedToday = 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(untippedToday[0]?.count || '0'); + if (untippedCount > 0) { + nudges.push({ + type: 'untipped', + text: `📅 Heute noch ${untippedCount} ${untippedCount === 1 ? 'Spiel' : 'Spiele'} ohne Tipp`, + }); + } + + // Leader info + 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] && leader[0].full_name) { + nudges.push({ + type: 'leader', + text: `🏆 ${leader[0].full_name} führt mit ${leader[0].total_points} Punkten`, + }); + } + + // Latest result with points + const latestResult = await query( + `SELECT m.home_team_short, m.away_team_short, m.score_home, m.score_away, t.points + 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 (latestResult[0]) { + const r = latestResult[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: userStats ? parseInt(userStats.rank) : null, + totalPoints: userStats ? parseInt(userStats.total_points) : 0, + streak, + }, + nudges, + }); + } catch (error) { + logger.error('Dashboard failed', { error }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; +``` + +- [ ] **Step 3: Mount dashboard route in backend** + +In `backend/src/index.ts`, add: + +```ts +import dashboardRouter from './routes/dashboard'; +// After other route mounts: +app.use('/api/dashboard', dashboardRouter); +``` + +- [ ] **Step 4: Add getDashboard to frontend API client** + +In `frontend/src/api/client.ts`, add the interface and method: + +```ts +export interface DashboardData { + hero: { + match: { + id: number; + homeTeam: { name: string; shortName: string; crest: string | null }; + awayTeam: { name: string; shortName: string; crest: string | null }; + utcDate: string; + status: string; + minutesUntilKickoff: number; + }; + userTip: { home: number; away: number } | null; + tippable: boolean; + } | null; + stats: { + rank: number | null; + totalPoints: number; + streak: number; + }; + nudges: Array<{ type: string; text: string; matchId?: number }>; +} + +// In api object: +getDashboard: () => request('/dashboard'), +``` + +- [ ] **Step 5: Build and verify** + +```bash +cd backend && npx tsc --noEmit && cd ../frontend && npx tsc --noEmit +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add /api/dashboard endpoint with hero match, stats, streak, nudges + +Single endpoint returns everything the dashboard needs: +- Next tippable match with user's tip +- Rank, points, and streak counter +- Contextual nudges (untipped today, leader, last result)" +``` + +--- + +## Task 5: Dashboard Page + +New startseite replacing the 104-match list as the landing page. + +**Files:** +- Create: `frontend/src/pages/DashboardPage.tsx` +- Create: `frontend/src/pages/DashboardPage.module.css` +- Modify: `frontend/src/App.tsx` (route `/` → DashboardPage) + +- [ ] **Step 1: Create DashboardPage component** + +Create `frontend/src/pages/DashboardPage.tsx`: + +```tsx +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api, DashboardData } from '../api/client'; +import styles from './DashboardPage.module.css'; + +interface Props { + devUser?: number; +} + +export default function DashboardPage({ devUser }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + api.getDashboard() + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [devUser]); + + if (loading) return
Laden...
; + if (!data) return
Dashboard konnte nicht geladen werden.
; + + const { hero, stats, nudges } = data; + + const formatCountdown = (minutes: number): string => { + if (minutes < 0) return 'Läuft'; + if (minutes < 60) return `in ${Math.round(minutes)} Min`; + if (minutes < 1440) return `in ${Math.round(minutes / 60)}h`; + const days = Math.round(minutes / 1440); + return `in ${days} ${days === 1 ? 'Tag' : 'Tagen'}`; + }; + + return ( +
+ {/* Hero: Next match */} + {hero ? ( +
navigate('/spiele')} + role="button" + tabIndex={0} + > +
+ {hero.tippable ? 'Nächstes Spiel' : 'Kommendes Spiel'} + + {formatCountdown(hero.match.minutesUntilKickoff)} + +
+
+
+ {hero.match.homeTeam.crest && ( + + )} + {hero.match.homeTeam.shortName} +
+ vs +
+ {hero.match.awayTeam.crest && ( + + )} + {hero.match.awayTeam.shortName} +
+
+ {hero.userTip ? ( +
+ Dein Tipp: {hero.userTip.home}:{hero.userTip.away} ✓ +
+ ) : hero.tippable ? ( + + ) : null} +
+ ) : ( +
+
Keine anstehenden Spiele
+
+ )} + + {/* Stats tiles */} +
+
+ {stats.rank ?? '—'} + Dein Rang +
+
+ {stats.totalPoints} + Punkte +
+
+ + {stats.streak > 0 ? `${stats.streak}🔥` : '0'} + + Streak +
+
+ + {/* Nudges */} + {nudges.length > 0 && ( +
+ {nudges.map((nudge, i) => ( +
{ + if (nudge.type === 'untipped') navigate('/spiele'); + if (nudge.type === 'leader') navigate('/rangliste'); + }} + > + {nudge.text} +
+ ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Create DashboardPage styles** + +Create `frontend/src/pages/DashboardPage.module.css` with hero card (gradient background, glassmorphism), stats row (3-column grid), nudge list. Use existing CSS variables from `index.css`. Hero gets a subtle gradient border and elevated shadow. + +Key patterns: +```css +.dashboard { padding: 16px; max-width: 600px; margin: 0 auto; } +.hero { background: var(--surface-mid); border-radius: var(--radius-lg); padding: 20px; cursor: pointer; } +.heroCountdown { color: var(--gold); font-weight: 700; } +.heroTip { background: var(--surface-high); border-radius: var(--radius-sm); padding: 8px 12px; color: var(--gold); } +.heroTipBtn { /* btn-primary style */ } +.statsRow { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin: 12px 0; } +.statTile { background: var(--surface-mid); border-radius: var(--radius-md); padding: 12px; text-align: center; } +.statValue { font-size: 1.5rem; font-weight: 700; color: var(--gold); } +.nudge { background: var(--surface-low); border-radius: var(--radius-sm); padding: 12px; cursor: pointer; } +``` + +- [ ] **Step 3: Wire up in App.tsx** + +```tsx +import DashboardPage from './pages/DashboardPage'; + +// Change route: +} /> +} /> +``` + +- [ ] **Step 4: Build and smoke test** + +```bash +cd frontend && npm run build +``` + +Open app — `/` should show dashboard, `/spiele` shows the match list. + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add Dashboard as new startseite + +Hero card with next match + countdown, stats tiles (rank, points, streak), +and contextual nudges. Replaces 104-match list as landing page." +``` + +--- + +## Task 6: Smart Sections in Spielplan + +Restructure MatchesPage from flat list to time-grouped accordion sections. + +**Files:** +- Modify: `frontend/src/pages/MatchesPage.tsx` +- Modify: `frontend/src/pages/MatchesPage.module.css` + +- [ ] **Step 1: Add section grouping logic** + +Replace the current date/group-based grouping in `MatchesPage.tsx` with time-relative sections: + +```tsx +type Section = { + key: string; + label: string; + matches: Match[]; + defaultOpen: boolean; + highlight: boolean; +}; + +function groupIntoSections(matches: Match[]): Section[] { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); + const endOfWeek = new Date(today); endOfWeek.setDate(today.getDate() + (7 - today.getDay())); + + const sections: Section[] = [ + { key: 'today', label: 'Heute', matches: [], defaultOpen: true, highlight: true }, + { key: 'tomorrow', label: 'Morgen', matches: [], defaultOpen: true, highlight: false }, + { key: 'week', label: 'Diese Woche', matches: [], defaultOpen: false, highlight: false }, + { key: 'later', label: 'Demnächst', matches: [], defaultOpen: false, highlight: false }, + { key: 'past', label: 'Vergangene Spiele', matches: [], defaultOpen: false, highlight: false }, + ]; + + for (const match of matches) { + const d = new Date(match.utcDate); + if (d < today) sections[4].matches.push(match); // past + else if (d < tomorrow) sections[0].matches.push(match); // today + else if (d < new Date(tomorrow.getTime() + 86400000)) sections[1].matches.push(match); // tomorrow + else if (d < endOfWeek) sections[2].matches.push(match); // this week + else sections[3].matches.push(match); // later + } + + // Sort past matches newest first + sections[4].matches.reverse(); + + return sections.filter(s => s.matches.length > 0); +} +``` + +- [ ] **Step 2: Replace filter buttons with dropdown + accordion UI** + +Replace the stage filter button row with a simple dropdown: + +```tsx + +``` + +Render sections as collapsible accordion: + +```tsx +{sections.map(section => ( +
+ + {openSections.has(section.key) && ( +
+ {section.matches.map(match => ( + + ))} +
+ )} +
+))} +``` + +Initialize `openSections` with sections that have `defaultOpen: true`. + +- [ ] **Step 3: Style the sections** + +In `MatchesPage.module.css`: +- `sectionHeader`: clickable, flex row, bold label, count badge, chevron +- `sectionHighlight`: subtle gold left border for "Heute" +- `sectionContent`: padding, gap between cards +- `stageFilter`: styled select replacing the button row +- Remove old filter button styles + +- [ ] **Step 4: Build and verify** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: smart sections in Spielplan (Heute/Morgen/Woche/Vergangen) + +Matches grouped by time relevance with collapsible accordion. +'Heute' and 'Morgen' open by default with highlight. +Stage filter simplified to dropdown." +``` + +--- + +## Task 7: Zustandsbasierte Match-Cards + +Redesign MatchCard to look visually different per match state. + +**Files:** +- Modify: `frontend/src/components/MatchCard.tsx` +- Modify: `frontend/src/components/MatchCard.module.css` + +- [ ] **Step 1: Derive visual state from match data** + +Add state derivation at the top of `MatchCard`: + +```tsx +type CardState = 'open' | 'tipped' | 'live' | 'finished' | 'missed'; + +function getCardState(match: Match): CardState { + if (match.status === 'IN_PLAY' || match.status === 'PAUSED') return 'live'; + if (match.status === 'FINISHED') { + return match.userTip ? 'finished' : 'missed'; + } + // SCHEDULED or TIMED + return match.userTip ? 'tipped' : 'open'; +} +``` + +- [ ] **Step 2: Render state-specific UI** + +For each state, adjust the card's visual treatment: + +- **open**: Standard look. "Tipp abgeben" button. If `minutesUntilKickoff < 60`, show pulsing countdown. +- **tipped**: Green left border. Show "Dein Tipp: 2:1" prominently. "Ändern" link instead of button. +- **live**: Red pulsing dot + "LIVE" badge. Show score if available. Lock icon for tip. +- **finished**: Show result + tip + points badge. Gold shimmer for exact (3pts), green for tendency (1pt), gray for wrong (0pts). +- **missed**: Reduced opacity (0.5). "Nicht getippt" label. No interaction. + +Apply state as CSS class: +```tsx +
+``` + +- [ ] **Step 3: Add countdown timer for urgent matches** + +```tsx +function CountdownTimer({ minutes }: { minutes: number }) { + const [remaining, setRemaining] = useState(minutes); + + useEffect(() => { + const interval = setInterval(() => { + setRemaining(r => Math.max(0, r - 1/60)); + }, 1000); + return () => clearInterval(interval); + }, []); + + if (remaining > 60) return null; + + const mins = Math.floor(remaining); + const urgent = remaining < 5; + + return ( + + Noch {mins} Min! + + ); +} +``` + +- [ ] **Step 4: Style each card state** + +In `MatchCard.module.css`: + +```css +.card_open { /* default styling */ } + +.card_tipped { + border-left: 3px solid var(--success); +} + +.card_live { + border-left: 3px solid var(--error); +} +.card_live .liveDot { + width: 8px; height: 8px; + background: var(--error); + border-radius: 50%; + animation: pulse 1.5s infinite; +} + +.card_finished .pointsBadge_exact { + background: linear-gradient(135deg, var(--gold), #FFD700); + color: #1a1a1a; + animation: shimmer 2s ease-in-out; +} +.card_finished .pointsBadge_tendency { + background: var(--success); + color: #1a1a1a; +} +.card_finished .pointsBadge_wrong { + background: var(--text-muted); +} + +.card_missed { + opacity: 0.45; + pointer-events: none; +} + +.countdown { + color: var(--error); + font-weight: 700; + font-size: 0.85rem; +} +.countdownUrgent { + animation: pulse 0.8s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes shimmer { + 0% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); } + 50% { box-shadow: 0 0 12px rgba(254, 174, 50, 0.6); } + 100% { box-shadow: 0 0 0 rgba(254, 174, 50, 0); } +} +``` + +- [ ] **Step 5: Build and verify** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A && git commit -m "feat: zustandsbasierte Match-Cards (open/tipped/live/finished/missed) + +Each card state has distinct visual treatment: +- Open: standard with countdown timer when <1h +- Tipped: green accent with tip display +- Live: pulsing red dot +- Finished: points badge (gold/green/gray) +- Missed: grayed out" +``` + +--- + +## Task 8: Tipp-Bestätigung Animation + +Add success animation after submitting a tip. + +**Files:** +- Modify: `frontend/src/components/TipModal.tsx` +- Modify: `frontend/src/components/TipModal.module.css` + +- [ ] **Step 1: Add success state to TipModal** + +After successful `api.submitTip()`, instead of immediately closing the modal, show a success state: + +```tsx +const [showSuccess, setShowSuccess] = useState(false); + +async function handleSave() { + // ... existing save logic ... + await api.submitTip(match.id, tipHome, tipAway); + setShowSuccess(true); + // Haptic feedback on mobile + if (navigator.vibrate) navigator.vibrate(50); + // Auto-close after animation + setTimeout(() => { + setShowSuccess(false); + onClose(true); // true = tip was saved + }, 1200); +} +``` + +- [ ] **Step 2: Render success overlay in modal** + +```tsx +{showSuccess && ( +
+
+
Dein Tipp ist drin! 🎯
+
+)} +``` + +- [ ] **Step 3: Style the success animation** + +```css +.successOverlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bg-deep) 90%, transparent); + border-radius: inherit; + animation: fadeIn 0.3s ease; + z-index: 10; +} + +.successCheck { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--success); + color: white; + font-size: 32px; + display: flex; + align-items: center; + justify-content: center; + animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.successText { + margin-top: 12px; + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); +} + +@keyframes popIn { + 0% { transform: scale(0); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +@keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } +} +``` + +- [ ] **Step 4: Build and verify** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: tip confirmation animation with haptic feedback + +Success overlay with animated checkmark and '🎯 Dein Tipp ist drin!' +message. Haptic vibration on mobile. Auto-closes after 1.2s." +``` + +--- + +## Task 9: Punkte-Reveal with Confetti + +Animated reveal when the user sees evaluated results for the first time. + +**Files:** +- Create: `frontend/src/components/ConfettiReveal.tsx` +- Create: `frontend/src/components/ConfettiReveal.module.css` +- Create: `frontend/src/hooks/useRevealQueue.ts` +- Modify: `frontend/src/pages/MatchesPage.tsx` (trigger reveals) +- Install: `canvas-confetti` + +- [ ] **Step 1: Install canvas-confetti** + +```bash +cd frontend && npm install canvas-confetti && npm install -D @types/canvas-confetti +``` + +- [ ] **Step 2: Create useRevealQueue hook** + +Create `frontend/src/hooks/useRevealQueue.ts`: + +```ts +import { useState, useEffect } from 'react'; +import { Match } from '../api/client'; + +const SEEN_KEY = 'tippspiel_seen_results'; + +function getSeenIds(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]')); + } catch { return new Set(); } +} + +function markSeen(matchId: number) { + const seen = getSeenIds(); + seen.add(matchId); + localStorage.setItem(SEEN_KEY, JSON.stringify([...seen])); +} + +export function useRevealQueue(matches: Match[]) { + const [queue, setQueue] = useState([]); + + useEffect(() => { + const seen = getSeenIds(); + const unseen = matches.filter( + m => m.status === 'FINISHED' && m.userTip && m.userTip.points !== null && !seen.has(m.id) + ); + setQueue(unseen); + }, [matches]); + + function dismissCurrent() { + if (queue.length === 0) return; + markSeen(queue[0].id); + setQueue(q => q.slice(1)); + } + + return { current: queue[0] || null, remaining: queue.length, dismissCurrent }; +} +``` + +- [ ] **Step 3: Create ConfettiReveal component** + +Create `frontend/src/components/ConfettiReveal.tsx`: + +```tsx +import { useEffect, useRef } from 'react'; +import confetti from 'canvas-confetti'; +import { Match } from '../api/client'; +import styles from './ConfettiReveal.module.css'; + +interface Props { + match: Match; + onDismiss: () => void; +} + +export default function ConfettiReveal({ match, onDismiss }: Props) { + const didFire = useRef(false); + const tip = match.userTip!; + const points = tip.points!; + + useEffect(() => { + if (points === 3 && !didFire.current) { + didFire.current = true; + confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }); + } + }, [points]); + + const resultLabel = points === 3 ? 'EXAKT! 🎉' : points === 1 ? 'Richtige Tendenz! 👏' : 'Knapp daneben... 😅'; + const resultClass = points === 3 ? styles.exact : points === 1 ? styles.tendency : styles.wrong; + + return ( +
+
e.stopPropagation()}> +
+ {match.homeTeam.shortName} {match.score.home}:{match.score.away} {match.awayTeam.shortName} +
+
+ Dein Tipp: {tip.home}:{tip.away} +
+
+ {points} {points === 1 ? 'Punkt' : 'Punkte'} +
+
{resultLabel}
+ +
+
+ ); +} +``` + +- [ ] **Step 4: Style the reveal** + +Create `frontend/src/components/ConfettiReveal.module.css` — centered modal overlay with animated entrance, gold/green/gray badge based on points. + +- [ ] **Step 5: Integrate into MatchesPage** + +In `MatchesPage.tsx`: + +```tsx +import { useRevealQueue } from '../hooks/useRevealQueue'; +import ConfettiReveal from '../components/ConfettiReveal'; + +// Inside component: +const { current: revealMatch, dismissCurrent } = useRevealQueue(matches); + +// In JSX, before the sections: +{revealMatch && ( + +)} +``` + +- [ ] **Step 6: Build and verify** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 7: Commit** + +```bash +git add -A && git commit -m "feat: Punkte-Reveal with confetti animation + +Shows animated reveal overlay for unseen match results. +Exact match (3pts) triggers confetti explosion. +Each reveal shown only once (localStorage tracking)." +``` + +--- + +## Task 10: Rang-Change Toast Notifications + +Toast notification when rank changes between visits. + +**Files:** +- Create: `frontend/src/components/Toast.tsx` +- Create: `frontend/src/components/Toast.module.css` +- Create: `frontend/src/hooks/useRankChange.ts` +- Modify: `frontend/src/App.tsx` (render toast) + +- [ ] **Step 1: Create useRankChange hook** + +Create `frontend/src/hooks/useRankChange.ts`: + +```ts +import { useState, useEffect } from 'react'; +import { api } from '../api/client'; + +const RANK_KEY = 'tippspiel_last_rank'; + +export function useRankChange() { + const [message, setMessage] = useState(null); + + useEffect(() => { + api.getMyStats().then(stats => { + if (!stats.rank) return; + const lastRank = parseInt(localStorage.getItem(RANK_KEY) || '0'); + if (lastRank > 0 && lastRank !== stats.rank) { + if (stats.rank < lastRank) { + setMessage(`⬆️ Du bist auf Platz ${stats.rank} aufgestiegen!`); + } else { + setMessage(`⬇️ Du bist auf Platz ${stats.rank} gerutscht — hol dir die Punkte zurück!`); + } + } + localStorage.setItem(RANK_KEY, String(stats.rank)); + }).catch(() => {}); + }, []); + + function dismiss() { setMessage(null); } + + return { message, dismiss }; +} +``` + +- [ ] **Step 2: Create Toast component** + +Create `frontend/src/components/Toast.tsx`: + +```tsx +import { useEffect } from 'react'; +import styles from './Toast.module.css'; + +interface Props { + message: string; + onDismiss: () => void; + duration?: number; +} + +export default function Toast({ message, onDismiss, duration = 5000 }: Props) { + useEffect(() => { + const timer = setTimeout(onDismiss, duration); + return () => clearTimeout(timer); + }, [onDismiss, duration]); + + return ( +
+ {message} +
+ ); +} +``` + +Style: fixed top, centered, glassmorphism background, slide-in from top animation, auto-dismiss after 5s. + +- [ ] **Step 3: Wire into App.tsx** + +```tsx +import Toast from './components/Toast'; +import { useRankChange } from './hooks/useRankChange'; + +// Inside App(): +const { message: rankMsg, dismiss: dismissRank } = useRankChange(); + +// In JSX, after
: +{rankMsg && } +``` + +- [ ] **Step 4: Build and commit** + +```bash +cd frontend && npm run build +git add -A && git commit -m "feat: rank change toast notifications + +Shows toast on app open when rank changed since last visit. +Auto-dismisses after 5 seconds. Tracks last rank in localStorage." +``` + +--- + +## Task 11: Streak Tracker UI + +Display streak on dashboard (already done via API) and profile page. Add streak break nudge. + +**Files:** +- Modify: `frontend/src/pages/DashboardPage.tsx` (already has streak in stats) +- Modify: `frontend/src/pages/ProfilePage.tsx` (add streak display) + +- [ ] **Step 1: Add streak milestone icons** + +Create a helper used in DashboardPage and ProfilePage: + +```tsx +function streakDisplay(streak: number): string { + if (streak >= 20) return `⚡${streak}`; + if (streak >= 10) return `🔥🔥${streak}`; + if (streak >= 3) return `🔥${streak}`; + return String(streak); +} +``` + +- [ ] **Step 2: Show streak on profile** + +In `ProfilePage.tsx`, fetch streak from dashboard endpoint and display prominently near the stats ring. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat: streak tracker UI with milestone icons + +Streak displayed on dashboard stats and profile. +Milestones: 🔥 at 3, 🔥🔥 at 10, ⚡ at 20." +``` + +--- + +## Task 12: Rich Profile Page + +Redesign ProfilePage with stats ring, tip history, and fun stats. + +**Files:** +- Create: `frontend/src/components/StatsRing.tsx` +- Create: `frontend/src/components/StatsRing.module.css` +- Modify: `frontend/src/pages/ProfilePage.tsx` +- Modify: `frontend/src/pages/ProfilePage.module.css` + +- [ ] **Step 1: Create StatsRing SVG component** + +Create `frontend/src/components/StatsRing.tsx` — a donut chart using SVG `` with `stroke-dasharray`: + +```tsx +interface Props { + exact: number; + tendency: number; + wrong: number; + total: number; +} + +export default function StatsRing({ exact, tendency, wrong, total }: Props) { + const radius = 60; + const circumference = 2 * Math.PI * radius; + const all = exact + tendency + wrong || 1; + + const segments = [ + { value: exact / all, color: 'var(--gold)', label: 'Exakt' }, + { value: tendency / all, color: 'var(--success)', label: 'Tendenz' }, + { value: wrong / all, color: 'var(--error)', label: 'Falsch' }, + ]; + + let offset = 0; + + return ( +
+ + {segments.map((seg, i) => { + const dashArray = `${seg.value * circumference} ${circumference}`; + const rotation = offset * 360 - 90; + offset += seg.value; + return ( + + ); + })} + + {total} + + + Punkte + + +
+ {segments.map((seg, i) => ( + + + {seg.label} + + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Rewrite ProfilePage** + +Replace the 4 stat boxes with: +1. Header card (avatar, name, rank badge, team) +2. StatsRing component +3. Tip history list (fetched from `api.getMyTips()`) +4. Fun stats (calculated client-side from tips data) + +```tsx +// Fun stats calculation: +const favoriteTip = tips.reduce((acc, t) => { + const key = `${t.tip_home}:${t.tip_away}`; + acc[key] = (acc[key] || 0) + 1; + return acc; +}, {} as Record); +const topTip = Object.entries(favoriteTip).sort((a, b) => b[1] - a[1])[0]; + +const homeWinTips = tips.filter(t => t.tip_home > t.tip_away).length; +const homeWinPct = tips.length > 0 ? Math.round((homeWinTips / tips.length) * 100) : 0; +``` + +- [ ] **Step 3: Style the rich profile** + +Update `ProfilePage.module.css` — header card with flex row, stats ring centered, tip history as scrollable list with match icons and point badges, fun stats as subtle text cards. + +- [ ] **Step 4: Build and verify** + +```bash +cd frontend && npm run build +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A && git commit -m "feat: rich profile page with stats ring, tip history, fun stats + +Donut chart showing exact/tendency/wrong distribution. +Scrollable tip history with point badges. +Fun stats: favorite tip, home win percentage, longest streak." +``` + +--- + +## Task 13: Final Polish — CSS Tokens & Theme Consistency + +Add new animation tokens, ensure light mode works for all new components. + +**Files:** +- Modify: `frontend/src/index.css` + +- [ ] **Step 1: Add animation and color tokens** + +Add to `index.css` `:root`: + +```css +:root { + /* ... existing vars ... */ + --primary-rgb: 75, 183, 248; + --streak-fire: #FF6B35; + --streak-lightning: #FFD700; + --transition-fast: 0.15s ease; + --transition-normal: 0.3s ease; +} +``` + +Ensure all new components look correct in `[data-theme="light"]` — verify that `var(--surface-mid)`, `var(--gold)`, etc. work in both themes. + +- [ ] **Step 2: Verify light mode for all new components** + +Open app in light mode, check: dashboard, bottom nav, match cards (all states), tip modal success, profile, toast. Fix any contrast or readability issues. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "style: add animation tokens, verify light mode for all new components" +``` + +--- + +## Task 14: Build, Deploy & Smoke Test + +Final integration build, push, and verification. + +**Files:** None (deployment only) + +- [ ] **Step 1: Full build** + +```bash +cd frontend && npm run build && cd ../backend && npx tsc --noEmit +``` + +- [ ] **Step 2: Commit and push** + +```bash +git add -A && git push +``` + +Wait for Gitea CI pipeline to build and deploy. + +- [ ] **Step 3: Smoke test all flows** + +Using Playwright or manual browser testing at `http://192.168.1.60:3301?devUser=1`: + +1. `/` → Dashboard loads with hero match, stats, nudges +2. Bottom nav visible on mobile viewport, all 4 tabs work +3. `/spiele` → Smart sections (Heute expanded, past collapsed) +4. Click "Tipp abgeben" → Slim modal, submit → success animation with haptic +5. Match cards show correct states (tipped = green border) +6. `/rangliste` → Leaderboard with existing podium +7. `/profil` → Stats ring, tip history, fun stats +8. Theme toggle → All components look correct in light mode +9. AgentChat widget is gone +10. Admin only visible as gear icon for devUser=1 (editor) + +- [ ] **Step 4: Final commit if fixes needed** + +```bash +git add -A && git commit -m "fix: smoke test fixes after Phase 1 integration" +git push +``` + +--- + +## Summary + +| Task | Description | New Files | Modified Files | +|------|-------------|-----------|---------------| +| 1 | Remove AgentChat | — | 2 (delete 3) | +| 2 | Simplify TipModal | — | 2 | +| 3 | Bottom Navigation | 2 | 2 | +| 4 | Dashboard Backend | 1 | 3 | +| 5 | Dashboard Page | 2 | 1 | +| 6 | Smart Sections | — | 2 | +| 7 | Match Card States | — | 2 | +| 8 | Tip Confirmation | — | 2 | +| 9 | Punkte-Reveal | 3 | 1 | +| 10 | Rank Toast | 3 | 1 | +| 11 | Streak UI | — | 2 | +| 12 | Rich Profile | 2 | 2 | +| 13 | CSS Polish | — | 1 | +| 14 | Deploy & Test | — | — | + +**Total: 14 tasks, ~13 new files, ~20 file modifications, 3 deletions**