2026-07-26 15:48:07 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// Leaderboard for en frittstående runde (ADR-033).
|
2026-07-26 15:48:07 +02:00
|
|
|
|
//
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// 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).
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react"
|
|
|
|
|
|
import Link from "next/link"
|
2026-08-03 17:30:57 +02:00
|
|
|
|
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
|
2026-07-26 15:48:07 +02:00
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
|
|
|
|
|
// --- API-typer ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
type ApiLeaderboardHole = {
|
|
|
|
|
|
hole_number: number
|
|
|
|
|
|
par: number
|
|
|
|
|
|
stroke_index: number
|
|
|
|
|
|
played: boolean
|
|
|
|
|
|
score: number | null
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
picked_up: boolean
|
2026-07-26 15:48:07 +02:00
|
|
|
|
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
|
2026-08-03 17:30:57 +02:00
|
|
|
|
round_side_id: string | null
|
|
|
|
|
|
handicap_index_snapshot: number | null
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
type ApiSide = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
type ApiRoundHeader = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string | null
|
|
|
|
|
|
course_name_snapshot: string
|
|
|
|
|
|
played_at: string
|
|
|
|
|
|
start_hole: number
|
|
|
|
|
|
holes_planned: number
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
play_format: string
|
|
|
|
|
|
sides: ApiSide[]
|
2026-07-26 15:48:07 +02:00
|
|
|
|
participants: ApiRoundParticipant[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
// 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.
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
const TWO_SIDED_FORMATS = [
|
2026-08-03 17:30:57 +02:00
|
|
|
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman",
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
]
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// 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"])
|
|
|
|
|
|
// 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",
|
|
|
|
|
|
}
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// --- Rangering (brukt av RoundLeaderboardMini) ------------------------------
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
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)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
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)
|
2026-07-26 15:48:07 +02:00
|
|
|
|
if (value === 0) {
|
2026-08-03 17:30:57 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<span className={cn("inline-flex items-center justify-center text-foreground", dims, font)}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
<span className="sr-only"> {toParWords(value)}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
const under = value < 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
2026-08-03 17:30:57 +02:00
|
|
|
|
"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",
|
2026-07-26 15:48:07 +02:00
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
{label}
|
|
|
|
|
|
<span className="sr-only"> {toParWords(value)}</span>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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() {
|
2026-07-26 15:48:07 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
function IndividualBoard({
|
|
|
|
|
|
players,
|
|
|
|
|
|
metrics,
|
|
|
|
|
|
defaultMetric,
|
|
|
|
|
|
pointsUnit = "p",
|
|
|
|
|
|
holes,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
players: IndividualPlayer[]
|
|
|
|
|
|
metrics: MetricKey[]
|
|
|
|
|
|
defaultMetric: MetricKey
|
|
|
|
|
|
pointsUnit?: string
|
|
|
|
|
|
holes: Hole[]
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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))
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{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))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
|
|
|
|
|
|
<Legend />
|
|
|
|
|
|
</div>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
function MetricToggle({
|
|
|
|
|
|
metrics,
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
pointsUnit,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
metrics: MetricKey[]
|
|
|
|
|
|
value: MetricKey
|
|
|
|
|
|
onChange: (m: MetricKey) => void
|
|
|
|
|
|
pointsUnit: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (metrics.length < 2) return null
|
2026-07-26 15:48:07 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
function IndividualRow({
|
|
|
|
|
|
player,
|
|
|
|
|
|
rank,
|
|
|
|
|
|
tied,
|
|
|
|
|
|
isLeader,
|
|
|
|
|
|
pill,
|
|
|
|
|
|
reason,
|
|
|
|
|
|
metric,
|
|
|
|
|
|
holes,
|
|
|
|
|
|
open,
|
|
|
|
|
|
onToggle,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
player: IndividualPlayer
|
|
|
|
|
|
rank: number | null
|
|
|
|
|
|
tied: boolean
|
|
|
|
|
|
isLeader: boolean
|
|
|
|
|
|
pill: React.ReactNode
|
|
|
|
|
|
reason?: string
|
|
|
|
|
|
metric: MetricKey
|
|
|
|
|
|
holes: Hole[]
|
|
|
|
|
|
open: boolean
|
|
|
|
|
|
onToggle: () => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
{open && (
|
|
|
|
|
|
<div id={panelId} className="px-3 pb-3">
|
|
|
|
|
|
<HoleByHole player={player} metric={metric} holes={holes} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metric: MetricKey; holes: Hole[] }) {
|
|
|
|
|
|
const front = holes.slice(0, 9)
|
|
|
|
|
|
const back = holes.slice(9)
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
function Legend() {
|
2026-07-26 15:48:07 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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)
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</span>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
|
<Crown aria-hidden="true" className="size-4 text-gold-foreground" /> Leder
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
</span>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
</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,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
sideA: SideTeamInfo
|
|
|
|
|
|
sideB: SideTeamInfo
|
|
|
|
|
|
results: HoleWinner[]
|
|
|
|
|
|
totalHoles: number
|
|
|
|
|
|
formatName: string
|
|
|
|
|
|
headline: string
|
|
|
|
|
|
sub: string
|
|
|
|
|
|
leader: "a" | "b" | null
|
|
|
|
|
|
isDormie: boolean
|
|
|
|
|
|
isClosed: boolean
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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>}
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</span>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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} />
|
|
|
|
|
|
</div>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
}: {
|
2026-08-03 17:30:57 +02:00
|
|
|
|
sideA: SideTeamInfo
|
|
|
|
|
|
sideB: SideTeamInfo
|
|
|
|
|
|
duels: HoleDuel[]
|
|
|
|
|
|
totalA: number
|
|
|
|
|
|
totalB: number
|
|
|
|
|
|
formatName: string
|
2026-07-29 12:44:51 +02:00
|
|
|
|
}) {
|
2026-08-03 17:30:57 +02:00
|
|
|
|
const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null
|
|
|
|
|
|
const fmt = (n: number) => NF1.format(n)
|
2026-07-29 12:44:51 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
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,
|
2026-07-26 15:48:07 +02:00
|
|
|
|
holes,
|
2026-08-03 17:30:57 +02:00
|
|
|
|
formatName,
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}: {
|
2026-08-03 17:30:57 +02:00
|
|
|
|
teamName: string
|
|
|
|
|
|
toPar: number | null
|
|
|
|
|
|
through: number
|
|
|
|
|
|
totalHoles: number
|
|
|
|
|
|
flight: FlightTeam[]
|
|
|
|
|
|
members: IndividualPlayer[]
|
|
|
|
|
|
holes: Hole[]
|
|
|
|
|
|
formatName: string
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}) {
|
2026-08-03 17:30:57 +02:00
|
|
|
|
const [showRaw, setShowRaw] = useState(false)
|
|
|
|
|
|
const ranked = [...flight].sort((a, b) => a.toPar - b.toPar)
|
|
|
|
|
|
const myRank = ranked.findIndex((t) => t.isThisTeam) + 1
|
|
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<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>}
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
|
|
|
|
|
|
{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) => (
|
|
|
|
|
|
<li key={m.id} className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-2 last:border-b-0">
|
|
|
|
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
|
|
|
|
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
|
|
|
|
|
|
{m.name}
|
|
|
|
|
|
{m.isSelf && <Badge tone="self">Deg</Badge>}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
{m.hcpIndex !== null ? `Hcp ${NF1.format(m.hcpIndex)}` : "Uten HCP"} · {playedHoles(m)}/{totalHoles} hull
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<MetricPill kind="topar" value={grossToPar(m, holes)} />
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ol>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
|
|
|
|
|
|
type ApiFormatResult = {
|
|
|
|
|
|
play_format: string
|
|
|
|
|
|
ready: boolean
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
match_lead: number | null
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
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
|
2026-08-03 17:30:57 +02:00
|
|
|
|
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
|
|
|
|
|
|
holes: ApiFormatHole[] | null
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
|
|
|
|
|
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((data: ApiFormatResult | null) => {
|
|
|
|
|
|
if (!cancelled) setResult(data)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [roundId, refreshKey])
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
|
|
|
|
|
|
const [standings, setStandings] = useState<FlightSibling[] | null>(null)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const flightRes = await fetch(`/rounds/${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(`/rounds/${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])
|
|
|
|
|
|
return standings
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
function useLeaderboardData(roundId: string) {
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
fetch("/auth/me", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((data: { id: string } | null) => {
|
|
|
|
|
|
if (!cancelled && data) setViewerId(data.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
|
|
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
|
|
|
|
|
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
|
|
|
|
|
return () => socket.close()
|
|
|
|
|
|
}, [roundId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const [roundRes, leaderboardRes] = await Promise.all([
|
|
|
|
|
|
fetch(`/rounds/${roundId}`, { credentials: "include" }),
|
|
|
|
|
|
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (roundRes.status === 403 || roundRes.status === 404) {
|
|
|
|
|
|
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!roundRes.ok || !leaderboardRes.ok) {
|
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const roundData: ApiRoundHeader = await roundRes.json()
|
|
|
|
|
|
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setRound(roundData)
|
|
|
|
|
|
setLeaderboard(leaderboardData)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [roundId, refreshKey])
|
|
|
|
|
|
|
|
|
|
|
|
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
return { round, leaderboard, viewerParticipantId, error, refreshKey }
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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]) },
|
|
|
|
|
|
]
|
2026-07-26 15:48:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
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
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
|
2026-08-02 16:34:54 +02:00
|
|
|
|
export function RoundLeaderboard({
|
|
|
|
|
|
roundId,
|
|
|
|
|
|
embedded = false,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
roundId: string
|
|
|
|
|
|
embedded?: boolean
|
|
|
|
|
|
}) {
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
|
2026-08-03 17:30:57 +02:00
|
|
|
|
const formatResult = useFormatResult(roundId, refreshKey)
|
|
|
|
|
|
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey)
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
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="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned)
|
|
|
|
|
|
const holes = buildHoles(leaderboard.entries, holeOrder)
|
|
|
|
|
|
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
2026-08-03 17:30:57 +02:00
|
|
|
|
const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format)
|
|
|
|
|
|
const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format]
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-02 16:34:54 +02:00
|
|
|
|
<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={`/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>
|
|
|
|
|
|
)}
|
2026-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
2026-07-26 15:48:07 +02:00
|
|
|
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
|
|
|
|
|
{round.name?.trim() || round.course_name_snapshot}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
{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.
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
</div>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
) : 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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()
|
|
|
|
|
|
) : (
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
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)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()
|
|
|
|
|
|
)
|
|
|
|
|
|
) : 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"}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
/>
|
2026-08-03 17:30:57 +02:00
|
|
|
|
)
|
|
|
|
|
|
})()
|
|
|
|
|
|
) : (
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult)
|
|
|
|
|
|
const config = METRICS_FOR_FORMAT[round.play_format] ?? METRICS_FOR_FORMAT.stroke
|
2026-07-26 15:48:07 +02:00
|
|
|
|
return (
|
2026-08-03 17:30:57 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
|
|
|
|
|
<IndividualBoard
|
|
|
|
|
|
players={players}
|
|
|
|
|
|
metrics={config.metrics}
|
|
|
|
|
|
defaultMetric={config.defaultMetric}
|
|
|
|
|
|
pointsUnit={config.pointsUnit}
|
|
|
|
|
|
holes={holes}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
2026-07-26 15:48:07 +02:00
|
|
|
|
)
|
2026-08-03 17:30:57 +02:00
|
|
|
|
})()
|
2026-07-26 15:48:07 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</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
|
|
|
|
|
|
|
2026-08-03 17:30:57 +02:00
|
|
|
|
// To-sidede formater (match/fourball/foursome/greensome/scramble/
|
|
|
|
|
|
// high_low_high) 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.
|
|
|
|
|
|
if (
|
|
|
|
|
|
TWO_SIDED_FORMATS.includes(round.play_format) ||
|
|
|
|
|
|
HIGH_LOW_FORMATS.has(round.play_format) ||
|
|
|
|
|
|
TEAM_RESULT_FORMATS.has(round.play_format)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|