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>
50 KiB
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/insightToggleRowJSX section - Imports for Sparkles, ChevronDown icons if only used by insight
Keep:
-
Match header (teams + flags — but remove
groupBadgeandkickoffBlock) -
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: 70pxto 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(addgetDashboard()) -
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:
- Header card (avatar, name, rank badge, team)
- StatsRing component
- Tip history list (fetched from
api.getMyTips()) - 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:
/→ Dashboard loads with hero match, stats, nudges- Bottom nav visible on mobile viewport, all 4 tabs work
/spiele→ Smart sections (Heute expanded, past collapsed)- Click "Tipp abgeben" → Slim modal, submit → success animation with haptic
- Match cards show correct states (tipped = green border)
/rangliste→ Leaderboard with existing podium/profil→ Stats ring, tip history, fun stats- Theme toggle → All components look correct in light mode
- AgentChat widget is gone
- 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