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