Offentlig "Følg live" for individuelle turneringer: V0-UI + full kobling
Fullfører ADR-098. Ny components/public-individual-live.tsx (V0-bygget,
presentasjonslaget uendret fra eksporten) koblet til de ekte endepunktene
fra forrige commit. Rettet en reell modellmismatch underveis: V0-mock-en
modellerte hver runde som sitt eget separate leaderboard, den ekte
backend-beregningen er ett samlet, kumulativt turnering-leaderboard --
rundevelgeren styrer nå kun hvilken rundes hull-for-hull som lastes.
Ny offentlig GET .../rounds/{round_id}/participants/{tournament_participant_id}
/holes for hull-for-hull-utvidelsen (egen rute, ikke gjenbruk av
frittstående-runders tilsvarende -- ulik datamodell).
app/t/[id]/live/page.tsx avgjør nå format SERVER-side før noe rendres,
unngår klient-side-feilklassen fra forrige commit helt for denne siden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f207be374f
commit
9fad4b3f38
7 changed files with 881 additions and 2 deletions
|
|
@ -9912,6 +9912,99 @@ utvidelsene faktisk bygges.
|
|||
|
||||
---
|
||||
|
||||
## ADR-098: Offentlig "Følg live" for individuelle turneringer (2026-08-21)
|
||||
|
||||
**Kontekst:** bruker satte "Klubbmesterskap 2026" (individuell turnering)
|
||||
til offentlig synlig og delte lenken. Mottaker (ikke innlogget) fikk et
|
||||
ødelagt Cup/lag-format-adminskjermbilde. Undersøkelse avdekket at
|
||||
org-individuelle turneringer aldri har hatt en fungerende offentlig
|
||||
tilskuerside -- bevisst utsatt 2026-08-14 (FEATURE_BACKLOG.md,
|
||||
"Flaggturnering Del B": "egen, større oppgave hvis/når etterspurt").
|
||||
Bruker: "gjør det ordentlig på første forsøk", pluss bruk V0 til det
|
||||
visuelle. Grundig todelt research-runde gjennomført FØR bygging (Cup-
|
||||
sidens fulle funksjonssett + ADR-027 sin sanntidsmekanisme; den
|
||||
autentiserte leaderboard-beregningens kostnad og offentlig-trygghet;
|
||||
eksisterende offentlig runde-leaderboard som designreferanse, IKKE som
|
||||
kode-gjenbruk -- ulik datamodell, se under).
|
||||
|
||||
**Beslutning A -- gjenbruk ADR-027 sin sanntidsmekanisme UENDRET, ingen ny
|
||||
WebSocket-infrastruktur.** `broadcast_live_update(tournament_id)` (samme
|
||||
funksjon Cup-formatets `recompute_and_cache_match_state` allerede kaller)
|
||||
kobles inn i `_recompute_round_score` (individual_tournaments.py) --
|
||||
kjøres etter HVER hull-innsending for en individuell runde, uansett
|
||||
scoringsmetode (Copenhagen/BBB sine egne recompute-funksjoner kalles
|
||||
alltid ETTER denne, dekket automatisk). Samme "noe endret, hent på
|
||||
nytt"-signal over samme `/ws/public/tournaments/{id}/live`-kanal Cup-
|
||||
siden allerede åpner -- den nye siden trenger ingen egen WS-rute.
|
||||
|
||||
**Beslutning B -- ETT samlet, kumulativt leaderboard, ikke ett per
|
||||
runde.** Den autentiserte `_compute_individual_standings` (gjenbrukt
|
||||
UENDRET for den offentlige varianten) beregner posisjon/i dag/thru/total
|
||||
på tvers av HELE turneringen, ikke isolert per runde -- "i dag" er alltid
|
||||
gjeldende/sist påbegynte runde, "total" er summen så langt. Den første
|
||||
V0-mock-modelleringen (hver runde = sitt eget separate leaderboard) matchet
|
||||
IKKE denne modellen -- rettet ved integrering: rundevelgeren i UI-et endrer
|
||||
IKKE selve leaderboard-radene lenger, den styrer kun hvilken rundes
|
||||
hull-for-hull-rutenett som lastes (lat, per rad) når en rad utvides.
|
||||
Kostnadsvurdert FØR bygging: samme billige ~4-spørrings-beregning som det
|
||||
autentiserte leaderboardet, trygt å kjøre på hver offentlige sidevisning
|
||||
og hvert sanntids-signal.
|
||||
|
||||
**Beslutning C -- offentlig-trygg respons, bevisst valgt HCP-synlighet.**
|
||||
Ny `PublicLeaderboardEntry`-modell (registration.py) speiler den
|
||||
autentiserte `LeaderboardEntry`, fjerner kun `player_id` (ingen annen PII
|
||||
funnet -- ingen DSQ-begrunnelsestekst finnes i modellen, `status` er en
|
||||
trygg enum). HCP-indeks lagt til bevisst etter eksplisitt spørsmål til
|
||||
bruker (AskUserQuestion) -- "ja, vis HCP", samme presedens som
|
||||
frittstående runders offentlige leaderboard (`round-leaderboard.tsx`)
|
||||
allerede har.
|
||||
|
||||
**Beslutning D -- rundestatus utledes, ingen ny kolonne.**
|
||||
`tournament_round` har ingen status-kolonne (bekreftet mot skjemaet).
|
||||
Ny `GET /public/tournaments/{id}/rounds` utleder "ikke startet"/"pågår"/
|
||||
"fullført" fra `tournament_round_score.holes_played` sammenlignet mot
|
||||
banens hullantall -- samme prinsipp den interne `today_sequence`-
|
||||
utregningen i `_attach_stroke_play_columns` allerede bruker for "dagens
|
||||
runde", generalisert til å gjelde HVER runde og lagt til en "alle
|
||||
ferdig"-gren. Ingen migrasjon.
|
||||
|
||||
**Beslutning E -- eksisterende offentlig runde-leaderboard
|
||||
(`/public/rounds/{id}/leaderboard`, `round-leaderboard.tsx` sin
|
||||
`publicMode`) er en DESIGNREFERANSE, ikke gjenbrukt kode.** Den hører til
|
||||
den frittstående personlige "Runder"-funksjonen (`round`/
|
||||
`round_participant`-tabellene), en helt annen datamodell enn org-
|
||||
turneringers `tournament_round`/`tournament_round_participant`. Egen ny
|
||||
rute (`GET .../rounds/{round_id}/participants/{tournament_participant_id}
|
||||
/holes`) bygget i stedet, samme spørringsmønster som den autentiserte
|
||||
`list_round_participant_holes`, men nøkkelt på `tournament_participant_id`
|
||||
(offentlig-trygg, matcher leaderboard-responsen) i stedet for det
|
||||
runde-interne `tournament_round_participant_id`.
|
||||
|
||||
**Beslutning F -- format avgjøres SERVER-side, ikke klient-side.**
|
||||
`app/tournaments/[id]/print/...` og `tournament-router.tsx` (samme dag,
|
||||
se punktet rett over dette i loggen) viste hvorfor: en klient-side
|
||||
formatoppslag som feiler (f.eks. 401) er lett å håndtere galt. Den nye
|
||||
`app/t/[id]/live/page.tsx` gjør derfor et enkelt server-side `fetch()`
|
||||
mot `/public/tournaments/{id}` (samme "ikke gjennom next.config.mjs sin
|
||||
rewrites()"-mønster som `app/logg-inn/page.tsx`) FØR noe rendres, og
|
||||
velger `PublicLive` (Cup) eller `PublicIndividualLive` (individuell)
|
||||
deretter -- en individuell turnering kan aldri, ikke engang kortvarig,
|
||||
vise Cup-sidens UI.
|
||||
|
||||
**Frontend:** ny `components/public-individual-live.tsx` (Claude-skrevet
|
||||
V0-prompt, `Temp-uploads/v0-prompt-public-individual-live.md`,
|
||||
presentasjonskomponentene UENDRET fra eksporten -- kun datalaget skrevet
|
||||
om for ekte fetching+sanntid). `public-tournament.tsx` sin "Program"-
|
||||
seksjon (viste før alltid en tom Cup-økt-liste for individuelle
|
||||
turneringer) viser nå en rundeliste med samme "Pågår nå"/"Fullført"-merker
|
||||
som resten av appen (`watch-round.tsx`).
|
||||
|
||||
**Status:** `tsc --noEmit` rent, 55/55 vitest, backend importverifisert
|
||||
(`app.main` boot-testet i kjørende container). IKKE bygget/rullet ut ennå.
|
||||
Ingen migrasjon.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -14178,3 +14178,36 @@ Neste steg:
|
|||
|
||||
`tsc --noEmit` rent, 55/55 vitest, ingen migrasjon (ingen nye
|
||||
kolonner -- status utledes, lagres ikke).
|
||||
|
||||
153. **Offentlig "Følg live" for individuelle turneringer FERDIG BYGGET,
|
||||
2026-08-21 (ADR-098) -- direkte fortsettelse av #152.** V0-eksporten
|
||||
kom tilbake (`Temp-uploads/`, samme filnavnmønster som tidligere
|
||||
zip-er denne uken -- innhold sjekket mot faktisk prompt, ikke
|
||||
filnavnet). Høy troskap mot prompten, men mock-en modellerte hver
|
||||
runde som sitt eget separate leaderboard -- matchet ikke den ekte,
|
||||
KUMULATIVE beregningen (`_compute_individual_standings`). Rettet ved
|
||||
integrering: rundevelgeren endrer ikke lenger leaderboard-radene,
|
||||
kun hvilken rundes hull-for-hull som lastes ved utvidelse (se
|
||||
ADR-098 Beslutning B for full begrunnelse).
|
||||
|
||||
Ny `GET .../rounds/{round_id}/participants/{tournament_participant_id}
|
||||
/holes` (offentlig, nøkler på tournament_participant_id -- IKKE det
|
||||
runde-interne tournament_round_participant_id) lagt til underveis,
|
||||
egen ny rute (ikke gjenbruk av frittstående-runders tilsvarende --
|
||||
ulik datamodell, ADR-098 Beslutning E). `PublicLeaderboardEntry` fikk
|
||||
`rounds`-feltet lagt til (per-runde-celler, til fremtidig bruk).
|
||||
|
||||
`app/t/[id]/live/page.tsx` skrevet om til å avgjøre format SERVER-side
|
||||
(enkelt `fetch()` mot `/public/tournaments/{id}`, samme mønster som
|
||||
`app/logg-inn/page.tsx`) FØR noe rendres -- unngår
|
||||
`tournament-router.tsx` sin klient-side-feilklasse fra #152 helt (en
|
||||
individuell turnering kan aldri vise Cup-UI-et, ikke engang kortvarig).
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent på hele frontend, 55/55 vitest,
|
||||
backend importverifisert (`app.main` boot-testet med de nye rutene i
|
||||
kjørende container -- IKKE deployet, kun kopiert inn for
|
||||
importsjekk). Ingen unused imports (sjekket for hånd, samme rutine
|
||||
som utskriftsfunksjonen). Ingen migrasjon.
|
||||
|
||||
**IKKE rullet ut ennå** -- venter på `docker compose build && up -d`
|
||||
med bekreftelse, samme som resten av dagens arbeid.
|
||||
|
|
|
|||
|
|
@ -598,6 +598,12 @@
|
|||
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
|
||||
Migrasjon 071 — se ADR-067.
|
||||
|
||||
**✅ BYGGET 2026-08-21 (ADR-098)** — `/t/[id]/live` forgrener seg nå
|
||||
server-side på `format_type`, ny `PublicIndividualLive`-side (V0-bygget)
|
||||
med rundevelger + samlet leaderboard + hull-for-hull, samme sanntids-
|
||||
kanal som Cup-siden. Se ARCHITECTURE_DECISIONS.md for full detalj,
|
||||
CHANGELOG.md for byggelogg. IKKE rullet ut ennå.
|
||||
|
||||
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
|
||||
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
||||
org-individuelle turneringer (`scoring_method = eclectic_gross/
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from ..db import org_connection, plain_connection
|
|||
from ..errors import app_error, translate_db_errors
|
||||
from .. import storage
|
||||
from ..team_authz import user_is_tournament_participant
|
||||
from .individual_tournaments import LeaderboardEntry, _compute_individual_standings
|
||||
from .individual_tournaments import LeaderboardEntry, RoundCellOut, _compute_individual_standings
|
||||
from .matches import MatchOut, fetch_matches
|
||||
from .scoring import Scorecard, fetch_scorecard
|
||||
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
|
||||
|
|
@ -340,6 +340,7 @@ class PublicLeaderboardEntry(BaseModel):
|
|||
status: str
|
||||
eclectic_total: int | None
|
||||
handicap_index: float | None
|
||||
rounds: list[RoundCellOut]
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -395,11 +396,75 @@ async def get_public_individual_leaderboard(
|
|||
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
|
||||
if hcp_by_participant.get(e.tournament_participant_id) is not None
|
||||
else None,
|
||||
rounds=e.rounds,
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
class PublicRoundHole(BaseModel):
|
||||
hole_number: int
|
||||
par: int
|
||||
gross_strokes: int | None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{tournament_id}/rounds/{round_id}/participants/{tournament_participant_id}/holes",
|
||||
response_model=list[PublicRoundHole],
|
||||
)
|
||||
async def get_public_round_participant_holes(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
tournament_participant_id: str,
|
||||
code: str | None = None,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[PublicRoundHole]:
|
||||
"""Hull-for-hull for ÉN spiller i ÉN runde -- brukt av den offentlige
|
||||
leaderboard-radens utvidbare rutenett. Nøkkelen er bevisst
|
||||
tournament_participant_id (samme id som i PublicLeaderboardEntry over),
|
||||
IKKE tournament_round_participant_id (den er runde-spesifikk og aldri
|
||||
eksponert offentlig) -- slår opp riktig runde-deltaker-rad internt."""
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2",
|
||||
round_id,
|
||||
tournament_id,
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke i denne turneringen.")
|
||||
|
||||
round_participant_id = await conn.fetchval(
|
||||
"SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
|
||||
round_id,
|
||||
tournament_participant_id,
|
||||
)
|
||||
if round_participant_id is None:
|
||||
return []
|
||||
|
||||
holes = await conn.fetch(
|
||||
"""
|
||||
SELECT h.hole_number, h.par, trh.gross_strokes
|
||||
FROM hole h
|
||||
LEFT JOIN tournament_round_hole trh
|
||||
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
|
||||
WHERE h.course_id = $2
|
||||
ORDER BY h.hole_number
|
||||
""",
|
||||
round_participant_id,
|
||||
round_row["course_id"],
|
||||
)
|
||||
return [PublicRoundHole(**dict(h)) for h in holes]
|
||||
|
||||
|
||||
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
|
||||
#
|
||||
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
|
||||
|
|
|
|||
|
|
@ -1,4 +1,15 @@
|
|||
import { PublicLive } from "@/components/public-live"
|
||||
import { PublicIndividualLive } from "@/components/public-individual-live"
|
||||
|
||||
// Server-side, IKKE nettleser-fetch -- samme mønster som app/logg-inn/
|
||||
// page.tsx og generateMetadata i app/t/[id]/page.tsx (peker direkte på
|
||||
// API-et, går IKKE gjennom next.config.mjs sin rewrites()). Avgjør
|
||||
// format_type FØR noe rendres, slik at en individuell turnering aldri
|
||||
// (heller ikke kortvarig) viser Cup-formatets side -- se CHANGELOG.md
|
||||
// 2026-08-21 for hele bakgrunnen (tournament-router.tsx sin tilsvarende
|
||||
// klient-side feil, som denne siden bevisst unngår ved å avgjøre server-
|
||||
// side i stedet).
|
||||
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
|
||||
|
||||
export default async function PublicLivePage({
|
||||
params,
|
||||
|
|
@ -9,5 +20,23 @@ export default async function PublicLivePage({
|
|||
}) {
|
||||
const { id } = await params
|
||||
const { code } = await searchParams
|
||||
|
||||
let formatType = "team"
|
||||
try {
|
||||
const url = `${API_ORIGIN}/public/tournaments/${id}${code ? `?code=${encodeURIComponent(code)}` : ""}`
|
||||
const res = await fetch(url, { cache: "no-store" })
|
||||
if (res.ok) {
|
||||
const data: { format_type?: string } = await res.json()
|
||||
formatType = data.format_type === "individual" ? "individual" : "team"
|
||||
}
|
||||
} catch {
|
||||
// Klarte ikke å avgjøre format server-side -- fall tilbake til Cup-
|
||||
// siden sin egen feilhåndtering (samme "trygg fallback"-prinsipp som
|
||||
// resten av appen), ikke en hard krasj her.
|
||||
}
|
||||
|
||||
if (formatType === "individual") {
|
||||
return <PublicIndividualLive tournamentId={id} code={code} />
|
||||
}
|
||||
return <PublicLive tournamentId={id} code={code} />
|
||||
}
|
||||
|
|
|
|||
653
frontend/components/public-individual-live.tsx
Normal file
653
frontend/components/public-individual-live.tsx
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
"use client"
|
||||
|
||||
// Offentlig "Følg live" for INDIVIDUELLE turneringer (2026-08-21) --
|
||||
// motstykket til public-live.tsx (Cup-format). Se CHANGELOG.md samme dag
|
||||
// for hele bakgrunnen (bevisst utsatt siden 2026-08-14, FEATURE_BACKLOG.md
|
||||
// "Flaggturnering Del B", nå bygget etter grundig todelt analyse).
|
||||
//
|
||||
// Visuelt: Claude-skrevet V0-eksport (v0-prompt-public-individual-live.md),
|
||||
// presentasjonskomponentene under er UENDRET fra eksporten. Denne filen
|
||||
// bytter ut mock-datalaget med ekte fetching + sanntid.
|
||||
//
|
||||
// VIKTIG forenkling fra mock til ekte data: mock-en modellerte hver runde
|
||||
// som sitt eget, helt separate leaderboard. Den ekte backend-modellen
|
||||
// (individual_tournaments.py _compute_individual_standings) beregner ETT
|
||||
// SAMLET, kumulativt turnering-leaderboard -- "i dag"/"thru" gjelder
|
||||
// alltid gjeldende/siste påbegynte runde, "total" er summen på tvers av
|
||||
// ALLE spilte runder. Rundevelgeren endrer derfor IKKE selve
|
||||
// leaderboard-radene (position/thru/i dag/total er alltid de samme,
|
||||
// uansett hvilken rundefane som er valgt) -- den styrer KUN hvilken
|
||||
// rundes hull-for-hull-rutenett som lastes når en rad utvides.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { ArrowLeft, Radio, Check, ChevronDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* API-typer (matcher app/routers/registration.py sine nye endepunkt) *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
type RoundStatus = "not_started" | "in_progress" | "completed"
|
||||
|
||||
type ApiRound = {
|
||||
id: string
|
||||
sequence: number
|
||||
name: string
|
||||
course_name: string
|
||||
scheduled_at: string | null
|
||||
status: RoundStatus
|
||||
}
|
||||
|
||||
type Tone = "under" | "even" | "over" | null
|
||||
|
||||
type ApiRoundCell = { round_number: number; label: string; tone: Tone }
|
||||
|
||||
type PlayerStatus = "active" | "dsq" | "rtd" | "dnf" | "dns"
|
||||
|
||||
type ApiLeaderboardEntry = {
|
||||
tournament_participant_id: string
|
||||
player_name: string
|
||||
class_id: string | null
|
||||
class_name: string | null
|
||||
position: string | null
|
||||
is_leader: boolean
|
||||
today_label: string | null
|
||||
thru_label: string | null
|
||||
total_label: string | null
|
||||
rank: number | null
|
||||
cut: boolean
|
||||
status: PlayerStatus
|
||||
handicap_index: number | null
|
||||
rounds: ApiRoundCell[]
|
||||
}
|
||||
|
||||
type ApiHole = { hole_number: number; par: number; gross_strokes: number | null }
|
||||
|
||||
type ApiTournamentInfo = { name: string; format_type: string; status: string }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Visningstyper for hull-rutenettet (per runde, hentes lat ved utvidelse)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
type HoleResult = { holeNumber: number; par: number; toParTone: Tone; label: string }
|
||||
|
||||
function toneOf(strokesOverPar: number): Tone {
|
||||
return strokesOverPar < 0 ? "under" : strokesOverPar > 0 ? "over" : "even"
|
||||
}
|
||||
|
||||
function holesFromApi(holes: ApiHole[]): HoleResult[] {
|
||||
return holes.map((h) => {
|
||||
if (h.gross_strokes == null) return { holeNumber: h.hole_number, par: h.par, toParTone: null, label: "" }
|
||||
return {
|
||||
holeNumber: h.hole_number,
|
||||
par: h.par,
|
||||
toParTone: toneOf(h.gross_strokes - h.par),
|
||||
label: String(h.gross_strokes),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Delt golfscore-visuelt språk -- UENDRET fra V0-eksporten. *
|
||||
* under = sirkel · over = avrundet firkant · even = ren tekst. *
|
||||
* emphasis = fylt (leder); ellers tint + kant. *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function ScoreShape({
|
||||
label,
|
||||
tone,
|
||||
emphasis = false,
|
||||
size = "md",
|
||||
}: {
|
||||
label: string | null
|
||||
tone: Tone
|
||||
emphasis?: boolean
|
||||
size?: "sm" | "md"
|
||||
}) {
|
||||
const dims = size === "sm" ? "min-h-7 min-w-7 px-1 text-xs" : "min-h-10 min-w-10 px-2 text-sm"
|
||||
const base = cn("inline-flex items-center justify-center font-bold tabular-nums", dims)
|
||||
|
||||
if (label == null) {
|
||||
return (
|
||||
<span className={cn(base, "text-muted-foreground")} aria-hidden="true">
|
||||
–
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (tone === "even") {
|
||||
return (
|
||||
<span className={cn(base, "text-foreground")}>
|
||||
{label}
|
||||
<span className="sr-only"> likt med par</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (tone == null) {
|
||||
return <span className={cn(base, "text-muted-foreground")}>{label}</span>
|
||||
}
|
||||
|
||||
const under = tone === "under"
|
||||
const shape = under ? "rounded-full" : "rounded-[5px]"
|
||||
const skin = emphasis
|
||||
? under
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-brand-orange text-brand-orange-foreground"
|
||||
: under
|
||||
? "border-2 border-primary/35 bg-primary/10 text-primary"
|
||||
: "border-2 border-brand-orange/35 bg-brand-orange/10 text-brand-orange"
|
||||
|
||||
return (
|
||||
<span className={cn(base, shape, skin)}>
|
||||
{label}
|
||||
<span className="sr-only">{under ? " under par" : " over par"}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) {
|
||||
if (!position) {
|
||||
return (
|
||||
<span className="inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg bg-muted px-1.5 text-sm font-bold text-muted-foreground">
|
||||
–
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg px-1.5 text-sm font-extrabold tabular-nums",
|
||||
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{position}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-h-8 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 px-2.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundStatusBadge({ status }: { status: RoundStatus }) {
|
||||
if (status === "in_progress") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 self-start rounded-full bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground">
|
||||
<Radio aria-hidden="true" className="size-3.5 animate-pulse" />
|
||||
Pågår nå
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (status === "completed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 self-start text-xs font-semibold text-muted-foreground">
|
||||
<Check aria-hidden="true" className="size-3.5" />
|
||||
Fullført
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="self-start text-xs font-semibold text-muted-foreground">Ikke startet</span>
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<Exclude<PlayerStatus, "active">, string> = {
|
||||
dsq: "DSQ",
|
||||
rtd: "RTD",
|
||||
dnf: "DNF",
|
||||
dns: "DNS",
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Utvidbart hull-for-hull-rutenett *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function HoleNine({ holes, label }: { holes: HoleResult[]; label: string }) {
|
||||
const parSum = holes.reduce((s, h) => s + h.par, 0)
|
||||
const strokeSum = holes.reduce((s, h) => s + (h.label ? Number(h.label) : 0), 0)
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground">
|
||||
<div className="py-1">{label}</div>
|
||||
{holes.map((h) => (
|
||||
<div key={h.holeNumber} className="py-1">
|
||||
{h.holeNumber}
|
||||
</div>
|
||||
))}
|
||||
<div className="py-1">Sum</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center border-t border-border text-center text-[11px] text-muted-foreground">
|
||||
<div className="py-1 font-semibold">Par</div>
|
||||
{holes.map((h) => (
|
||||
<div key={h.holeNumber} className="py-1 tabular-nums">
|
||||
{h.par}
|
||||
</div>
|
||||
))}
|
||||
<div className="py-1 font-semibold tabular-nums">{parSum}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center justify-items-center border-t border-border py-1 text-center">
|
||||
<div className="text-[11px] font-semibold text-foreground">Deg</div>
|
||||
{holes.map((h) =>
|
||||
h.label ? (
|
||||
<ScoreShape key={h.holeNumber} label={h.label} tone={h.toParTone} size="sm" />
|
||||
) : (
|
||||
<span key={h.holeNumber} className="text-xs text-muted-foreground" aria-hidden="true">
|
||||
–
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
<div className="text-xs font-bold tabular-nums text-foreground">{strokeSum || "–"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HoleGrid({ holes }: { holes: HoleResult[] }) {
|
||||
if (holes.length === 0) {
|
||||
return <p className="px-3 pb-3 text-sm text-muted-foreground">Ingen hull registrert for denne runden ennå.</p>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 pb-3">
|
||||
<HoleNine holes={holes.slice(0, 9)} label="Ut" />
|
||||
{holes.length > 9 && <HoleNine holes={holes.slice(9)} label="Inn" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Leaderboard-rad -- henter hull-for-hull LAT, for VALGT runde, *
|
||||
* kun når raden faktisk utvides. *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function LeaderboardRow({
|
||||
entry,
|
||||
organizationlessBase,
|
||||
roundId,
|
||||
}: {
|
||||
entry: ApiLeaderboardEntry
|
||||
organizationlessBase: string
|
||||
roundId: string | null
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [holes, setHoles] = useState<HoleResult[] | null>(null)
|
||||
const [loadingHoles, setLoadingHoles] = useState(false)
|
||||
const panelId = `holes-${entry.tournament_participant_id}`
|
||||
const isStatus = entry.status !== "active"
|
||||
|
||||
// Rundevalg endret seg mens raden var åpen -- kast cachet data, hent på nytt.
|
||||
useEffect(() => {
|
||||
setHoles(null)
|
||||
}, [roundId])
|
||||
|
||||
async function toggle() {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (next && roundId && holes === null) {
|
||||
setLoadingHoles(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${organizationlessBase}/rounds/${roundId}/participants/${entry.tournament_participant_id}/holes`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (res.ok) {
|
||||
const data: ApiHole[] = await res.json()
|
||||
setHoles(holesFromApi(data))
|
||||
}
|
||||
} finally {
|
||||
setLoadingHoles(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rowInner = (
|
||||
<>
|
||||
<RankBadge position={entry.position} isLeader={entry.is_leader} />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[15px] font-bold text-foreground">{entry.player_name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`}
|
||||
{entry.handicap_index != null && entry.class_name ? " · " : ""}
|
||||
{entry.class_name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Thru</span>
|
||||
<span className="text-sm font-bold tabular-nums text-foreground">{entry.thru_label ?? "-"}</span>
|
||||
</span>
|
||||
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">I dag</span>
|
||||
{isStatus ? (
|
||||
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
||||
–
|
||||
</span>
|
||||
) : (
|
||||
<ScoreShape
|
||||
label={entry.today_label}
|
||||
tone={entry.today_label?.startsWith("-") || entry.today_label?.startsWith("−") ? "under" : entry.today_label?.startsWith("+") ? "over" : entry.today_label === "E" ? "even" : null}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Total</span>
|
||||
{isStatus || entry.cut ? (
|
||||
<StatusPill label={entry.cut ? "Kuttet" : STATUS_LABEL[entry.status as Exclude<PlayerStatus, "active">]} />
|
||||
) : (
|
||||
<ScoreShape
|
||||
label={entry.total_label}
|
||||
tone={entry.total_label?.startsWith("-") || entry.total_label?.startsWith("−") ? "under" : entry.total_label?.startsWith("+") ? "over" : entry.total_label === "E" ? "even" : null}
|
||||
emphasis={entry.is_leader}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
roundId ? (open ? "rotate-180" : "") : "invisible",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (!roundId) {
|
||||
return <li className="flex items-center gap-2.5 px-3 py-3 sm:px-4">{rowInner}</li>
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-4"
|
||||
>
|
||||
{rowInner}
|
||||
<span className="sr-only">{open ? "Skjul hull for hull" : "Vis hull for hull"}</span>
|
||||
</button>
|
||||
<div
|
||||
id={panelId}
|
||||
className={cn(
|
||||
"grid transition-all duration-300 ease-out",
|
||||
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn(!open && "invisible")} aria-hidden={!open}>
|
||||
{loadingHoles ? (
|
||||
<p className="px-3 pb-3 text-sm text-muted-foreground">Laster hull …</p>
|
||||
) : (
|
||||
<HoleGrid holes={holes ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Side *
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function pickDefaultRound(rounds: ApiRound[]): string | null {
|
||||
const live = rounds.find((r) => r.status === "in_progress")
|
||||
if (live) return live.id
|
||||
const completed = [...rounds].reverse().find((r) => r.status === "completed")
|
||||
if (completed) return completed.id
|
||||
return rounds[0]?.id ?? null
|
||||
}
|
||||
|
||||
function rankKey(e: ApiLeaderboardEntry): number {
|
||||
if (e.position) return Number.parseInt(e.position.replace(/\D/g, ""), 10) || 999
|
||||
return 1000
|
||||
}
|
||||
|
||||
export function PublicIndividualLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
|
||||
const base = `/public/tournaments/${tournamentId}`
|
||||
|
||||
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
|
||||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||||
const [entries, setEntries] = useState<ApiLeaderboardEntry[]>([])
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [classFilter, setClassFilter] = useState<string>("all")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function loadAll(isFirst: boolean) {
|
||||
const [tRes, rRes, lRes] = await Promise.all([
|
||||
isFirst ? fetch(`${base}${codeParam}`, { credentials: "include" }) : Promise.resolve(null),
|
||||
fetch(`${base}/rounds${codeParam}`, { credentials: "include" }),
|
||||
fetch(`${base}/individual-leaderboard${codeParam}`, { credentials: "include" }),
|
||||
])
|
||||
if (tRes?.ok) {
|
||||
const data: ApiTournamentInfo = await tRes.json()
|
||||
setTournament(data)
|
||||
}
|
||||
if (rRes.ok) {
|
||||
const data: ApiRound[] = await rRes.json()
|
||||
setRounds(data)
|
||||
if (isFirst) setSelectedRoundId(pickDefaultRound(data))
|
||||
}
|
||||
if (lRes.ok) {
|
||||
const data: ApiLeaderboardEntry[] = await lRes.json()
|
||||
setEntries(data)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadAll(true).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tournamentId])
|
||||
|
||||
// Sanntid -- SAMME WebSocket-kanal og "noe endret, hent på nytt"-mønster
|
||||
// som public-live.tsx (Cup-format) allerede bruker, ADR-027.
|
||||
const isFirstRefresh = useRef(true)
|
||||
useEffect(() => {
|
||||
const proto = window.location.protocol === "https:" ? "wss" : "ws"
|
||||
const ws = new WebSocket(
|
||||
`${proto}://${window.location.host}/ws/public/tournaments/${tournamentId}/live${codeParam}`,
|
||||
)
|
||||
ws.onmessage = () => {
|
||||
if (isFirstRefresh.current) {
|
||||
isFirstRefresh.current = false
|
||||
return
|
||||
}
|
||||
void loadAll(false)
|
||||
}
|
||||
return () => ws.close()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tournamentId])
|
||||
|
||||
const classes = useMemo(
|
||||
() => Array.from(new Set(entries.map((e) => e.class_name).filter((c): c is string => !!c))),
|
||||
[entries],
|
||||
)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const filtered = classFilter === "all" ? entries : entries.filter((e) => e.class_name === classFilter)
|
||||
return [...filtered].sort((a, b) => rankKey(a) - rankKey(b))
|
||||
}, [entries, classFilter])
|
||||
|
||||
const advancing = visible.filter((e) => e.status === "active" && !e.cut)
|
||||
const belowCut = visible.filter((e) => e.cut || e.status !== "active")
|
||||
const hasCut = belowCut.length > 0
|
||||
|
||||
const isFinished = tournament?.status === "completed" || tournament?.status === "archived"
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background text-foreground">
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3">
|
||||
<a
|
||||
href={`/t/${tournamentId}${codeParam}`}
|
||||
aria-label="Tilbake til turneringen"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</a>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-2 rounded-full", isFinished ? "bg-muted-foreground" : "bg-primary animate-pulse")}
|
||||
/>
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
||||
{isFinished ? "Avsluttet" : "Følg live"}
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="truncate text-lg font-extrabold leading-tight text-foreground text-balance">
|
||||
{tournament?.name ?? "Turnering"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Wordmark compact />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-3xl flex-col gap-5 px-4 py-5">
|
||||
{rounds.length > 0 && (
|
||||
<nav aria-label="Velg runde" className="-mx-4 overflow-x-auto px-4">
|
||||
<ul className="flex gap-2.5">
|
||||
{rounds.map((r) => {
|
||||
const selected = r.id === selectedRoundId
|
||||
const date = r.scheduled_at
|
||||
? new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }).format(
|
||||
new Date(r.scheduled_at),
|
||||
)
|
||||
: null
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedRoundId(r.id)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-w-[10.5rem] flex-col gap-1.5 rounded-2xl border p-3 text-left transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||||
: "border-border bg-card hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-extrabold text-foreground">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{r.course_name}
|
||||
{date ? ` · ${date}` : ""}
|
||||
</span>
|
||||
<RoundStatusBadge status={r.status} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<section aria-label="Resultatliste" className="flex flex-col gap-3">
|
||||
{classes.length > 0 && entries.length > 0 && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Filtrer på klasse"
|
||||
className="flex flex-wrap gap-1.5 rounded-xl border border-border bg-card p-1"
|
||||
>
|
||||
{[{ key: "all", label: "Alle" }, ...classes.map((c) => ({ key: c, label: c }))].map((opt) => {
|
||||
const active = classFilter === opt.key
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => setClassFilter(opt.key)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-lg px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
|
||||
<p className="text-sm font-semibold text-foreground">Ingen resultater ennå</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground text-pretty">
|
||||
Ingen resultater registrert ennå for denne turneringen.
|
||||
</p>
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Ingen spillere i denne klassen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/[0.06]">
|
||||
{advancing.map((e) => (
|
||||
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
|
||||
))}
|
||||
|
||||
{hasCut && (
|
||||
<li aria-hidden="true" className="flex items-center gap-3 bg-muted/50 px-4 py-2">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Cut · {advancing.length} videre
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</li>
|
||||
)}
|
||||
|
||||
{belowCut.map((e) => (
|
||||
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<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="flex size-5 items-center justify-center rounded-full border-2 border-primary/35 bg-primary/10 text-[10px] font-bold text-primary">
|
||||
−
|
||||
</span>
|
||||
Under par
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-bold text-foreground">E</span>
|
||||
Par
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange/35 bg-brand-orange/10 text-[10px] font-bold text-brand-orange">
|
||||
+
|
||||
</span>
|
||||
Over par
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue