diff --git a/frontend/src/components/StatsRing.module.css b/frontend/src/components/StatsRing.module.css new file mode 100644 index 0000000..1dc68be --- /dev/null +++ b/frontend/src/components/StatsRing.module.css @@ -0,0 +1,35 @@ +.ring { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; +} + +.svg { + width: 160px; + height: 160px; +} + +.legend { + display: flex; + gap: 16px; + margin-top: 12px; + font-size: 0.8rem; + color: var(--text-secondary); + flex-wrap: wrap; + justify-content: center; +} + +.legendItem { + display: flex; + align-items: center; + gap: 4px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} diff --git a/frontend/src/components/StatsRing.tsx b/frontend/src/components/StatsRing.tsx new file mode 100644 index 0000000..697a746 --- /dev/null +++ b/frontend/src/components/StatsRing.tsx @@ -0,0 +1,65 @@ +import styles from './StatsRing.module.css'; + +interface Props { + exact: number; + tendency: number; + wrong: number; + total: number; +} + +export default function StatsRing({ exact, tendency, wrong, total }: Props) { + const radius = 55; + 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 ( +
+ + {/* Background circle */} + + {segments.map((seg, i) => { + if (seg.value === 0) return null; + const dashArray = `${seg.value * circumference} ${circumference}`; + const rotation = offset * 360 - 90; + offset += seg.value; + return ( + + ); + })} + + {total} + + + Punkte + + +
+ {segments.map((seg, i) => ( + + + {seg.label}: {Math.round(seg.value * all)} + + ))} +
+
+ ); +} diff --git a/frontend/src/pages/ProfilePage.module.css b/frontend/src/pages/ProfilePage.module.css index 00639b5..ad7cc74 100644 --- a/frontend/src/pages/ProfilePage.module.css +++ b/frontend/src/pages/ProfilePage.module.css @@ -1,18 +1,42 @@ +/* ── Layout ── */ .page { display: flex; flex-direction: column; gap: 20px; max-width: 640px; } + .loading { display: flex; justify-content: center; padding: 60px; } .spinner { width: 32px; height: 32px; border: 3px solid var(--surface-high); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; } .spinnerSm { width: 14px; height: 14px; border: 2px solid var(--surface-high); border-top-color: var(--text-primary); border-radius: 50%; animation: spin 0.7s linear infinite; display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } .empty { color: var(--text-secondary); padding: 40px; text-align: center; } -.heroCard { padding: 28px; display: flex; align-items: flex-start; gap: 20px; } -.avatar { width: 60px; height: 60px; border-radius: 50%; background: var(--primary-dim); border: 2px solid rgba(75,183,248,0.3); display: flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', sans-serif; font-size: 22px; font-weight: 800; color: var(--primary); flex-shrink: 0; margin-top: 2px; } -.heroInfo { flex: 1; min-width: 0; } -.name { font-size: 22px; font-weight: 800; } -.rankBadge { font-size: 13px; color: var(--gold); margin-top: 4px; font-weight: 600; } +/* ── Header card ── */ +.heroCard { + padding: 24px; + display: flex; + align-items: flex-start; + gap: 20px; +} -/* Team-Feld */ -.teamRow { margin-top: 8px; } +.avatar { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--primary-dim); + border: 2px solid rgba(75,183,248,0.3); + display: flex; + align-items: center; + justify-content: center; + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 24px; + font-weight: 800; + color: var(--primary); + flex-shrink: 0; +} + +.heroInfo { flex: 1; min-width: 0; } +.name { font-size: 22px; font-weight: 800; margin: 0 0 4px; } +.rankBadge { font-size: 13px; color: var(--gold); font-weight: 600; margin-bottom: 8px; } + +/* ── Team field ── */ +.teamRow { margin-top: 4px; } .teamBtn { background: none; @@ -85,22 +109,122 @@ .teamMsg { font-size: 12px; margin-top: 4px; } .teamMsgOk { color: var(--success); } .teamMsgErr { color: var(--error); } -.heroPoints { text-align: right; } -.pointsVal { font-size: 40px; font-weight: 800; color: var(--primary); line-height: 1; display: block; } -.pointsLbl { font-size: 12px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; } -.statsGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } -.statCard { padding: 20px; text-align: center; } -.statVal { font-size: 36px; font-weight: 800; display: block; } -.statLbl { font-size: 13px; color: var(--text-secondary); display: block; margin-top: 4px; } +/* ── Section title ── */ +.sectionTitle { + font-size: 14px; + font-weight: 700; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 4px; +} -.accuracyCard { padding: 24px; } -.accuracyHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; } -.accuracyLabel { font-size: 14px; color: var(--text-secondary); } -.accuracyVal { font-size: 28px; font-weight: 800; color: var(--text-primary); } -.bar { height: 10px; background: var(--surface-high); border-radius: 5px; overflow: hidden; display: flex; margin-bottom: 12px; } -.barFill { height: 100%; transition: width 0.5s ease; } -.exact { background: var(--gold); } -.tendency { background: var(--primary); } -.barLegend { display: flex; gap: 16px; font-size: 12px; color: var(--text-secondary); } -.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; } +/* ── Stats ring card ── */ +.ringCard { padding: 20px 24px 16px; } + +.accuracyRow { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--surface-high); +} +.accuracyLabel { font-size: 13px; color: var(--text-secondary); } +.accuracyVal { font-size: 24px; font-weight: 800; color: var(--text-primary); } + +/* ── Tip history ── */ +.historyCard { padding: 20px 0 8px; overflow: hidden; } +.historyCard .sectionTitle { padding: 0 20px; } + +.tipList { + list-style: none; + margin: 8px 0 0; + padding: 0; + max-height: 340px; + overflow-y: auto; +} + +.tipRow { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + font-size: 13px; + min-width: 0; +} + +.tipRowAlt { + background: rgba(255,255,255,0.03); +} + +.tipMatch { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-weight: 500; +} + +.tipScore { + color: var(--text-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.pointBadge { + font-size: 12px; + font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + white-space: nowrap; + flex-shrink: 0; + background: var(--surface-high); + color: var(--text-muted); +} + +.badgeExact { background: rgba(255,196,0,0.15); color: var(--gold); } +.badgeTendency { background: rgba(34,197,94,0.15); color: var(--success); } +.badgeWrong { background: rgba(239,68,68,0.12); color: var(--error); } + +/* ── Fun stats ── */ +.funStats { display: flex; flex-direction: column; gap: 12px; } + +.funGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +@media (max-width: 400px) { + .funGrid { grid-template-columns: 1fr; } +} + +.funCard { + padding: 16px 12px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; +} + +.funIcon { font-size: 22px; line-height: 1; } + +.funLabel { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 2px; +} + +.funValue { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + word-break: break-all; +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 5e21db0..4b817d1 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,23 +1,49 @@ import { useState, useEffect } from 'react'; -import { api, UserStats } from '../api/client'; +import { api, UserStats, MyTip } from '../api/client'; +import StatsRing from '../components/StatsRing'; import styles from './ProfilePage.module.css'; function initials(name: string) { return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); } +function mostCommonTip(tips: MyTip[]): string { + const counts: Record = {}; + for (const t of tips) { + const key = `${t.tip_home}:${t.tip_away}`; + counts[key] = (counts[key] ?? 0) + 1; + } + let best = ''; + let max = 0; + for (const [key, count] of Object.entries(counts)) { + if (count > max) { max = count; best = key; } + } + return best ? `${best} (${max}x getippt)` : '—'; +} + +function homeWinPct(tips: MyTip[]): number { + if (!tips.length) return 0; + const homeWins = tips.filter(t => t.tip_home > t.tip_away).length; + return Math.round((homeWins / tips.length) * 100); +} + export default function ProfilePage() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [teamEdit, setTeamEdit] = useState(false); - const [teamValue, setTeamValue] = useState(''); + const [stats, setStats] = useState(null); + const [tips, setTips] = useState([]); + 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); + const [teamMsg, setTeamMsg] = useState<{ ok: boolean; text: string } | null>(null); useEffect(() => { - api.getMyStats().then(s => { + Promise.all([ + api.getMyStats(), + api.getMyTips(), + ]).then(([s, t]) => { setStats(s); setTeamValue(s.team ?? ''); + setTips(t.tips); }).finally(() => setLoading(false)); }, []); @@ -41,19 +67,37 @@ export default function ProfilePage() { if (loading) return
; if (!stats) return
Profil nicht verfügbar.
; - const evaluated = stats.exactCount + stats.tendencyCount + stats.wrongCount; + const evaluatedTips = tips.filter(t => t.points !== null); + const recentTips = evaluatedTips.slice(0, 10); + + const favTip = mostCommonTip(tips); + const homePct = homeWinPct(tips); + + function pointBadgeClass(points: number | null) { + if (points === null) return ''; + if (points >= 3) return styles.badgeExact; + if (points >= 1) return styles.badgeTendency; + return styles.badgeWrong; + } + + function pointLabel(points: number | null) { + if (points === null) return ''; + if (points >= 3) return `${points} Pkt ✓✓`; + if (points >= 1) return `${points} Pkt ✓`; + return `${points} Pkt ✗`; + } return (
- {/* Hero card */} + {/* Header card */}
{initials(stats.fullName)}

{stats.fullName}

{stats.rank &&
🏆 Platz {stats.rank}
} - {/* Team-Feld */} + {/* Team field */}
{teamEdit ? (
@@ -64,12 +108,20 @@ export default function ProfilePage() { placeholder="z. B. Vertrieb Süd" maxLength={80} autoFocus - onKeyDown={e => { if (e.key === 'Enter') saveTeam(); if (e.key === 'Escape') setTeamEdit(false); }} + onKeyDown={e => { + if (e.key === 'Enter') saveTeam(); + if (e.key === 'Escape') setTeamEdit(false); + }} /> - +
) : (
)}
-
- {stats.totalPoints} - Punkte -
- {/* Stats grid */} -
-
- {stats.exactCount} - 🎯 Exakt -
-
- {stats.tendencyCount} - ✓ Tendenz -
-
- {stats.wrongCount} - ✗ Falsch -
-
- {stats.tipsCount} - Tipps gesamt -
-
- - {/* Accuracy bar */} - {evaluated > 0 && ( -
-
+ {/* Stats donut ring */} +
+

Tipp-Statistik

+ + {stats.accuracy > 0 && ( +
Trefferquote {stats.accuracy}%
-
-
-
-
-
- Exakt - Tendenz - Falsch + )} +
+ + {/* Tip history */} + {recentTips.length > 0 && ( +
+

Letzte Tipps

+
    + {recentTips.map((tip, i) => ( +
  • + + {tip.home_team_short} vs {tip.away_team_short} + + + Tipp: {tip.tip_home}:{tip.tip_away} + + + {pointLabel(tip.points)} + +
  • + ))} +
+
+ )} + + {/* Fun stats */} + {tips.length > 0 && ( +
+

Fun Facts

+
+
+ 🎯 + Lieblings-Tipp + {favTip} +
+
+ 🏠 + Heimsiege getippt + {homePct}% +
+
+ 📊 + Tipps abgegeben + {stats.tipsCount} +
)}