This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/docs/superpowers/plans/2026-04-11-phase1-engagement-ux.md
T
Ronny 6a0b267660 docs: Phase 1 implementation plan — 14 tasks
Step-by-step plan covering:
- Cleanup (remove AgentChat, simplify TipModal)
- Bottom Nav, Dashboard, Smart Sections
- Match Card states, animations, confetti
- Streak tracker, rank toasts, rich profile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:34:24 +02:00

1721 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 */}
<AgentChat />
```
- [ ] **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:
<div className={styles.teamsRow}>
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
<img src={match.homeTeam.crest || ''} alt={match.homeTeam.name} className={styles.flagImg} />
</div>
<span className={styles.teamName}>{match.homeTeam.shortName}</span>
</div>
<div className={styles.vsBlock}>
<span className={styles.vsText}>vs</span>
</div>
<div className={styles.teamBlock}>
<div className={styles.flagLarge}>
<img src={match.awayTeam.crest || ''} alt={match.awayTeam.name} className={styles.flagImg} />
</div>
<span className={styles.teamName}>{match.awayTeam.shortName}</span>
</div>
</div>
```
- [ ] **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 (
<nav className={styles.bottomNav}>
<NavLink to="/" end className={linkClass}>
<Home size={20} />
<span>Home</span>
</NavLink>
<NavLink to="/spiele" className={linkClass}>
<span className={styles.emojiIcon}></span>
<span>Spiele</span>
</NavLink>
<NavLink to="/rangliste" className={linkClass}>
<Trophy size={20} />
<span>Rangliste</span>
</NavLink>
<NavLink to="/profil" className={linkClass}>
<User size={20} />
<span>Profil</span>
</NavLink>
</nav>
);
}
```
- [ ] **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 `<BottomNav />` before closing `</div>`
- 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):
<nav className={styles.nav}>
<NavLink to="/" end className={...}>Home</NavLink>
<NavLink to="/spiele" className={...}>Spiele</NavLink>
<NavLink to="/rangliste" className={...}>Rangliste</NavLink>
<NavLink to="/profil" className={...}>Mein Profil</NavLink>
<NavLink to="/admin" className={styles.adminLink}>
<Settings size={16} />
</NavLink>
<button className={styles.themeToggle} onClick={toggleTheme}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
</nav>
// Routes:
<Route path="/" element={<MatchesPage key={refreshKey} devUser={devUser} />} />
<Route path="/spiele" element={<MatchesPage key={refreshKey} devUser={devUser} />} />
<Route path="/rangliste" element={<LeaderboardPage key={refreshKey} />} />
<Route path="/profil" element={<ProfilePage key={refreshKey} />} />
<Route path="/admin" element={<AdminPage />} />
// After </main>, before DevPanel:
<BottomNav />
```
- [ ] **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<void> => {
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<any>(
`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<any>(
`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<any>(
`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<DashboardData>('/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<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
api.getDashboard()
.then(setData)
.catch(() => {})
.finally(() => setLoading(false));
}, [devUser]);
if (loading) return <div className={styles.loading}>Laden...</div>;
if (!data) return <div className={styles.error}>Dashboard konnte nicht geladen werden.</div>;
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 (
<div className={styles.dashboard}>
{/* Hero: Next match */}
{hero ? (
<section
className={styles.hero}
onClick={() => navigate('/spiele')}
role="button"
tabIndex={0}
>
<div className={styles.heroLabel}>
{hero.tippable ? 'Nächstes Spiel' : 'Kommendes Spiel'}
<span className={styles.heroCountdown}>
{formatCountdown(hero.match.minutesUntilKickoff)}
</span>
</div>
<div className={styles.heroTeams}>
<div className={styles.heroTeam}>
{hero.match.homeTeam.crest && (
<img src={hero.match.homeTeam.crest} alt="" className={styles.heroCrest} />
)}
<span>{hero.match.homeTeam.shortName}</span>
</div>
<span className={styles.heroVs}>vs</span>
<div className={styles.heroTeam}>
{hero.match.awayTeam.crest && (
<img src={hero.match.awayTeam.crest} alt="" className={styles.heroCrest} />
)}
<span>{hero.match.awayTeam.shortName}</span>
</div>
</div>
{hero.userTip ? (
<div className={styles.heroTip}>
Dein Tipp: {hero.userTip.home}:{hero.userTip.away}
</div>
) : hero.tippable ? (
<button
className={styles.heroTipBtn}
onClick={(e) => { e.stopPropagation(); navigate('/spiele'); }}
>
Jetzt tippen
</button>
) : null}
</section>
) : (
<section className={styles.hero}>
<div className={styles.heroLabel}>Keine anstehenden Spiele</div>
</section>
)}
{/* Stats tiles */}
<section className={styles.statsRow}>
<div className={styles.statTile}>
<span className={styles.statValue}>{stats.rank ?? '—'}</span>
<span className={styles.statLabel}>Dein Rang</span>
</div>
<div className={styles.statTile}>
<span className={styles.statValue}>{stats.totalPoints}</span>
<span className={styles.statLabel}>Punkte</span>
</div>
<div className={styles.statTile}>
<span className={styles.statValue}>
{stats.streak > 0 ? `${stats.streak}🔥` : '0'}
</span>
<span className={styles.statLabel}>Streak</span>
</div>
</section>
{/* Nudges */}
{nudges.length > 0 && (
<section className={styles.nudges}>
{nudges.map((nudge, i) => (
<div
key={i}
className={styles.nudge}
onClick={() => {
if (nudge.type === 'untipped') navigate('/spiele');
if (nudge.type === 'leader') navigate('/rangliste');
}}
>
{nudge.text}
</div>
))}
</section>
)}
</div>
);
}
```
- [ ] **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:
<Route path="/" element={<DashboardPage key={refreshKey} devUser={devUser} />} />
<Route path="/spiele" element={<MatchesPage key={refreshKey} devUser={devUser} />} />
```
- [ ] **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
<select
className={styles.stageFilter}
value={stageFilter}
onChange={(e) => setStageFilter(e.target.value)}
>
<option value="">Alle Phasen</option>
<option value="GROUP_STAGE">Gruppenphase</option>
<option value="ROUND_OF_32">Runde der 32</option>
{/* ... other stages */}
</select>
```
Render sections as collapsible accordion:
```tsx
{sections.map(section => (
<div key={section.key} className={section.highlight ? styles.sectionHighlight : styles.section}>
<button
className={styles.sectionHeader}
onClick={() => toggleSection(section.key)}
>
<span>{section.label}</span>
<span className={styles.sectionCount}>{section.matches.length} Spiele</span>
<span className={styles.sectionChevron}>
{openSections.has(section.key) ? '▾' : '▸'}
</span>
</button>
{openSections.has(section.key) && (
<div className={styles.sectionContent}>
{section.matches.map(match => (
<MatchCard key={match.id} match={match} onTip={...} />
))}
</div>
)}
</div>
))}
```
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
<div className={`${styles.card} ${styles[`card_${state}`]}`}>
```
- [ ] **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 (
<span className={`${styles.countdown} ${urgent ? styles.countdownUrgent : ''}`}>
Noch {mins} Min!
</span>
);
}
```
- [ ] **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 && (
<div className={styles.successOverlay}>
<div className={styles.successCheck}></div>
<div className={styles.successText}>Dein Tipp ist drin! 🎯</div>
</div>
)}
```
- [ ] **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<number> {
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<Match[]>([]);
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 (
<div className={styles.overlay} onClick={onDismiss}>
<div className={styles.card} onClick={e => e.stopPropagation()}>
<div className={styles.result}>
{match.homeTeam.shortName} {match.score.home}:{match.score.away} {match.awayTeam.shortName}
</div>
<div className={styles.tipLine}>
Dein Tipp: {tip.home}:{tip.away}
</div>
<div className={`${styles.pointsBadge} ${resultClass}`}>
{points} {points === 1 ? 'Punkt' : 'Punkte'}
</div>
<div className={styles.label}>{resultLabel}</div>
<button className={styles.dismissBtn} onClick={onDismiss}>Weiter</button>
</div>
</div>
);
}
```
- [ ] **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 && (
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
)}
```
- [ ] **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<string | null>(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 (
<div className={styles.toast} onClick={onDismiss}>
{message}
</div>
);
}
```
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 <main>:
{rankMsg && <Toast message={rankMsg} onDismiss={dismissRank} />}
```
- [ ] **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 `<circle>` 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 (
<div className={styles.ring}>
<svg viewBox="0 0 140 140" className={styles.svg}>
{segments.map((seg, i) => {
const dashArray = `${seg.value * circumference} ${circumference}`;
const rotation = offset * 360 - 90;
offset += seg.value;
return (
<circle
key={i}
cx="70" cy="70" r={radius}
fill="none"
stroke={seg.color}
strokeWidth="14"
strokeDasharray={dashArray}
transform={`rotate(${rotation} 70 70)`}
strokeLinecap="round"
/>
);
})}
<text x="70" y="70" textAnchor="middle" dominantBaseline="central"
fill="var(--text-primary)" fontSize="24" fontWeight="700">
{total}
</text>
<text x="70" y="88" textAnchor="middle"
fill="var(--text-secondary)" fontSize="10">
Punkte
</text>
</svg>
<div className={styles.legend}>
{segments.map((seg, i) => (
<span key={i} className={styles.legendItem}>
<span className={styles.dot} style={{ background: seg.color }} />
{seg.label}
</span>
))}
</div>
</div>
);
}
```
- [ ] **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<string, number>);
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**