From 82619e6db36a07007111d8db077cb838b59fde34 Mon Sep 17 00:00:00 2001 From: Ronny Date: Sat, 11 Apr 2026 19:05:09 +0200 Subject: [PATCH] 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. --- frontend/src/pages/MatchesPage.module.css | 113 +++++------ frontend/src/pages/MatchesPage.tsx | 236 +++++++++------------- 2 files changed, 146 insertions(+), 203 deletions(-) diff --git a/frontend/src/pages/MatchesPage.module.css b/frontend/src/pages/MatchesPage.module.css index 6f511a4..fbb5551 100644 --- a/frontend/src/pages/MatchesPage.module.css +++ b/frontend/src/pages/MatchesPage.module.css @@ -28,83 +28,66 @@ letter-spacing: 0.05em; } -.filters { - display: flex; - flex-wrap: wrap; - gap: 8px; +.stageFilter { + background: var(--surface-mid); + color: var(--text-primary); + 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 { - padding: 7px 16px; - border-radius: 20px; - font-size: 13px; - 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); +.section { + margin-bottom: 8px; + border-radius: var(--radius-md); + overflow: hidden; } -/* Gruppen-Filter (zweite Ebene) */ -.groupFilters { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 4px 0; - border-top: 1px solid var(--border-subtle); - margin-top: -8px; +.sectionHighlight { + border-left: 3px solid var(--gold); } -.groupFilter, .groupFilterActive { - 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; +.sectionHeader { display: flex; 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; } - -.todayDot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--primary); - box-shadow: 0 0 6px rgba(75,183,248,0.6); - flex-shrink: 0; +.sectionHeader:hover { + background: var(--surface-high); } -.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 */ .loadingState, .errorState, .emptyState { diff --git a/frontend/src/pages/MatchesPage.tsx b/frontend/src/pages/MatchesPage.tsx index 72806f4..31a8e9e 100644 --- a/frontend/src/pages/MatchesPage.tsx +++ b/frontend/src/pages/MatchesPage.tsx @@ -1,33 +1,64 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { api, Match } from '../api/client'; import MatchCard from '../components/MatchCard'; import TipModal from '../components/TipModal'; import styles from './MatchesPage.module.css'; -const STAGES: { key: string; label: string }[] = [ - { key: '', label: 'Alle' }, - { key: 'GROUP_STAGE', label: 'Gruppenphase' }, - { key: 'LAST_32', label: 'Runde der 32' }, - { key: 'LAST_16', label: 'Achtelfinale' }, - { key: 'QUARTER_FINALS', label: 'Viertelfinale' }, - { key: 'SEMI_FINALS', label: 'Halbfinale' }, - { key: 'THIRD_PLACE', label: 'Platz 3' }, - { key: 'FINAL', label: 'Finale' }, -]; +type Section = { + key: string; + label: string; + matches: Match[]; + defaultOpen: boolean; + highlight: boolean; +}; -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() { const [allMatches, setAllMatches] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [stage, setStage] = useState(''); - const [group, setGroup] = useState(''); + const [stageFilter, setStageFilter] = useState(''); const [selectedMatch, setSelectedMatch] = useState(null); - const todayRef = useRef(null); - const hasScrolled = useRef(false); + const [openSections, setOpenSections] = useState>(new Set()); - // Alle Spiele einmalig laden — Filterung passiert im Frontend const loadMatches = useCallback(async () => { setLoading(true); setError(null); @@ -43,15 +74,14 @@ export default function MatchesPage() { useEffect(() => { loadMatches(); }, [loadMatches]); - // Auto-Scroll zum heutigen Datum nach dem ersten Laden + // Initialize open sections after initial load useEffect(() => { - if (!loading && !hasScrolled.current && todayRef.current && !stage) { - hasScrolled.current = true; - setTimeout(() => { - todayRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 100); + if (allMatches.length > 0) { + const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter); + const sections = groupIntoSections(filteredMatches); + setOpenSections(new Set(sections.filter(s => s.defaultOpen).map(s => s.key))); } - }, [loading, stage]); + }, [allMatches]); // only on initial load const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => { setAllMatches(prev => prev.map(m => @@ -62,46 +92,28 @@ export default function MatchesPage() { setSelectedMatch(null); }; - // Gefilterte Matches für aktuelle Ansicht - const matches = allMatches - .filter(m => !stage || m.stage === stage) - .filter(m => !group || m.group === `GROUP_${group}`); + function toggleSection(key: string) { + setOpenSections(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } - // Stats immer über alle Spiele (nicht gefiltert) + const filteredMatches = allMatches.filter(m => !stageFilter || m.stage === stageFilter); + 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; - // 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>((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); - return acc; - }, {}); - return (
{/* Header Stats */}
- {matches.length} + {allMatches.length} Spiele gesamt
@@ -114,39 +126,21 @@ export default function MatchesPage() {
- {/* Stage Filter */} -
- {STAGES.map(s => ( - - ))} -
- - {/* Gruppen-Filter (nur bei Gruppenphase) */} - {availableGroups.length > 0 && ( -
- - {availableGroups.map(g => ( - - ))} -
- )} + {/* Stage Filter Dropdown */} + {/* Content */} {loading && ( @@ -163,7 +157,7 @@ export default function MatchesPage() {
)} - {!loading && !error && matches.length === 0 && ( + {!loading && !error && filteredMatches.length === 0 && (

Noch keine Spiele vorhanden.

@@ -173,56 +167,22 @@ export default function MatchesPage() {
)} - {!loading && !error && (() => { - const entries = Object.entries(grouped); - if (stage === 'GROUP_STAGE' && !group) { - entries.sort(([a], [b]) => a.localeCompare(b)); - } - - const todayLabel = new Date().toLocaleDateString('de-DE', { - weekday: 'long', day: 'numeric', month: 'long' - }); - - // "Nächster bevorstehender Tag" als Fallback wenn kein heutiger Spieltag - 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 ( -
-

- {label === todayLabel ? ( - <>Heute — {label} - ) : label} -

-
- {labelMatches - .sort((a, b) => new Date(a.utcDate).getTime() - new Date(b.utcDate).getTime()) - .map(match => ( - setSelectedMatch(match)} - /> - ))} -
+ {!loading && !error && sections.map(section => ( +
+ + {openSections.has(section.key) && ( +
+ {section.matches.map(match => ( + setSelectedMatch(match)} /> + ))}
- ); - }); - })()} + )} +
+ ))} {selectedMatch && (