teecup/frontend/components/round-leaderboard.tsx
Erol Haagenrud e2ea0c0fe3 Live. Kort oppsummert:
Taltastaturet for Slag viser nå Eagle/Birdie/Par/Bogey/Dobbel bogey under hvert tall, regnet ut fra hullets par — samme prinsipp som videoen, ingen hoderegning nødvendig.
Automatisk fremdrift: når en spillers registrering er "god nok" (slag, eller slag+putter avhengig av statistikknivå), viser knappen nederst enten "Neste: {navn}" (bytter aktiv spiller på samme hull) eller "Neste hull" (er hen den siste, går videre og starter på spiller 1 igjen) — ingen manuell skrolling opp til spillerlisten for å bytte spiller.
Bevisst forskjell fra GameBook: ikke en full skjermovertakende veiviser (det ville vært et større og mer risikofylt bygg uten visuell testing) — samme side, men med den friksjonsreduserende logikken lagt inn. Test gjerne selv, spesielt hvordan det føles å gå gjennom begge spillerne på et hull etter hverandre.
2026-07-26 15:48:07 +02:00

560 lines
22 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"
// Leaderboard for en frittstående runde (ADR-033), 2026-07-26.
// Håndkodet (ikke via V0 -- brukeren gikk tom for credits, ba meg bygge
// direkte etter samme designspesifikasjon som var skrevet til V0-prompten,
// se FEATURE_BACKLOG.md). Gjenbruker etablerte mønstre fra
// round-scorecard.tsx (signedToPar-formatering, form-før-farge-språket fra
// ScoreMark, stableford-formelen) og round-stats.tsx/round-scorecard.tsx sin
// WS-sanntid-/viewerId-oppkobling, i stedet for å finne opp nye.
//
// Radene er BEVISST minimale i kollapset tilstand (kun rangering/navn/tall,
// 2026-07-26 -- brukeren ba om å fjerne "Deg"/"Eier"/pokal/hull-spilt-
// teksten derfra og la radene flyte sømløst i hverandre uten egne
// kort-rammer) -- den informasjonen flyttet inn i den utvidbare seksjonen
// sammen med hullscorene, ikke fjernet helt.
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Trophy } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type ApiLeaderboardHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
strokes_received: number | null
}
type ApiLeaderboardEntry = {
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
holes: ApiLeaderboardHole[]
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
}
type ApiRoundParticipant = {
id: string
user_id: string | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
participants: ApiRoundParticipant[]
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// --- Rangering ---------------------------------------------------------------
// Delt plassering (likt resultat) skal vises som "T-N", ikke to ulike tall.
// Deltakere uten et tall for gjeldende visning (brutto/netto/poeng) havner
// samlet nederst, uten rangeringstall -- IKKE skjult, IKKE en krasj.
// Poeng (stableford) rangeres SYNKENDE (høyest vinner) -- motsatt av
// brutto/netto til-par (lavest vinner).
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiLeaderboardEntry & {
rank: number | null
tied: boolean
isSelf: boolean
}
function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): 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, isSelf: e.participant_id === viewerParticipantId })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
// --- Verdi-merke (brutto/netto til-par, eller poeng) ------------------------
// Samme "form + farge, aldri kun farge"-språk som ScoreMark i
// round-scorecard.tsx: sirkel = under par, firkant = over par, ren tekst ved
// jevnt med par. Poeng har ingen retning (høyere er alltid bedre) -- én fast
// sirkelform, men samme visuelle vekt/fyll-mønster for lederen. Lederen
// (emphasize) får en FYLT variant for litt ekstra visuell vekt.
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>
)
}
// --- Hullscorer per deltaker (2026-07-26, etterspurt av bruker) -----------
// Samme "form + farge, aldri kun farge"-visuelle språk som ScoreMark i
// round-scorecard.tsx (sirkel/firkant/fylt-ved-2+-av) -- egen, liten
// lokal versjon her (ikke en delt import) siden round-scorecard.tsx sin
// ScoreMark er skreddersydd for ETT hull om gangen i en tabell, ikke en
// kompakt horisontal strip for MANGE deltakere i en liste.
type HoleScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classifyHoleScore(gross: number, par: number): HoleScoreClass {
const diff = gross - par
if (diff <= -2) return "eagle"
if (diff === -1) return "birdie"
if (diff === 0) return "par"
if (diff === 1) return "bogey"
return "double"
}
// Netto/poeng PER HULL (2026-07-26, etterspurt av bruker etter å ha vist
// referansebilder fra en konkurrentapp -- bevisst IKKE en kopi av den appens
// layout/farger, kun det strukturelle prinsippet "brutto fremhevet øverst,
// netto/poeng plassert rolig under" gjenskapt i TeeCups egen stil).
// `strokes_received` er allerede backend sin ferdigberegnede allokering
// (samme som resten av appen) -- ingen egen slagfordeling regnes ut her.
function netForHole(hole: ApiLeaderboardHole): number | null {
if (!hole.played || hole.score === null || hole.strokes_received === null) return null
return hole.score - hole.strokes_received
}
function pointsForHole(hole: ApiLeaderboardHole): number | null {
const net = netForHole(hole)
if (net === null) return null
return Math.max(0, hole.par - net + 2)
}
function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondaryValue: number | null | undefined }) {
const shared = "flex size-7 items-center justify-center text-xs font-extrabold tabular-nums"
const markClass =
!hole.played || hole.score === null
? "text-muted-foreground"
: (() => {
const kind = classifyHoleScore(hole.score, hole.par)
if (kind === "eagle") return "rounded-full bg-primary text-primary-foreground"
if (kind === "birdie") return "rounded-full border-2 border-primary bg-primary/10 text-primary"
if (kind === "bogey") return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
if (kind === "double") return "rounded-[4px] bg-brand-orange text-brand-orange-foreground"
return "font-bold text-foreground"
})()
return (
<span className="flex w-8 shrink-0 flex-col items-center gap-0.5">
<span aria-hidden="true" className="text-[10px] font-semibold text-muted-foreground">
{hole.hole_number}
</span>
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
appen (fylt/farget merke), øverst -- den fremste raden. */}
<span className={cn(shared, markClass)}>{hole.played && hole.score !== null ? hole.score : ""}</span>
{/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp
som referansebildene (aldri fremhevet på samme måte som brutto).
Utelates helt i brutto-modus (ingenting nytt å vise der). */}
{secondaryValue !== undefined && (
<span className="text-[11px] font-semibold tabular-nums text-muted-foreground">
{secondaryValue !== null ? secondaryValue : ""}
</span>
)}
</span>
)
}
function HoleStrip({
holes,
startHole,
holesPlanned,
mode,
}: {
holes: ApiLeaderboardHole[]
startHole: number
holesPlanned: number
mode: Mode
}) {
// Samme sirkulære start_hole-rekkefølge som round-detail.tsx/
// round-scorecard.tsx/round-stats.tsx -- viser hullene i FAKTISK
// spillerekkefølge, ikke nødvendigvis rå hullnummer 1-18.
const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1)
const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined)
const secondaryLabel = mode === "net" ? "Netto" : mode === "points" ? "Poeng" : null
return (
<div className="flex flex-col gap-1">
{secondaryLabel && (
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Slag · {secondaryLabel}
</span>
)}
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
<div className="flex gap-1.5">
{ordered.map((h) => (
<HoleMark
key={h.hole_number}
hole={h}
secondaryValue={mode === "net" ? netForHole(h) : mode === "points" ? pointsForHole(h) : undefined}
/>
))}
</div>
</div>
</div>
)
}
// --- Full leaderboard-side -----------------------------------------------
function useLeaderboardData(roundId: string) {
const [round, setRound] = useState<ApiRoundHeader | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Sanntid (samme "noe endret seg, hent på nytt"-mønster som
// round-stats.tsx/round-scorecard.tsx sin refreshKey).
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`/rounds/${roundId}`, { credentials: "include" }),
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
])
if (roundRes.status === 403 || roundRes.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!roundRes.ok || !leaderboardRes.ok) {
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
return
}
const roundData: ApiRoundHeader = await roundRes.json()
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
if (!cancelled) {
setRound(roundData)
setLeaderboard(leaderboardData)
}
}
void load()
return () => {
cancelled = true
}
}, [roundId, refreshKey])
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
return { round, leaderboard, viewerParticipantId, error }
}
const MODE_MISSING_LABEL: Record<Mode, string> = {
gross: "",
net: "netto-tall",
points: "poengsum",
}
export function RoundLeaderboard({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId, error } = useLeaderboardData(roundId)
const [mode, setMode] = useState<Mode>("gross")
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
// etterspurt av bruker) -- kollapset som default (samme "trekkspill
// starter lukket"-konvensjon som round-stats.tsx sine StatCard-seksjoner)
// slik at listen forblir kompakt og skalerer til mange spillere.
const [expandedId, setExpandedId] = useState<string | null>(null)
if (error) {
return (
<div className="flex min-h-dvh 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="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round || !leaderboard) {
return (
<div className="flex min-h-dvh 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(leaderboard.entries, mode, viewerParticipantId)
return (
<div className="min-h-dvh 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={`/my-rounds/${roundId}`}
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" />
Leaderboard
</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">
{round.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span>
</div>
<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>
{/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) --
IKKE separate kort med mellomrom lenger. Kun ytterkanten er
avrundet/rammet inn; radene skilles med en tynn linje
(divide-y) og flyter sømløst i hverandre. En utvidet rad får en
svak bakgrunnstone som eneste "dette er åpnet"-signal. */}
<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
const expanded = expandedId === entry.participant_id
return (
<li key={entry.participant_id} className={cn(expanded && "bg-muted/30")}>
<button
type="button"
onClick={() => setExpandedId((prev) => (prev === entry.participant_id ? null : entry.participant_id))}
aria-expanded={expanded}
aria-label={`Vis detaljer for ${entry.display_name}`}
className="flex w-full items-center gap-3 p-3 text-left transition-colors hover:bg-accent/30 sm:p-4"
>
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
<ChevronDown
aria-hidden="true"
className={cn("size-5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{expanded && (
<div className="flex flex-col gap-2 px-3 pb-3 pt-0.5 sm:px-4">
{/* Det som lå i selve raden før 2026-07-26 (Deg/Eier/
leder-ikon/fremdrift) -- fortsatt tilgjengelig, bare
flyttet hit slik at den kollapsede listen forblir
ren "rangering, navn, tall". */}
<div className="flex flex-wrap items-center gap-1.5">
{entry.isSelf && <Badge variant="default">Deg</Badge>}
{entry.is_owner && <Badge variant="outline">Eier</Badge>}
{isLeader && <Trophy aria-hidden="true" className="size-4 text-primary" />}
<span className="text-sm text-muted-foreground">
{leaderboard.completed
? "Ferdig"
: entry.holes_played > 0
? `${entry.holes_played}/${leaderboard.holes_planned} hull spilt`
: "Ikke startet"}
</span>
</div>
{entry.holes_played > 0 ? (
<HoleStrip holes={entry.holes} startHole={round.start_hole} holesPlanned={leaderboard.holes_planned} mode={mode} />
) : (
<p className="py-1 text-sm text-muted-foreground">Ingen hull registrert ennå.</p>
)}
</div>
)}
</li>
)
})}
</ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground">
Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.
</p>
)}
</main>
</div>
)
}
// --- Kompakt mini-variant, til rundens egen detaljside --------------------
// Topp 3 (brutto) + "og N til", lenke til hele leaderboardet. Kun relevant
// for runder med mer enn én deltaker.
export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId } = useLeaderboardData(roundId)
if (!round || !leaderboard || round.participants.length < 2) return null
const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId)
const top = ranked.slice(0, 3)
const restCount = ranked.length - top.length
return (
<Link
href={`/my-rounds/${roundId}/leaderboard`}
className="mb-5 flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/30"
>
<span className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
<Trophy aria-hidden="true" className="size-4 text-primary" />
Leaderboard
</span>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</span>
<span className="flex flex-col gap-1">
{top.map((entry) => (
<span key={entry.participant_id} className="flex items-center gap-2 text-sm">
<span className="w-8 shrink-0 font-bold tabular-nums text-muted-foreground">
{entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : ""}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-foreground">{entry.display_name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{entry.score_to_par !== null ? formatToPar(entry.score_to_par) : ""}
</span>
</span>
))}
</span>
{restCount > 0 && <span className="text-sm text-muted-foreground">og {restCount} til</span>}
</Link>
)
}