1883 lines
75 KiB
TypeScript
1883 lines
75 KiB
TypeScript
"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, 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[]
|
||
}
|
||
|
||
type ApiLeaderboard = {
|
||
holes_planned: number
|
||
completed: boolean
|
||
entries: ApiLeaderboardEntry[]
|
||
}
|
||
|
||
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<Record<string, "bbb_points" | "copenhagen_points">> = {
|
||
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<string, string> = {
|
||
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 (
|
||
<span className={cn(shared, cls)}>
|
||
{gross}
|
||
<span className="sr-only"> ({scoreClassLabel(gross, par)})</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<span className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide", map[tone])}>
|
||
{children}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<span className={cn("inline-flex items-center justify-center gap-0.5 rounded-full bg-primary text-primary-foreground", dims, font)}>
|
||
{value}
|
||
{unit ? <span className="text-xs font-bold">{unit}</span> : null}
|
||
</span>
|
||
)
|
||
}
|
||
const label = toParLabel(value)
|
||
if (value === 0) {
|
||
return (
|
||
<span className={cn("inline-flex items-center justify-center text-foreground", dims, font)}>
|
||
{label}
|
||
<span className="sr-only"> {toParWords(value)}</span>
|
||
</span>
|
||
)
|
||
}
|
||
const under = value < 0
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center justify-center",
|
||
dims,
|
||
font,
|
||
under ? "rounded-full bg-primary/15 text-primary" : "rounded-[8px] bg-brand-orange/15 text-brand-orange",
|
||
)}
|
||
>
|
||
{label}
|
||
<span className="sr-only"> {toParWords(value)}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 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)[]
|
||
}
|
||
|
||
function hasHcp(p: IndividualPlayer): boolean {
|
||
return p.strokesReceived.some((v) => v !== null)
|
||
}
|
||
|
||
const METRIC_LABEL: Record<MetricKey, string> = {
|
||
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
|
||
}
|
||
|
||
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<number>((s, v) => s + (v ?? 0), 0)
|
||
}
|
||
|
||
function evalMetric(
|
||
p: IndividualPlayer,
|
||
metric: MetricKey,
|
||
pointsUnit: string,
|
||
holes: Hole[],
|
||
): {
|
||
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: <MetricPill kind="topar" value={v} size="lg" /> }
|
||
}
|
||
if (metric === "netto") {
|
||
const v = nettoToPar(p, holes)
|
||
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
|
||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||
}
|
||
if (metric === "stableford") {
|
||
const v = stablefordTotal(p, holes)
|
||
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
|
||
return { rankable: true, sortVal: v, higherWins: true, pill: <MetricPill kind="points" value={v} unit="p" size="lg" /> }
|
||
}
|
||
const v = pointsTotal(p)
|
||
return { rankable: true, sortVal: v, higherWins: true, pill: <MetricPill kind="points" value={v} unit={pointsUnit} size="lg" /> }
|
||
}
|
||
|
||
function NoData() {
|
||
return (
|
||
<span className="inline-flex min-h-11 items-center justify-center rounded-full border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground">
|
||
–<span className="sr-only">ingen rangering</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function IndividualBoard({
|
||
players,
|
||
metrics,
|
||
defaultMetric,
|
||
pointsUnit = "p",
|
||
holes,
|
||
commentsHref,
|
||
}: {
|
||
players: IndividualPlayer[]
|
||
metrics: MetricKey[]
|
||
defaultMetric: MetricKey
|
||
pointsUnit?: string
|
||
holes: Hole[]
|
||
commentsHref: string
|
||
}) {
|
||
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
||
const [openId, setOpenId] = useState<string | null>(null)
|
||
|
||
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) }))
|
||
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<number, number> = {}
|
||
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 (
|
||
<div className="flex flex-col gap-3">
|
||
<MetricToggle metrics={metrics} value={metric} onChange={setMetric} pointsUnit={pointsUnit} />
|
||
|
||
<ol className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card">
|
||
{rankable.map((e, i) => (
|
||
<IndividualRow
|
||
key={e.player.id}
|
||
player={e.player}
|
||
rank={rankInfo[i].rank}
|
||
tied={rankInfo[i].tied}
|
||
isLeader={i === 0}
|
||
pill={e.pill}
|
||
metric={metric}
|
||
holes={holes}
|
||
open={openId === e.player.id}
|
||
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
))}
|
||
{unranked.map((e) => (
|
||
<IndividualRow
|
||
key={e.player.id}
|
||
player={e.player}
|
||
rank={null}
|
||
tied={false}
|
||
isLeader={false}
|
||
pill={e.pill}
|
||
reason={e.reason}
|
||
metric={metric}
|
||
holes={holes}
|
||
open={openId === e.player.id}
|
||
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
))}
|
||
</ol>
|
||
|
||
<Legend />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MetricToggle({
|
||
metrics,
|
||
value,
|
||
onChange,
|
||
pointsUnit,
|
||
}: {
|
||
metrics: MetricKey[]
|
||
value: MetricKey
|
||
onChange: (m: MetricKey) => void
|
||
pointsUnit: string
|
||
}) {
|
||
if (metrics.length < 2) return null
|
||
return (
|
||
<div role="group" aria-label="Velg visning" className="flex gap-1 rounded-full border border-border bg-muted p-1">
|
||
{metrics.map((m) => {
|
||
const active = m === value
|
||
const label = m === "points" && pointsUnit !== "p" ? `${METRIC_LABEL[m]}` : METRIC_LABEL[m]
|
||
return (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={() => onChange(m)}
|
||
className={cn(
|
||
"min-h-11 flex-1 rounded-full px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
||
active ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<li className={cn("border-b border-border last:border-b-0", isLeader && "bg-primary/5")}>
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={open}
|
||
aria-controls={panelId}
|
||
className="flex min-h-16 w-full items-center gap-3 px-3 py-2.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||
>
|
||
<span className="flex w-9 shrink-0 justify-center">
|
||
{rank === null ? (
|
||
<span className="text-sm font-bold text-muted-foreground" aria-hidden="true">–</span>
|
||
) : (
|
||
<span className={cn("text-base font-extrabold tabular-nums", isLeader ? "text-primary" : "text-foreground")}>
|
||
{tied ? "T" : ""}
|
||
{rank}
|
||
</span>
|
||
)}
|
||
<span className="sr-only">{rank === null ? "Uten rangering" : `${tied ? "Delt " : ""}${rank}. plass`}</span>
|
||
</span>
|
||
|
||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
<span className="flex items-center gap-2">
|
||
{isLeader && <Crown aria-hidden="true" className="size-4 shrink-0 text-gold-foreground" />}
|
||
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && <Badge tone="self">Deg</Badge>}
|
||
{player.isOwner && <Badge tone="owner">Eier</Badge>}
|
||
</span>
|
||
<span className="truncate text-sm text-muted-foreground">
|
||
{player.hcpIndex !== null ? `Hcp ${NF1.format(player.hcpIndex)} · ` : "Uten HCP · "}
|
||
{reason ?? progress}
|
||
</span>
|
||
</span>
|
||
|
||
<span className="shrink-0">{pill}</span>
|
||
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||
</button>
|
||
|
||
{open && (
|
||
<div id={panelId} className="px-3 pb-3">
|
||
<HoleByHole player={player} metric={metric} holes={holes} rank={rank} tied={tied} commentsHref={commentsHref} />
|
||
</div>
|
||
)}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
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<number>((s, g) => s + (g ?? 0), 0)
|
||
const holesWithScore = player.gross.filter((g) => g !== null).length
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
|
||
<p className="text-xs font-semibold text-muted-foreground">
|
||
Hull-for-hull · brutto (form) og netto (tall). Sirkel = under par, firkant = over par.
|
||
</p>
|
||
{[front, back].filter((set) => set.length > 0).map((set, si) => {
|
||
const offset = si === 0 ? 0 : front.length
|
||
return (
|
||
<div key={si} className="relative min-w-0 overflow-x-auto">
|
||
<table className="w-full min-w-[34rem] border-collapse text-center">
|
||
<thead>
|
||
<tr className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
|
||
<th scope="row" className="sticky left-0 bg-background pr-2 text-left font-bold">Hull</th>
|
||
{set.map((h) => (
|
||
<th key={h.holeNumber} scope="col" className="px-1 font-extrabold text-foreground">{h.holeNumber}</th>
|
||
))}
|
||
<th scope="col" className="px-1">{si === 0 ? "Ut" : "Inn"}</th>
|
||
</tr>
|
||
<tr className="text-[11px] text-muted-foreground">
|
||
<th scope="row" className="sticky left-0 bg-background pr-2 text-left font-semibold">Par · idx</th>
|
||
{set.map((h) => (
|
||
<td key={h.holeNumber} className="px-1">{h.par}·{h.index}</td>
|
||
))}
|
||
<td />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Brutto</th>
|
||
{set.map((h, i) => {
|
||
const g = player.gross[offset + i]
|
||
return (
|
||
<td key={h.holeNumber} className="px-1 py-1">
|
||
{g === null ? <span className="text-muted-foreground">–</span> : <ScoreMark gross={g} par={h.par} size="sm" />}
|
||
</td>
|
||
)
|
||
})}
|
||
<td className="px-1 font-extrabold tabular-nums text-foreground">
|
||
{set.reduce((s, h, i) => s + (player.gross[offset + i] ?? 0), 0) || "–"}
|
||
</td>
|
||
</tr>
|
||
{hasHcp(player) && (
|
||
<tr className="text-sm">
|
||
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Netto</th>
|
||
{set.map((h, i) => {
|
||
const g = player.gross[offset + i]
|
||
const received = player.strokesReceived[offset + i]
|
||
if (g === null || received === null) return <td key={h.holeNumber} className="px-1 text-muted-foreground">–</td>
|
||
return <td key={h.holeNumber} className="px-1 font-semibold tabular-nums text-foreground">{g - received}</td>
|
||
})}
|
||
<td />
|
||
</tr>
|
||
)}
|
||
{metric === "points" && player.holePoints && (
|
||
<tr className="text-sm">
|
||
<th scope="row" className="sticky left-0 bg-background pr-2 text-left text-xs font-bold text-muted-foreground">Poeng</th>
|
||
{set.map((h, i) => {
|
||
const pt = player.holePoints?.[offset + i]
|
||
return <td key={h.holeNumber} className="px-1 font-bold tabular-nums text-primary">{pt ?? "–"}</td>
|
||
})}
|
||
<td className="px-1 font-extrabold tabular-nums text-primary">
|
||
{set.reduce((s, h, i) => s + (player.holePoints?.[offset + i] ?? 0), 0)}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border pt-2">
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
Par {totalPar} · Score {holesWithScore > 0 ? totalGross : "–"}
|
||
{rank !== null ? ` · ${tied ? "Delt " : ""}${rank}. plass` : ""}
|
||
</span>
|
||
<Link
|
||
href={commentsHref}
|
||
className="inline-flex h-11 items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
|
||
>
|
||
<MessageSquare aria-hidden="true" className="size-4" />
|
||
Kommentarer
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Legend() {
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-flex size-5 items-center justify-center rounded-full border-2 border-primary bg-primary/10 text-[10px] font-bold text-primary">3</span>
|
||
Under par (sirkel)
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="inline-flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] font-bold text-brand-orange">5</span>
|
||
Over par (firkant)
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<Crown aria-hidden="true" className="size-4 text-gold-foreground" /> Leder
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 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 (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card p-5">
|
||
<span className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||
{formatName}
|
||
{isClosed && <Badge tone="neutral">Avgjort</Badge>}
|
||
{isDormie && !isClosed && <Badge tone="neutral">Dormie</Badge>}
|
||
</span>
|
||
<div className="flex w-full items-center justify-between gap-3">
|
||
<SideName side="a" team={sideA} leading={leader === "a"} />
|
||
<div className="flex shrink-0 flex-col items-center">
|
||
<span className="text-2xl font-extrabold text-foreground">{headline}</span>
|
||
<span className="text-xs font-semibold text-muted-foreground">{sub}</span>
|
||
</div>
|
||
<SideName side="b" team={sideB} leading={leader === "b"} align="right" />
|
||
</div>
|
||
|
||
<div className="relative h-4 w-full overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||
<div className="absolute inset-y-0 left-0 w-1/2" style={{ background: "color-mix(in oklch, var(--color-primary) 22%, transparent)" }} />
|
||
<div className="absolute inset-y-0 right-0 w-1/2" style={{ background: "color-mix(in oklch, var(--color-brand-orange) 22%, transparent)" }} />
|
||
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" />
|
||
{leader && (
|
||
<span
|
||
className={cn("absolute inset-y-0 rounded-full", leader === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
style={leader === "a" ? { left: `${markerLeft}%`, right: "50%" } : { left: "50%", right: `${100 - markerLeft}%` }}
|
||
/>
|
||
)}
|
||
<span className="absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground" />
|
||
</div>
|
||
</div>
|
||
|
||
<HoleWinners sideA={sideA} sideB={sideB} results={results} totalHoles={totalHoles} />
|
||
|
||
<MatchDetailSection
|
||
showDetail={showDetail}
|
||
onToggle={() => setShowDetail((v) => !v)}
|
||
round={round}
|
||
roundId={roundId}
|
||
viewerId={viewerId}
|
||
refreshKey={refreshKey}
|
||
apiPrefix={apiPrefix}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SideName({ side, team, leading, align = "left" }: { side: "a" | "b"; team: SideTeamInfo; leading: boolean; align?: "left" | "right" }) {
|
||
return (
|
||
<div className={cn("flex min-w-0 flex-1 flex-col gap-1", align === "right" && "items-end text-right")}>
|
||
<span className="flex min-w-0 items-center gap-1.5">
|
||
<span className={cn("inline-block size-3 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")} aria-hidden="true" />
|
||
<span className="min-w-0 truncate text-sm font-extrabold text-foreground">{team.name}</span>
|
||
{leading && <FlagIcon aria-hidden="true" className="size-3.5 shrink-0 text-gold-foreground" />}
|
||
</span>
|
||
<span className="truncate text-xs text-muted-foreground">{team.players.join(" / ")}</span>
|
||
{leading && <span className="sr-only">leder</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInfo; sideB: SideTeamInfo; results: HoleWinner[]; totalHoles: number }) {
|
||
return (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
|
||
<h3 className="px-1 text-sm font-bold text-foreground">Hull-for-hull</h3>
|
||
<div className="relative min-w-0 overflow-x-auto">
|
||
<div className="flex min-w-max gap-1.5">
|
||
{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 (
|
||
<div key={holeNumber} className="flex w-9 flex-col items-center gap-1">
|
||
<span className="text-[11px] font-bold tabular-nums text-muted-foreground">{holeNumber}</span>
|
||
<span
|
||
className={cn(
|
||
"flex size-9 items-center justify-center rounded-md text-sm font-extrabold",
|
||
r === "a" && "bg-primary text-primary-foreground",
|
||
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||
r === null && "border border-dashed border-border text-muted-foreground",
|
||
)}
|
||
>
|
||
{r === "a" ? "A" : r === "b" ? "B" : r === "halved" ? "=" : ""}
|
||
<span className="sr-only">Hull {holeNumber}: {label}</span>
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1 pt-1 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-primary text-[10px] font-bold text-primary-foreground">A</span>{sideA.name}</span>
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-brand-orange text-[10px] font-bold text-brand-orange-foreground">B</span>{sideB.name}</span>
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="rounded-2xl border border-border bg-card">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={showDetail}
|
||
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||
>
|
||
<span className="text-sm font-bold text-foreground">Vis hull-for-hull med score</span>
|
||
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showDetail && "rotate-180")} />
|
||
</button>
|
||
{showDetail && (
|
||
<div className="flex flex-col gap-3 border-t border-border p-3">
|
||
<MatchScorecardGrid round={toGridRound(round)} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
|
||
<Link
|
||
href={commentsHref}
|
||
className="inline-flex h-11 w-fit items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
|
||
>
|
||
<MessageSquare aria-hidden="true" className="size-4" />
|
||
Kommentarer
|
||
</Link>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 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 (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
|
||
<span className="text-center text-xs font-bold uppercase tracking-wide text-muted-foreground">{formatName} · totale poeng</span>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{(["a", "b"] as const).map((s) => {
|
||
const team = s === "a" ? sideA : sideB
|
||
const val = s === "a" ? totalA : totalB
|
||
const isLead = leader === s
|
||
return (
|
||
<div
|
||
key={s}
|
||
className={cn(
|
||
"flex flex-col items-center gap-1 rounded-xl border p-4",
|
||
isLead ? (s === "a" ? "border-primary bg-primary/5" : "border-brand-orange bg-brand-orange/5") : "border-border",
|
||
)}
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className={cn("inline-block size-3 rounded-full", s === "a" ? "bg-primary" : "bg-brand-orange")} aria-hidden="true" />
|
||
<span className="truncate text-sm font-extrabold text-foreground">{team.name}</span>
|
||
</span>
|
||
<span className={cn("text-4xl font-extrabold tabular-nums", s === "a" ? "text-primary" : "text-brand-orange")}>{fmt(val)}</span>
|
||
<span className="text-xs font-semibold text-muted-foreground">poeng {isLead ? "· leder" : ""}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<p className="text-center text-xs text-muted-foreground">
|
||
To uavhengige dueller per hull: laveste mot laveste og høyeste mot høyeste netto/Stableford-poeng. 0–2 poeng per hull.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
|
||
<h3 className="px-1 text-sm font-bold text-foreground">Hull-for-hull · lav / høy</h3>
|
||
<div className="relative min-w-0 overflow-x-auto">
|
||
<div className="flex min-w-max flex-col gap-1.5">
|
||
<DuelRow label="Hull" cells={duels.map((_, i) => ({ text: String(i + 1), head: true }))} />
|
||
<DuelRow label="Lav" cells={duels.map((d) => duelCell(d.low))} />
|
||
<DuelRow label="Høy" cells={duels.map((d) => duelCell(d.high))} />
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1 pt-1 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-primary text-[10px] font-bold text-primary-foreground">A</span>{sideA.name}</span>
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded bg-brand-orange text-[10px] font-bold text-brand-orange-foreground">B</span>{sideB.name}</span>
|
||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt (½–½)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<MatchDetailSection
|
||
showDetail={showDetail}
|
||
onToggle={() => setShowDetail((v) => !v)}
|
||
round={round}
|
||
roundId={roundId}
|
||
viewerId={viewerId}
|
||
refreshKey={refreshKey}
|
||
apiPrefix={apiPrefix}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="sticky left-0 w-12 shrink-0 bg-card text-xs font-bold text-muted-foreground">{label}</span>
|
||
{cells.map((c, i) => (
|
||
<span
|
||
key={i}
|
||
className={cn(
|
||
"flex size-8 shrink-0 items-center justify-center rounded-md text-sm font-extrabold",
|
||
c.head && "text-muted-foreground",
|
||
c.tone === "a" && "bg-primary text-primary-foreground",
|
||
c.tone === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||
c.tone === "halved" && "border border-border bg-muted text-muted-foreground",
|
||
c.tone === null && !c.head && "border border-dashed border-border text-muted-foreground",
|
||
)}
|
||
>
|
||
{c.text}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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<string | null>(null)
|
||
const ranked = [...flight].sort((a, b) => a.toPar - b.toPar)
|
||
const myRank = ranked.findIndex((t) => t.isThisTeam) + 1
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-5">
|
||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{formatName} · ditt lag</span>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="flex min-w-0 flex-col">
|
||
<span className="truncate text-lg font-extrabold text-foreground">{teamName}</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
{through === totalHoles ? "Ferdig" : `${through}/${totalHoles} hull`}
|
||
{flight.length > 1 && myRank > 0 ? ` · ${myRank}. i flighten` : ""}
|
||
</span>
|
||
</div>
|
||
{toPar !== null ? <MetricPill kind="topar" value={toPar} size="lg" /> : <span className="text-sm font-semibold text-muted-foreground">Ingen hull ferdig ennå</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{ranked.length > 1 && (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
|
||
<h3 className="px-1 text-sm font-bold text-foreground">Flight-gruppe</h3>
|
||
<ol className="flex flex-col">
|
||
{ranked.map((t, i) => (
|
||
<li
|
||
key={t.id}
|
||
className={cn(
|
||
"flex min-h-14 items-center gap-3 rounded-xl px-3 py-2",
|
||
t.isThisTeam && "bg-primary/5 ring-1 ring-inset ring-primary/30",
|
||
)}
|
||
>
|
||
<span className="w-7 text-center text-base font-extrabold tabular-nums text-foreground">{i + 1}</span>
|
||
<span className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
|
||
{i === 0 && <Crown aria-hidden="true" className="size-4 shrink-0 text-gold-foreground" />}
|
||
{t.name}
|
||
{t.isThisTeam && <Badge tone="self">Ditt</Badge>}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">{t.through === totalHoles ? "Ferdig" : `${t.through}/${totalHoles} hull`}</span>
|
||
</span>
|
||
<MetricPill kind="topar" value={t.toPar} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-2xl border border-border bg-card">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowRaw((v) => !v)}
|
||
aria-expanded={showRaw}
|
||
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||
>
|
||
<span className="text-sm font-bold text-foreground">Spillernes råscore</span>
|
||
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showRaw && "rotate-180")} />
|
||
</button>
|
||
{showRaw && (
|
||
<ol className="flex flex-col border-t border-border">
|
||
{members.map((m) => (
|
||
<TeamMemberRow
|
||
key={m.id}
|
||
member={m}
|
||
holes={holes}
|
||
totalHoles={totalHoles}
|
||
open={openMemberId === m.id}
|
||
onToggle={() => setOpenMemberId((cur) => (cur === m.id ? null : m.id))}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<li className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
aria-expanded={open}
|
||
aria-controls={panelId}
|
||
className="flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||
>
|
||
<span className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
|
||
{member.name}
|
||
{member.isSelf && <Badge tone="self">Deg</Badge>}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
{member.hcpIndex !== null ? `Hcp ${NF1.format(member.hcpIndex)}` : "Uten HCP"} · {playedHoles(member)}/{totalHoles} hull
|
||
</span>
|
||
</span>
|
||
<MetricPill kind="topar" value={grossToPar(member, holes)} />
|
||
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||
</button>
|
||
{open && (
|
||
<div id={panelId} className="px-4 pb-3">
|
||
<HoleByHole player={member} metric="brutto" holes={holes} rank={null} tied={false} commentsHref={commentsHref} />
|
||
</div>
|
||
)}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
// ===========================================================================
|
||
// 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<string, number> | null
|
||
bbb_points: Record<string, number> | 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<ApiFormatResult | null>(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<FlightSibling[] | null>(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<ApiRoundHeader | null>(null)
|
||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(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<number, Map<string, number>>()
|
||
if (ownPointsField && formatResult?.holes) {
|
||
for (const h of formatResult.holes) {
|
||
const m = new Map<string, number>()
|
||
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,
|
||
}
|
||
})
|
||
}
|
||
|
||
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<string, { metrics: MetricKey[]; defaultMetric: MetricKey; pointsUnit: string }> = {
|
||
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 (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||
<p className="text-base font-medium text-destructive">{error}</p>
|
||
<Link
|
||
href={publicMode ? "/dashboard" : "/my-rounds"}
|
||
className="text-base font-semibold text-primary underline underline-offset-2"
|
||
>
|
||
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!round || !leaderboard) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||
{!embedded && (
|
||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||
<Link
|
||
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
|
||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||
Leaderboard
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
)}
|
||
|
||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||
{round.name?.trim() || round.course_name_snapshot}
|
||
</span>
|
||
{round.play_format !== "stroke" && (
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||
{FORMAT_LABELS[round.play_format] ?? round.play_format}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="text-sm font-semibold text-muted-foreground">
|
||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
||
</span>
|
||
</div>
|
||
|
||
{isTwoSided || isHighLow ? (
|
||
!formatResult ? null : !formatResult.ready ? (
|
||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før stillingen kan vises.
|
||
</div>
|
||
) : isHighLow ? (
|
||
(() => {
|
||
const [sideA, sideB] = buildSideTeams(round, leaderboard.entries)
|
||
const duels = (formatResult.holes ?? []).map(highLowDuelForHole)
|
||
return (
|
||
<HighLowBoard
|
||
sideA={sideA}
|
||
sideB={sideB}
|
||
duels={duels}
|
||
totalA={formatResult.hlh_points_a ?? 0}
|
||
totalB={formatResult.hlh_points_b ?? 0}
|
||
formatName="High-low-high"
|
||
round={round}
|
||
roundId={roundId}
|
||
viewerId={viewerParticipantId}
|
||
refreshKey={refreshKey}
|
||
apiPrefix={apiPrefix}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
)
|
||
})()
|
||
) : (
|
||
(() => {
|
||
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 (
|
||
<TwoSidedBoard
|
||
sideA={sideA}
|
||
sideB={sideB}
|
||
results={results}
|
||
totalHoles={round.holes_planned}
|
||
formatName="Matchstatus"
|
||
headline={headline}
|
||
sub={sub}
|
||
leader={leader}
|
||
isDormie={Boolean(formatResult.match_is_dormie)}
|
||
isClosed={Boolean(formatResult.match_is_closed)}
|
||
round={round}
|
||
roundId={roundId}
|
||
viewerId={viewerParticipantId}
|
||
refreshKey={refreshKey}
|
||
apiPrefix={apiPrefix}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
)
|
||
})()
|
||
)
|
||
) : 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 (
|
||
<TeamFlightBoard
|
||
teamName={round.name?.trim() || round.course_name_snapshot}
|
||
toPar={toPar}
|
||
through={resultHoles.length}
|
||
totalHoles={round.holes_planned}
|
||
flight={flight}
|
||
members={members}
|
||
holes={holes}
|
||
formatName={round.play_format === "shamble" ? "Shamble" : "Money Ball"}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
)
|
||
})()
|
||
) : isScrambleSolo ? (
|
||
!formatResult ? null : <ScrambleSoloResultView result={buildScrambleSoloResult(round, formatResult)} />
|
||
) : (
|
||
(() => {
|
||
const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult)
|
||
const config = METRICS_FOR_FORMAT[round.play_format] ?? METRICS_FOR_FORMAT.stroke
|
||
return (
|
||
<>
|
||
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
||
<IndividualBoard
|
||
players={players}
|
||
metrics={config.metrics}
|
||
defaultMetric={config.defaultMetric}
|
||
pointsUnit={config.pointsUnit}
|
||
holes={holes}
|
||
commentsHref={commentsHref}
|
||
/>
|
||
</>
|
||
)
|
||
})()
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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 (
|
||
<Link
|
||
href={`/my-rounds/${roundId}/leaderboard`}
|
||
className="mb-5 flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/30"
|
||
>
|
||
<span className="flex items-center justify-between gap-2">
|
||
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||
<Trophy aria-hidden="true" className="size-4 text-primary" />
|
||
Leaderboard
|
||
</span>
|
||
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
|
||
</span>
|
||
<span className="flex flex-col gap-1">
|
||
{top.map((entry) => (
|
||
<span key={entry.participant_id} className="flex items-center gap-2 text-sm">
|
||
<span className="w-8 shrink-0 font-bold tabular-nums text-muted-foreground">
|
||
{entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : "–"}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate font-semibold text-foreground">{entry.display_name}</span>
|
||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||
{entry.score_to_par !== null ? formatToPar(entry.score_to_par) : "–"}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
{restCount > 0 && <span className="text-sm text-muted-foreground">og {restCount} til</span>}
|
||
</Link>
|
||
)
|
||
}
|