// /src/app/components/MatchDetails.tsx 'use client' import { useState, useEffect, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { format } from 'date-fns' import { de } from 'date-fns/locale' import Table from './Table' import PremierRankBadge from './PremierRankBadge' import CompRankBadge from './CompRankBadge' import EditMatchMetaModal from './EditMatchMetaModal' import EditMatchPlayersModal from './EditMatchPlayersModal' import type { EditSide } from './EditMatchPlayersModal' import type { Match, MatchPlayer } from '../types/match' import Button from './Button' import { MAP_OPTIONS } from '../lib/mapOptions' import MapVoteBanner from './MapVoteBanner' import { useSSEStore } from '@/app/lib/useSSEStore' import { Team } from '../types/team' import Alert from './Alert' import Image from 'next/image' import Link from 'next/link' type TeamWithPlayers = Team & { players?: MatchPlayer[] } /* ─────────────────── Hilfsfunktionen ────────────────────────── */ const kdr = (k?: number, d?: number) => typeof k === 'number' && typeof d === 'number' ? d === 0 ? '∞' : (k / d).toFixed(2) : '-' const adr = (dmg?: number, rounds?: number) => typeof dmg === 'number' && typeof rounds === 'number' && rounds > 0 ? (dmg / rounds).toFixed(1) : '-' const normalizeMapKey = (raw?: string) => (raw ?? '').toLowerCase().replace(/\.bsp$/, '').replace(/^.*\//, '') type VoteAction = 'BAN' | 'PICK' | 'DECIDER' type VoteStep = { order: number; action: VoteAction; map?: string | null } const mapLabelFromKey = (key?: string) => { const k = (key ?? '').toLowerCase().replace(/\.bsp$/,'').replace(/^.*\//,'') return ( MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD') ) } // Maps aus dem MapVote (nur PICK/DECIDER, sortiert) function extractSeriesMaps(match: Match): string[] { const steps = (match.mapVote?.steps ?? []) as unknown as VoteStep[] const picks = steps .filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER')) .sort((a,b) => (a.order ?? 0) - (b.order ?? 0)) .map(s => s.map ?? '') const n = Math.max(1, match.bestOf ?? 1) return picks.slice(0, n) } function SeriesStrip({ bestOf, scoreA = 0, scoreB = 0, maps, }: { bestOf: number scoreA?: number | null scoreB?: number | null maps: string[] }) { const winsA = Math.max(0, scoreA ?? 0) const winsB = Math.max(0, scoreB ?? 0) const needed = Math.ceil(bestOf / 2) const total = Math.max(bestOf, maps.length || 1) const finished = winsA >= needed || winsB >= needed const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1) return (
Best of {bestOf} • First to {needed}
{winsA}:{winsB}
{Array.from({ length: total }).map((_, i) => { const key = maps[i] ?? '' const label = mapLabelFromKey(key) const isDone = i < winsA + winsB const isCurrent = i === currentIdx const isFuture = i > winsA + winsB return (
Map {i + 1} {label}
{isDone && ( )} {isCurrent && !isDone && !isFuture && ( LIVE / als Nächstes )}
) })}
) } /* ─────────────────── Komponente ─────────────────────────────── */ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: number }) { const { data: session } = useSession() const { lastEvent } = useSSEStore() const router = useRouter() const isAdmin = !!session?.user?.isAdmin // Hydration-sicher: keine sich ändernden Werte im SSR rendern // Wir brauchen "now" nur, um zu entscheiden, ob Mapvote schon gestartet ist const [now, setNow] = useState(initialNow) const [editMetaOpen, setEditMetaOpen] = useState(false) // Lokale Overrides (analog MapVoteBanner), damit die Clients sofort reagieren const [opensAtOverride, setOpensAtOverride] = useState(null) const [leadOverride, setLeadOverride] = useState(null) const lastHandledKeyRef = useRef('') /* ─── Rollen & Rechte ─────────────────────────────────────── */ const me = session?.user const userId = me?.steamId const isLeaderA = !!userId && userId === match.teamA?.leader?.steamId const isLeaderB = !!userId && userId === match.teamB?.leader?.steamId const canEditA = isAdmin || isLeaderA const canEditB = isAdmin || isLeaderB const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] /* ─── Map-Label ───────────────────────────────────────────── */ const mapKey = normalizeMapKey(match.map) const mapLabel = MAP_OPTIONS.find(opt => opt.key === mapKey)?.label ?? MAP_OPTIONS.find(opt => opt.key === 'lobby_mapvote')?.label ?? 'Unbekannte Map' /* ─── Match-Zeitpunkt (vom Server; ändert sich via router.refresh) ─── */ const dateString = match.matchDate ?? match.demoDate const readableDate = dateString ? format(new Date(dateString), 'PPpp', { locale: de }) : 'Unbekannt' const seriesMaps = useMemo(() => { const fromVote = extractSeriesMaps(match) const n = Math.max(1, match.bestOf ?? 1) return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')] }, [match.bestOf, match.mapVote?.steps?.length]) /* ─── Modal-State ─────────────────────────────────────────── */ const [editSide, setEditSide] = useState(null) /* ─── Live-Uhr für Mapvote-Startfenster ───────────────────── */ useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, []) // Basiszeit des Matches (stabil; für Berechnung von opensAt-Fallback) const matchBaseTs = useMemo(() => { const raw = match.matchDate ?? match.demoDate ?? initialNow return new Date(raw).getTime() }, [match.matchDate, match.demoDate, initialNow]) // Zeitpunkt, wann der Mapvote öffnet (Parent errechnet und an Banner gereicht) const voteOpensAtTs = useMemo(() => { if (opensAtOverride != null) return opensAtOverride if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime() const lead = (leadOverride != null) ? leadOverride : (Number.isFinite(match.mapVote?.leadMinutes ?? NaN) ? (match.mapVote!.leadMinutes as number) : 60) return matchBaseTs - lead * 60_000 }, [opensAtOverride, match.mapVote?.opensAt, match.mapVote?.leadMinutes, matchBaseTs, leadOverride]) const sseOpensAtTs = voteOpensAtTs const sseLeadMinutes = leadOverride const endDate = new Date(voteOpensAtTs) const mapvoteStarted = (match.mapVote?.isOpen ?? false) || now >= voteOpensAtTs const showEditA = canEditA && !mapvoteStarted const showEditB = canEditB && !mapvoteStarted /* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */ useEffect(() => { if (!lastEvent) return // robustes Unwrap const outer = lastEvent as any const maybeInner = outer?.payload const base = (maybeInner && typeof maybeInner === 'object' && 'type' in maybeInner && 'payload' in maybeInner) ? maybeInner : outer const type = base?.type const evt = base?.payload ?? base if (!evt?.matchId || evt.matchId !== match.id) return // Dedupe-Key const key = `${type}|${evt.matchId}|${evt.opensAt ?? ''}|${Number.isFinite(evt.leadMinutes) ? evt.leadMinutes : ''}` if (key === lastHandledKeyRef.current) { // identisches Event bereits verarbeitet → ignorieren return } lastHandledKeyRef.current = key // eigentliche Verarbeitung if (type === 'map-vote-updated') { if (evt?.opensAt) { const ts = new Date(evt.opensAt).getTime() setOpensAtOverride(ts) } if (Number.isFinite(evt?.leadMinutes)) { const lead = Number(evt.leadMinutes) setLeadOverride(lead) if (!evt?.opensAt) { const baseTs = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime() setOpensAtOverride(baseTs - lead * 60_000) } } // damit match.matchDate & Co. neu vom Server kommen router.refresh() return } const REFRESH_TYPES = new Set(['map-vote-reset', 'map-vote-locked', 'map-vote-unlocked', 'match-lineup-updated']) if (REFRESH_TYPES.has(type) && evt?.matchId === match.id) { router.refresh() } }, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]) /* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */ const ColGroup = () => ( {Array.from({ length: 13 }).map((_, i) => ( ))} ) /* ─── Match löschen ────────────────────────────────────────── */ const handleDelete = async () => { if (!confirm('Match wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return try { const res = await fetch(`/api/matches/${match.id}/delete`, { method: 'POST' }) if (!res.ok) { const j = await res.json().catch(() => ({})) alert(j.message ?? 'Löschen fehlgeschlagen') return } router.push('/schedule') } catch (e) { console.error('[MatchDetails] delete failed', e) alert('Löschen fehlgeschlagen.') } } /* ─── Spieler-Tabelle (pure; keine Hooks hier drin!) ──────── */ const renderTable = (players: MatchPlayer[]) => { const sorted = [...players].sort( (a, b) => (b.stats?.totalDamage ?? 0) - (a.stats?.totalDamage ?? 0), ) return ( {[ 'Spieler', 'Rank', 'Aim', 'K', 'A', 'D', '1K', '2K', '3K', '4K', '5K', 'K/D', 'ADR', 'HS%', 'Damage', ].map((h) => ( {h} ))} {sorted.map((p) => ( router.push(`/profile/${p.user.steamId}`)} > {p.user.name}
{p.user.name ?? 'Unbekannt'}
{match.matchType === 'premier' ? : } {match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( 0 ? 'text-green-500' : p.stats.rankChange < 0 ? 'text-red-500' : '' }`} > {p.stats.rankChange > 0 ? '+' : ''} {p.stats.rankChange} )}
{Number.isFinite(Number(p.stats?.aim)) ? `${Number(p.stats?.aim).toFixed(0)} %` : '-'} {p.stats?.kills ?? '-'} {p.stats?.assists ?? '-'} {p.stats?.deaths ?? '-'} {p.stats?.oneK ?? '-'} {p.stats?.twoK ?? '-'} {p.stats?.threeK ?? '-'} {p.stats?.fourK ?? '-'} {p.stats?.fiveK ?? '-'} {kdr(p.stats?.kills, p.stats?.deaths)} {adr(p.stats?.totalDamage, match.roundCount)} {((p.stats?.headshotPct ?? 0) * 100).toFixed(0)}% {p.stats?.totalDamage?.toFixed(0) ?? '-'}
))}
) } /* ─── Render ─────────────────────────────────────────────── */ return (
{/* Kopfzeile: Zurück + Admin-Buttons */}
{isAdmin && (
)}

Match auf {mapLabel} ({match.matchType})

{/* Hydration-sicher: Datum kommt vom Server und ändert sich nach SSE via router.refresh() */}

Datum: {readableDate}

{(match.bestOf ?? 1) > 1 && (
)}
Teams: {match.teamA?.name ?? 'Unbekannt'} vs. {match.teamB?.name ?? 'Unbekannt'}
Score: {match.scoreA ?? 0}:{match.scoreB ?? 0}
{/* MapVote-Banner erhält die aktuell berechneten (SSE-konformen) Werte */} {(match.matchType === 'community' && )} {/* ───────── Team-Blöcke ───────── */}
{/* Team A */}

{match.teamA?.logo && ( Teamlogo )} {match.teamB?.name ?? 'Team B'}

{canEditA && !mapvoteStarted && (
Du kannst die Aufstellung noch bis{' '} {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten.
)}
{renderTable(teamAPlayers)}
{/* Team B */}

{match.teamB?.logo && ( Teamlogo )} {match.teamB?.name ?? 'Team B'}

{canEditB && !mapvoteStarted && (
Du kannst die Aufstellung noch bis{' '} {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten.
)}
{renderTable(teamBPlayers)}
{/* ───────── Modal ───────── */} {editSide && ( setEditSide(null)} matchId={match.id} teamA={match.teamA} teamB={match.teamB} side={editSide} initialA={teamAPlayers.map((mp) => mp.user.steamId)} initialB={teamBPlayers.map((mp) => mp.user.steamId)} onSaved={() => router.refresh()} /> )} {editMetaOpen && ( setEditMetaOpen(false)} matchId={match.id} defaultTitle={match.title} defaultTeamAId={match.teamA?.id ?? null} defaultTeamBId={match.teamB?.id ?? null} defaultTeamAName={match.teamA?.name ?? null} defaultTeamBName={match.teamB?.name ?? null} defaultDateISO={match.matchDate ?? match.demoDate ?? null} defaultMap={match.map ?? null} defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60} defaultBestOf={Number(match.bestOf) === 5 ? 5 : 3} onSaved={() => { setTimeout(() => router.refresh(), 0) }} /> )}
) }