"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 ( ) } 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 : "–"} )} ) } 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 (

{error}

Tilbake til egne runder
) } if (!round || !leaderboard) { return (
) } const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId) 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))}

Stilling

{/* É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 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} ) }