383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
"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
|
||
// ADR-020: hva stillingen ville blitt om pågående, ikke-avgjorte matcher
|
||
// holder seg som de står nå.
|
||
projected_points: number
|
||
}
|
||
|
||
type ApiSessionStanding = {
|
||
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 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 på 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 (
|
||
<>
|
||
<div className="flex flex-col gap-3">
|
||
<SegmentedBar
|
||
label="Stilling nå"
|
||
teamA={teamA}
|
||
teamB={teamB}
|
||
pointsA={teamA.points}
|
||
pointsB={teamB.points}
|
||
/>
|
||
<SegmentedBar
|
||
label="Projisert (hvis pågående matcher holder seg)"
|
||
teamA={teamA}
|
||
teamB={teamB}
|
||
pointsA={teamA.projected_points}
|
||
pointsB={teamB.projected_points}
|
||
/>
|
||
</div>
|
||
<div className="mt-4">
|
||
<Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} />
|
||
</div>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// --- Segmentert bar (to per skjerm: faktisk + projisert, ADR-020) ----------
|
||
// Ett rektangel delt i to fargede segmenter proporsjonalt med hvert lags
|
||
// poeng -- viser umiddelbart hvem som leder, ikke bare tall side om side.
|
||
// Ved 0-0 (ingenting avgjort/projisert ennå) splittes 50/50 nøytralt.
|
||
|
||
function SegmentedBar({
|
||
label,
|
||
teamA,
|
||
teamB,
|
||
pointsA,
|
||
pointsB,
|
||
}: {
|
||
label: string
|
||
teamA: ApiTeamStanding
|
||
teamB: ApiTeamStanding
|
||
pointsA: number
|
||
pointsB: number
|
||
}) {
|
||
const total = pointsA + pointsB
|
||
const widthA = total > 0 ? (pointsA / total) * 100 : 50
|
||
const widthB = 100 - widthA
|
||
const colorA = teamA.color ?? "#64748b"
|
||
const colorB = teamB.color ?? "#64748b"
|
||
|
||
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 h-11 w-full overflow-hidden rounded-2xl shadow-md shadow-black/8">
|
||
<div
|
||
className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300"
|
||
style={{ width: `${widthA}%`, backgroundColor: colorA }}
|
||
>
|
||
{widthA > 12 && formatPoints(pointsA)}
|
||
</div>
|
||
<div
|
||
className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300"
|
||
style={{ width: `${widthB}%`, backgroundColor: colorB }}
|
||
>
|
||
{widthB > 12 && formatPoints(pointsB)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between text-xs font-medium text-muted-foreground">
|
||
<span className="truncate">{teamA.team_name}</span>
|
||
<span className="truncate">{teamB.team_name}</span>
|
||
</div>
|
||
</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-md shadow-black/8">
|
||
<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-md shadow-black/8 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>
|
||
)
|
||
}
|