"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden". Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production. Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
791 lines
33 KiB
TypeScript
791 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"]
|
||
|
||
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 }: { roundId: string }) {
|
||
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="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-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>
|
||
)
|
||
}
|