teecup/frontend/components/round-leaderboard.tsx

804 lines
33 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
picked_up: boolean
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 ApiSide = {
id: string
label: string | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
play_format: string
sides: ApiSide[]
participants: ApiRoundParticipant[]
}
// To-sidede formater har ingen individuell "rangering" -- to lag/spillere i
// en match har en STATUS (hvem leder, hull for hull), ikke en topp-3-liste.
// Delt-ball-formatene (foursome/greensome/scramble) har i tillegg ALDRI
// noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én
// score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville
// derfor vist en tom/meningsløs liste for dem uansett.
const TWO_SIDED_FORMATS = [
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
]
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"
// "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
// vises som "PU" i stedet for det underliggende Net Double Bogey-tallet.
const markClass = hole.picked_up
? "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange"
: !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.picked_up ? "PU" : 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>
)
}
// Par/Hcp-referanserad (2026-07-29, etterspurt av bruker -- manglet helt)
// -- vist ÉN gang for hele leaderboardet (par/stroke-index er banedata,
// identisk for alle deltakere), i stedet for gjentatt i hver enkelt
// deltakers utvidbare HoleStrip. Samme kolonnebredde (w-8) som HoleMark,
// slik at radene visuelt stemmer overens med hver deltakers strip under.
function HoleReferenceStrip({
holes,
startHole,
holesPlanned,
}: {
holes: ApiLeaderboardHole[]
startHole: number
holesPlanned: number
}) {
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)
if (ordered.length === 0) return null
return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Hull · Par · Hcp</span>
<div className="-mx-1 overflow-x-auto px-1">
<div className="flex gap-1.5">
{ordered.map((h) => (
<span key={h.hole_number} className="flex w-8 shrink-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold text-muted-foreground">{h.hole_number}</span>
<span className="text-xs font-bold tabular-nums text-foreground">{h.par}</span>
<span className="text-[10px] font-medium tabular-nums text-muted-foreground">{h.stroke_index}</span>
</span>
))}
</div>
</div>
</div>
)
}
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>
)
}
// --- Matchstatus for to-sidede formater (2026-07-28) ------------------------
// En match/fourball/foursome/greensome/scramble har ingen individuell
// rangering -- kun en status (hvem leder, hull for hull). Egen, liten
// versjon her (samme mønster som HoleMark over -- lokal duplisering
// fremfor cross-file-import for et lite presentasjons-stykke), ikke en
// full brutto/netto-tabell (den finnes allerede på scorekort-siden sin
// "Matchforløp"-seksjon -- lenket til herfra i stedet for duplisert).
type ApiFormatResult = {
play_format: string
ready: boolean
match_lead: number | null
match_holes_played: number | null
match_holes_remaining: number | null
match_is_closed: boolean | null
match_is_dormie: boolean | null
match_status_text: string | null
hole_results: string[] | null
}
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx) -- den ledende
// sidens sone strekker seg proporsjonalt forbi midtlinjen. Ingen navn her
// (vises allerede andre steder på leaderboard-siden), kun fargesonene +
// den sentrerte statuspillen, for full visuell konsistens på tvers av alle
// steder en matchstatus vises.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, refreshKey])
return result
}
// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid
// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin
// egen sortering av side-id-er). Erstatt med det faktiske side-navnet.
function substituteSideLabels(text: string, sides: ApiSide[]): string {
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
}
function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) {
const result = useFormatResult(roundId, refreshKey)
if (!result) return null
if (!result.ready) {
return (
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
Venter at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
</div>
)
}
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
const lead = result.match_lead ?? 0
const holesPlayed = result.match_holes_played ?? 0
const holesRemaining = result.match_holes_remaining ?? 0
const totalHoles = holesPlayed + holesRemaining
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
</div>
<div
className="grid h-20 w-full overflow-hidden rounded-2xl"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
<div className="flex h-full items-center justify-center px-3">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{statusText ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{statusText}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
</div>
{result.hole_results && result.hole_results.length > 0 && (
<ul className="flex flex-wrap gap-1">
{result.hole_results.map((r, i) => (
<li
key={i}
aria-label={
r === "halved"
? `Hull ${i + 1}: delt`
: `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
}
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
r === "a" && "bg-primary text-primary-foreground",
r === "b" && "bg-brand-orange text-brand-orange-foreground",
r === "halved" && "border border-border bg-muted text-muted-foreground",
)}
>
{r === "halved" ? "½" : r.toUpperCase()}
</li>
))}
</ul>
)}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="text-sm font-semibold text-primary underline underline-offset-2"
>
Se hull for hull med brutto/netto
</Link>
</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, refreshKey }
}
const MODE_MISSING_LABEL: Record<Mode, string> = {
gross: "",
net: "netto-tall",
points: "poengsum",
}
export function RoundLeaderboard({
roundId,
embedded = false,
}: {
roundId: string
// ADR-040 ("det store grepet") -- dropper denne komponentens egen
// sticky header når den rendres under den delte `RoundHeader`
// (via `RoundPageShell`) i stedet for standalone.
embedded?: boolean
}) {
const { round, leaderboard, viewerParticipantId, error, refreshKey } = 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)
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
return (
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<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-md shadow-black/8">
<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>
{/* To-sidede formater (match/fourball/foursome/greensome/scramble)
har ingen individuell rangering å vise -- kun en matchstatus
(2026-07-28, se MatchStatusSection over). Delt-ball-formatene
har i tillegg ALDRI individuelle round_hole-rader (ADR-039
Beslutning C), så den vanlige listen under ville uansett vært
tom for dem. */}
{isTwoSided ? (
<MatchStatusSection roundId={roundId} sides={round.sides} refreshKey={refreshKey} />
) : (
<>
<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>
{leaderboard.entries[0]?.holes && (
<HoleReferenceStrip
holes={leaderboard.entries[0].holes}
startHole={round.start_hole}
holesPlanned={leaderboard.holes_planned}
/>
)}
{/* É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
// To-sidede formater (match/fourball/foursome/greensome/scramble) vises
// allerede av FormatResultPanel rett under denne komponenten sitt
// kallsted i round-detail.tsx -- ingen individuell brutto-rangering å
// vise her (og for delt-ball-formatene ville leaderboard.entries uansett
// alltid vært tom, ADR-039 Beslutning C).
if (TWO_SIDED_FORMATS.includes(round.play_format)) 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>
)
}