teecup/frontend/components/session-individual-leaderboard.tsx
Erol Haagenrud d6c1a38318 Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene

Avklar åpne designspørsmål med bruker

Bygg: flytte spiller mellom lag (backend + frontend)

Bygg: individuell rangering i turnering (backend + frontend)

Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)

Typesjekket produksjonsbuild av frontend

Scratch-verifiser alt tre + full regresjon

Browser-verifiser alt tre (Chrome DevTools)

Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md

Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:

Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00

306 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"
// Individuell rangering PER ØKT i en org-turnering (FEATURE_BACKLOG.md
// "Leaderboard for runder og turneringer", avklart 2026-07-28 -- omfang
// bevisst AVGRENSET til én økt, ikke summert på tvers av turneringen, se
// CLAUDE.md for begrunnelse). Ved siden av, ikke i stedet for, det
// eksisterende lag-poeng-leaderboardet (tournament-leaderboard.tsx) -- KUN
// meningsfullt for scoring_mode='stroke'-økter i format singles/fourball,
// backend avviser (400) ellers.
//
// Gjenbruker samme rangering/mode-toggle/verdi-merke-mønster som
// round-leaderboard.tsx (brutto/netto/poeng, "form + farge"-språket, delt
// plassering "T-N") -- egen, enklere lokal versjon her siden denne
// leaderboardet ikke har noen hull-for-hull-data å utvide til (backend
// returnerer kun aggregerte tall per deltaker, ikke per hull).
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiEntry = {
participant_id: string
display_name: string
team_id: string
team_name: string
team_color: string | null
thru: number
gross_score: number | null
gross_to_par: number | null
net_score: number | null
net_to_par: number | null
total_points: number | null
}
type ApiLeaderboard = {
session_id: string
format: string
entries: ApiEntry[]
}
const FORMAT_LABELS: Record<string, string> = {
singles: "Singles",
fourball: "Fourball",
}
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
function valueFor(entry: ApiEntry, mode: Mode): number | null {
if (mode === "gross") return entry.gross_to_par
if (mode === "net") return entry.net_to_par
return entry.total_points
}
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
export function SessionIndividualLeaderboard({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [data, setData] = useState<ApiLeaderboard | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>("gross")
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/individual-leaderboard`, {
credentials: "include",
})
if (res.status === 400) {
if (!cancelled)
setError("Denne økten støtter ikke individuell rangering -- kun slagspill i format singles eller fourball.")
return
}
if (res.status === 404) {
if (!cancelled) setError("Økten finnes ikke.")
return
}
if (!res.ok) {
if (!cancelled) setError("Klarte ikke å hente rangeringen. Prøv igjen om litt.")
return
}
const parsed: ApiLeaderboard = await res.json()
if (!cancelled) setData(parsed)
}
void load()
return () => {
cancelled = true
}
}, [organizationId, sessionId])
const backHref = `/tournaments/${tournamentId}/sessions/${sessionId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (error) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href={backHref} className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til økten
</Link>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const ranked = rankEntries(data.entries, mode)
return (
<div className="min-h-[100dvh] bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={backHref}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Individuell rangering
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
</div>
{data.entries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
</p>
) : (
<>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
return (
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: entry.team_color ?? "var(--muted-foreground)" }}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{entry.team_name} · {entry.thru > 0 ? `${entry.thru} hull spilt` : "Ikke startet"}
</span>
</span>
{isLeader && entry.rank === 1 && <Trophy aria-hidden="true" className="size-4 shrink-0 text-primary" />}
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
</li>
)
})}
</ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground">
Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
rangering.
</p>
)}
</>
)}
</main>
</div>
)
}