teecup/frontend/components/tournament-leaderboard.tsx
Erol Haagenrud 317e24a800 Alt er ferdig og live. Kort oppsummering av hele runden:
Backend (ADR-020):

tournament.join_code — kort, unik kode generert automatisk, overstyrer synlighet
match.leading_side — cachet ledende side, oppdateres ved hvert hull
Leaderboardet fikk projected_points — hva stillingen blir om pågående matcher holder seg
Ny migrasjon 011, kjørt mot ekte teecup_db, test_isolation.sql 12/12
Frontend:

Login-skjermen har nå et kode-felt som tar deg rett til turneringen, uten innlogging
Invitasjonskoden vises i turnering-detalj med kopier-knapp
Leaderboardet har to fargesegmenterte barer øverst — faktisk stilling og projisert stilling
Matchlisten i blind draw er fargekodet etter hvem som leder
Underveis oppsto en reell hendelse: en feilformulert kommando eksponerte teeoff_db sitt superbrukerpassord. Det ble flagget umiddelbart, du valgte å rotere det, og det ble gjort trygt uten at verdien noensinne ble vist på nytt — bekreftet med rene logger og 200 på både teeoff.no og teecup.teeoff.no etterpå.

Alt er verifisert i scratch før utrulling, typesjekket build kjørt, og .md-filene (CLAUDE.md, FEATURE_BACKLOG.md, ARCHITECTURE_DECISIONS.md) er oppdatert. Jeg kommuniserer på norsk videre i dette prosjektet.
2026-07-19 09:33:34 +02:00

383 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 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 (
<>
<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-sm shadow-black/5">
<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-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>
)
}