diff --git a/.env b/.env index 21ee999..ba59c2d 100644 --- a/.env +++ b/.env @@ -19,6 +19,13 @@ PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022 PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM PTERO_SERVER_ID=37a11489 -NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local -NEXT_PUBLIC_CS2_WS_PORT=443 -NEXT_PUBLIC_CS2_WS_PATH=/telemetry \ No newline at end of file + +# 🌍 Meta-WebSocket (CS2 Server Plugin) +NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de +NEXT_PUBLIC_CS2_META_WS_PORT=443 +NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry + +# πŸ–₯️ Positionen / GSI-WebSocket (lokaler Aggregator) +NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local +NEXT_PUBLIC_CS2_POS_WS_PORT=8082 +NEXT_PUBLIC_CS2_POS_WS_PATH=/positions diff --git a/src/app/api/matches/[matchId]/mapvote/route.ts b/src/app/api/matches/[matchId]/mapvote/route.ts index 764d305..b8e866c 100644 --- a/src/app/api/matches/[matchId]/mapvote/route.ts +++ b/src/app/api/matches/[matchId]/mapvote/route.ts @@ -1,4 +1,5 @@ // /app/api/matches/[id]/mapvote/route.ts + import { NextResponse, NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/app/lib/auth' diff --git a/src/app/api/matches/[matchId]/meta/route.ts b/src/app/api/matches/[matchId]/meta/route.ts index a507cb6..66bb00b 100644 --- a/src/app/api/matches/[matchId]/meta/route.ts +++ b/src/app/api/matches/[matchId]/meta/route.ts @@ -1,12 +1,13 @@ // /app/api/matches/[matchId]/meta/route.ts + import { NextResponse, type NextRequest } from 'next/server' import { prisma } from '@/app/lib/prisma' import { getServerSession } from 'next-auth' import { authOptions } from '@/app/lib/auth' import { sendServerSSEMessage } from '@/app/lib/sse-server-client' -export const runtime = 'nodejs' // πŸ‘ˆ wie bei mapvote -export const dynamic = 'force-dynamic' // πŸ‘ˆ wie bei mapvote +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' // Hilfsfunktion: akzeptiert Date | string | number | null | undefined function parseDateOrNull(v: unknown): Date | null | undefined { @@ -44,7 +45,7 @@ function parseDateOrNull(v: unknown): Date | null | undefined { return undefined } -// wie in mapvote: Basiszeit -> opensAt +// Basiszeit -> opensAt function voteOpensAt(base: Date, leadMinutes: number) { return new Date(base.getTime() - leadMinutes * 60_000) } @@ -90,7 +91,7 @@ export async function PUT( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } - // 1) Matching-Daten zusammenbauen + // 1) Match-Felder zusammenbauen const updateData: any = {} if (typeof title !== 'undefined') updateData.title = title if (typeof matchType === 'string') updateData.matchType = matchType @@ -105,14 +106,14 @@ export async function PUT( if (parsedDemoDate !== undefined) { updateData.demoDate = parsedDemoDate } else if (parsedMatchDate instanceof Date) { - // demoDate mitziehen, wenn matchDate geΓ€ndert und demoDate nicht gesendet wurde + // demoDate mitschieben, wenn matchDate geΓ€ndert und demoDate nicht gesendet wurde updateData.demoDate = parsedMatchDate } // 2) Lead bestimmen (Body > gespeicherter Wert > default 60) const leadBodyRaw = Number(voteLeadMinutes) const leadBody = Number.isFinite(leadBodyRaw) ? leadBodyRaw : undefined - const currentLead = match.mapVote?.leadMinutes ?? 60 // erfordert Feld im Schema + const currentLead = match.mapVote?.leadMinutes ?? 60 const leadMinutes = leadBody ?? currentLead // 3) Basiszeit (neu oder alt) @@ -121,7 +122,7 @@ export async function PUT( (match.matchDate ?? null) ?? (match.demoDate ?? null) - // 4) Updaten & opensAt ggf. neu setzen – analog zu mapvote + // 4) Updaten & opensAt ggf. neu setzen const updated = await prisma.$transaction(async (tx) => { const m = await tx.match.update({ where: { id }, @@ -129,16 +130,13 @@ export async function PUT( include: { mapVote: true }, }) - // Wenn wir eine Basiszeit haben β†’ opensAt neu berechnen if (baseDate) { const opensAt = voteOpensAt(baseDate, leadMinutes) - if (!m.mapVote) { - // MapVote existiert noch nicht β†’ nur opensAt/leadMinutes anlegen (Schritte erstellt get /mapvote) await tx.mapVote.create({ data: { matchId: m.id, - leadMinutes: leadMinutes, + leadMinutes, opensAt, }, }) @@ -146,16 +144,16 @@ export async function PUT( await tx.mapVote.update({ where: { id: m.mapVote.id }, data: { - ...(leadBody !== undefined ? { leadMinutes: leadMinutes } : {}), + ...(leadBody !== undefined ? { leadMinutes } : {}), opensAt, }, }) } } else if (leadBody !== undefined && m.mapVote) { - // Keine Basiszeit-Γ„nderung, aber Lead explizit gesetzt β†’ nur leadMinutes persistieren + // Nur Lead geΓ€ndert await tx.mapVote.update({ where: { id: m.mapVote.id }, - data: { leadMinutes: leadMinutes }, + data: { leadMinutes }, }) } @@ -171,32 +169,19 @@ export async function PUT( if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 }) - // 5) Events senden – Shape identisch zur mapvote-Route - // a) FΓΌr das Voting/Countdown: map-vote-updated (liefert opensAt) - await sendServerSSEMessage({ - type: 'map-vote-updated', - payload: { - matchId: updated.id, - opensAt: updated.mapVote?.opensAt ?? null, // JSON.stringify -> ISO - }, - }) + // Immer map-vote-updated senden, wenn es einen MapVote gibt + if (updated.mapVote) { + await sendServerSSEMessage({ + type: 'map-vote-updated', + payload: { + matchId: updated.id, + leadMinutes: updated.mapVote.leadMinutes, + ...(updated.mapVote.opensAt ? { opensAt: updated.mapVote.opensAt } : {}), + }, + }) + } - // b) ZusΓ€tzlich Meta-Event fΓΌr andere UIs - await sendServerSSEMessage({ - type: 'match-meta-updated', - payload: { - matchId: updated.id, - updatedAt: new Date().toISOString(), - }, - }) - - // (Optional) allgemeines match-updated, falls du andere Bereiche triggern willst - await sendServerSSEMessage({ - type: 'match-updated', - payload: { matchId: updated.id, updatedAt: new Date().toISOString() }, - }) - - // 6) Response (no-store) + // 6) Response return NextResponse.json({ id: updated.id, title: updated.title, diff --git a/src/app/components/MapVoteBanner.tsx b/src/app/components/MapVoteBanner.tsx index a6acfa4..c54f08b 100644 --- a/src/app/components/MapVoteBanner.tsx +++ b/src/app/components/MapVoteBanner.tsx @@ -1,5 +1,4 @@ // /app/components/MapVoteBanner.tsx - 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -35,18 +34,32 @@ function formatLead(minutes: number) { return `${m}min` } - -export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpensAtTs, sseLeadMinutes }: Props) { +export default function MapVoteBanner({ + match, + initialNow, + matchBaseTs, + sseOpensAtTs, + sseLeadMinutes, +}: Props) { const router = useRouter() const { data: session } = useSession() const { lastEvent } = useSSEStore() + const [state, setState] = useState(null) const [error, setError] = useState(null) - const [leadOverride, setLeadOverride] = useState(null); + const [leadOverride, setLeadOverride] = useState(null) const [opensAtOverride, setOpensAtOverride] = useState(null) - // deterministische Hydration + 1s-Ticker + // ⚠️ Hydration-sicher: auf dem Server rendern wir ein statisches Placeholder + const [mounted, setMounted] = useState(false) + useEffect(() => { setMounted(true) }, []) + + // clientseitiger Ticker const [now, setNow] = useState(initialNow) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) const load = useCallback(async () => { try { @@ -64,24 +77,17 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens setError(e?.message ?? 'Unbekannter Fehler') } }, [match.id]) - + + // initial + bei Meta-Γ„nderungen + useEffect(() => { load() }, [load]) + useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load]) + const matchDateTs = useMemo( () => (typeof matchBaseTs === 'number' ? matchBaseTs : null), [matchBaseTs] ) - useEffect(() => { - const id = setInterval(() => setNow(Date.now()), 1000) - return () => clearInterval(id) - }, []) - - // initial - useEffect(() => { load() }, [load]) - - // πŸ” Neu laden, wenn Match-Metadaten (z. B. matchDate/bestOf) sich durch refresh Γ€ndern - useEffect(() => { load() }, [match.matchDate, match.demoDate, match.bestOf, load]) - - // πŸ” Live-Refresh via SSE + // SSE: nur map-vote-updated & Co. beachten useEffect(() => { if (!lastEvent) return const { type } = lastEvent as any @@ -101,7 +107,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens ? (typeof evt.opensAt === 'string' ? evt.opensAt : new Date(evt.opensAt).toISOString()) : undefined - // sofortige lokale Overrides, ohne auf fetch zu warten + // Sofort lokale Overrides setzen if (nextOpensAtISO) { setOpensAtOverride(new Date(nextOpensAtISO).getTime()) } else if (Number.isFinite(parsedLead) && matchDateTs != null) { @@ -109,7 +115,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens } if (Number.isFinite(parsedLead)) setLeadOverride(parsedLead as number) - // sichtbares Mergen (fΓΌr UI-Text) + // sichtbares Mergen (fΓΌr UI-Texte) if (nextOpensAtISO !== undefined || Number.isFinite(parsedLead)) { setState(prev => ({ ...(prev ?? {} as any), @@ -117,28 +123,23 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens ...(Number.isFinite(parsedLead) ? { leadMinutes: parsedLead } : {}), }) as any) } else { - // nur nachladen, wenn Event keine konkreten Werte trug load() } }, [lastEvent, match.id, matchDateTs, load]) - // Γ–ffnet wann? + // Γ–ffnet wann? (PrioritΓ€t: Parent-SSE β†’ lokale SSE β†’ Server β†’ Fallback) const opensAt = useMemo(() => { - // hΓΆchste PrioritΓ€t: vom Parent (MatchDetails) gereichter TS if (typeof sseOpensAtTs === 'number') return sseOpensAtTs - // dann lokaler SSE-Override if (opensAtOverride != null) return opensAtOverride - // dann Serverwert aus /mapvote if (state?.opensAt) return new Date(state.opensAt).getTime() - // Fallback aus Basis + Lead if (matchDateTs == null) return new Date(initialNow).getTime() const lead = (typeof sseLeadMinutes === 'number') ? sseLeadMinutes - : (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? state!.leadMinutes as number : 60)) + : (leadOverride ?? (Number.isFinite(state?.leadMinutes) ? (state!.leadMinutes as number) : 60)) return matchDateTs - lead * 60_000 }, [sseOpensAtTs, opensAtOverride, state?.opensAt, matchDateTs, initialNow, sseLeadMinutes, leadOverride, state?.leadMinutes]) - // Text β€žstartet X vor Matchbeginnβ€œ + // β€žstartet X vor Matchbeginnβ€œ const leadMinutes = useMemo(() => { if (matchDateTs != null && opensAt != null) { return Math.max(0, Math.round((matchDateTs - opensAt) / 60_000)) @@ -149,16 +150,16 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens return 60 }, [matchDateTs, opensAt, sseLeadMinutes, leadOverride, state?.leadMinutes]) - const isOpen = now >= opensAt + const isOpen = mounted && now >= opensAt const msToOpen = Math.max(opensAt - now, 0) - const current = state?.steps?.[state.currentIndex] + const current = state?.steps?.[state?.currentIndex ?? 0] const whoIsUp = current?.teamId ? (current.teamId === match.teamA?.id ? match.teamA?.name : match.teamB?.name) : null - const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session.user.steamId - const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session.user.steamId + const isLeaderA = !!session?.user?.steamId && match.teamA?.leader?.steamId === session?.user?.steamId + const isLeaderB = !!session?.user?.steamId && match.teamB?.leader?.steamId === session?.user?.steamId const isAdmin = !!session?.user?.isAdmin const iCanAct = Boolean( isOpen && @@ -176,7 +177,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens 'dark:border-neutral-700 shadow-sm cursor-pointer focus:outline-none transition ' + (isOpen ? 'ring-1 ring-green-500/15 hover:ring-green-500/30 hover:shadow-lg' - : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md'); + : 'ring-1 ring-neutral-500/10 hover:ring-neutral-500/20 hover:shadow-md') return (
) : ( - - Γ–ffnet in {formatCountdown(msToOpen)} + // πŸ”‘ Hydration-safe: vor dem Mount nur ein Placeholder rendern + + Γ–ffnet in {mounted ? formatCountdown(msToOpen) : '–:–:–'} )}
@@ -254,7 +259,7 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens ); background-size: 200% 100%; background-repeat: repeat-x; - animation: slide-x 6s linear infinite; /* etwas ruhiger */ + animation: slide-x 6s linear infinite; } :global(.dark) .mapVoteGradient { background-image: repeating-linear-gradient( @@ -290,12 +295,10 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens transform: translateX(-120%) skewX(-20deg); transition: opacity .2s; } - /* nur wenn die Karte offen ist und gehovert wird */ :global(.group:hover) .shine::before { animation: shine 3.8s ease-out infinite; } - /* Respektiere Bewegungs-PrΓ€ferenzen */ @media (prefers-reduced-motion: reduce) { .mapVoteGradient { animation: none; } .shine::before { animation: none !important; transform: none !important; opacity: 0 !important; } @@ -303,4 +306,4 @@ export default function MapVoteBanner({ match, initialNow, matchBaseTs, sseOpens `} ) -} \ No newline at end of file +} diff --git a/src/app/components/MapVotePanel.tsx b/src/app/components/MapVotePanel.tsx index 323fce2..be7f235 100644 --- a/src/app/components/MapVotePanel.tsx +++ b/src/app/components/MapVotePanel.tsx @@ -1,3 +1,5 @@ +// MapVotePanel.tsx + 'use client' import { useEffect, useMemo, useState, useCallback, useRef } from 'react' @@ -50,23 +52,62 @@ export default function MapVotePanel({ match }: Props) { const [error, setError] = useState(null) const [adminEditMode, setAdminEditMode] = useState(false) const [overlayShownOnce, setOverlayShownOnce] = useState(false) + const [opensAtOverrideTs, setOpensAtOverrideTs] = useState(null) /* -------- Timers / open window -------- */ - const opensAtTs = useMemo(() => { - const base = new Date(match.matchDate ?? match.demoDate ?? Date.now()) - return base.getTime() - 60 * 60 * 1000 + // ⚠️ Wichtig: hier keine Date.now()-abhΓ€ngigen Ausgaben im SSR! + + // stabile Basiszeit aus Props (SSR-safe; kein Date.now()) + const matchBaseTs = useMemo(() => { + const raw = match.matchDate ?? match.demoDate ?? null + return raw ? new Date(raw).getTime() : null }, [match.matchDate, match.demoDate]) - const [nowTs, setNowTs] = useState(() => Date.now()) + // vom Server geliefertes opensAt (hat Vorrang) + const opensAtTsFromServer = useMemo(() => { + return state?.opensAt ? new Date(state.opensAt).getTime() : null + }, [state?.opensAt]) + + const fallbackOpensAtTs = useMemo(() => { + return matchBaseTs != null ? matchBaseTs - 60 * 60 * 1000 : null + }, [matchBaseTs]) + + // Reihenfolge: SSE-Override β†’ Server-Stand β†’ Fallback + const openTs = opensAtOverrideTs ?? opensAtTsFromServer ?? fallbackOpensAtTs ?? null + + // Mount-Flag, damit wir den Countdown erst clientseitig laufen lassen + const [mounted, setMounted] = useState(false) + useEffect(() => { setMounted(true) }, []) + + // Countdown-State (nur nach Mount updaten, sekundensynchron) + const [msLeft, setMsLeft] = useState(0) useEffect(() => { - const t = setInterval(() => setNowTs(Date.now()), 1000) - return () => clearInterval(t) - }, []) + if (!mounted) return + if (openTs == null) { setMsLeft(0); return } + + const update = () => setMsLeft(Math.max(openTs - Date.now(), 0)) + update() + + // an Sekundengrenze koppeln + const drift = 1000 - (Date.now() % 1000) + let intervalId: number | null = null + const timeoutId = window.setTimeout(() => { + update() + intervalId = window.setInterval(update, 1000) + }, drift) + + return () => { + window.clearTimeout(timeoutId) + if (intervalId) window.clearInterval(intervalId) + } + }, [openTs, mounted]) + + const isOpen = mounted && (openTs != null ? msLeft <= 0 : false) + const msToOpen = msLeft /* -------- Overlay integration -------- */ const overlayIsForThisMatch = overlayData?.matchId === match.id - // Merken: Overlay wurde fΓΌr dieses Match mindestens einmal angezeigt useEffect(() => { if (overlayOpen && overlayIsForThisMatch) setOverlayShownOnce(true) }, [overlayOpen, overlayIsForThisMatch]) @@ -109,12 +150,38 @@ export default function MapVotePanel({ match }: Props) { useEffect(() => { load() }, [load]) + // πŸ”” Reagiere auf alle relevanten Match-Events (inkl. match-updated) useEffect(() => { if (!lastEvent) return - if (!MATCH_EVENTS.has(lastEvent.type)) return - if (lastEvent.payload?.matchId !== match.id) return - load() - }, [lastEvent, match.id, load]) + + // robustes Unwrapping (doppelte payload) + const unwrap = (e: any) => e?.payload?.payload ?? e?.payload ?? e + const evt = unwrap(lastEvent) + const type = lastEvent.type ?? evt?.type + + if (evt?.matchId !== match.id) return + + // ⬅️ Nur noch map-vote-updated fΓΌr Counter/opensAt + if (type === 'map-vote-updated') { + const { opensAt, leadMinutes } = evt ?? {} + if (opensAt) { + const ts = new Date(opensAt).getTime() + if (Number.isFinite(ts)) setOpensAtOverrideTs(ts) + setState(prev => (prev ? { ...prev, opensAt } : prev)) + } else if (Number.isFinite(leadMinutes) && matchBaseTs != null) { + const ts = matchBaseTs - Number(leadMinutes) * 60_000 + setOpensAtOverrideTs(ts) + setState(prev => (prev ? { ...prev, opensAt: new Date(ts).toISOString() } : prev)) + } + } + + // ⬅️ Nur Map-Vote-Events triggern ein Reload (kein match-updated mehr) + const MAPVOTE_REFRESH = new Set(['map-vote-updated', 'map-vote-reset', 'map-vote-admin-edit']) + if (MAPVOTE_REFRESH.has(type)) { + load() + } + }, [lastEvent, match.id, load, matchBaseTs]) + /* -------- Admin-Edit Mirror -------- */ const adminEditingBy = state?.adminEdit?.by ?? null @@ -125,14 +192,6 @@ export default function MapVotePanel({ match }: Props) { }, [adminEditingEnabled, adminEditingBy, session?.user?.steamId]) /* -------- Derived flags & memoized maps -------- */ - const opensAt = useMemo( - () => (state?.opensAt ? new Date(state.opensAt).getTime() : null), - [state?.opensAt] - ) - const isOpenFromMatch = nowTs >= opensAtTs - const isOpen = opensAt != null ? nowTs >= opensAt : isOpenFromMatch - const msToOpen = Math.max((opensAt ?? opensAtTs) - nowTs, 0) - const me = session?.user const isAdmin = !!me?.isAdmin const mySteamId = me?.steamId @@ -278,8 +337,7 @@ export default function MapVotePanel({ match }: Props) { } if (holdMapRef.current === map) { resetHold() - setProgressByMap(prev => ({ ...prev, [map]: 0 })) - } + setProgressByMap(prev => ({ ...prev, [map]: 0 }))} }, [progressByMap, resetHold, finishAndSubmit]) const onTouchStart = (map: string, allowed: boolean) => (e: React.TouchEvent) => { @@ -495,7 +553,7 @@ export default function MapVotePanel({ match }: Props) { )} ) : isOpen ? ( - isFrozenByAdmin ? ( + adminEditingEnabled && adminEditingBy !== session?.user?.steamId ? ( πŸ”’ Admin-Edit aktiv – Voting pausiert {(() => { @@ -519,8 +577,11 @@ export default function MapVotePanel({ match }: Props) { ) ) : ( - - Γ–ffnet in {fmtCountdown(msToOpen)} + + Γ–ffnet in {mounted && openTs != null ? fmtCountdown(msToOpen) : '–:–:–'} )} @@ -541,17 +602,21 @@ export default function MapVotePanel({ match }: Props) {
{/* Linke Spalte */}
- {teamLeft?.name + {match.teamA?.name
-
{teamLeft?.name ?? 'Team'}
+
{match.teamA?.name ?? 'Team'}
- + ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
- {playersLeft.map((p: MatchPlayer) => ( + {playersA.map((p: MatchPlayer) => ( router.push(`/profile/${p.user.steamId}`)} - isLeader={(state?.teams?.[teamLeftKey]?.leader?.steamId ?? teamLeft?.leader?.steamId) === p.user.steamId} - isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamLeftKey]?.id ?? teamLeft?.id) && !state.locked} + isLeader={(state?.teams?.teamA?.leader?.steamId ?? match.teamA?.leader?.steamId) === p.user.steamId} + isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamA?.id ?? match.teamA?.id) && !state.locked} /> ))}
@@ -597,22 +662,37 @@ export default function MapVotePanel({ match }: Props) { const visualDisabled = `bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700 cursor-not-allowed ${isFrozenByAdmin ? 'opacity-60' : ''}` const visualClasses = taken ? visualTaken : isAvailable ? visualAvailable : visualDisabled + // fΓΌr DECIDER den β€žChooserβ€œ ermitteln (letztes Ban davor) + const steps = state?.steps ?? [] + const decIdx = steps.findIndex(s => s.action === 'decider') + let deciderChooserTeamId: string | null = null + if (decIdx >= 0) { + for (let i = decIdx - 1; i >= 0; i--) { + const s = steps[i] + if (s.action === 'ban' && s.teamId) { deciderChooserTeamId = s.teamId; break } + } + } + const effectiveTeamId = status === 'decider' ? deciderChooserTeamId : decision?.teamId ?? null - const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === teamLeft?.id - const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === teamRight?.id + const pickedByLeft = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamA?.id + const pickedByRight = (status === 'pick' || status === 'decider') && effectiveTeamId === match.teamB?.id + const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg` + + // ⬇️ Fortschritt aus den TOP-LEVEL Hooks nutzen const progress = progressByMap[map] ?? 0 const showProgress = isAvailable && progress > 0 && progress < 1 - const bg = state?.mapVisuals?.[map]?.bg ?? `/assets/img/maps/${map}/1.jpg` - const disabledTitle = isFrozenByAdmin ? 'Ein Admin bearbeitet gerade – Voting gesperrt' : 'Nur der Team-Leader (oder Admin) darf wΓ€hlen' + const disabledTitle = isFrozenByAdmin + ? 'Ein Admin bearbeitet gerade – Voting gesperrt' + : 'Nur der Team-Leader (oder Admin) darf wΓ€hlen' return (
  • {pickedByLeft ? ( - {teamLeft?.name + {match.teamA?.name ) :
    } {pickedByRight ? ( - {teamRight?.name + {match.teamB?.name ) :
    }
  • ) @@ -679,17 +759,21 @@ export default function MapVotePanel({ match }: Props) { {/* Rechte Spalte */}
    - + ({ premierRank: p.stats?.rankNew ?? 0 })) as any} />
    -
    {teamRight?.name ?? 'Team'}
    +
    {match.teamB?.name ?? 'Team'}
    - {teamRight?.name + {match.teamB?.name
    - {playersRight.map((p: MatchPlayer) => ( + {playersB.map((p: MatchPlayer) => ( router.push(`/profile/${p.user.steamId}`)} - isLeader={(state?.teams?.[teamRightKey]?.leader?.steamId ?? teamRight?.leader?.steamId) === p.user.steamId} - isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.[teamRightKey]?.id ?? teamRight?.id) && !state.locked} + isLeader={(state?.teams?.teamB?.leader?.steamId ?? match.teamB?.leader?.steamId) === p.user.steamId} + isActiveTurn={!!currentStep?.teamId && currentStep.teamId === (state?.teams?.teamB?.id ?? match.teamB?.id) && !state.locked} /> ))}
    @@ -717,16 +801,19 @@ export default function MapVotePanel({ match }: Props) { const chosenSteps = (state.steps ?? []).filter( s => (s.action === 'pick' || s.action === 'decider') && s.map ) - const decIdx = (state.steps ?? []).findIndex(s => s.action === 'decider') + // ermitteln, wer beim DECIDER "zΓ€hlt" + const steps = state?.steps ?? [] + const decIdx = steps.findIndex(s => s.action === 'decider') let chooserTeamId: string | null = null if (decIdx >= 0) { for (let i = decIdx - 1; i >= 0; i--) { - const s = state.steps![i] + const s = steps[i] if (s.action === 'ban' && s.teamId) { chooserTeamId = s.teamId; break } } } - const teamLeftLogo = getTeamLogo(teamLeft?.logo) - const teamRightLogo = getTeamLogo(teamRight?.logo) + + const teamLeftLogo = getTeamLogo(match.teamA?.logo) + const teamRightLogo = getTeamLogo(match.teamB?.logo) return ( @@ -740,8 +827,8 @@ export default function MapVotePanel({ match }: Props) { const pickTeamId = action === 'pick' ? (step?.teamId ?? null) : action === 'decider' ? chooserTeamId : null - const pickedByLeft = pickTeamId && pickTeamId === teamLeft?.id - const pickedByRight = pickTeamId && pickTeamId === teamRight?.id + const pickedByLeft = pickTeamId && pickTeamId === match.teamA?.id + const pickedByRight = pickTeamId && pickTeamId === match.teamB?.id const sideLogo = pickedByLeft ? teamLeftLogo : pickedByRight ? teamRightLogo : null const frameClasses = action === 'pick' ? 'ring-2 ring-green-500' diff --git a/src/app/components/MatchDetails.tsx b/src/app/components/MatchDetails.tsx index 1a207a5..0931d96 100644 --- a/src/app/components/MatchDetails.tsx +++ b/src/app/components/MatchDetails.tsx @@ -1,8 +1,7 @@ // /app/components/MatchDetails.tsx - 'use client' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { format } from 'date-fns' @@ -23,7 +22,6 @@ import { useSSEStore } from '@/app/lib/useSSEStore' import { Team } from '../types/team' import Alert from './Alert' import Image from 'next/image' -import { MATCH_EVENTS } from '../lib/sseEvents' import Link from 'next/link' type TeamWithPlayers = Team & { players?: MatchPlayer[] } @@ -50,8 +48,7 @@ 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') + MAP_OPTIONS.find(o => o.key === k)?.label ?? (k ? k : 'TBD') ) } @@ -62,7 +59,6 @@ function extractSeriesMaps(match: Match): string[] { .filter(s => s && (s.action === 'PICK' || s.action === 'DECIDER')) .sort((a,b) => (a.order ?? 0) - (b.order ?? 0)) .map(s => s.map ?? '') - // auf bestOf begrenzen const n = Math.max(1, match.bestOf ?? 1) return picks.slice(0, n) } @@ -83,13 +79,11 @@ function SeriesStrip({ const needed = Math.ceil(bestOf / 2) const total = Math.max(bestOf, maps.length || 1) - // index der "aktuellen" Map: sobald jemand fertig ist β†’ keine aktuelle Markierung const finished = winsA >= needed || winsB >= needed const currentIdx = finished ? -1 : Math.min(winsA + winsB, total - 1) return (
    - {/* Kopfzeile der Serie */}
    Best of {bestOf} β€’ First to {needed} @@ -99,14 +93,11 @@ function SeriesStrip({
    - {/* Kartenleiste */}
    {Array.from({ length: total }).map((_, i) => { const key = maps[i] ?? '' const label = mapLabelFromKey(key) - // Siegerbadge pro Map (heuristisch): mapsiegereihenfolge = Sum wins so far? - // Da wir kein per-Map Ergebnis haben, markieren wir nur globalen Fortschritt: const isDone = i < winsA + winsB const isCurrent = i === currentIdx const isFuture = i > winsA + winsB @@ -144,18 +135,22 @@ function SeriesStrip({ ) } - /* ─────────────────── 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) - const [opensAtOverride, setOpensAtOverride] = useState(null); - const [leadOverride, setLeadOverride] = useState(null); + // 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 @@ -168,14 +163,14 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: const teamAPlayers = (match.teamA as TeamWithPlayers).players ?? [] const teamBPlayers = (match.teamB as TeamWithPlayers).players ?? [] - /* ─── Map ─────────────────────────────────────────────────── */ + /* ─── 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 ─────────────────────────────────────── */ + /* ─── 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' @@ -185,32 +180,33 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: return fromVote.length >= n ? fromVote : [...fromVote, ...Array.from({ length: n - fromVote.length }, () => '')] }, [match.bestOf, match.mapVote?.steps?.length]) - - /* ─── Modal-State: null = geschlossen, 'A' / 'B' = offen ─── */ + /* ─── Modal-State ─────────────────────────────────────────── */ const [editSide, setEditSide] = useState(null) - /* ─── Live-Uhr (fΓΌr vote-Zeitpunkt) ───────────────────────── */ + /* ─── Live-Uhr fΓΌr Mapvote-Startfenster ───────────────────── */ useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) }, []) - - // Basiszeit des Matches einmal berechnen + // 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]); + 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; // SSE hat Vorrang - if (match.mapVote?.opensAt) return new Date(match.mapVote.opensAt).getTime(); // vom Server - const lead = (leadOverride != null) ? leadOverride : 60; // kein 60-min Zwang - return matchBaseTs - lead * 60_000; - }, [opensAtOverride, match.mapVote?.opensAt, matchBaseTs, leadOverride]); - - const sseOpensAtTs = voteOpensAtTs; - const sseLeadMinutes = leadOverride; + 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 @@ -218,39 +214,53 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: const showEditA = canEditA && !mapvoteStarted const showEditB = canEditB && !mapvoteStarted - /* ─── SSE-Listener ─────────────────────────────────────────── */ + /* ─── SSE-Listener (nur map-vote-updated & Co.) ───────────── */ useEffect(() => { - if (!lastEvent) return; - const evt = (lastEvent as any).payload ?? lastEvent; - if (evt?.matchId !== match.id) return; + if (!lastEvent) return - if (lastEvent.type === 'map-vote-updated') { - // opensAt aus Event ΓΌbernehmen + // 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 = typeof evt.opensAt === 'string' - ? new Date(evt.opensAt).getTime() - : new Date(evt.opensAt).getTime(); - setOpensAtOverride(ts); + const ts = new Date(evt.opensAt).getTime() + setOpensAtOverride(ts) } - // leadMinutes mitschneiden u. ggf. opensAt daraus ableiten if (Number.isFinite(evt?.leadMinutes)) { - const lead = Number(evt.leadMinutes); - setLeadOverride(lead); + const lead = Number(evt.leadMinutes) + setLeadOverride(lead) if (!evt?.opensAt) { - const base = new Date(match.matchDate ?? match.demoDate ?? initialNow).getTime(); - setOpensAtOverride(base - lead * 60_000); + 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-updated','match-lineup-updated', - ]); - if (REFRESH_TYPES.has(lastEvent.type) && evt?.matchId === match.id) { - router.refresh(); + 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]); + }, [lastEvent, match.id, router, match.matchDate, match.demoDate, initialNow]) /* ─── Tabellen-Layout (fixe Breiten) ───────────────────────── */ const ColGroup = () => ( @@ -292,21 +302,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: {[ - 'Spieler', - 'Rank', - 'Aim', - 'K', - 'A', - 'D', - '1K', - '2K', - '3K', - '4K', - '5K', - 'K/D', - 'ADR', - 'HS%', - 'Damage', + 'Spieler', 'Rank', 'Aim', 'K', 'A', 'D', + '1K', '2K', '3K', '4K', '5K', + 'K/D', 'ADR', 'HS%', 'Damage', ].map((h) => ( {h} @@ -333,11 +331,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
    - {match.matchType === 'premier' ? ( - - ) : ( - - )} + {match.matchType === 'premier' + ? + : } {match.matchType === 'premier' && typeof p.stats?.rankChange === 'number' && ( {/* Kopfzeile: ZurΓΌck + Admin-Buttons */}
    - {/* Links: ZurΓΌck */} - {/* Rechts: Admin-Buttons */} {isAdmin && (
    )} @@ -435,6 +430,7 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: Score: {match.scoreA ?? 0}:{match.scoreB ?? 0}
    + {/* MapVote-Banner erhΓ€lt die aktuell berechneten (SSE-konformen) Werte */}
    - {showEditA ? ( - <> - {/* Unlocked-Icon */} - - - - - ) : ( - <> - {/* Locked-Icon */} - - - - - ) - } + {canEditA && !mapvoteStarted ? ( + + + + ) : ( + + + + )} - {showEditA ? ( + {canEditA && !mapvoteStarted ? ( <> Du kannst die Aufstellung noch bis{' '} {format(endDate, 'dd.MM.yyyy HH:mm')} bearbeiten. @@ -483,10 +472,10 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow: