teecup/frontend/components/public-live.tsx

488 lines
16 KiB
TypeScript

"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<string, number>
projected_points_by_team: Record<string, number>
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<string, string> = {
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<string | null>(null)
const [tournamentName, setTournamentName] = useState("")
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null)
const [sessions, setSessions] = useState<ApiSession[]>([])
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 (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (accessError) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
</div>
)
}
const teamsById = new Map(leaderboard?.teams.map((t) => [t.team_id, t]) ?? [])
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href={backHref}
aria-label="Tilbake til turneringssiden"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span aria-hidden="true" className="size-1.5 animate-pulse rounded-full bg-primary" />
Følg live
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
</div>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
{leaderboard && leaderboard.teams.length === 2 && <LeaderboardCard leaderboard={leaderboard} />}
<section aria-label="Økter og matcher" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Matcher</h2>
{sessions.length === 0 && (
<p className="text-sm text-muted-foreground">Ingen økter opprettet ennå.</p>
)}
{sessions.map((s) => (
<SessionRow
key={s.id}
session={s}
tournamentId={tournamentId}
codeParam={codeParam}
teamsById={teamsById}
refreshKey={refreshKey}
/>
))}
</section>
</div>
</main>
</div>
)
}
// --- Leaderboard -------------------------------------------------------------
function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) {
const [a, b] = leaderboard.teams
return (
<section
aria-label="Stilling"
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5"
>
<div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-primary" />
<h2 className="text-base font-bold text-foreground">Stilling</h2>
</div>
<StandingBar label="Nå" a={a} b={b} aValue={a.points} bValue={b.points} />
<StandingBar label="Projisert (hvis pågående matcher holder seg)" a={a} b={b} aValue={a.projected_points} bValue={b.projected_points} />
<p className="text-xs text-muted-foreground">
{leaderboard.matches_decided} av {leaderboard.matches_total} matcher avgjort
</p>
</section>
)
}
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 (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<div className="flex items-center justify-between text-sm font-bold text-foreground">
<span>{a.team_name} · {formatPoints(aValue)}</span>
<span>{formatPoints(bValue)} · {b.team_name}</span>
</div>
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
<div style={{ width: `${aPct}%`, backgroundColor: a.color ?? "#64748b" }} />
<div style={{ width: `${100 - aPct}%`, backgroundColor: b.color ?? "#64748b" }} />
</div>
</div>
)
}
// --- Session/match-liste -----------------------------------------------------
function SessionRow({
session,
tournamentId,
codeParam,
teamsById,
refreshKey,
}: {
session: ApiSession
tournamentId: string
codeParam: string
teamsById: Map<string, TeamStanding>
refreshKey: number
}) {
const [open, setOpen] = useState(false)
const [matches, setMatches] = useState<ApiMatch[] | null>(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 (
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 px-4 py-3.5 text-left"
>
<div className="flex flex-col">
<span className="font-semibold text-foreground">{title}</span>
<span className="text-xs text-muted-foreground">
{FORMAT_LABELS[session.format] ?? session.format}
{!session.revealed && " · ikke avslørt ennå"}
</span>
</div>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-border p-3">
{loadingMatches && (
<div className="flex justify-center py-4">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
)}
{matches?.length === 0 && <p className="px-1 py-2 text-sm text-muted-foreground">Ingen matcher opprettet ennå.</p>}
{matches?.map((m) => (
<MatchRow
key={m.id}
match={m}
tournamentId={tournamentId}
codeParam={codeParam}
teamA={teamsById.get(m.team_a_id)}
teamB={teamsById.get(m.team_b_id)}
refreshKey={refreshKey}
/>
))}
</div>
)}
</div>
)
}
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<ApiScorecard | null>(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 (
<div
className="overflow-hidden rounded-xl border border-border"
style={{ borderLeftWidth: 4, borderLeftColor: leadingColor ?? "transparent" }}
>
<button type="button" onClick={toggleScorecard} className="flex w-full flex-col gap-1.5 px-3 py-2.5 text-left">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{teamA?.team_name ?? "Lag A"} vs. {teamB?.team_name ?? "Lag B"}
</span>
{match.status_text && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-foreground">
{match.status_text}
</span>
)}
</div>
{match.participants.length > 0 && (
<span className="truncate text-xs text-muted-foreground">
{match.participants.map((p) => p.player_name).join(" · ")}
</span>
)}
</button>
{showScorecard && (
<div className="border-t border-border p-3">
{loadingScorecard ? (
<div className="flex justify-center py-3">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
) : scorecard && scorecard.holes.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{scorecard.holes.map((h) => {
const color = h.result === "a" ? teamA?.color : h.result === "b" ? teamB?.color : undefined
return (
<li
key={h.hole_number}
title={`Hull ${h.hole_number}`}
className="flex size-8 items-center justify-center rounded-lg text-xs font-bold text-primary-foreground"
style={{ backgroundColor: color ?? "var(--muted)" }}
>
<span className={color ? "" : "text-muted-foreground"}>{h.hole_number}</span>
</li>
)
})}
</ul>
) : (
<p className="text-xs text-muted-foreground">Ingen hull registrert ennå.</p>
)}
</div>
)}
</div>
)
}