488 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|