2026-07-26 15:48:07 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
// Leaderboard for en frittstående runde (ADR-033), 2026-07-26.
|
|
|
|
|
|
// Håndkodet (ikke via V0 -- brukeren gikk tom for credits, ba meg bygge
|
|
|
|
|
|
// direkte etter samme designspesifikasjon som var skrevet til V0-prompten,
|
|
|
|
|
|
// se FEATURE_BACKLOG.md). Gjenbruker etablerte mønstre fra
|
|
|
|
|
|
// round-scorecard.tsx (signedToPar-formatering, form-før-farge-språket fra
|
|
|
|
|
|
// ScoreMark, stableford-formelen) og round-stats.tsx/round-scorecard.tsx sin
|
|
|
|
|
|
// WS-sanntid-/viewerId-oppkobling, i stedet for å finne opp nye.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Radene er BEVISST minimale i kollapset tilstand (kun rangering/navn/tall,
|
|
|
|
|
|
// 2026-07-26 -- brukeren ba om å fjerne "Deg"/"Eier"/pokal/hull-spilt-
|
|
|
|
|
|
// teksten derfra og la radene flyte sømløst i hverandre uten egne
|
|
|
|
|
|
// kort-rammer) -- den informasjonen flyttet inn i den utvidbare seksjonen
|
|
|
|
|
|
// sammen med hullscorene, ikke fjernet helt.
|
|
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react"
|
|
|
|
|
|
import Link from "next/link"
|
|
|
|
|
|
import { ArrowLeft, ChevronDown, ChevronRight, Trophy } from "lucide-react"
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
|
|
|
|
|
// --- API-typer ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
type ApiLeaderboardHole = {
|
|
|
|
|
|
hole_number: number
|
|
|
|
|
|
par: number
|
|
|
|
|
|
stroke_index: number
|
|
|
|
|
|
played: boolean
|
|
|
|
|
|
score: number | null
|
|
|
|
|
|
strokes_received: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiLeaderboardEntry = {
|
|
|
|
|
|
participant_id: string
|
|
|
|
|
|
display_name: string
|
|
|
|
|
|
is_owner: boolean
|
|
|
|
|
|
holes_played: number
|
|
|
|
|
|
total_score: number | null
|
|
|
|
|
|
score_to_par: number | null
|
|
|
|
|
|
net_score_to_par: number | null
|
|
|
|
|
|
total_points: number | null
|
|
|
|
|
|
holes: ApiLeaderboardHole[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiLeaderboard = {
|
|
|
|
|
|
holes_planned: number
|
|
|
|
|
|
completed: boolean
|
|
|
|
|
|
entries: ApiLeaderboardEntry[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiRoundParticipant = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
user_id: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
const TWO_SIDED_FORMATS = ["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"]
|
|
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
|
|
|
|
|
|
|
|
|
|
|
// --- Rangering ---------------------------------------------------------------
|
|
|
|
|
|
// Delt plassering (likt resultat) skal vises som "T-N", ikke to ulike tall.
|
|
|
|
|
|
// Deltakere uten et tall for gjeldende visning (brutto/netto/poeng) havner
|
|
|
|
|
|
// samlet nederst, uten rangeringstall -- IKKE skjult, IKKE en krasj.
|
|
|
|
|
|
// Poeng (stableford) rangeres SYNKENDE (høyest vinner) -- motsatt av
|
|
|
|
|
|
// brutto/netto til-par (lavest vinner).
|
|
|
|
|
|
|
|
|
|
|
|
type Mode = "gross" | "net" | "points"
|
|
|
|
|
|
|
|
|
|
|
|
type RankedEntry = ApiLeaderboardEntry & {
|
|
|
|
|
|
rank: number | null
|
|
|
|
|
|
tied: boolean
|
|
|
|
|
|
isSelf: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null {
|
|
|
|
|
|
if (mode === "gross") return entry.score_to_par
|
|
|
|
|
|
if (mode === "net") return entry.net_score_to_par
|
|
|
|
|
|
return entry.total_points
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): RankedEntry[] {
|
|
|
|
|
|
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
|
|
|
|
|
|
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
|
|
|
|
|
|
const direction = mode === "points" ? -1 : 1
|
|
|
|
|
|
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
|
|
|
|
|
|
|
|
|
|
|
|
const ranked: RankedEntry[] = []
|
|
|
|
|
|
let rank = 0
|
|
|
|
|
|
let prevValue: number | null = null
|
|
|
|
|
|
for (let i = 0; i < withValue.length; i++) {
|
|
|
|
|
|
const e = withValue[i]
|
|
|
|
|
|
const v = valueFor(e, mode) as number
|
|
|
|
|
|
if (v !== prevValue) {
|
|
|
|
|
|
rank = i + 1
|
|
|
|
|
|
prevValue = v
|
|
|
|
|
|
}
|
|
|
|
|
|
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
|
|
|
|
|
|
ranked.push({ ...e, rank, tied, isSelf: e.participant_id === viewerParticipantId })
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const e of withoutValue) {
|
|
|
|
|
|
ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId })
|
|
|
|
|
|
}
|
|
|
|
|
|
return ranked
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatToPar(value: number): string {
|
|
|
|
|
|
if (value === 0) return "E"
|
|
|
|
|
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Verdi-merke (brutto/netto til-par, eller poeng) ------------------------
|
|
|
|
|
|
// Samme "form + farge, aldri kun farge"-språk som ScoreMark i
|
|
|
|
|
|
// round-scorecard.tsx: sirkel = under par, firkant = over par, ren tekst ved
|
|
|
|
|
|
// jevnt med par. Poeng har ingen retning (høyere er alltid bedre) -- én fast
|
|
|
|
|
|
// sirkelform, men samme visuelle vekt/fyll-mønster for lederen. Lederen
|
|
|
|
|
|
// (emphasize) får en FYLT variant for litt ekstra visuell vekt.
|
|
|
|
|
|
|
|
|
|
|
|
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
|
|
|
|
|
if (value === 0) {
|
|
|
|
|
|
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
const under = value < 0
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
|
|
|
|
|
|
under ? "rounded-full" : "rounded-[6px]",
|
|
|
|
|
|
emphasize
|
|
|
|
|
|
? under
|
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
|
: "bg-brand-orange text-brand-orange-foreground"
|
|
|
|
|
|
: under
|
|
|
|
|
|
? "border-2 border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatToPar(value)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
|
|
|
|
|
|
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{value}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
|
|
|
|
|
|
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
|
|
|
|
|
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : "–"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
|
|
|
|
|
const options: { value: Mode; label: string }[] = [
|
|
|
|
|
|
{ value: "gross", label: "Brutto" },
|
|
|
|
|
|
{ value: "net", label: "Netto" },
|
|
|
|
|
|
{ value: "points", label: "Poeng" },
|
|
|
|
|
|
]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
|
|
|
|
|
|
{options.map((opt) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={opt.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="tab"
|
|
|
|
|
|
aria-selected={mode === opt.value}
|
|
|
|
|
|
onClick={() => onChange(opt.value)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
|
|
|
|
|
|
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{opt.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Hullscorer per deltaker (2026-07-26, etterspurt av bruker) -----------
|
|
|
|
|
|
// Samme "form + farge, aldri kun farge"-visuelle språk som ScoreMark i
|
|
|
|
|
|
// round-scorecard.tsx (sirkel/firkant/fylt-ved-2+-av) -- egen, liten
|
|
|
|
|
|
// lokal versjon her (ikke en delt import) siden round-scorecard.tsx sin
|
|
|
|
|
|
// ScoreMark er skreddersydd for ETT hull om gangen i en tabell, ikke en
|
|
|
|
|
|
// kompakt horisontal strip for MANGE deltakere i en liste.
|
|
|
|
|
|
|
|
|
|
|
|
type HoleScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
|
|
|
|
|
|
|
|
|
|
|
function classifyHoleScore(gross: number, par: number): HoleScoreClass {
|
|
|
|
|
|
const diff = gross - par
|
|
|
|
|
|
if (diff <= -2) return "eagle"
|
|
|
|
|
|
if (diff === -1) return "birdie"
|
|
|
|
|
|
if (diff === 0) return "par"
|
|
|
|
|
|
if (diff === 1) return "bogey"
|
|
|
|
|
|
return "double"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Netto/poeng PER HULL (2026-07-26, etterspurt av bruker etter å ha vist
|
|
|
|
|
|
// referansebilder fra en konkurrentapp -- bevisst IKKE en kopi av den appens
|
|
|
|
|
|
// layout/farger, kun det strukturelle prinsippet "brutto fremhevet øverst,
|
|
|
|
|
|
// netto/poeng plassert rolig under" gjenskapt i TeeCups egen stil).
|
|
|
|
|
|
// `strokes_received` er allerede backend sin ferdigberegnede allokering
|
|
|
|
|
|
// (samme som resten av appen) -- ingen egen slagfordeling regnes ut her.
|
|
|
|
|
|
|
|
|
|
|
|
function netForHole(hole: ApiLeaderboardHole): number | null {
|
|
|
|
|
|
if (!hole.played || hole.score === null || hole.strokes_received === null) return null
|
|
|
|
|
|
return hole.score - hole.strokes_received
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pointsForHole(hole: ApiLeaderboardHole): number | null {
|
|
|
|
|
|
const net = netForHole(hole)
|
|
|
|
|
|
if (net === null) return null
|
|
|
|
|
|
return Math.max(0, hole.par - net + 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondaryValue: number | null | undefined }) {
|
|
|
|
|
|
const shared = "flex size-7 items-center justify-center text-xs font-extrabold tabular-nums"
|
|
|
|
|
|
const markClass =
|
|
|
|
|
|
!hole.played || hole.score === null
|
|
|
|
|
|
? "text-muted-foreground"
|
|
|
|
|
|
: (() => {
|
|
|
|
|
|
const kind = classifyHoleScore(hole.score, hole.par)
|
|
|
|
|
|
if (kind === "eagle") return "rounded-full bg-primary text-primary-foreground"
|
|
|
|
|
|
if (kind === "birdie") return "rounded-full border-2 border-primary bg-primary/10 text-primary"
|
|
|
|
|
|
if (kind === "bogey") return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
|
|
|
|
|
|
if (kind === "double") return "rounded-[4px] bg-brand-orange text-brand-orange-foreground"
|
|
|
|
|
|
return "font-bold text-foreground"
|
|
|
|
|
|
})()
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="flex w-8 shrink-0 flex-col items-center gap-0.5">
|
|
|
|
|
|
<span aria-hidden="true" className="text-[10px] font-semibold text-muted-foreground">
|
|
|
|
|
|
{hole.hole_number}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{/* Brutto -- fremhevet, samme "form + farge"-språk som resten av
|
|
|
|
|
|
appen (fylt/farget merke), øverst -- den fremste raden. */}
|
|
|
|
|
|
<span className={cn(shared, markClass)}>{hole.played && hole.score !== null ? hole.score : "–"}</span>
|
|
|
|
|
|
{/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp
|
|
|
|
|
|
som referansebildene (aldri fremhevet på samme måte som brutto).
|
|
|
|
|
|
Utelates helt i brutto-modus (ingenting nytt å vise der). */}
|
|
|
|
|
|
{secondaryValue !== undefined && (
|
|
|
|
|
|
<span className="text-[11px] font-semibold tabular-nums text-muted-foreground">
|
|
|
|
|
|
{secondaryValue !== null ? secondaryValue : "–"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HoleStrip({
|
|
|
|
|
|
holes,
|
|
|
|
|
|
startHole,
|
|
|
|
|
|
holesPlanned,
|
|
|
|
|
|
mode,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
holes: ApiLeaderboardHole[]
|
|
|
|
|
|
startHole: number
|
|
|
|
|
|
holesPlanned: number
|
|
|
|
|
|
mode: Mode
|
|
|
|
|
|
}) {
|
|
|
|
|
|
// Samme sirkulære start_hole-rekkefølge som round-detail.tsx/
|
|
|
|
|
|
// round-scorecard.tsx/round-stats.tsx -- viser hullene i FAKTISK
|
|
|
|
|
|
// spillerekkefølge, ikke nødvendigvis rå hullnummer 1-18.
|
|
|
|
|
|
const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1)
|
|
|
|
|
|
const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined)
|
|
|
|
|
|
const secondaryLabel = mode === "net" ? "Netto" : mode === "points" ? "Poeng" : null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
{secondaryLabel && (
|
|
|
|
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
|
Slag · {secondaryLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="-mx-1 overflow-x-auto px-1 pb-0.5">
|
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
|
{ordered.map((h) => (
|
|
|
|
|
|
<HoleMark
|
|
|
|
|
|
key={h.hole_number}
|
|
|
|
|
|
hole={h}
|
|
|
|
|
|
secondaryValue={mode === "net" ? netForHole(h) : mode === "points" ? pointsForHole(h) : undefined}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// --- Matchstatus for to-sidede formater (2026-07-28) ------------------------
|
|
|
|
|
|
// En match/fourball/foursome/greensome/scramble har ingen individuell
|
|
|
|
|
|
// rangering -- kun en status (hvem leder, hull for hull). Egen, liten
|
|
|
|
|
|
// versjon her (samme mønster som HoleMark over -- lokal duplisering
|
|
|
|
|
|
// fremfor cross-file-import for et lite presentasjons-stykke), ikke en
|
|
|
|
|
|
// full brutto/netto-tabell (den finnes allerede på scorekort-siden sin
|
|
|
|
|
|
// "Matchforløp"-seksjon -- lenket til herfra i stedet for duplisert).
|
|
|
|
|
|
|
|
|
|
|
|
type ApiFormatResult = {
|
|
|
|
|
|
play_format: string
|
|
|
|
|
|
ready: boolean
|
|
|
|
|
|
match_holes_played: number | null
|
|
|
|
|
|
match_holes_remaining: number | null
|
|
|
|
|
|
match_is_closed: boolean | null
|
|
|
|
|
|
match_is_dormie: boolean | null
|
|
|
|
|
|
match_status_text: string | null
|
|
|
|
|
|
hole_results: string[] | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
|
|
|
|
|
const [result, setResult] = useState<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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid
|
|
|
|
|
|
// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin
|
|
|
|
|
|
// egen sortering av side-id-er). Erstatt med det faktiske side-navnet.
|
|
|
|
|
|
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
|
|
|
|
|
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
|
|
|
|
|
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) {
|
|
|
|
|
|
const result = useFormatResult(roundId, refreshKey)
|
|
|
|
|
|
if (!result) return null
|
|
|
|
|
|
|
|
|
|
|
|
if (!result.ready) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<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 matchstatus kan vises.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
|
|
|
|
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
|
|
|
|
|
|
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
|
|
|
|
|
|
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
{result.match_holes_played} hull spilt
|
|
|
|
|
|
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{result.hole_results && result.hole_results.length > 0 && (
|
|
|
|
|
|
<ul className="flex flex-wrap gap-1">
|
|
|
|
|
|
{result.hole_results.map((r, i) => (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
aria-label={
|
|
|
|
|
|
r === "halved"
|
|
|
|
|
|
? `Hull ${i + 1}: delt`
|
|
|
|
|
|
: `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
|
|
|
|
|
|
}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex size-7 items-center justify-center rounded-full text-xs 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 === "halved" ? "½" : r.toUpperCase()}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={`/my-rounds/${roundId}/scorecard`}
|
|
|
|
|
|
className="text-sm font-semibold text-primary underline underline-offset-2"
|
|
|
|
|
|
>
|
|
|
|
|
|
Se hull for hull med brutto/netto →
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 15:48:07 +02:00
|
|
|
|
// --- Full leaderboard-side -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
// Sanntid (samme "noe endret seg, hent på nytt"-mønster som
|
|
|
|
|
|
// round-stats.tsx/round-scorecard.tsx sin refreshKey).
|
|
|
|
|
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
fetch("/auth/me", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((data: { id: string } | null) => {
|
|
|
|
|
|
if (!cancelled && data) setViewerId(data.id)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
|
|
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
|
|
|
|
|
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
|
|
|
|
|
return () => socket.close()
|
|
|
|
|
|
}, [roundId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
const [roundRes, leaderboardRes] = await Promise.all([
|
|
|
|
|
|
fetch(`/rounds/${roundId}`, { credentials: "include" }),
|
|
|
|
|
|
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
|
|
|
|
|
|
])
|
|
|
|
|
|
if (roundRes.status === 403 || roundRes.status === 404) {
|
|
|
|
|
|
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!roundRes.ok || !leaderboardRes.ok) {
|
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const roundData: ApiRoundHeader = await roundRes.json()
|
|
|
|
|
|
const leaderboardData: ApiLeaderboard = await leaderboardRes.json()
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setRound(roundData)
|
|
|
|
|
|
setLeaderboard(leaderboardData)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
void load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [roundId, refreshKey])
|
|
|
|
|
|
|
|
|
|
|
|
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const MODE_MISSING_LABEL: Record<Mode, string> = {
|
|
|
|
|
|
gross: "",
|
|
|
|
|
|
net: "netto-tall",
|
|
|
|
|
|
points: "poengsum",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
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-07-26 15:48:07 +02:00
|
|
|
|
const [mode, setMode] = useState<Mode>("gross")
|
|
|
|
|
|
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
|
|
|
|
|
// etterspurt av bruker) -- kollapset som default (samme "trekkspill
|
|
|
|
|
|
// starter lukket"-konvensjon som round-stats.tsx sine StatCard-seksjoner)
|
|
|
|
|
|
// slik at listen forblir kompakt og skalerer til mange spillere.
|
|
|
|
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId)
|
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-07-26 15:48:07 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="min-h-dvh bg-background">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
|
|
|
|
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
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 (match/fourball/foursome/greensome/scramble)
|
|
|
|
|
|
har ingen individuell rangering å vise -- kun en matchstatus
|
|
|
|
|
|
(2026-07-28, se MatchStatusSection over). Delt-ball-formatene
|
|
|
|
|
|
har i tillegg ALDRI individuelle round_hole-rader (ADR-039
|
|
|
|
|
|
Beslutning C), så den vanlige listen under ville uansett vært
|
|
|
|
|
|
tom for dem. */}
|
|
|
|
|
|
{isTwoSided ? (
|
|
|
|
|
|
<MatchStatusSection roundId={roundId} sides={round.sides} refreshKey={refreshKey} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
|
|
|
|
|
<ModeToggle mode={mode} onChange={setMode} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) --
|
2026-07-26 15:48:07 +02:00
|
|
|
|
IKKE separate kort med mellomrom lenger. Kun ytterkanten er
|
|
|
|
|
|
avrundet/rammet inn; radene skilles med en tynn linje
|
|
|
|
|
|
(divide-y) og flyter sømløst i hverandre. En utvidet rad får en
|
|
|
|
|
|
svak bakgrunnstone som eneste "dette er åpnet"-signal. */}
|
|
|
|
|
|
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
|
|
|
|
|
{ranked.map((entry) => {
|
|
|
|
|
|
const value = valueFor(entry, mode)
|
|
|
|
|
|
const isLeader = entry.rank === 1
|
|
|
|
|
|
const expanded = expandedId === entry.participant_id
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={entry.participant_id} className={cn(expanded && "bg-muted/30")}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setExpandedId((prev) => (prev === entry.participant_id ? null : entry.participant_id))}
|
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
|
aria-label={`Vis detaljer for ${entry.display_name}`}
|
|
|
|
|
|
className="flex w-full items-center gap-3 p-3 text-left transition-colors hover:bg-accent/30 sm:p-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
|
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
|
|
|
|
|
{value !== null ? (
|
|
|
|
|
|
<ValueMark mode={mode} value={value} emphasize={isLeader} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="shrink-0 text-sm font-semibold text-muted-foreground">–</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<ChevronDown
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={cn("size-5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{expanded && (
|
|
|
|
|
|
<div className="flex flex-col gap-2 px-3 pb-3 pt-0.5 sm:px-4">
|
|
|
|
|
|
{/* Det som lå i selve raden før 2026-07-26 (Deg/Eier/
|
|
|
|
|
|
leder-ikon/fremdrift) -- fortsatt tilgjengelig, bare
|
|
|
|
|
|
flyttet hit slik at den kollapsede listen forblir
|
|
|
|
|
|
ren "rangering, navn, tall". */}
|
|
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
|
|
|
|
{entry.isSelf && <Badge variant="default">Deg</Badge>}
|
|
|
|
|
|
{entry.is_owner && <Badge variant="outline">Eier</Badge>}
|
|
|
|
|
|
{isLeader && <Trophy aria-hidden="true" className="size-4 text-primary" />}
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
|
|
|
|
{leaderboard.completed
|
|
|
|
|
|
? "Ferdig"
|
|
|
|
|
|
: entry.holes_played > 0
|
|
|
|
|
|
? `${entry.holes_played}/${leaderboard.holes_planned} hull spilt`
|
|
|
|
|
|
: "Ikke startet"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{entry.holes_played > 0 ? (
|
|
|
|
|
|
<HoleStrip holes={entry.holes} startHole={round.start_hole} holesPlanned={leaderboard.holes_planned} mode={mode} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="py-1 text-sm text-muted-foreground">Ingen hull registrert ennå.</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
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
|
|
|
|
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
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
|
|
|
|
|
|
|
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 (match/fourball/foursome/greensome/scramble) vises
|
|
|
|
|
|
// allerede av FormatResultPanel rett under denne komponenten sitt
|
|
|
|
|
|
// kallsted i round-detail.tsx -- ingen individuell brutto-rangering å
|
|
|
|
|
|
// vise her (og for delt-ball-formatene ville leaderboard.entries uansett
|
|
|
|
|
|
// alltid vært tom, ADR-039 Beslutning C).
|
|
|
|
|
|
if (TWO_SIDED_FORMATS.includes(round.play_format)) return null
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|