style: Spielplan — glassmorphism stats card, timeline date headers
- Stats: glass card with 2/104 progress + Punkte/Exakt/Offen details - Date sections: timeline divider with centered label + lines instead of accordion (no more broken border-radius) - Past matches: simple toggle button, separate from timeline - Match list: clean vertical flow without section containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,84 +1,148 @@
|
|||||||
.page { display: flex; flex-direction: column; gap: 16px; }
|
.page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
/* Compact stats line */
|
/* ── Stats Card (glassmorphism) ── */
|
||||||
.statsLine {
|
.statsCard {
|
||||||
|
padding: 18px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 16px;
|
||||||
padding: 10px 16px;
|
|
||||||
background: var(--surface-mid);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsText {
|
.statsMain {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
color: var(--text-secondary);
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsText strong {
|
.statsProgress {
|
||||||
color: var(--text-primary);
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsReminder {
|
.statsNum {
|
||||||
font-size: 12px;
|
font-size: 28px;
|
||||||
color: var(--gold);
|
font-weight: 800;
|
||||||
|
color: var(--primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsSlash {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 300;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsTotal {
|
||||||
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sections */
|
.statsLabel {
|
||||||
.section {
|
font-size: 11px;
|
||||||
border-radius: var(--radius-md);
|
color: var(--text-muted);
|
||||||
overflow: hidden;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHighlight {
|
.statsDivider {
|
||||||
border-left: 3px solid var(--gold);
|
width: 1px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.statsDetails {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsDetail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsDetailValue {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsDetailLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Date Headers (timeline dividers) ── */
|
||||||
|
.dateHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
padding: 4px 0;
|
||||||
background: var(--surface-mid);
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
gap: 8px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.sectionHeader:hover {
|
|
||||||
background: var(--surface-high);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionLabel {
|
.dateLine {
|
||||||
font-weight: 700;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: left;
|
height: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionCount {
|
.dateLabel {
|
||||||
font-size: 0.8rem;
|
font-size: 14px;
|
||||||
color: var(--text-muted);
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dateCount {
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.sectionChevron {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionContent {
|
/* ── Match List ── */
|
||||||
|
.matchList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px 0;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* States */
|
/* ── Past matches toggle ── */
|
||||||
|
.pastToggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--surface-low);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.pastToggle:hover {
|
||||||
|
background: var(--surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── States ── */
|
||||||
.loadingState, .errorState, .emptyState {
|
.loadingState, .errorState, .emptyState {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ import { useRevealQueue } from '../hooks/useRevealQueue';
|
|||||||
import ConfettiReveal from '../components/ConfettiReveal';
|
import ConfettiReveal from '../components/ConfettiReveal';
|
||||||
import styles from './MatchesPage.module.css';
|
import styles from './MatchesPage.module.css';
|
||||||
|
|
||||||
type Section = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
matches: Match[];
|
|
||||||
defaultOpen: boolean;
|
|
||||||
highlight: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDateLabel(dateStr: string): string {
|
function formatDateLabel(dateStr: string): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -32,11 +24,12 @@ function formatDateLabel(dateStr: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupByDate(matches: Match[]): Section[] {
|
type DateGroup = { dateKey: string; label: string; matches: Match[] };
|
||||||
|
|
||||||
|
function groupByDate(matches: Match[]): { upcoming: DateGroup[]; past: Match[] } {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
// Separate past and upcoming
|
|
||||||
const past: Match[] = [];
|
const past: Match[] = [];
|
||||||
const upcoming: Match[] = [];
|
const upcoming: Match[] = [];
|
||||||
|
|
||||||
@@ -50,42 +43,25 @@ function groupByDate(matches: Match[]): Section[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group upcoming by date
|
// Group upcoming by date
|
||||||
const dateGroups = new Map<string, Match[]>();
|
const dateGroups: DateGroup[] = [];
|
||||||
|
const groupMap = new Map<string, Match[]>();
|
||||||
|
|
||||||
for (const m of upcoming) {
|
for (const m of upcoming) {
|
||||||
const dateKey = new Date(m.utcDate).toISOString().split('T')[0];
|
const dateKey = new Date(m.utcDate).toISOString().split('T')[0];
|
||||||
if (!dateGroups.has(dateKey)) dateGroups.set(dateKey, []);
|
if (!groupMap.has(dateKey)) groupMap.set(dateKey, []);
|
||||||
dateGroups.get(dateKey)!.push(m);
|
groupMap.get(dateKey)!.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sections: Section[] = [];
|
for (const [dateKey, dateMatches] of groupMap) {
|
||||||
|
dateGroups.push({
|
||||||
// Add upcoming date sections (first 3 open, rest collapsed)
|
dateKey,
|
||||||
let idx = 0;
|
label: formatDateLabel(dateMatches[0].utcDate),
|
||||||
for (const [dateKey, dateMatches] of dateGroups) {
|
|
||||||
const label = formatDateLabel(dateMatches[0].utcDate);
|
|
||||||
sections.push({
|
|
||||||
key: dateKey,
|
|
||||||
label,
|
|
||||||
matches: dateMatches,
|
matches: dateMatches,
|
||||||
defaultOpen: idx < 3,
|
|
||||||
highlight: label === 'Heute',
|
|
||||||
});
|
});
|
||||||
idx++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add past section if any
|
|
||||||
if (past.length > 0) {
|
|
||||||
past.reverse(); // newest first
|
past.reverse(); // newest first
|
||||||
sections.push({
|
return { upcoming: dateGroups, past };
|
||||||
key: 'past',
|
|
||||||
label: 'Vergangene Spiele',
|
|
||||||
matches: past,
|
|
||||||
defaultOpen: false,
|
|
||||||
highlight: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MatchesPage() {
|
export default function MatchesPage() {
|
||||||
@@ -93,7 +69,7 @@ export default function MatchesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
|
const [showPast, setShowPast] = useState(false);
|
||||||
|
|
||||||
const loadMatches = useCallback(async () => {
|
const loadMatches = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -110,18 +86,6 @@ export default function MatchesPage() {
|
|||||||
|
|
||||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||||
|
|
||||||
// Initialize open sections
|
|
||||||
useEffect(() => {
|
|
||||||
if (allMatches.length > 0) {
|
|
||||||
const sections = groupByDate(allMatches);
|
|
||||||
if (sections.length <= 3) {
|
|
||||||
setOpenSections(new Set(sections.map(s => s.key)));
|
|
||||||
} else {
|
|
||||||
setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [allMatches]);
|
|
||||||
|
|
||||||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||||||
setAllMatches(prev => prev.map(m =>
|
setAllMatches(prev => prev.map(m =>
|
||||||
m.id === matchId
|
m.id === matchId
|
||||||
@@ -131,18 +95,12 @@ export default function MatchesPage() {
|
|||||||
setSelectedMatch(null);
|
setSelectedMatch(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
function toggleSection(key: string) {
|
|
||||||
setOpenSections(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches);
|
const { current: revealMatch, dismissCurrent } = useRevealQueue(allMatches);
|
||||||
const sections = groupByDate(allMatches);
|
const { upcoming, past } = groupByDate(allMatches);
|
||||||
|
|
||||||
const tipped = allMatches.filter(m => m.userTip).length;
|
const tipped = allMatches.filter(m => m.userTip).length;
|
||||||
|
const totalPoints = allMatches.reduce((sum, m) => sum + (m.userTip?.points ?? 0), 0);
|
||||||
|
const exactCount = allMatches.filter(m => m.userTip?.points === 3).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@@ -150,17 +108,32 @@ export default function MatchesPage() {
|
|||||||
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
|
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Compact stats line */}
|
{/* Stats card */}
|
||||||
{allMatches.length > 0 && (
|
{allMatches.length > 0 && (
|
||||||
<div className={styles.statsLine}>
|
<div className={`card ${styles.statsCard}`}>
|
||||||
<span className={styles.statsText}>
|
<div className={styles.statsMain}>
|
||||||
<strong>{tipped}</strong> von <strong>{allMatches.length}</strong> Spielen getippt
|
<div className={styles.statsProgress}>
|
||||||
</span>
|
<span className={styles.statsNum}>{tipped}</span>
|
||||||
{tipped < allMatches.length && (
|
<span className={styles.statsSlash}>/</span>
|
||||||
<span className={styles.statsReminder}>
|
<span className={styles.statsTotal}>{allMatches.length}</span>
|
||||||
Noch {allMatches.length - tipped} offen
|
</div>
|
||||||
</span>
|
<span className={styles.statsLabel}>Tipps abgegeben</span>
|
||||||
)}
|
</div>
|
||||||
|
<div className={styles.statsDivider} />
|
||||||
|
<div className={styles.statsDetails}>
|
||||||
|
<div className={styles.statsDetail}>
|
||||||
|
<span className={styles.statsDetailValue}>{totalPoints}</span>
|
||||||
|
<span className={styles.statsDetailLabel}>Punkte</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDetail}>
|
||||||
|
<span className={styles.statsDetailValue}>{exactCount}</span>
|
||||||
|
<span className={styles.statsDetailLabel}>Exakt</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDetail}>
|
||||||
|
<span className={styles.statsDetailValue}>{allMatches.length - tipped}</span>
|
||||||
|
<span className={styles.statsDetailLabel}>Offen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -186,24 +159,43 @@ export default function MatchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && sections.map(section => (
|
{/* Upcoming matches — date headers as timeline dividers */}
|
||||||
<div key={section.key} className={`${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`}>
|
{!loading && !error && upcoming.map(group => (
|
||||||
<button className={styles.sectionHeader} onClick={() => toggleSection(section.key)}>
|
<div key={group.dateKey}>
|
||||||
<span className={styles.sectionLabel}>{section.label}</span>
|
<div className={styles.dateHeader}>
|
||||||
<span className={styles.sectionCount}>
|
<span className={styles.dateLine} />
|
||||||
{section.matches.length} {section.matches.length === 1 ? 'Spiel' : 'Spiele'}
|
<span className={styles.dateLabel}>
|
||||||
|
{group.label}
|
||||||
|
<span className={styles.dateCount}>
|
||||||
|
{group.matches.length} {group.matches.length === 1 ? 'Spiel' : 'Spiele'}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.sectionChevron}>{openSections.has(section.key) ? '▾' : '▸'}</span>
|
</span>
|
||||||
|
<span className={styles.dateLine} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.matchList}>
|
||||||
|
{group.matches.map(match => (
|
||||||
|
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Past matches — collapsed by default */}
|
||||||
|
{!loading && !error && past.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button className={styles.pastToggle} onClick={() => setShowPast(p => !p)}>
|
||||||
|
<span>Vergangene Spiele ({past.length})</span>
|
||||||
|
<span>{showPast ? '▾' : '▸'}</span>
|
||||||
</button>
|
</button>
|
||||||
{openSections.has(section.key) && (
|
{showPast && (
|
||||||
<div className={styles.sectionContent}>
|
<div className={styles.matchList}>
|
||||||
{section.matches.map(match => (
|
{past.map(match => (
|
||||||
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{selectedMatch && (
|
{selectedMatch && (
|
||||||
<TipModal
|
<TipModal
|
||||||
|
|||||||
Reference in New Issue
Block a user