804 lines
33 KiB
TypeScript
804 lines
33 KiB
TypeScript
"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 på 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>
|
||
)
|
||
}
|