feat: smart sections in Spielplan (Heute/Morgen/Woche/Vergangen)

Matches grouped by time relevance with collapsible accordion.
Heute and Morgen open by default. Stage filter simplified to dropdown.
This commit is contained in:
Ronny
2026-04-11 19:05:09 +02:00
parent d48bc2d449
commit 82619e6db3
2 changed files with 146 additions and 203 deletions
+48 -65
View File
@@ -28,83 +28,66 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.filters { .stageFilter {
display: flex; background: var(--surface-mid);
flex-wrap: wrap; color: var(--text-primary);
gap: 8px; border: 1px solid rgba(75, 183, 248, 0.15);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 0.85rem;
width: 100%;
max-width: 200px;
margin-bottom: 12px;
} }
.filter, .filterActive { .section {
padding: 7px 16px; margin-bottom: 8px;
border-radius: 20px; border-radius: var(--radius-md);
font-size: 13px; overflow: hidden;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-secondary);
transition: all 0.15s;
}
.filter:hover { border-color: var(--primary); color: var(--primary); }
.filterActive {
background: var(--primary-dim);
border-color: rgba(75,183,248,0.3);
color: var(--primary);
} }
/* Gruppen-Filter (zweite Ebene) */ .sectionHighlight {
.groupFilters { border-left: 3px solid var(--gold);
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 0;
border-top: 1px solid var(--border-subtle);
margin-top: -8px;
} }
.groupFilter, .groupFilterActive { .sectionHeader {
padding: 5px 13px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-subtle);
background: transparent;
color: var(--text-muted);
transition: all 0.15s;
}
.groupFilter:hover { border-color: rgba(75,183,248,0.3); color: var(--text-secondary); }
.groupFilterActive {
background: rgba(75,183,248,0.12);
border-color: rgba(75,183,248,0.25);
color: var(--primary);
}
.dayGroup { display: flex; flex-direction: column; gap: 12px; }
.dayHeader {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0 4px;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%;
padding: 12px 16px;
background: var(--surface-mid);
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 0.95rem;
gap: 8px; gap: 8px;
} }
.sectionHeader:hover {
.todayDot { background: var(--surface-high);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
box-shadow: 0 0 6px rgba(75,183,248,0.6);
flex-shrink: 0;
} }
.matchList { display: flex; flex-direction: column; gap: 10px; } .sectionLabel {
font-weight: 600;
flex: 1;
text-align: left;
}
.sectionCount {
font-size: 0.8rem;
color: var(--text-muted);
}
.sectionChevron {
font-size: 0.85rem;
color: var(--text-muted);
}
.sectionContent {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
/* States */ /* States */
.loadingState, .errorState, .emptyState { .loadingState, .errorState, .emptyState {
+96 -136
View File
@@ -1,33 +1,64 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { api, Match } from '../api/client'; import { api, Match } from '../api/client';
import MatchCard from '../components/MatchCard'; import MatchCard from '../components/MatchCard';
import TipModal from '../components/TipModal'; import TipModal from '../components/TipModal';
import styles from './MatchesPage.module.css'; import styles from './MatchesPage.module.css';
const STAGES: { key: string; label: string }[] = [ type Section = {
{ key: '', label: 'Alle' }, key: string;
{ key: 'GROUP_STAGE', label: 'Gruppenphase' }, label: string;
{ key: 'LAST_32', label: 'Runde der 32' }, matches: Match[];
{ key: 'LAST_16', label: 'Achtelfinale' }, defaultOpen: boolean;
{ key: 'QUARTER_FINALS', label: 'Viertelfinale' }, highlight: boolean;
{ key: 'SEMI_FINALS', label: 'Halbfinale' }, };
{ key: 'THIRD_PLACE', label: 'Platz 3' },
{ key: 'FINAL', label: 'Finale' },
];
const GROUPS = ['A','B','C','D','E','F','G','H','I','J','K','L']; function groupIntoSections(matches: Match[]): Section[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrowStart = new Date(todayStart);
tomorrowStart.setDate(todayStart.getDate() + 1);
const dayAfterTomorrow = new Date(todayStart);
dayAfterTomorrow.setDate(todayStart.getDate() + 2);
const weekEnd = new Date(todayStart);
weekEnd.setDate(todayStart.getDate() + 7);
const sections: Section[] = [
{ key: 'today', label: 'Heute', matches: [], defaultOpen: true, highlight: true },
{ key: 'tomorrow', label: 'Morgen', matches: [], defaultOpen: true, highlight: false },
{ key: 'week', label: 'Diese Woche', matches: [], defaultOpen: false, highlight: false },
{ key: 'later', label: 'Demnächst', matches: [], defaultOpen: false, highlight: false },
{ key: 'past', label: 'Vergangene Spiele', matches: [], defaultOpen: false, highlight: false },
];
for (const match of matches) {
const d = new Date(match.utcDate);
if (d < todayStart) {
sections[4].matches.push(match); // past
} else if (d < tomorrowStart) {
sections[0].matches.push(match); // today
} else if (d < dayAfterTomorrow) {
sections[1].matches.push(match); // tomorrow
} else if (d < weekEnd) {
sections[2].matches.push(match); // this week
} else {
sections[3].matches.push(match); // later
}
}
// Past matches: most recent first
sections[4].matches.reverse();
return sections.filter(s => s.matches.length > 0);
}
export default function MatchesPage() { export default function MatchesPage() {
const [allMatches, setAllMatches] = useState<Match[]>([]); const [allMatches, setAllMatches] = useState<Match[]>([]);
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 [stage, setStage] = useState(''); const [stageFilter, setStageFilter] = useState('');
const [group, setGroup] = useState('');
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null); const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
const todayRef = useRef<HTMLDivElement | null>(null); const [openSections, setOpenSections] = useState<Set<string>>(new Set());
const hasScrolled = useRef(false);
// Alle Spiele einmalig laden — Filterung passiert im Frontend
const loadMatches = useCallback(async () => { const loadMatches = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -43,15 +74,14 @@ export default function MatchesPage() {
useEffect(() => { loadMatches(); }, [loadMatches]); useEffect(() => { loadMatches(); }, [loadMatches]);
// Auto-Scroll zum heutigen Datum nach dem ersten Laden // Initialize open sections after initial load
useEffect(() => { useEffect(() => {
if (!loading && !hasScrolled.current && todayRef.current && !stage) { if (allMatches.length > 0) {
hasScrolled.current = true; const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
setTimeout(() => { const sections = groupIntoSections(filteredMatches);
todayRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key)));
}, 100);
} }
}, [loading, stage]); }, [allMatches]); // only on initial load
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 =>
@@ -62,46 +92,28 @@ export default function MatchesPage() {
setSelectedMatch(null); setSelectedMatch(null);
}; };
// Gefilterte Matches für aktuelle Ansicht function toggleSection(key: string) {
const matches = allMatches setOpenSections(prev => {
.filter(m => !stage || m.stage === stage) const next = new Set(prev);
.filter(m => !group || m.group === `GROUP_${group}`); if (next.has(key)) next.delete(key);
else next.add(key);
// Stats immer über alle Spiele (nicht gefiltert) return next;
const tipped = allMatches.filter(m => m.userTip).length;
const tippable = allMatches.filter(m => m.tippable && !m.userTip).length;
// Verfügbare Gruppen für den aktiven Stage-Filter
const availableGroups = stage === 'GROUP_STAGE'
? [...new Set(allMatches
.filter(m => m.stage === 'GROUP_STAGE' && m.group)
.map(m => m.group!.replace('GROUP_', ''))
)].sort()
: [];
// Spiele nach Datum gruppieren (Gruppenphase) oder nach Gruppe (wenn Gruppe gewählt)
const grouped = matches.reduce<Record<string, Match[]>>((acc, m) => {
let key: string;
if (stage === 'GROUP_STAGE' && !group) {
// Gruppenphase ohne Gruppen-Filter: nach Gruppe gruppieren
key = `Gruppe ${m.group?.replace('GROUP_', '') ?? '?'}`;
} else {
// Sonst nach Datum
key = new Date(m.utcDate).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long'
}); });
} }
if (!acc[key]) acc[key] = [];
acc[key].push(m); const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter);
return acc; const sections = groupIntoSections(filteredMatches);
}, {});
// Stats always over all matches (unfiltered)
const tipped = allMatches.filter(m => m.userTip).length;
const tippable = allMatches.filter(m => m.tippable && !m.userTip).length;
return ( return (
<div className={styles.page}> <div className={styles.page}>
{/* Header Stats */} {/* Header Stats */}
<div className={styles.statsRow}> <div className={styles.statsRow}>
<div className={`card ${styles.statCard}`}> <div className={`card ${styles.statCard}`}>
<span className={styles.statValue}>{matches.length}</span> <span className={styles.statValue}>{allMatches.length}</span>
<span className={styles.statLabel}>Spiele gesamt</span> <span className={styles.statLabel}>Spiele gesamt</span>
</div> </div>
<div className={`card ${styles.statCard}`}> <div className={`card ${styles.statCard}`}>
@@ -114,39 +126,21 @@ export default function MatchesPage() {
</div> </div>
</div> </div>
{/* Stage Filter */} {/* Stage Filter Dropdown */}
<div className={styles.filters}> <select
{STAGES.map(s => ( className={styles.stageFilter}
<button value={stageFilter}
key={s.key} onChange={e => setStageFilter(e.target.value)}
className={stage === s.key ? styles.filterActive : styles.filter}
onClick={() => { setStage(s.key); setGroup(''); }}
> >
{s.label} <option value="">Alle Phasen</option>
</button> <option value="GROUP_STAGE">Gruppenphase</option>
))} <option value="ROUND_OF_32">Runde der 32</option>
</div> <option value="LAST_16">Achtelfinale</option>
<option value="QUARTER_FINALS">Viertelfinale</option>
{/* Gruppen-Filter (nur bei Gruppenphase) */} <option value="SEMI_FINALS">Halbfinale</option>
{availableGroups.length > 0 && ( <option value="THIRD_PLACE">Platz 3</option>
<div className={styles.groupFilters}> <option value="FINAL">Finale</option>
<button </select>
className={group === '' ? styles.groupFilterActive : styles.groupFilter}
onClick={() => setGroup('')}
>
Alle Gruppen
</button>
{availableGroups.map(g => (
<button
key={g}
className={group === g ? styles.groupFilterActive : styles.groupFilter}
onClick={() => setGroup(g)}
>
Gruppe {g}
</button>
))}
</div>
)}
{/* Content */} {/* Content */}
{loading && ( {loading && (
@@ -163,7 +157,7 @@ export default function MatchesPage() {
</div> </div>
)} )}
{!loading && !error && matches.length === 0 && ( {!loading && !error && filteredMatches.length === 0 && (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<span className={styles.emptyIcon}></span> <span className={styles.emptyIcon}></span>
<p>Noch keine Spiele vorhanden.</p> <p>Noch keine Spiele vorhanden.</p>
@@ -173,56 +167,22 @@ export default function MatchesPage() {
</div> </div>
)} )}
{!loading && !error && (() => { {!loading && !error && sections.map(section => (
const entries = Object.entries(grouped); <div key={section.key} className={`${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`}>
if (stage === 'GROUP_STAGE' && !group) { <button className={styles.sectionHeader} onClick={() => toggleSection(section.key)}>
entries.sort(([a], [b]) => a.localeCompare(b)); <span className={styles.sectionLabel}>{section.label}</span>
} <span className={styles.sectionCount}>{section.matches.length} Spiele</span>
<span className={styles.sectionChevron}>{openSections.has(section.key) ? '▾' : '▸'}</span>
const todayLabel = new Date().toLocaleDateString('de-DE', { </button>
weekday: 'long', day: 'numeric', month: 'long' {openSections.has(section.key) && (
}); <div className={styles.sectionContent}>
{section.matches.map(match => (
// "Nächster bevorstehender Tag" als Fallback wenn kein heutiger Spieltag <MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
let scrollTarget: string | null = null;
if (!stage) {
if (entries.find(([l]) => l === todayLabel)) {
scrollTarget = todayLabel;
} else {
const now = Date.now();
const future = entries.find(([, ms]) => new Date(ms[0].utcDate).getTime() > now);
scrollTarget = future?.[0] ?? null;
}
}
return entries.map(([label, labelMatches]) => {
const isScrollTarget = label === scrollTarget;
return (
<div
key={label}
className={styles.dayGroup}
ref={isScrollTarget ? todayRef : null}
>
<h2 className={styles.dayHeader}>
{label === todayLabel ? (
<><span className={styles.todayDot} />Heute {label}</>
) : label}
</h2>
<div className={styles.matchList}>
{labelMatches
.sort((a, b) => new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime())
.map(match => (
<MatchCard
key={match.id}
match={match}
onTip={() => setSelectedMatch(match)}
/>
))} ))}
</div> </div>
)}
</div> </div>
); ))}
});
})()}
{selectedMatch && ( {selectedMatch && (
<TipModal <TipModal