# 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**