# 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.shortName}
vs
{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 (
Home
⚽
Spiele
Rangliste
Profil
);
}
```
- [ ] **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):
Home
Spiele
Rangliste
Mein Profil
{theme === 'dark' ? : }
// 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 ? (
{ e.stopPropagation(); navigate('/spiele'); }}
>
Jetzt tippen
) : null}
) : (
)}
{/* 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
setStageFilter(e.target.value)}
>
Alle Phasen
Gruppenphase
Runde der 32
{/* ... other stages */}
```
Render sections as collapsible accordion:
```tsx
{sections.map(section => (
toggleSection(section.key)}
>
{section.label}
{section.matches.length} Spiele
{openSections.has(section.key) ? '▾' : '▸'}
{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 && (
)}
```
- [ ] **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}
Weiter
);
}
```
- [ ] **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**