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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user