feat: Stadium Elite Design, Rangliste, Profil-Team, User-Upsert & n8n Cronjob

- MatchCard + TipModal: Uhrzeit statt VS zwischen Flaggen, Gruppe zentriert
- LeaderboardPage: Podium (2./1./3.), DU-Badge, Trend-Pfeile, Team-Zeile, CTA-Card
- AdminPage: Stadium Elite Redesign mit Result-Bar und Inline-Spinner
- ProfilePage: Team-Feld inline editierbar (PATCH /api/profile/team)
- User-Upsert beim ersten App-Aufruf (Matches-Route) statt erst beim Tipp
- DB Migration 002: team-Spalte in users, Leaderboard View aktualisiert
- Leaderboard-Refresh automatisch nach Tipps-Auswertung
- n8n Workflow angelegt: stündlicher Sync + Auswertung (ID: t3SDspIGDXwkfOt3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ronny Mueller
2026-04-03 23:37:38 +02:00
parent e967f36f6c
commit e27a62a37b
20 changed files with 1515 additions and 297 deletions
+36 -23
View File
@@ -9,7 +9,7 @@ interface Props {
const STATUS_LABELS: Record<string, string> = {
SCHEDULED: 'Geplant',
TIMED: 'Terminiert',
IN_PLAY: '🔴 Live',
IN_PLAY: 'Live',
PAUSED: 'Pause',
FINISHED: 'Beendet',
POSTPONED: 'Verschoben',
@@ -18,20 +18,31 @@ const STATUS_LABELS: Record<string, string> = {
function formatKickoff(utcDate: string): string {
return new Date(utcDate).toLocaleString('de-DE', {
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin'
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin',
}) + ' Uhr';
}
function CountdownBadge({ minutes }: { minutes: number }) {
if (minutes <= 0) return null;
if (minutes < 60) return <span className={styles.badgeUrgent}>in {minutes} Min.</span>;
if (minutes < 60) return <span className={styles.badgeUrgent}> in {minutes} Min.</span>;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 24) return <span className={styles.badge}>in {h}h {m > 0 ? `${m}m` : ''}</span>;
if (h < 24) return <span className={styles.badge}>in {h}h{m > 0 ? ` ${m}m` : ''}</span>;
const d = Math.floor(h / 24);
return <span className={styles.badge}>in {d} Tag{d > 1 ? 'en' : ''}</span>;
}
function FlagBox({ crest, name }: { crest: string | null; name: string }) {
return (
<div className={styles.flagBox}>
{crest
? <img className={styles.crest} src={crest} alt={name} />
: <span style={{ fontSize: 18 }}>🏳</span>
}
</div>
);
}
export default function MatchCard({ match, onTip }: Props) {
const isFinished = match.status === 'FINISHED';
const isLive = match.status === 'IN_PLAY' || match.status === 'PAUSED';
@@ -39,51 +50,53 @@ export default function MatchCard({ match, onTip }: Props) {
return (
<div className={`card ${styles.card} ${isLive ? styles.live : ''}`}>
{/* Status + Kickoff */}
{/* Top row: Status / Kickoff / Badges */}
<div className={styles.topRow}>
<span className={`${styles.status} ${isLive ? styles.statusLive : ''}`}>
{STATUS_LABELS[match.status] ?? match.status}
{isLive && '● '}{STATUS_LABELS[match.status] ?? match.status}
</span>
<span className={styles.kickoff}>{formatKickoff(match.utcDate)}</span>
{match.group && (
<span className={styles.group}>
{match.group.replace('GROUP_', 'Gruppe ')}
</span>
)}
{match.tippable && <CountdownBadge minutes={match.minutesUntilKickoff} />}
{match.group && <span className={styles.group}>{match.group.replace('GROUP_', 'Gruppe ')}</span>}
</div>
{/* Teams + Score */}
<div className={styles.matchRow}>
{/* Home Team */}
{/* Home */}
<div className={styles.teamHome}>
{match.homeTeam.crest && (
<img className={styles.crest} src={match.homeTeam.crest} alt="" />
)}
<span className={styles.teamName}>{match.homeTeam.name}</span>
<FlagBox crest={match.homeTeam.crest} name={match.homeTeam.name} />
</div>
{/* Score / VS */}
{/* Score / Kickoff time */}
<div className={styles.scoreBox}>
{isFinished || isLive ? (
<span className={styles.score}>
{match.score.home ?? ''} : {match.score.away ?? ''}
{match.score.home ?? ''}&nbsp;:&nbsp;{match.score.away ?? ''}
</span>
) : (
<span className={styles.vs}>vs</span>
<div className={styles.kickoffCenter}>
<span className={styles.kickoffCenterTime}>{formatKickoff(match.utcDate)}</span>
</div>
)}
</div>
{/* Away Team */}
{/* Away */}
<div className={styles.teamAway}>
<FlagBox crest={match.awayTeam.crest} name={match.awayTeam.name} />
<span className={styles.teamName}>{match.awayTeam.name}</span>
{match.awayTeam.crest && (
<img className={styles.crest} src={match.awayTeam.crest} alt="" />
)}
</div>
</div>
{/* Tipp-Bereich */}
{/* Tipp area */}
<div className={styles.tipRow}>
{hasTip ? (
<div className={styles.tipDisplay}>
<span className={styles.tipLabel}>Dein Tipp:</span>
<span className={styles.tipLabel}>Dein Tipp</span>
<span className={styles.tipScore}>
{match.userTip!.home} : {match.userTip!.away}
</span>
@@ -93,7 +106,7 @@ export default function MatchCard({ match, onTip }: Props) {
match.userTip!.points === 1 ? styles.tendency : styles.wrong
}`}>
{match.userTip!.points === 3 ? '🎯 3 Punkte' :
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
match.userTip!.points === 1 ? '✓ 1 Punkt' : '✗ 0 Punkte'}
</span>
)}
{match.tippable && (
@@ -102,7 +115,7 @@ export default function MatchCard({ match, onTip }: Props) {
</div>
) : match.tippable ? (
<button className={`btn-primary ${styles.tipBtn}`} onClick={onTip}>
Tipp abgeben
Tipp abgeben
</button>
) : (
<span className={styles.noTip}>Kein Tipp abgegeben</span>