"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 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 ( ) } 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 ( {/* 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 : "–"} )} ) } // 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 (
Hull · Par · Hcp
{ordered.map((h) => ( {h.hole_number} {h.par} {h.stroke_index} ))}
) } 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_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 } 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 return (
Matchstatus {result.match_is_closed && Avgjort} {result.match_is_dormie && !result.match_is_closed && Dormie}
{statusText && {statusText}} {result.match_holes_played} hull spilt {result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`} {result.hole_results && result.hole_results.length > 0 && (
    {result.hole_results.map((r, i) => (
  • {r === "halved" ? "½" : r.toUpperCase()}
  • ))}
)} Se hull for hull med brutto/netto →
) } // --- 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, refreshKey } } const MODE_MISSING_LABEL: Record = { gross: "", net: "netto-tall", points: "poengsum", } export function RoundLeaderboard({ roundId }: { roundId: string }) { const { round, leaderboard, viewerParticipantId, error, refreshKey } = 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 (

{error}

Tilbake til egne runder
) } if (!round || !leaderboard) { return (
) } const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId) const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format) return (
{round.name?.trim() || round.course_name_snapshot} {round.name?.trim() ? `${round.course_name_snapshot} · ` : ""} {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
{/* 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. */}
    {ranked.map((entry) => { const value = valueFor(entry, mode) const isLeader = entry.rank === 1 const expanded = expandedId === entry.participant_id return (
  • {expanded && (
    {/* 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". */}
    {entry.isSelf && Deg} {entry.is_owner && Eier} {isLeader &&
    {entry.holes_played > 0 ? ( ) : (

    Ingen hull registrert ennå.

    )}
    )}
  • ) })}
{mode !== "gross" && ranked.some((e) => e.rank === null) && (

Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.

)} )}
) } // --- 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 ( {top.map((entry) => ( {entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : "–"} {entry.display_name} {entry.score_to_par !== null ? formatToPar(entry.score_to_par) : "–"} ))} {restCount > 0 && og {restCount} til} ) }