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

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

50 KiB
Raw Blame History

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:

// DELETE these lines:
import AgentChat from './components/AgentChat';
// ...
{/* Fußball-Experte Chat-Widget  immer sichtbar */}
<AgentChat />
  • Step 2: Delete AgentChat component files
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:

// DELETE: import and route mount for agent
import agentRouter from './routes/agent';
// ...
app.use('/api/agent', agentRouter);

Then delete the route file:

rm backend/src/routes/agent.ts
  • Step 4: Build and verify
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
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):

// 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
cd frontend && npx tsc --noEmit
  • Step 5: Commit
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:

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:

.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:

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
/* 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
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
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:

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:

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:

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:

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
cd backend && npx tsc --noEmit && cd ../frontend && npx tsc --noEmit
  • Step 6: Commit
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:

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:

.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
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
cd frontend && npm run build

Open app — / should show dashboard, /spiele shows the match list.

  • Step 5: Commit
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:

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:

<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:

{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

cd frontend && npm run build
  • Step 5: Commit
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:

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:

<div className={`${styles.card} ${styles[`card_${state}`]}`}>
  • Step 3: Add countdown timer for urgent matches
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:

.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
cd frontend && npm run build
  • Step 6: Commit
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:

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
{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
.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
cd frontend && npm run build
  • Step 5: Commit
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

cd frontend && npm install canvas-confetti && npm install -D @types/canvas-confetti
  • Step 2: Create useRevealQueue hook

Create frontend/src/hooks/useRevealQueue.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:

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:

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
cd frontend && npm run build
  • Step 7: Commit
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:

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:

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
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
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:

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
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:

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)
// 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
cd frontend && npm run build
  • Step 5: Commit
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:

: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
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
cd frontend && npm run build && cd ../backend && npx tsc --noEmit
  • Step 2: Commit and push
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
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