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
+124 -37
View File
@@ -1,20 +1,32 @@
import { useState, useEffect } from 'react';
import { api, LeaderboardEntry } from '../api/client';
import { useNavigate } from 'react-router-dom';
import { api, LeaderboardEntry, LeaderboardResponse } from '../api/client';
import styles from './LeaderboardPage.module.css';
const MEDALS = ['🥇', '🥈', '🥉'];
function initials(name: string) {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
function TrendIcon({ entry, prev }: { entry: LeaderboardEntry; prev: LeaderboardEntry | undefined }) {
if (!prev) return <span className={styles.trendNeutral}></span>;
if (entry.total_points > prev.total_points) return <span className={styles.trendUp}></span>;
if (entry.total_points < prev.total_points) return <span className={styles.trendDown}></span>;
return <span className={styles.trendNeutral}></span>;
}
export default function LeaderboardPage() {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [myRank, setMyRank] = useState<number | null>(null);
const [total, setTotal] = useState(0);
const [data, setData] = useState<LeaderboardResponse | null>(null);
const [tippableCount, setTippableCount] = useState(0);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
api.getLeaderboard().then(res => {
setEntries(res.entries);
setMyRank(res.currentUserRank);
setTotal(res.totalParticipants);
Promise.all([
api.getLeaderboard(),
api.getMatches(),
]).then(([lb, matches]) => {
setData(lb);
setTippableCount(matches.matches.filter(m => m.tippable && !m.userTip).length);
}).finally(() => setLoading(false));
}, []);
@@ -22,12 +34,25 @@ export default function LeaderboardPage() {
<div className={styles.loading}><div className={styles.spinner} /></div>
);
if (!data) return null;
const { entries, currentUserId, currentUserRank, totalParticipants } = data;
const top3 = entries.slice(0, 3);
const rest = entries.slice(3);
// Podium order: 2nd left, 1st center, 3rd right
type PodiumSlot = { entry: LeaderboardEntry; rank: 1 | 2 | 3; medal: string; colorClass: string; barHeight: string };
const podiumSlots: PodiumSlot[] = [];
if (top3[1]) podiumSlots.push({ entry: top3[1], rank: 2, medal: '🥈', colorClass: styles.silver, barHeight: '64px' });
if (top3[0]) podiumSlots.push({ entry: top3[0], rank: 1, medal: '🥇', colorClass: styles.gold, barHeight: '96px' });
if (top3[2]) podiumSlots.push({ entry: top3[2], rank: 3, medal: '🥉', colorClass: styles.bronze, barHeight: '48px' });
return (
<div className={styles.page}>
<div className={styles.pageHeader}>
<h1 className={`font-display ${styles.title}`}>🏆 Rangliste</h1>
<h1 className={`font-display ${styles.title}`}>Rangliste</h1>
<div className={styles.meta}>
{total} Teilnehmer{myRank ? ` · Du: Platz ${myRank}` : ''}
{totalParticipants} Teilnehmer{currentUserRank ? ` · Du: Platz ${currentUserRank}` : ''}
</div>
</div>
@@ -36,33 +61,95 @@ export default function LeaderboardPage() {
Noch keine Punkte vergeben. Spiele müssen erst abgeschlossen sein.
</div>
) : (
<div className={styles.list}>
{entries.map((entry, i) => (
<div key={entry.user_id} className={`card ${styles.row} ${i < 3 ? styles.topThree : ''}`}>
<div className={styles.rank}>
{i < 3 ? MEDALS[i] : <span className={styles.rankNum}>{entry.rank}</span>}
</div>
<div className={styles.name}>{entry.full_name}</div>
<div className={styles.stats}>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.exact_count}</span>
<span className={styles.statLbl}>🎯</span>
</span>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.tendency_count}</span>
<span className={styles.statLbl}></span>
</span>
<span className={styles.stat}>
<span className={styles.statVal}>{entry.tips_count}</span>
<span className={styles.statLbl}>Tipps</span>
</span>
</div>
<div className={`${styles.points} ${i === 0 ? styles.gold : i === 1 ? styles.silver : i === 2 ? styles.bronze : ''}`}>
{entry.total_points} <span className={styles.ptLabel}>Pkt</span>
</div>
<>
{/* ── Podium ── */}
{top3.length > 0 && (
<div className={styles.podiumWrap}>
{podiumSlots.map(({ entry, rank, medal, colorClass, barHeight }) => {
const isMe = entry.user_id === currentUserId;
const isFirst = rank === 1;
return (
<div key={entry.user_id} className={`${styles.podiumCard} ${isFirst ? styles.podiumFirst : ''}`}>
<div className={styles.podiumMedal}>{medal}</div>
<div className={`${styles.podiumAvatar} ${colorClass} ${isFirst ? styles.podiumAvatarLarge : ''} ${isMe ? styles.podiumAvatarMe : ''}`}>
{initials(entry.full_name)}
</div>
<div className={`${styles.podiumName} ${isMe ? styles.nameMe : ''}`}>
{entry.full_name.split(' ')[0]}{isMe ? ' (Ich)' : ''}
</div>
<div className={`${styles.podiumPoints} ${colorClass}`}>
{entry.total_points.toLocaleString('de-DE')}
<span className={styles.podiumPtLabel}> Pkt</span>
</div>
<div className={`${styles.podiumBar} ${colorClass}Bar`} style={{ height: barHeight }} />
</div>
);
})}
</div>
))}
</div>
)}
{/* ── List header ── */}
{rest.length > 0 && (
<>
<div className={styles.listHeader}>
<span>POS</span>
<span>SPIELER</span>
<span>TREND</span>
<span>PUNKTE</span>
</div>
<div className={styles.list}>
{rest.map((entry, i) => {
const isMe = entry.user_id === currentUserId;
const prev = rest[i - 1];
return (
<div key={entry.user_id} className={`card ${styles.row} ${isMe ? styles.rowMe : ''}`}>
<div className={styles.rankCol}>
<span className={styles.rankNum}>{entry.rank}</span>
</div>
<div className={`${styles.avatarSmall} ${isMe ? styles.avatarSmallMe : ''}`}>
{initials(entry.full_name)}
</div>
<div className={styles.nameCol}>
<div className={styles.nameRow}>
<span className={`${styles.rowName} ${isMe ? styles.nameMe : ''}`}>
{entry.full_name}{isMe ? ' (Ich)' : ''}
</span>
</div>
{entry.team && <div className={styles.rowTeam}>{entry.team}</div>}
{isMe && <div className={styles.aufholjagd}>AUFHOLJAGD!</div>}
</div>
<div className={styles.trendCol}>
<TrendIcon entry={entry} prev={prev} />
</div>
<div className={styles.pointsCol}>
{entry.total_points.toLocaleString('de-DE')}
</div>
</div>
);
})}
</div>
</>
)}
{/* ── CTA Card ── */}
{tippableCount > 0 && (
<div className={`card ${styles.ctaCard}`}>
<div className={styles.ctaText}>
<div className={styles.ctaTitle}>Punkte sichern!</div>
<div className={styles.ctaBody}>
{tippableCount} Spiel{tippableCount !== 1 ? 'e' : ''} noch ohne Tipp kletter nach oben.
</div>
</div>
<button
className={`btn-primary ${styles.ctaBtn}`}
onClick={() => navigate('/')}
>
TIPPEN
</button>
</div>
)}
</>
)}
</div>
);