"use client" // Leaderboard for en frittstående runde (ADR-033). // // FULLSTENDIG OMBYGGET 2026-08-03 etter V0-eksport "zip 28": brukeren ba // EKSPLISITT om V0 sin visuelle tolkning i FULL BREDDE, ikke bare de // formatene som var reelt ødelagte/manglet ("Jeg vil ABSOLUTT at du // implementerer V0 sin tolkning... Jeg vil ikke at du skal gjøre // personlige endringer eller holde tilbake på noe i det hele tatt"). // Forrige runde samme dag portet kun High-low-high/BBB-Københavner/ // Shamble-Money-Ball (reelle bugs) og lot resten av det gamle, // HÅNDKODEDE designet (2026-07-26, bygget FØR V0 da brukeren gikk tom // for credits) stå urørt -- brukeren testet en vanlig slagspill-runde // (det vanligste tilfellet) og så null visuell endring, med rette. // // Datalaget (typer, useLeaderboardData/useFormatResult/ // useFlightTeamStandings, rankEntries brukt av RoundLeaderboardMini) er // UENDRET fra forrige runde -- kun det visuelle laget er nå V0 sin egen // kode, portet mot ekte data i stedet for V0 sine mock-arrays (samme // "aldri blind-kopier en fil"-disiplin som alltid, bare nå anvendt på // ALLE komponentene eksporten rørte, ikke et utvalg). import { useEffect, useState } from "react" import Link from "next/link" import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Target, Trophy } from "lucide-react" import { cn } from "@/lib/utils" import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result" import { MatchScorecardGrid } from "@/components/round-scorecard" // --- 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[] // "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av // huntNettoToPar() til å regne ut hunt-visningens tall klientsidig. course_handicap: number | null } type ApiLeaderboard = { holes_planned: number completed: boolean entries: ApiLeaderboardEntry[] net_display_style: "standard" | "hunt" } type ApiRoundParticipant = { id: string user_id: string | null round_side_id: string | null handicap_index_snapshot: number | null // Felt under er allerede returnert av samme `${apiPrefix}/{roundId}`- // endepunkt (samme JSON som round-scorecard.tsx sin `ApiParticipant` // fetcher, se der) -- bare ikke tidligere typet her, siden de ikke // trengtes før MatchScorecardGrid-gjenbruken (ny utvid-seksjon i // TwoSidedBoard/HighLowBoard). `guest_name` er valgfritt -- fraværende i // publicMode (PublicRoundParticipantOut har ikke feltet), defaultes til // null der (se toGridRound under) -- ubrukt av MatchScorecardGrid uansett // (den leser kun display_name). guest_name?: string | null display_name: string is_owner: boolean playing_handicap: number | null } type ApiSide = { id: string label: string | null // KUN satt for scramble_solo (migrasjon 056) -- null for alle andre // formater, som ikke skiller sidene fra hverandre strukturelt. side_role: "team" | "individual" | null } type ApiRoundHeader = { id: string name: string | null course_name_snapshot: string // Samme "allerede i JSON-en, bare ikke tidligere typet"-begrunnelse som // ApiRoundParticipant sine nye felt over. tee_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", "chapman", // scramble_solo_match (migrasjon 057) -- ekte hull-for-hull matchspill // (compute_match_state på backend-siden), samme generiske match_lead/ // match_holes_played/-remaining/is_closed/is_dormie-felter som resten // av denne listen. Asymmetrisk lag-mot-individuell-struktur (IKKE // fast antall spillere per side), men TwoSidedBoard/buildSideTeams // grener allerede på FAKTISK antall spillere per side, ikke et fast // tall -- bekreftet trygt å gjenbruke uendret. scramble_solo (stroke- // varianten, migrasjon 056) er BEVISST IKKE her, se SCRAMBLE_SOLO_ // FORMATS under. "scramble_solo_match", ] // High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et // hull-ledelse-konsept ("X UP"), se HighLowBoard. const HIGH_LOW_FORMATS = new Set(["high_low_high"]) // Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets // EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard. const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"]) // Scramble mot enkeltspiller (migrasjon 056) -- BEVISST IKKE i // TWO_SIDED_FORMATS: dette er netto SLAGSPILL-sammenligning // (net_stroke_play_margin), ikke matchspill/hull-ledelse -- egen enkel // visning, se ScrambleSoloResultView. const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo"]) // Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå // slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk // Stableford (backend regner den likt uansett format), FEIL for disse to. const FORMAT_OWN_POINTS_FIELD: Partial> = { bbb: "bbb_points", copenhagen: "copenhagen_points", } // Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor // cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx). const FORMAT_LABELS: Record = { stroke: "Slagspill", stableford: "Stableford", match: "Match", skins: "Skins", fourball: "Fourball", foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", chapman: "Chapman", copenhagen: "Københavner", bbb: "Bingo Bango Bongo", flag: "Flaggturnering", shamble: "Shamble", money_ball: "Money Ball", high_low_high: "High-low-high", scramble_solo: "Scramble mot enkeltspiller", scramble_solo_match: "Scramble mot enkeltspiller (matchspill)", } const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) // --- Rangering (brukt av RoundLeaderboardMini) ------------------------------ 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)}` } // =========================================================================== // V0 sitt visuelle vokabular (zip 28, 2026-08-03) -- portet verbatim, kun // HOLES/TOTAL_HOLES er ikke lenger en hardkodet modul-konstant (V0 sin // egen mock-bane) men en `holes: Hole[]`/`totalHoles: number`-parameter // bygget fra RUNDENS EGNE par/indeks (se buildHoles under) -- ellers // samme kode, samme klassenavn, samme struktur. // =========================================================================== type Hole = { holeNumber: number; par: number; index: number } const SIDE = { a: { fill: "bg-primary", text: "text-primary-foreground", ink: "text-primary" }, b: { fill: "bg-brand-orange", text: "text-brand-orange-foreground", ink: "text-brand-orange" }, } as const type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" function classify(gross: number, par: number): ScoreClass { const d = gross - par if (d <= -2) return "eagle" if (d === -1) return "birdie" if (d === 0) return "par" if (d === 1) return "bogey" return "double" } function scoreClassLabel(gross: number, par: number): string { const d = gross - par if (d <= -1) return "under par" if (d === 0) return "par" return "over par" } function ScoreMark({ gross, par, size = "md" }: { gross: number; par: number; size?: "sm" | "md" }) { const kind = classify(gross, par) const dim = size === "sm" ? "size-8 text-sm" : "size-9 text-base" const shared = cn("inline-flex items-center justify-center font-extrabold tabular-nums", dim) let cls = "" if (kind === "eagle") cls = "rounded-full bg-primary text-primary-foreground" else if (kind === "birdie") cls = "rounded-full border-2 border-primary bg-primary/10 text-primary" else if (kind === "bogey") cls = "rounded-[6px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange" else if (kind === "double") cls = "rounded-[6px] bg-brand-orange text-brand-orange-foreground" else cls = "text-foreground" return ( {gross} ({scoreClassLabel(gross, par)}) ) } function toParLabel(diff: number): string { if (diff === 0) return "E" return diff > 0 ? `+${diff}` : `${diff}` } function toParWords(diff: number): string { if (diff === 0) return "likt med par" return diff > 0 ? `${diff} over par` : `${Math.abs(diff)} under par` } const NF1 = new Intl.NumberFormat("no-NO", { maximumFractionDigits: 1 }) function Badge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "self" | "owner" }) { const map = { neutral: "bg-muted text-muted-foreground", self: "bg-primary/15 text-primary", owner: "bg-gold/25 text-gold-foreground", } as const return ( {children} ) } function MetricPill({ kind, value, unit, size = "md", }: { kind: "topar" | "points" value: number unit?: string size?: "md" | "lg" }) { const dims = size === "lg" ? "min-h-12 min-w-12 px-3 text-xl" : "min-h-11 min-w-11 px-2.5 text-lg" const font = "font-extrabold tabular-nums" if (kind === "points") { return ( {value} {unit ? {unit} : null} ) } const label = toParLabel(value) if (value === 0) { return ( {label} {toParWords(value)} ) } const under = value < 0 return ( {label} {toParWords(value)} ) } // =========================================================================== // 1. INDIVIDUAL leaderboard (stroke / stableford / skins / flag / BBB / KBH) // =========================================================================== type MetricKey = "brutto" | "netto" | "stableford" | "points" type IndividualPlayer = { id: string name: string isSelf?: boolean isOwner?: boolean // Ekte per-hull mottatte slag (allerede korrekt utregnet av backend, // samme allokering som resten av appen) -- IKKE en rå course handicap + // en egen klientsidig strokes_received-utregning, som V0 sin mock- // versjon gjorde uten et ekte backend å hente fra. `null` for et hull // betyr "ingen HCP spores for denne spilleren", ikke "0 slag". strokesReceived: (number | null)[] hcpIndex: number | null finished?: boolean gross: (number | null)[] holePoints?: (number | null)[] // "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av // huntNettoToPar() (i stedet for strokesReceived, som er gradvis/ // partial-allokert). Samme null-betydning som hcpIndex/strokesReceived. courseHandicap: number | null } function hasHcp(p: IndividualPlayer): boolean { return p.strokesReceived.some((v) => v !== null) } const METRIC_LABEL: Record = { brutto: "Brutto", netto: "Netto", stableford: "Stableford", points: "Poeng", } function playedHoles(p: IndividualPlayer): number { return p.gross.filter((g) => g !== null).length } function grossToPar(p: IndividualPlayer, holes: Hole[]): number { let diff = 0 p.gross.forEach((g, i) => { if (g !== null) diff += g - holes[i].par }) return diff } function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null { if (!hasHcp(p)) return null let diff = 0 p.gross.forEach((g, i) => { const received = p.strokesReceived[i] if (g !== null && received !== null) diff += g - received - holes[i].par }) return diff } // "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- trekker fra HELE // spillehandicapet med én gang i stedet for kun de slagene som er "tjent // inn" for de spilte hullene (nettoToPar over). Konvergerer til NØYAKTIG // samme tall som nettoToPar når alle 18 hull er spilt. function huntNettoToPar(p: IndividualPlayer, holes: Hole[]): number | null { if (p.courseHandicap === null) return null let diff = 0 p.gross.forEach((g, i) => { if (g !== null) diff += g - holes[i].par }) return diff - p.courseHandicap } function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null { if (!hasHcp(p)) return null let pts = 0 p.gross.forEach((g, i) => { const received = p.strokesReceived[i] if (g !== null && received !== null) pts += Math.max(0, 2 + (holes[i].par - (g - received))) }) return pts } function pointsTotal(p: IndividualPlayer): number { return (p.holePoints ?? []).reduce((s, v) => s + (v ?? 0), 0) } function evalMetric( p: IndividualPlayer, metric: MetricKey, pointsUnit: string, holes: Hole[], netDisplayStyle: "standard" | "hunt" = "standard", ): { rankable: boolean sortVal: number higherWins: boolean reason?: string pill: React.ReactNode } { if (metric === "brutto") { const v = grossToPar(p, holes) return { rankable: true, sortVal: v, higherWins: false, pill: } } if (metric === "netto") { const v = netDisplayStyle === "hunt" ? huntNettoToPar(p, holes) : nettoToPar(p, holes) if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: } return { rankable: true, sortVal: v, higherWins: false, pill: } } if (metric === "stableford") { const v = stablefordTotal(p, holes) if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: } return { rankable: true, sortVal: v, higherWins: true, pill: } } const v = pointsTotal(p) return { rankable: true, sortVal: v, higherWins: true, pill: } } function NoData() { return ( ingen rangering ) } function IndividualBoard({ players, metrics, defaultMetric, pointsUnit = "p", holes, commentsHref, netDisplayStyle = "standard", }: { players: IndividualPlayer[] metrics: MetricKey[] defaultMetric: MetricKey pointsUnit?: string holes: Hole[] commentsHref: string // "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style. netDisplayStyle?: "standard" | "hunt" }) { const [metric, setMetric] = useState(defaultMetric) const [openId, setOpenId] = useState(null) const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes, netDisplayStyle) })) const rankable = evaluated.filter((e) => e.rankable) const unranked = evaluated.filter((e) => !e.rankable) rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal)) const rankInfo: { rank: number; tied: boolean }[] = [] const counts: Record = {} rankable.forEach((e, i) => { const rank = i > 0 && rankable[i - 1].sortVal === e.sortVal ? rankInfo[i - 1].rank : i + 1 rankInfo.push({ rank, tied: false }) counts[rank] = (counts[rank] ?? 0) + 1 }) rankInfo.forEach((x) => (x.tied = counts[x.rank] > 1)) return (
    {rankable.map((e, i) => ( setOpenId((cur) => (cur === e.player.id ? null : e.player.id))} commentsHref={commentsHref} /> ))} {unranked.map((e) => ( setOpenId((cur) => (cur === e.player.id ? null : e.player.id))} commentsHref={commentsHref} /> ))}
) } function MetricToggle({ metrics, value, onChange, pointsUnit, }: { metrics: MetricKey[] value: MetricKey onChange: (m: MetricKey) => void pointsUnit: string }) { if (metrics.length < 2) return null return (
{metrics.map((m) => { const active = m === value const label = m === "points" && pointsUnit !== "p" ? `${METRIC_LABEL[m]}` : METRIC_LABEL[m] return ( ) })}
) } function IndividualRow({ player, rank, tied, isLeader, pill, reason, metric, holes, open, onToggle, commentsHref, }: { player: IndividualPlayer rank: number | null tied: boolean isLeader: boolean pill: React.ReactNode reason?: string metric: MetricKey holes: Hole[] open: boolean onToggle: () => void commentsHref: string }) { const played = playedHoles(player) const totalHoles = holes.length const progress = player.finished ? "Ferdig" : `${played}/${totalHoles} hull` const panelId = `hbh-${player.id}` return (
  • {open && (
    )}
  • ) } function HoleByHole({ player, metric, holes, rank, tied, commentsHref, }: { player: IndividualPlayer metric: MetricKey holes: Hole[] rank: number | null tied: boolean commentsHref: string }) { const front = holes.slice(0, 9) const back = holes.slice(9) const totalPar = holes.reduce((s, h) => s + h.par, 0) const totalGross = player.gross.reduce((s, g) => s + (g ?? 0), 0) const holesWithScore = player.gross.filter((g) => g !== null).length return (

    Hull-for-hull · brutto (form) og netto (tall). Sirkel = under par, firkant = over par.

    {[front, back].filter((set) => set.length > 0).map((set, si) => { const offset = si === 0 ? 0 : front.length return (
    {set.map((h) => ( ))} {set.map((h) => ( ))} {set.map((h, i) => { const g = player.gross[offset + i] return ( ) })} {hasHcp(player) && ( {set.map((h, i) => { const g = player.gross[offset + i] const received = player.strokesReceived[offset + i] if (g === null || received === null) return return })} )} {metric === "points" && player.holePoints && ( {set.map((h, i) => { const pt = player.holePoints?.[offset + i] return })} )}
    Hull{h.holeNumber}{si === 0 ? "Ut" : "Inn"}
    Par · idx{h.par}·{h.index}
    Brutto {g === null ? : } {set.reduce((s, h, i) => s + (player.gross[offset + i] ?? 0), 0) || "–"}
    Netto{g - received}
    Poeng{pt ?? "–"} {set.reduce((s, h, i) => s + (player.holePoints?.[offset + i] ?? 0), 0)}
    ) })}
    Par {totalPar} · Score {holesWithScore > 0 ? totalGross : "–"} {rank !== null ? ` · ${tied ? "Delt " : ""}${rank}. plass` : ""}
    ) } function Legend() { return (
    3 Under par (sirkel) 5 Over par (firkant)
    ) } // =========================================================================== // 2. TWO-SIDED match status (match / fourball / foursome / greensome / …) // =========================================================================== type SideTeamInfo = { id: "a" | "b"; name: string; players: string[] } type HoleWinner = "a" | "b" | "halved" | null // Statusteksten/lederen/dormie/avgjort kommer FERDIG REGNET fra backend // (samme tall som allerede vises andre steder i appen, f.eks. // round-detail.tsx sin FormatResultPanel) -- V0 sin egen `matchStatus()` // regnet dette på nytt fra `results` klientsidig; her brukes heller de // allerede korrekte/testede server-tallene, med SAMME visuelle "tug of // war"-presentasjon V0 tegnet. Unngår to divergerende utregninger av // samme ting. function TwoSidedBoard({ sideA, sideB, results, totalHoles, formatName, headline, sub, leader, isDormie, isClosed, round, roundId, viewerId, refreshKey, apiPrefix, commentsHref, }: { sideA: SideTeamInfo sideB: SideTeamInfo results: HoleWinner[] totalHoles: number formatName: string headline: string sub: string leader: "a" | "b" | null isDormie: boolean isClosed: boolean round: ApiRoundHeader roundId: string viewerId: string | null refreshKey: number apiPrefix: string commentsHref: string }) { const [showDetail, setShowDetail] = useState(false) const holesPlayed = results.filter((r) => r !== null).length let aWins = 0 let bWins = 0 results.forEach((r) => { if (r === "a") aWins++ else if (r === "b") bWins++ }) const diff = Math.abs(aWins - bWins) const maxUp = Math.max(3, diff) const offsetPct = (diff / maxUp) * 50 const markerLeft = leader === "a" ? 50 - offsetPct : leader === "b" ? 50 + offsetPct : 50 return (
    {formatName} {isClosed && Avgjort} {isDormie && !isClosed && Dormie}
    {headline} {sub}
    ) } function SideName({ side, team, leading, align = "left" }: { side: "a" | "b"; team: SideTeamInfo; leading: boolean; align?: "left" | "right" }) { return (
    {team.players.join(" / ")} {leading && leder}
    ) } function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInfo; sideB: SideTeamInfo; results: HoleWinner[]; totalHoles: number }) { return (

    Hull-for-hull

    {Array.from({ length: totalHoles }, (_, i) => i + 1).map((holeNumber, i) => { const r = results[i] ?? null const label = r === "a" ? sideA.name : r === "b" ? sideB.name : r === "halved" ? "delt" : "ikke spilt" return (
    {holeNumber} {r === "a" ? "A" : r === "b" ? "B" : r === "halved" ? "=" : ""} Hull {holeNumber}: {label}
    ) })}
    A{sideA.name} B{sideB.name} =Delt
    ) } // Delt av TwoSidedBoard/HighLowBoard (ADR-047) -- ÉN utvidbar detaljseksjon // for HELE kampen (ikke et per-rad-mønster, siden formatet kun har 2 sider, // ikke en spillerliste). Viser faktiske hull-for-hull-SCORER (ikke bare // hvem som vant hullet, som HoleWinners/DuelRow allerede gjør statisk over) // via den allerede eksisterende, nå eksporterte MatchScorecardGrid -- // samme scorekort som `/my-rounds/{id}/scorecard` viser, ingen ny visuell // design. `MatchScorecardGrid` gjør sin egen `useFormatResult`-henting // internt, så dette henter kun ved faktisk utvidelse, ikke ved mount. // `MatchScorecardGrid` (round-scorecard.tsx) sin `ApiParticipant.guest_name` // er required (string|null), men `publicMode` sin `PublicRoundParticipantOut` // har ikke feltet i det hele tatt (undefined ved kjøretid) -- defaultes til // null her. Trygt: MatchScorecardGrid leser aldri guest_name (kun // display_name via participantLabel), så dette endrer ingen visning. function toGridRound(round: ApiRoundHeader) { return { ...round, participants: round.participants.map((p) => ({ ...p, guest_name: p.guest_name ?? null })), } } function MatchDetailSection({ showDetail, onToggle, round, roundId, viewerId, refreshKey, apiPrefix, commentsHref, }: { showDetail: boolean onToggle: () => void round: ApiRoundHeader roundId: string viewerId: string | null refreshKey: number apiPrefix: string commentsHref: string }) { return (
    {showDetail && (
    )}
    ) } // =========================================================================== // 3. HIGH-LOW-HIGH -- to duller per hull, poeng per side (IKKE hull-opp) // =========================================================================== type Duel = "a" | "b" | "halved" | null type HoleDuel = { low: Duel; high: Duel } function HighLowBoard({ sideA, sideB, duels, totalA, totalB, formatName, round, roundId, viewerId, refreshKey, apiPrefix, commentsHref, }: { sideA: SideTeamInfo sideB: SideTeamInfo duels: HoleDuel[] totalA: number totalB: number formatName: string round: ApiRoundHeader roundId: string viewerId: string | null refreshKey: number apiPrefix: string commentsHref: string }) { const [showDetail, setShowDetail] = useState(false) const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null const fmt = (n: number) => NF1.format(n) return (
    {formatName} · totale poeng
    {(["a", "b"] as const).map((s) => { const team = s === "a" ? sideA : sideB const val = s === "a" ? totalA : totalB const isLead = leader === s return (
    {fmt(val)} poeng {isLead ? "· leder" : ""}
    ) })}

    To uavhengige dueller per hull: laveste mot laveste og høyeste mot høyeste netto/Stableford-poeng. 0–2 poeng per hull.

    Hull-for-hull · lav / høy

    ({ text: String(i + 1), head: true }))} /> duelCell(d.low))} /> duelCell(d.high))} />
    A{sideA.name} B{sideB.name} =Delt (½–½)
    setShowDetail((v) => !v)} round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} commentsHref={commentsHref} />
    ) } function duelCell(d: Duel): { text: string; tone: Duel } { return { text: d === "a" ? "A" : d === "b" ? "B" : d === "halved" ? "=" : "–", tone: d } } function DuelRow({ label, cells }: { label: string; cells: { text: string; head?: boolean; tone?: Duel }[] }) { return (
    {label} {cells.map((c, i) => ( {c.text} ))}
    ) } // Lav/høy-rollen per spiller per hull er ikke tagget eksplisitt av // backend, men er alltid MIN/MAKS av de to lagkameratenes Stableford- // poeng den hullet (samme regel som handicap_engine.py sin // high_low_high_points_for_hole) -- utledes trygt her uten noen // backend-endring. function highLowDuelForHole(hole: ApiFormatHole): HoleDuel { const aPoints = hole.entries.filter((e) => e.side === "a" && e.points !== null).map((e) => e.points as number) const bPoints = hole.entries.filter((e) => e.side === "b" && e.points !== null).map((e) => e.points as number) if (aPoints.length < 2 || bPoints.length < 2) return { low: null, high: null } const lowA = Math.min(...aPoints) const highA = Math.max(...aPoints) const lowB = Math.min(...bPoints) const highB = Math.max(...bPoints) return { low: lowA > lowB ? "a" : lowB > lowA ? "b" : "halved", high: highA > highB ? "a" : highB > highA ? "b" : "halved", } } // =========================================================================== // 4. TEAM / FLIGHT (Shamble / Money Ball) // =========================================================================== type FlightTeam = { id: string; name: string; toPar: number; through: number; isThisTeam?: boolean } function TeamFlightBoard({ teamName, toPar, through, totalHoles, flight, members, holes, formatName, commentsHref, }: { teamName: string toPar: number | null through: number totalHoles: number flight: FlightTeam[] members: IndividualPlayer[] holes: Hole[] formatName: string commentsHref: string }) { const [showRaw, setShowRaw] = useState(false) const [openMemberId, setOpenMemberId] = useState(null) const ranked = [...flight].sort((a, b) => a.toPar - b.toPar) const myRank = ranked.findIndex((t) => t.isThisTeam) + 1 return (
    {formatName} · ditt lag
    {teamName} {through === totalHoles ? "Ferdig" : `${through}/${totalHoles} hull`} {flight.length > 1 && myRank > 0 ? ` · ${myRank}. i flighten` : ""}
    {toPar !== null ? : Ingen hull ferdig ennå}
    {ranked.length > 1 && (

    Flight-gruppe

      {ranked.map((t, i) => (
    1. {i + 1} {i === 0 && {t.through === totalHoles ? "Ferdig" : `${t.through}/${totalHoles} hull`}
    2. ))}
    )}
    {showRaw && (
      {members.map((m) => ( setOpenMemberId((cur) => (cur === m.id ? null : m.id))} commentsHref={commentsHref} /> ))}
    )}
    ) } // Utvidbar rad for ETT medlem av flighten (ADR-047) -- samme mønster // (klikk → hull-for-hull via HoleByHole) som IndividualRow, men uten // rangeringskolonnen (et lags medlemmer rangeres ikke seg imellom her, // kun laget som helhet gjør, vist i flight-gruppen over). function TeamMemberRow({ member, holes, totalHoles, open, onToggle, commentsHref, }: { member: IndividualPlayer holes: Hole[] totalHoles: number open: boolean onToggle: () => void commentsHref: string }) { const panelId = `tfm-${member.id}` return (
  • {open && (
    )}
  • ) } // =========================================================================== // Datalag: fetch-hooks (uendret fra forrige runde) // =========================================================================== type ApiFormatHoleEntry = { id: string label: string side: "a" | "b" | null gross: number | null net: number | null strokes_received: number | null counted: boolean points: number | null } type ApiFormatHole = { hole_number: number par: number stroke_index: number entries: ApiFormatHoleEntry[] result: string | null } 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 copenhagen_points: Record | null bbb_points: Record | null shamble_team_score: number | null money_ball_team_score: number | null hlh_points_a: number | null hlh_points_b: number | null // Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer, // side "a" = laget, side "b" = individualspilleren (fast, se // _build_scramble_solo_result). margin > 0 = laget leder. scramble_solo_team_net: number | null scramble_solo_individual_net: number | null scramble_solo_margin: number | null holes: ApiFormatHole[] | null } function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null { const [result, setResult] = useState(null) useEffect(() => { let cancelled = false fetch(`${apiPrefix}/${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, apiPrefix]) return result } // Bygger ScrambleSoloResultView sin props-form fra det generiske // ApiFormatResult-svaret. Hull-entryen sitt `label`-felt (satt av // _build_scramble_solo_result til deltakerens/lagets faktiske // visningsnavn) foretrekkes over sidens EGEN label (som bare er den // faste "Laget"/"Individuell motstander"-teksten satt ved opprettelse, // se new-round.tsx sin submitWizard) -- faller tilbake til sidens label // når ingen hull er spilt ennå. function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatResult | null): ScrambleSoloResult { const teamSide = round.sides.find((s) => s.side_role === "team") const individualSide = round.sides.find((s) => s.side_role === "individual") const firstHoleEntries = formatResult?.holes?.[0]?.entries ?? [] const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget" const individualLabel = firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander" const holes: ScrambleSoloResult["holes"] = (formatResult?.holes ?? []).map((h) => { const teamEntry = h.entries.find((e) => e.side === "a") const individualEntry = h.entries.find((e) => e.side === "b") const result: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved" return { holeNumber: h.hole_number, par: h.par, strokeIndex: h.stroke_index, result, team: { gross: teamEntry?.gross ?? 0, net: teamEntry?.net ?? 0, strokesReceived: teamEntry?.strokes_received ?? 0, }, individual: { gross: individualEntry?.gross ?? 0, net: individualEntry?.net ?? 0, strokesReceived: individualEntry?.strokes_received ?? 0, }, } }) return { ready: formatResult?.ready ?? false, teamLabel, individualLabel, teamNet: formatResult?.scramble_solo_team_net ?? null, individualNet: formatResult?.scramble_solo_individual_net ?? null, margin: formatResult?.scramble_solo_margin ?? null, holes, } } type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number } function useFlightTeamStandings( roundId: string, playFormat: string, refreshKey: number, apiPrefix: string = "/rounds", ) { const [standings, setStandings] = useState(null) useEffect(() => { let cancelled = false async function load() { const flightRes = await fetch(`${apiPrefix}/${roundId}/flight-group`, { credentials: "include" }) if (!flightRes.ok) return const flight: { flight_group_id: string | null; flights: { round_id: string; name: string | null; is_anchor: boolean }[] } = await flightRes.json() if (flight.flights.length < 2) { if (!cancelled) setStandings(null) return } const results = await Promise.all( flight.flights.map(async (f) => { const r = await fetch(`${apiPrefix}/${f.round_id}/format-result`, { credentials: "include" }) const data: ApiFormatResult | null = r.ok ? await r.json() : null const teamScore = playFormat === "shamble" ? data?.shamble_team_score ?? null : data?.money_ball_team_score ?? null const holes = data?.holes ?? [] const par = holes.length > 0 ? holes.reduce((s, h) => s + h.par, 0) : null return { round_id: f.round_id, name: f.name, isAnchor: f.is_anchor, teamScore, par, through: holes.length } }), ) if (!cancelled) setStandings(results) } void load() return () => { cancelled = true } }, [roundId, playFormat, refreshKey, apiPrefix]) return standings } function useLeaderboardData(roundId: string, apiPrefix: string = "/rounds") { const [round, setRound] = useState(null) const [leaderboard, setLeaderboard] = useState(null) const [viewerId, setViewerId] = useState(null) const [error, setError] = useState(null) const [refreshKey, setRefreshKey] = useState(0) const publicMode = apiPrefix !== "/rounds" 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 wsPath = publicMode ? "ws/public/rounds" : "ws/rounds" const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`) socket.onmessage = () => setRefreshKey((k) => k + 1) return () => socket.close() }, [roundId, publicMode]) useEffect(() => { let cancelled = false async function load() { const [roundRes, leaderboardRes] = await Promise.all([ fetch(`${apiPrefix}/${roundId}`, { credentials: "include" }), fetch(`${apiPrefix}/${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, apiPrefix]) const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null return { round, leaderboard, viewerParticipantId, error, refreshKey } } // =========================================================================== // Adaptere: ekte API-data -> V0 sine props (ingen mock-data noe sted) // =========================================================================== function buildHoleOrder(startHole: number, holesPlanned: number): number[] { return Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1) } function buildHoles(entries: ApiLeaderboardEntry[], holeOrder: number[]): Hole[] { const anyEntryWithHoles = entries.find((e) => e.holes.length > 0) if (!anyEntryWithHoles) return [] return holeOrder .map((n) => anyEntryWithHoles.holes.find((h) => h.hole_number === n)) .filter((h): h is ApiLeaderboardHole => h !== undefined) .map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index })) } function buildIndividualPlayers( entries: ApiLeaderboardEntry[], participants: ApiRoundParticipant[], holeOrder: number[], viewerParticipantId: string | null, ownPointsField: "bbb_points" | "copenhagen_points" | undefined, formatResult: ApiFormatResult | null, ): IndividualPlayer[] { const pointsByHoleNumber = new Map>() if (ownPointsField && formatResult?.holes) { for (const h of formatResult.holes) { const m = new Map() for (const e of h.entries) { if (e.points !== null) m.set(e.id, e.points) } pointsByHoleNumber.set(h.hole_number, m) } } const hcpByParticipant = new Map(participants.map((p) => [p.id, p.handicap_index_snapshot])) return entries.map((e) => { const gross = holeOrder.map((n) => { const h = e.holes.find((x) => x.hole_number === n) return h && h.played && h.score !== null ? h.score : null }) // Ekte per-hull mottatte slag -- allerede korrekt utregnet av backend // (samme allokering `allocate_strokes_by_index` som resten av appen // bruker), null når HCP ikke spores for deltakeren. IKKE en egen // klientsidig course-hcp+stroke-indeks-utregning (V0 sin mock- // versjon måtte gjøre det siden den ikke hadde et ekte backend). const strokesReceived = holeOrder.map((n) => { const h = e.holes.find((x) => x.hole_number === n) return h?.strokes_received ?? null }) const holePoints = ownPointsField ? holeOrder.map((n) => pointsByHoleNumber.get(n)?.get(e.participant_id) ?? null) : undefined return { id: e.participant_id, name: e.display_name, isSelf: e.participant_id === viewerParticipantId, isOwner: e.is_owner, strokesReceived, hcpIndex: hcpByParticipant.get(e.participant_id) ?? null, finished: false, gross, holePoints, courseHandicap: e.course_handicap, } }) } function buildSideTeams(round: ApiRoundHeader, entries: ApiLeaderboardEntry[]): [SideTeamInfo, SideTeamInfo] { const nameByParticipant = new Map(entries.map((e) => [e.participant_id, e.display_name])) const sideIds = round.sides.map((s) => s.id) const namesFor = (sideId: string) => round.participants.filter((p) => p.round_side_id === sideId).map((p) => nameByParticipant.get(p.id) ?? "Medspiller") return [ { id: "a", name: round.sides[0]?.label?.trim() || "Side A", players: namesFor(sideIds[0]) }, { id: "b", name: round.sides[1]?.label?.trim() || "Side B", players: namesFor(sideIds[1]) }, ] } function buildHoleWinners(holeResults: string[] | null, totalHoles: number): HoleWinner[] { const out: HoleWinner[] = Array.from({ length: totalHoles }, () => null) if (!holeResults) return out holeResults.forEach((r, i) => { if (i < totalHoles && (r === "a" || r === "b" || r === "halved")) out[i] = r }) return out } const METRICS_FOR_FORMAT: Record = { stroke: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, stableford: { metrics: ["stableford", "netto", "brutto"], defaultMetric: "stableford", pointsUnit: "p" }, skins: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, flag: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, bbb: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" }, copenhagen: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" }, } // =========================================================================== // Full leaderboard-side // =========================================================================== export function RoundLeaderboard({ roundId, embedded = false, publicMode = false, }: { roundId: string embedded?: boolean // ADR-044 (tilskuer-visning, 2026-08-06) -- samme mønster som // `RoundScorecard` sin `publicMode`: bytter alle `/rounds/*`-kall til // `/public/rounds/*` og WS-kanalen til den offentlige. INGEN endring // i eiersidens oppførsel når denne er `false` (standard). publicMode?: boolean }) { const apiPrefix = publicMode ? "/public/rounds" : "/rounds" const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId, apiPrefix) const formatResult = useFormatResult(roundId, refreshKey, apiPrefix) const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey, apiPrefix) if (error) { return (

    {error}

    {publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
    ) } if (!round || !leaderboard) { return (
    ) } // Delt kommentartråd (ADR-047) -- round_message-reaksjoner/-kommentarer // fester seg til ETT spesifikt innlegg, ikke runden som helhet, så // hver utvidet rad/kamp-seksjon lenker til den SAMME, allerede // eksisterende kommentarseksjonen på selve rundesiden (anker-id // "kommentarer" i round-detail.tsx/watch-round.tsx) i stedet for å late // som om det finnes en egen tråd per spiller/kamp. const commentsHref = publicMode ? `/watch/${roundId}#kommentarer` : `/my-rounds/${roundId}#kommentarer` const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned) const holes = buildHoles(leaderboard.entries, holeOrder) const isHighLow = HIGH_LOW_FORMATS.has(round.play_format) const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format) const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format) const isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format) const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format] return (
    {!embedded && (
    )}
    {round.name?.trim() || round.course_name_snapshot} {round.play_format !== "stroke" && ( {FORMAT_LABELS[round.play_format] ?? round.play_format} )} {round.name?.trim() ? `${round.course_name_snapshot} · ` : ""} {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
    {isTwoSided || isHighLow ? ( !formatResult ? null : !formatResult.ready ? (
    Venter på at begge sider er komplette og har beregnet handicap før stillingen kan vises.
    ) : isHighLow ? ( (() => { const [sideA, sideB] = buildSideTeams(round, leaderboard.entries) const duels = (formatResult.holes ?? []).map(highLowDuelForHole) return ( ) })() ) : ( (() => { const [sideA, sideB] = buildSideTeams(round, leaderboard.entries) const results = buildHoleWinners(formatResult.hole_results, round.holes_planned) const lead = formatResult.match_lead ?? 0 const leader: "a" | "b" | null = lead > 0 ? "a" : lead < 0 ? "b" : null const holesPlayed = formatResult.match_holes_played ?? 0 const holesRemaining = formatResult.match_holes_remaining ?? 0 const diff = Math.abs(lead) // V0 sin egen, korte headline-tekst ("2 opp"/"Dormie 2 opp"), // IKKE backend sin `match_status_text` (som bevisst bygger inn // side-NAVNET, "2 UP (Lag Bjørk)" -- designet for et sted i // appen UTEN separate side-navn ved siden av, ulikt her hvor // SideName allerede viser navnet på hver side. Gjenbrukt // ordlyd, men mot EKTE tall (match_lead/-holes_played/ // -remaining/-is_dormie/-is_closed) -- ingen egen klientsidig // utregning av selve stillingen. const headline = holesPlayed === 0 ? "Ingen hull spilt ennå" : formatResult.match_is_closed ? `Ferdig ${diff}&${holesRemaining}` : holesRemaining === 0 ? leader ? `Vant ${diff} opp` : "Delt" : !leader ? "Alt likt" : formatResult.match_is_dormie ? `Dormie ${diff} opp` : `${diff} opp` const sub = holesPlayed > 0 ? `${holesPlayed} hull spilt${holesRemaining > 0 ? `, ${holesRemaining} igjen` : ""}` : "" return ( ) })() ) ) : isTeamResult ? ( (() => { const teamScore = round.play_format === "shamble" ? formatResult?.shamble_team_score ?? null : formatResult?.money_ball_team_score ?? null const resultHoles = formatResult?.holes ?? [] const par = resultHoles.length > 0 ? resultHoles.reduce((s, h) => s + h.par, 0) : null const toPar = teamScore !== null && par !== null ? teamScore - par : null const flight: FlightTeam[] = (standings ?? []) .filter((s) => s.teamScore !== null && s.par !== null) .map((s) => ({ id: s.round_id, name: s.name || round.course_name_snapshot, toPar: (s.teamScore as number) - (s.par as number), through: s.through, isThisTeam: s.round_id === roundId, })) const members = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, undefined, null) return ( ) })() ) : isScrambleSolo ? ( !formatResult ? null : ) : ( (() => { const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult) const config = METRICS_FOR_FORMAT[round.play_format] ?? METRICS_FOR_FORMAT.stroke return ( <>

    Stilling

    {leaderboard.net_display_style === "hunt" && (
    )} ) })() )}
    ) } // --- 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/ // high_low_high/scramble_solo_match) vises allerede av FormatResultPanel // rett under denne komponenten sitt kallsted i round-detail.tsx -- ingen // individuell brutto-rangering å vise her. Shamble/Money Ball har heller // ingen meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er // det som teller, vist i TeamFlightBoard på selve leaderboard-siden i // stedet for en misvisende råscore-rangering her. scramble_solo // (stroke-varianten) har av samme grunn ingen meningsfull individuell // rangering -- lag-mot-individuell netto-sammenligning vises av // ScrambleSoloResultView i stedet (fikset her, opprinnelig glemt da // scramble_solo ble bygget 2026-08-05). if ( TWO_SIDED_FORMATS.includes(round.play_format) || HIGH_LOW_FORMATS.has(round.play_format) || TEAM_RESULT_FORMATS.has(round.play_format) || SCRAMBLE_SOLO_FORMATS.has(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} ) }