"use client" import { useEffect, useRef, useState } from "react" import Link from "next/link" import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react" import { Wordmark } from "@/components/wordmark" import { cn } from "@/lib/utils" // --- Typer (matcher app/routers/tournaments.py/matches.py/scoring.py) ------ type TeamStanding = { team_id: string team_name: string color: string | null points: number projected_points: number } type SessionStanding = { session_id: string sequence: number name: string | null points_by_team: Record projected_points_by_team: Record matches_total: number matches_decided: number } type Leaderboard = { teams: TeamStanding[] matches_total: number matches_decided: number sessions: SessionStanding[] } type ApiSession = { id: string sequence: number name: string | null format: string scoring_mode: string revealed: boolean } type ApiParticipant = { id: string; team_side: "a" | "b"; player_name: string; tee_name: string } type ApiMatch = { id: string sequence: number team_a_id: string team_b_id: string status_text: string | null leading_side: "a" | "b" | null participants: ApiParticipant[] } type ApiScorecardHole = { hole_number: number; result: "a" | "b" | "halved" } type ApiScorecard = { match_id: string status_text: string | null holes: ApiScorecardHole[] } const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", fourball: "Fourball", singles: "Singel", } function formatPoints(points: number) { return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") } function liveWsUrl(tournamentId: string, code?: string): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" const query = code ? `?code=${encodeURIComponent(code)}` : "" return `${protocol}//${window.location.host}/ws/public/tournaments/${tournamentId}/live${query}` } export function PublicLive({ tournamentId, code }: { tournamentId: string; code?: string }) { const [loading, setLoading] = useState(true) const [accessError, setAccessError] = useState(null) const [tournamentName, setTournamentName] = useState("") const [leaderboard, setLeaderboard] = useState(null) const [sessions, setSessions] = useState([]) const [refreshKey, setRefreshKey] = useState(0) const codeParam = code ? `?code=${encodeURIComponent(code)}` : "" // Sanntid (ADR-027): et rent "noe endret seg"-signal over WebSocket -- // klienten reagerer ved å telle opp refreshKey, som utløser refetch her // OG i SessionRow/MatchRow under (kun for det som faktisk er åpent/synlig). useEffect(() => { const socket = new WebSocket(liveWsUrl(tournamentId, code)) socket.onmessage = () => setRefreshKey((k) => k + 1) return () => socket.close() }, [tournamentId, code]) const isFirstRefresh = useRef(true) useEffect(() => { if (isFirstRefresh.current) { isFirstRefresh.current = false return } fetch(`/public/tournaments/${tournamentId}/leaderboard${codeParam}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((lb: Leaderboard | null) => { if (lb) setLeaderboard(lb) }) .catch(() => {}) }, [refreshKey, tournamentId, codeParam]) useEffect(() => { let cancelled = false async function load() { try { const infoRes = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { credentials: "include" }) if (infoRes.status === 403 || infoRes.status === 404) { const body = await infoRes.json().catch(() => null) if (!cancelled) { setAccessError( body?.detail?.message ?? (infoRes.status === 404 ? "Turneringen finnes ikke." : "Du har ikke tilgang til denne turneringen."), ) } return } if (!infoRes.ok) throw new Error(`info: ${infoRes.status}`) const info: { name: string } = await infoRes.json() if (cancelled) return setTournamentName(info.name) const [lbRes, sessionsRes] = await Promise.all([ fetch(`/public/tournaments/${tournamentId}/leaderboard${codeParam}`, { credentials: "include" }), fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, { credentials: "include" }), ]) if (lbRes.ok) { const lb: Leaderboard = await lbRes.json() if (!cancelled) setLeaderboard(lb) } if (sessionsRes.ok) { const s: ApiSession[] = await sessionsRes.json() if (!cancelled) setSessions(s.sort((a, b) => a.sequence - b.sequence)) } } catch { if (!cancelled) setAccessError("Klarte ikke å laste stillingen. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [tournamentId, codeParam]) const backHref = `/t/${tournamentId}${code ? `?code=${encodeURIComponent(code)}` : ""}` if (loading) { return (
) } if (accessError) { return (

{accessError}

) } const teamsById = new Map(leaderboard?.teams.map((t) => [t.team_id, t]) ?? []) return (
{leaderboard && leaderboard.teams.length === 2 && }

Matcher

{sessions.length === 0 && (

Ingen økter opprettet ennå.

)} {sessions.map((s) => ( ))}
) } // --- Leaderboard ------------------------------------------------------------- function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) { const [a, b] = leaderboard.teams return (

{leaderboard.matches_decided} av {leaderboard.matches_total} matcher avgjort

) } function StandingBar({ label, a, b, aValue, bValue, }: { label: string a: TeamStanding b: TeamStanding aValue: number bValue: number }) { const total = aValue + bValue const aPct = total > 0 ? (aValue / total) * 100 : 50 return (
{label}
{a.team_name} · {formatPoints(aValue)} {formatPoints(bValue)} · {b.team_name}
) } // --- Session/match-liste ----------------------------------------------------- function SessionRow({ session, tournamentId, codeParam, teamsById, refreshKey, }: { session: ApiSession tournamentId: string codeParam: string teamsById: Map refreshKey: number }) { const [open, setOpen] = useState(false) const [matches, setMatches] = useState(null) const [loadingMatches, setLoadingMatches] = useState(false) const title = session.name?.trim() || `Økt ${session.sequence}` async function fetchMatches() { setLoadingMatches(true) try { const res = await fetch( `/public/tournaments/${tournamentId}/sessions/${session.id}/matches${codeParam}`, { credentials: "include" }, ) if (res.ok) setMatches(await res.json()) } finally { setLoadingMatches(false) } } function toggle() { const next = !open setOpen(next) if (next) void fetchMatches() } // Sanntid: hent matchene på nytt når WS sier noe endret seg -- men KUN // hvis økten faktisk er åpen (ingen vits i å hente noe brukeren ikke ser). const isFirstRefresh = useRef(true) useEffect(() => { if (isFirstRefresh.current) { isFirstRefresh.current = false return } if (open) void fetchMatches() // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshKey]) return (
{open && (
{loadingMatches && (
)} {matches?.length === 0 &&

Ingen matcher opprettet ennå.

} {matches?.map((m) => ( ))}
)}
) } function MatchRow({ match, tournamentId, codeParam, teamA, teamB, refreshKey, }: { match: ApiMatch tournamentId: string codeParam: string teamA?: TeamStanding teamB?: TeamStanding refreshKey: number }) { const [showScorecard, setShowScorecard] = useState(false) const [scorecard, setScorecard] = useState(null) const [loadingScorecard, setLoadingScorecard] = useState(false) const leadingColor = match.leading_side === "a" ? teamA?.color : match.leading_side === "b" ? teamB?.color : undefined async function fetchScorecard() { setLoadingScorecard(true) try { const res = await fetch( `/public/tournaments/${tournamentId}/matches/${match.id}/scorecard${codeParam}`, { credentials: "include" }, ) if (res.ok) setScorecard(await res.json()) } finally { setLoadingScorecard(false) } } function toggleScorecard() { const next = !showScorecard setShowScorecard(next) if (next) void fetchScorecard() } const isFirstRefresh = useRef(true) useEffect(() => { if (isFirstRefresh.current) { isFirstRefresh.current = false return } if (showScorecard) void fetchScorecard() // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshKey]) return (
{showScorecard && (
{loadingScorecard ? (
) : scorecard && scorecard.holes.length > 0 ? (
    {scorecard.holes.map((h) => { const color = h.result === "a" ? teamA?.color : h.result === "b" ? teamB?.color : undefined return (
  • {h.hole_number}
  • ) })}
) : (

Ingen hull registrert ennå.

)}
)}
) }