teecup/frontend/components/tournament-leaderboard.tsx
Erol Haagenrud d51c0153bb Update Todos
Diffe zip 8 mot live-treet

Integrere tournament-leaderboard.tsx mot ekte API

Legge til Leaderboard-fane i program- og roster-skjermen

Ekte typesjekket frontend-build

Rulle ut live

Oppdatere .md-filer + slette zip 8
Leaderboard-skjermen er live, og hele "bygg i rekkefølgen ting brukes"-serien er komplett: oppsett (lag/roster) → program → blind draw → scorekort → leaderboard, alle live og verifisert. Leaderboard-fanen er nå med i alle turnering-skjermenes fanerad. .md-filene er oppdatert, zip 8 slettet.

Gjenstående i backloggen, ingen haster spesielt:

PWA (manifest, service worker, offline-cache) — ikke startet
Kommunikasjon (lag-chat/offentlig feed) — ikke startet
Noen åpne beslutninger (walkover/konsesjon, sanntid vs. polling, tilskuer-rolle) som venter på egne avklaringsrunder
2026-07-18 18:22:10 +02:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}