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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
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
|
`tsc --noEmit` rent, 55/55 vitest, ingen migrasjon (ingen nye
|
||||||
kolonner -- status utledes, lagres ikke).
|
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).
|
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
|
||||||
Migrasjon 071 — se ADR-067.
|
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,
|
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
|
||||||
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
||||||
org-individuelle turneringer (`scoring_method = eclectic_gross/
|
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 ..errors import app_error, translate_db_errors
|
||||||
from .. import storage
|
from .. import storage
|
||||||
from ..team_authz import user_is_tournament_participant
|
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 .matches import MatchOut, fetch_matches
|
||||||
from .scoring import Scorecard, fetch_scorecard
|
from .scoring import Scorecard, fetch_scorecard
|
||||||
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
|
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
|
||||||
|
|
@ -340,6 +340,7 @@ class PublicLeaderboardEntry(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
eclectic_total: int | None
|
eclectic_total: int | None
|
||||||
handicap_index: float | None
|
handicap_index: float | None
|
||||||
|
rounds: list[RoundCellOut]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -395,11 +396,75 @@ async def get_public_individual_leaderboard(
|
||||||
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
|
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
|
||||||
if hcp_by_participant.get(e.tournament_participant_id) is not None
|
if hcp_by_participant.get(e.tournament_participant_id) is not None
|
||||||
else None,
|
else None,
|
||||||
|
rounds=e.rounds,
|
||||||
)
|
)
|
||||||
for e in entries
|
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 -------------
|
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
|
||||||
#
|
#
|
||||||
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
|
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
import { PublicLive } from "@/components/public-live"
|
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({
|
export default async function PublicLivePage({
|
||||||
params,
|
params,
|
||||||
|
|
@ -9,5 +20,23 @@ export default async function PublicLivePage({
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { code } = await searchParams
|
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} />
|
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