teecup/frontend/components/tournament-leaderboard.tsx

313 lines
11 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Check, Circle, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py sin leaderboard-endepunkt)
type ApiTeamStanding = {
team_id: string
team_name: string
color: string | null
points: number
}
type ApiSessionStanding = {
session_id: string
sequence: number
name: string | null
points_by_team: Record<string, number>
matches_total: number
matches_decided: number
}
type ApiLeaderboard = {
teams: [ApiTeamStanding, ApiTeamStanding]
matches_total: number
matches_decided: number
sessions: ApiSessionStanding[]
}
// --- Formatting helpers ----------------------------------------------------
function formatPoints(value: number): string {
return value.toLocaleString("nb-NO", { maximumFractionDigits: 1 })
}
function sessionTitle(s: ApiSessionStanding): string {
return s.name?.trim() || `Økt ${s.sequence}`
}
// --- Component -------------------------------------------------------------
export function TournamentLeaderboard({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const [board, setBoard] = useState<ApiLeaderboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/leaderboard`, {
credentials: "include",
})
if (!res.ok) throw new Error(`load failed: ${res.status}`)
const data: ApiLeaderboard = await res.json()
if (cancelled) return
setBoard({ ...data, sessions: [...data.sessions].sort((a, b) => a.sequence - b.sequence) })
} catch {
if (!cancelled) setError("Klarte ikke å laste leaderboardet. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId])
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
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-4xl items-center gap-3 px-5 py-4">
<Link
href={detailHref}
aria-label="Tilbake til turnering"
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-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
<Link
href={detailHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Lag og spillere
</Link>
<Link
href={programHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Program
</Link>
<span
aria-current="page"
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Leaderboard
</span>
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Totalt antall poeng summert tvers av alle økter, oppdatert etter hvert som matcher
avgjøres.
</p>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{loading || !board ? (
<div className="flex justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : (
<>
{(() => {
const [teamA, teamB] = board.teams
const leaderId =
teamA.points === teamB.points ? null : teamA.points > teamB.points ? teamA.team_id : teamB.team_id
return (
<>
<Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} />
<p className="mt-3 text-center text-sm font-medium text-muted-foreground">
{board.matches_decided} av {board.matches_total} matcher avgjort
</p>
<div className="mt-8 flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Poeng per økt</h3>
{board.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen økter opprettet ennå.</p>
) : (
board.sessions.map((s) => (
<SessionRow key={s.session_id} session={s} teamA={teamA} teamB={teamB} />
))
)}
</div>
</>
)
})()}
</>
)}
</main>
</div>
)
}
// --- Scoreboard ------------------------------------------------------------
function Scoreboard({
teamA,
teamB,
leaderId,
}: {
teamA: ApiTeamStanding
teamB: ApiTeamStanding
leaderId: string | null
}) {
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5">
<TeamScore team={teamA} isLeader={leaderId === teamA.team_id} align="left" />
<div className="flex items-center justify-center bg-border/40 px-2">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">vs</span>
</div>
<TeamScore team={teamB} isLeader={leaderId === teamB.team_id} align="right" />
</div>
)
}
function TeamScore({
team,
isLeader,
align,
}: {
team: ApiTeamStanding
isLeader: boolean
align: "left" | "right"
}) {
const color = team.color ?? "#64748b"
return (
<div
className={cn(
"relative flex flex-col gap-2 p-5 sm:p-6",
align === "right" ? "items-end text-right" : "items-start text-left",
)}
style={{
borderTop: `4px solid ${color}`,
backgroundColor: isLeader ? `${color}14` : undefined,
}}
>
<div className={cn("flex items-center gap-2", align === "right" && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="truncate text-sm font-bold text-foreground sm:text-base">{team.team_name}</span>
</div>
{isLeader && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-bold text-foreground">
<Trophy aria-hidden="true" className="size-3 fill-primary text-primary" />
Leder
</span>
)}
<span
className={cn(
"font-extrabold tabular-nums leading-none tracking-tight",
isLeader ? "text-6xl sm:text-7xl" : "text-5xl sm:text-6xl text-foreground/80",
)}
style={isLeader ? { color } : undefined}
>
{formatPoints(team.points)}
</span>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">poeng</span>
</div>
)
}
// --- Session row -----------------------------------------------------------
function SessionRow({
session,
teamA,
teamB,
}: {
session: ApiSessionStanding
teamA: ApiTeamStanding
teamB: ApiTeamStanding
}) {
const notStarted = session.matches_decided === 0
const complete = session.matches_decided === session.matches_total
const aPts = session.points_by_team[teamA.team_id] ?? 0
const bPts = session.points_by_team[teamB.team_id] ?? 0
const colorA = teamA.color ?? "#64748b"
const colorB = teamB.color ?? "#64748b"
return (
<div className="rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-bold text-foreground">{sessionTitle(session)}</h4>
{complete ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2.5 py-1 text-xs font-semibold text-foreground">
<Check aria-hidden="true" className="size-3.5 text-primary" />
Fullført
</span>
) : notStarted ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
<Circle aria-hidden="true" className="size-3" />
Ikke startet ennå
</span>
) : (
<span className="text-xs font-medium text-muted-foreground">
{session.matches_decided} av {session.matches_total} matcher avgjort
</span>
)}
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: colorA }} />
<span className="truncate text-sm font-medium text-foreground">{teamA.team_name}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 tabular-nums">
<span className={cn("text-lg font-extrabold", notStarted ? "text-muted-foreground" : "text-foreground")}>
{formatPoints(aPts)}
</span>
<span className="text-sm font-semibold text-muted-foreground"></span>
<span className={cn("text-lg font-extrabold", notStarted ? "text-muted-foreground" : "text-foreground")}>
{formatPoints(bPts)}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<span className="truncate text-right text-sm font-medium text-foreground">{teamB.team_name}</span>
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: colorB }} />
</div>
</div>
</div>
)
}