style: Spielplan — glassmorphism stats card, timeline date headers
Build & Deploy Tippspiel / build (push) Successful in 50s
Build & Deploy Tippspiel / build (push) Successful in 50s
- 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; }
|
||||
|
||||
/* Compact stats line */
|
||||
.statsLine {
|
||||
/* ── Stats Card (glassmorphism) ── */
|
||||
.statsCard {
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface-mid);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.statsText {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
.statsMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statsText strong {
|
||||
color: var(--text-primary);
|
||||
.statsProgress {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statsReminder {
|
||||
font-size: 12px;
|
||||
color: var(--gold);
|
||||
.statsNum {
|
||||
font-size: 28px;
|
||||
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;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
.statsLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sectionHighlight {
|
||||
border-left: 3px solid var(--gold);
|
||||
.statsDivider {
|
||||
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;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
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);
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-weight: 700;
|
||||
.dateLine {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
.sectionCount {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
.dateLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dateCount {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sectionChevron {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
/* ── Match List ── */
|
||||
.matchList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -6,14 +6,6 @@ import { useRevealQueue } from '../hooks/useRevealQueue';
|
||||
import ConfettiReveal from '../components/ConfettiReveal';
|
||||
import styles from './MatchesPage.module.css';
|
||||
|
||||
type Section = {
|
||||
key: string;
|
||||
label: string;
|
||||
matches: Match[];
|
||||
defaultOpen: boolean;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
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 today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
// Separate past and upcoming
|
||||
const past: Match[] = [];
|
||||
const upcoming: Match[] = [];
|
||||
|
||||
@@ -50,42 +43,25 @@ function groupByDate(matches: Match[]): Section[] {
|
||||
}
|
||||
|
||||
// Group upcoming by date
|
||||
const dateGroups = new Map<string, Match[]>();
|
||||
const dateGroups: DateGroup[] = [];
|
||||
const groupMap = new Map<string, Match[]>();
|
||||
|
||||
for (const m of upcoming) {
|
||||
const dateKey = new Date(m.utcDate).toISOString().split('T')[0];
|
||||
if (!dateGroups.has(dateKey)) dateGroups.set(dateKey, []);
|
||||
dateGroups.get(dateKey)!.push(m);
|
||||
if (!groupMap.has(dateKey)) groupMap.set(dateKey, []);
|
||||
groupMap.get(dateKey)!.push(m);
|
||||
}
|
||||
|
||||
const sections: Section[] = [];
|
||||
|
||||
// Add upcoming date sections (first 3 open, rest collapsed)
|
||||
let idx = 0;
|
||||
for (const [dateKey, dateMatches] of dateGroups) {
|
||||
const label = formatDateLabel(dateMatches[0].utcDate);
|
||||
sections.push({
|
||||
key: dateKey,
|
||||
label,
|
||||
for (const [dateKey, dateMatches] of groupMap) {
|
||||
dateGroups.push({
|
||||
dateKey,
|
||||
label: formatDateLabel(dateMatches[0].utcDate),
|
||||
matches: dateMatches,
|
||||
defaultOpen: idx < 3,
|
||||
highlight: label === 'Heute',
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
|
||||
// Add past section if any
|
||||
if (past.length > 0) {
|
||||
past.reverse(); // newest first
|
||||
sections.push({
|
||||
key: 'past',
|
||||
label: 'Vergangene Spiele',
|
||||
matches: past,
|
||||
defaultOpen: false,
|
||||
highlight: false,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
past.reverse(); // newest first
|
||||
return { upcoming: dateGroups, past };
|
||||
}
|
||||
|
||||
export default function MatchesPage() {
|
||||
@@ -93,7 +69,7 @@ export default function MatchesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | 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 () => {
|
||||
setLoading(true);
|
||||
@@ -110,18 +86,6 @@ export default function MatchesPage() {
|
||||
|
||||
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) => {
|
||||
setAllMatches(prev => prev.map(m =>
|
||||
m.id === matchId
|
||||
@@ -131,18 +95,12 @@ export default function MatchesPage() {
|
||||
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 sections = groupByDate(allMatches);
|
||||
const { upcoming, past } = groupByDate(allMatches);
|
||||
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
@@ -150,17 +108,32 @@ export default function MatchesPage() {
|
||||
<ConfettiReveal match={revealMatch} onDismiss={dismissCurrent} />
|
||||
)}
|
||||
|
||||
{/* Compact stats line */}
|
||||
{/* Stats card */}
|
||||
{allMatches.length > 0 && (
|
||||
<div className={styles.statsLine}>
|
||||
<span className={styles.statsText}>
|
||||
<strong>{tipped}</strong> von <strong>{allMatches.length}</strong> Spielen getippt
|
||||
</span>
|
||||
{tipped < allMatches.length && (
|
||||
<span className={styles.statsReminder}>
|
||||
Noch {allMatches.length - tipped} offen
|
||||
</span>
|
||||
)}
|
||||
<div className={`card ${styles.statsCard}`}>
|
||||
<div className={styles.statsMain}>
|
||||
<div className={styles.statsProgress}>
|
||||
<span className={styles.statsNum}>{tipped}</span>
|
||||
<span className={styles.statsSlash}>/</span>
|
||||
<span className={styles.statsTotal}>{allMatches.length}</span>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -186,24 +159,43 @@ export default function MatchesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sections.map(section => (
|
||||
<div key={section.key} className={`${styles.section} ${section.highlight ? styles.sectionHighlight : ''}`}>
|
||||
<button className={styles.sectionHeader} onClick={() => toggleSection(section.key)}>
|
||||
<span className={styles.sectionLabel}>{section.label}</span>
|
||||
<span className={styles.sectionCount}>
|
||||
{section.matches.length} {section.matches.length === 1 ? 'Spiel' : 'Spiele'}
|
||||
{/* Upcoming matches — date headers as timeline dividers */}
|
||||
{!loading && !error && upcoming.map(group => (
|
||||
<div key={group.dateKey}>
|
||||
<div className={styles.dateHeader}>
|
||||
<span className={styles.dateLine} />
|
||||
<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 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>
|
||||
{openSections.has(section.key) && (
|
||||
<div className={styles.sectionContent}>
|
||||
{section.matches.map(match => (
|
||||
{showPast && (
|
||||
<div className={styles.matchList}>
|
||||
{past.map(match => (
|
||||
<MatchCard key={match.id} match={match} onTip={() => setSelectedMatch(match)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{selectedMatch && (
|
||||
<TipModal
|
||||
|
||||
Reference in New Issue
Block a user