feat: Ergebnis-Banner, Dev-Simulations-Panel & Spiele-Reset
- MatchCard: farbiger Ergebnis-Banner (Exakt/Tendenz/Falsch) ersetzt tipRow, passender Card-Glow je Ergebnis; Lucide-Icons (lucide-react) - MatchCard: Ändern-Button links, vertikal mittig zur Tipp-Box ausgerichtet - DevPanel: Simulationsmodus mit User-Switcher, Zeit- & Status-Presets - DevPanel: Reset-Section mit "Spiel zurücksetzen", "Alle Spiele" und "Tipps löschen" (3 Buttons, farblich differenziert) - Backend: dev.ts mit set-time, set-status, reset-tips, reset-match - reset-match stellt Original-Datum wieder her (original_utc_date Spalte) - set-time sichert Original-Datum per COALESCE beim ersten Aufruf - Supabase Migration: original_utc_date TIMESTAMPTZ zu matches hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,34 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Gruppen-Filter (zweite Ebene) */
|
||||
.groupFilters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.groupFilter, .groupFilterActive {
|
||||
padding: 5px 13px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
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 {
|
||||
@@ -62,6 +90,18 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.matchList { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { api, Match } from '../api/client';
|
||||
import MatchCard from '../components/MatchCard';
|
||||
import TipModal from '../components/TipModal';
|
||||
@@ -7,37 +7,54 @@ import styles from './MatchesPage.module.css';
|
||||
const STAGES: { key: string; label: string }[] = [
|
||||
{ key: '', label: 'Alle' },
|
||||
{ key: 'GROUP_STAGE', label: 'Gruppenphase' },
|
||||
{ key: 'ROUND_OF_32', label: 'Runde der 32' },
|
||||
{ key: 'ROUND_OF_16', label: 'Achtelfinale' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
const GROUPS = ['A','B','C','D','E','F','G','H','I','J','K','L'];
|
||||
|
||||
export default function MatchesPage() {
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [allMatches, setAllMatches] = useState<Match[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState('');
|
||||
const [group, setGroup] = useState('');
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const todayRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrolled = useRef(false);
|
||||
|
||||
// Alle Spiele einmalig laden — Filterung passiert im Frontend
|
||||
const loadMatches = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getMatches(stage ? { stage } : undefined);
|
||||
setMatches(res.matches);
|
||||
const res = await api.getMatches();
|
||||
setAllMatches(res.matches);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stage]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadMatches(); }, [loadMatches]);
|
||||
|
||||
// Auto-Scroll zum heutigen Datum nach dem ersten Laden
|
||||
useEffect(() => {
|
||||
if (!loading && !hasScrolled.current && todayRef.current && !stage) {
|
||||
hasScrolled.current = true;
|
||||
setTimeout(() => {
|
||||
todayRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}, [loading, stage]);
|
||||
|
||||
const handleTipSaved = (matchId: number, tipHome: number, tipAway: number) => {
|
||||
setMatches(prev => prev.map(m =>
|
||||
setAllMatches(prev => prev.map(m =>
|
||||
m.id === matchId
|
||||
? { ...m, userTip: { home: tipHome, away: tipAway, points: null } }
|
||||
: m
|
||||
@@ -45,19 +62,40 @@ export default function MatchesPage() {
|
||||
setSelectedMatch(null);
|
||||
};
|
||||
|
||||
// Spiele nach Datum gruppieren
|
||||
// Gefilterte Matches für aktuelle Ansicht
|
||||
const matches = allMatches
|
||||
.filter(m => !stage || m.stage === stage)
|
||||
.filter(m => !group || m.group === `GROUP_${group}`);
|
||||
|
||||
// Stats immer über alle Spiele (nicht gefiltert)
|
||||
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) => {
|
||||
const day = new Date(m.utcDate).toLocaleDateString('de-DE', {
|
||||
weekday: 'long', day: 'numeric', month: 'long'
|
||||
});
|
||||
if (!acc[day]) acc[day] = [];
|
||||
acc[day].push(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;
|
||||
}, {});
|
||||
|
||||
const tipped = matches.filter(m => m.userTip).length;
|
||||
const tippable = matches.filter(m => m.tippable).length;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
{/* Header Stats */}
|
||||
@@ -82,13 +120,34 @@ export default function MatchesPage() {
|
||||
<button
|
||||
key={s.key}
|
||||
className={stage === s.key ? styles.filterActive : styles.filter}
|
||||
onClick={() => setStage(s.key)}
|
||||
onClick={() => { setStage(s.key); setGroup(''); }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Gruppen-Filter (nur bei Gruppenphase) */}
|
||||
{availableGroups.length > 0 && (
|
||||
<div className={styles.groupFilters}>
|
||||
<button
|
||||
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 */}
|
||||
{loading && (
|
||||
<div className={styles.loadingState}>
|
||||
@@ -114,20 +173,56 @@ export default function MatchesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && Object.entries(grouped).map(([day, dayMatches]) => (
|
||||
<div key={day} className={styles.dayGroup}>
|
||||
<h2 className={styles.dayHeader}>{day}</h2>
|
||||
<div className={styles.matchList}>
|
||||
{dayMatches.map(match => (
|
||||
<MatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
onTip={() => setSelectedMatch(match)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!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 (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{selectedMatch && (
|
||||
<TipModal
|
||||
|
||||
Reference in New Issue
Block a user