teecup/frontend/components/tournament-leaderboard.tsx
Erol Haagenrud fa6a1fa78d Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.

To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).

Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00

392 lines
14 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
// 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 overflow-x-auto px-5 pb-3"
aria-label="Turneringsseksjoner"
>
<Link
href={detailHref}
className="shrink-0 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="shrink-0 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="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Leaderboard
</span>
<Link
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Presentasjon
</Link>
</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 (
<>
<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>
)
}