// /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 ?? '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 && (
)}
{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 && (
)}
{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) }}
/>
)}
)
}