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
+70 -7
View File
@@ -2,27 +2,88 @@ import { useState, useEffect } from 'react';
import { api, UserStats } from '../api/client';
import styles from './ProfilePage.module.css';
function initials(name: string) {
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
}
export default function ProfilePage() {
const [stats, setStats] = useState<UserStats | null>(null);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UserStats | null>(null);
const [loading, setLoading] = useState(true);
const [teamEdit, setTeamEdit] = useState(false);
const [teamValue, setTeamValue] = useState('');
const [teamSaving, setTeamSaving] = useState(false);
const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null);
useEffect(() => {
api.getMyStats().then(setStats).finally(() => setLoading(false));
api.getMyStats().then(s => {
setStats(s);
setTeamValue(s.team ?? '');
}).finally(() => setLoading(false));
}, []);
const saveTeam = async () => {
if (!teamValue.trim()) return;
setTeamSaving(true);
setTeamMsg(null);
try {
const res = await api.updateTeam(teamValue);
setStats(prev => prev ? { ...prev, team: res.team } : prev);
setTeamValue(res.team);
setTeamEdit(false);
setTeamMsg({ ok: true, text: 'Team gespeichert' });
} catch (e) {
setTeamMsg({ ok: false, text: (e as Error).message });
} finally {
setTeamSaving(false);
}
};
if (loading) return <div className={styles.loading}><div className={styles.spinner} /></div>;
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
if (!stats) return <div className={styles.empty}>Profil nicht verfügbar.</div>;
const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount;
return (
<div className={styles.page}>
{/* Hero card */}
<div className={`card ${styles.heroCard}`}>
<div className={styles.avatar}>{stats.fullName.charAt(0).toUpperCase()}</div>
<div className={styles.avatar}>{initials(stats.fullName)}</div>
<div className={styles.heroInfo}>
<h1 className={`font-display ${styles.name}`}>{stats.fullName}</h1>
{stats.rank && (
<div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>
{stats.rank && <div className={styles.rankBadge}>🏆 Platz {stats.rank}</div>}
{/* Team-Feld */}
<div className={styles.teamRow}>
{teamEdit ? (
<div className={styles.teamEditRow}>
<input
className={styles.teamInput}
value={teamValue}
onChange={e => setTeamValue(e.target.value)}
placeholder="z. B. Vertrieb Süd"
maxLength={80}
autoFocus
onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }}
/>
<button className={styles.teamSaveBtn} onClick={saveTeam} disabled={teamSaving}>
{teamSaving ? <span className={styles.spinnerSm} /> : '✓'}
</button>
<button className={styles.teamCancelBtn} onClick={() => { setTeamEdit(false); setTeamValue(stats.team ?? ''); }}></button>
</div>
) : (
<button className={styles.teamBtn} onClick={() => setTeamEdit(true)}>
{stats.team
? <><span className={styles.teamName}>{stats.team}</span><span className={styles.teamEditHint}>bearbeiten</span></>
: <span className={styles.teamPlaceholder}>+ Team hinzufügen</span>
}
</button>
)}
</div>
{teamMsg && (
<div className={`${styles.teamMsg} ${teamMsg.ok ? styles.teamMsgOk : styles.teamMsgErr}`}>
{teamMsg.text}
</div>
)}
</div>
<div className={styles.heroPoints}>
@@ -31,6 +92,7 @@ export default function ProfilePage() {
</div>
</div>
{/* Stats grid */}
<div className={styles.statsGrid}>
<div className={`card ${styles.statCard}`}>
<span className={`font-display ${styles.statVal} text-gold`}>{stats.exactCount}</span>
@@ -50,6 +112,7 @@ export default function ProfilePage() {
</div>
</div>
{/* Accuracy bar */}
{evaluated > 0 && (
<div className={`card ${styles.accuracyCard}`}>
<div className={styles.accuracyHeader}>