"use client"
// Leaderboard for en frittstående runde (ADR-033), 2026-07-26.
// Håndkodet (ikke via V0 -- brukeren gikk tom for credits, ba meg bygge
// direkte etter samme designspesifikasjon som var skrevet til V0-prompten,
// se FEATURE_BACKLOG.md). Gjenbruker etablerte mønstre fra
// round-scorecard.tsx (signedToPar-formatering, form-før-farge-språket fra
// ScoreMark, stableford-formelen) og round-stats.tsx/round-scorecard.tsx sin
// WS-sanntid-/viewerId-oppkobling, i stedet for å finne opp nye.
//
// Radene er BEVISST minimale i kollapset tilstand (kun rangering/navn/tall,
// 2026-07-26 -- brukeren ba om å fjerne "Deg"/"Eier"/pokal/hull-spilt-
// teksten derfra og la radene flyte sømløst i hverandre uten egne
// kort-rammer) -- den informasjonen flyttet inn i den utvidbare seksjonen
// sammen med hullscorene, ikke fjernet helt.
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Trophy } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
type ApiLeaderboardHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
strokes_received: number | null
}
type ApiLeaderboardEntry = {
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
holes: ApiLeaderboardHole[]
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
}
type ApiRoundParticipant = {
id: string
user_id: string | null
}
type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
participants: ApiRoundParticipant[]
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
// --- Rangering ---------------------------------------------------------------
// Delt plassering (likt resultat) skal vises som "T-N", ikke to ulike tall.
// Deltakere uten et tall for gjeldende visning (brutto/netto/poeng) havner
// samlet nederst, uten rangeringstall -- IKKE skjult, IKKE en krasj.
// Poeng (stableford) rangeres SYNKENDE (høyest vinner) -- motsatt av
// brutto/netto til-par (lavest vinner).
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiLeaderboardEntry & {
rank: number | null
tied: boolean
isSelf: boolean
}
function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied, isSelf: e.participant_id === viewerParticipantId })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
}
// --- Verdi-merke (brutto/netto til-par, eller poeng) ------------------------
// Samme "form + farge, aldri kun farge"-språk som ScoreMark i
// round-scorecard.tsx: sirkel = under par, firkant = over par, ren tekst ved
// jevnt med par. Poeng har ingen retning (høyere er alltid bedre) -- én fast
// sirkelform, men samme visuelle vekt/fyll-mønster for lederen. Lederen
// (emphasize) får en FYLT variant for litt ekstra visuell vekt.
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
if (value === 0) {
return 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"
const markClass =
!hole.played || hole.score === null
? "text-muted-foreground"
: (() => {
const kind = classifyHoleScore(hole.score, hole.par)
if (kind === "eagle") return "rounded-full bg-primary text-primary-foreground"
if (kind === "birdie") return "rounded-full border-2 border-primary bg-primary/10 text-primary"
if (kind === "bogey") return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
if (kind === "double") return "rounded-[4px] bg-brand-orange text-brand-orange-foreground"
return "font-bold text-foreground"
})()
return (
{hole.hole_number}
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
appen (fylt/farget merke), øverst -- den fremste raden. */}
{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 : "–"}
)}
)
}
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) => (
))}
)
}
// --- Full leaderboard-side -----------------------------------------------
function useLeaderboardData(roundId: string) {
const [round, setRound] = useState(null)
const [leaderboard, setLeaderboard] = useState(null)
const [viewerId, setViewerId] = useState(null)
const [error, setError] = useState(null)
// Sanntid (samme "noe endret seg, hent på nytt"-mønster som
// round-stats.tsx/round-scorecard.tsx sin refreshKey).
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`/rounds/${roundId}`, { credentials: "include" }),
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
])
if (roundRes.status === 403 || roundRes.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!roundRes.ok || !leaderboardRes.ok) {
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
return
}
const roundData: ApiRoundHeader = await roundRes.json()
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
if (!cancelled) {
setRound(roundData)
setLeaderboard(leaderboardData)
}
}
void load()
return () => {
cancelled = true
}
}, [roundId, refreshKey])
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
return { round, leaderboard, viewerParticipantId, error }
}
const MODE_MISSING_LABEL: Record = {
gross: "",
net: "netto-tall",
points: "poengsum",
}
export function RoundLeaderboard({ roundId }: { roundId: string }) {
const { round, leaderboard, viewerParticipantId, error } = useLeaderboardData(roundId)
const [mode, setMode] = useState("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(null)
if (error) {
return (
{/* É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". */}