6a0b267660
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>
1721 lines
50 KiB
Markdown
1721 lines
50 KiB
Markdown
# 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**
|