"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 E
}
const under = value < 0
return (
{formatToPar(value)}
)
}
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
return (
{value}
)
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
return mode === "points" ? :
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : "–"}
)
}
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 (
{options.map((opt) => (
))}
)
}
// --- 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 (
{hole.hole_number}
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
appen (fylt/farget merke), øverst -- den fremste raden. */}
{hole.picked_up ? "PU" : hole.played && hole.score !== null ? hole.score : "–"}
{/* 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 && (
{secondaryValue !== null ? secondaryValue : "–"}
)}
)
}
// 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 (
)
}
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 (
{secondaryLabel && (
Slag · {secondaryLabel}
)}
{ordered.map((h) => (
))}
)
}
// --- 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(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 (
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
)
}
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 (
{/* 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 ? (
) : (
<>
Stilling
{leaderboard.entries[0]?.holes && (
)}
{/* É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. */}
{/* 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". */}