Offentlig "Følg live" for individuelle turneringer: backend + Program-fiks
Org-individuelle turneringer manglet en fungerende offentlig tilskuerside
helt (bevisst utsatt i FEATURE_BACKLOG.md 2026-08-14, "hvis/når etterspurt"
-- nå etterspurt). Denne runden bygger backend-grunnmuren:
- PublicTournamentInfo += format_type (manglet, landingssiden kunne ikke
skille format offentlig).
- Ny GET /public/tournaments/{id}/rounds -- rundeliste med utledet status
(ikke startet/pågår/fullført).
- Ny GET /public/tournaments/{id}/individual-leaderboard -- gjenbruker
_compute_individual_standings uendret, fjerner kun player_id, inkluderer
HCP (bruker bekreftet eksplisitt).
- Sanntid: broadcast_live_update() koblet inn i _recompute_round_score,
gjenbruker samme WebSocket-kanal Cup-siden allerede bruker (ADR-027-
mønsteret), ingen ny WS-infrastruktur.
- public-tournament.tsx sin "Program"-seksjon viser nå rundeliste for
individuelle turneringer i stedet for en alltid-tom Cup-økt-liste.
Selve "Følg live"-sidens UI (V0) og /t/[id]/live sin format-forgrening
gjenstår -- egen commit når V0-eksporten er klar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f9ae32f4e1
commit
f207be374f
5 changed files with 347 additions and 17 deletions
70
CHANGELOG.md
70
CHANGELOG.md
|
|
@ -14108,3 +14108,73 @@ Neste steg:
|
||||||
trenger spillere lagt til på nytt via "Spillere"/"Grupper og
|
trenger spillere lagt til på nytt via "Spillere"/"Grupper og
|
||||||
startliste" -- de gamle deltaker-radene forsvant da Klubbmesterskap
|
startliste" -- de gamle deltaker-radene forsvant da Klubbmesterskap
|
||||||
sine runder ble gjenopprettet med nye ID-er.
|
sine runder ble gjenopprettet med nye ID-er.
|
||||||
|
|
||||||
|
152. **To reelle bugs funnet mens bruker testet offentlig deling av
|
||||||
|
Klubbmesterskap 2026, 2026-08-21.** Bruker satte turneringen til
|
||||||
|
"Offentlig" og delte URL-en rett fra egen adresselinje
|
||||||
|
(`/tournaments/{id}?org=...`) -- en ikke-innlogget mottaker fikk et
|
||||||
|
ødelagt Cup/lag-format-administrasjonsskjermbilde ("Lag og spillere",
|
||||||
|
"Klarte ikke å laste lag og spillere"), selv om turneringen er
|
||||||
|
individuell.
|
||||||
|
|
||||||
|
**Fiks A -- riktig lenke nå kopierbar.** Den ekte offentlige lenken
|
||||||
|
(`/t/{id}`) fantes fra før, men kun som ren, ikke-kopierbar tekst i
|
||||||
|
"Presentasjon"-fanen -- lett å bomme på til fordel for adresselinjens
|
||||||
|
egen (feil) URL. `tournament-presentation.tsx` fikk en ekte
|
||||||
|
kopier-knapp (samme mønster som `JoinCodeChip`).
|
||||||
|
|
||||||
|
**Fiks B -- ekte bug, uavhengig av feil lenke.**
|
||||||
|
`tournament-router.tsx` hentet turneringsformat med et innlogget-only
|
||||||
|
kall, og falt ved ENHVER feil (inkl. 401/ikke-innlogget) ubetinget
|
||||||
|
tilbake til Cup-format-adminskjermen -- uansett turneringens faktiske
|
||||||
|
format. Skiller nå eksplisitt 401 fra andre feil: en ikke-innlogget
|
||||||
|
besøkende på en organisator-URL sendes videre til den ekte offentlige
|
||||||
|
siden (`/t/{id}`) i stedet for å se en ødelagt adminskjerm.
|
||||||
|
|
||||||
|
Underveis oppdaget bruker et TREDJE, større hull: **org-individuelle
|
||||||
|
turneringer har ingen fungerende offentlig "Følg live"-side i det
|
||||||
|
hele tatt** -- allerede notert som bevisst utsatt i FEATURE_BACKLOG.md
|
||||||
|
("Flaggturnering Del B", 2026-08-14: "egen, større oppgave hvis/når
|
||||||
|
etterspurt"). Bruker: "gjør det ordentlig på første forsøk", V0 til
|
||||||
|
det visuelle. Grundig todelt analyse gjennomført (Cup-sidens fulle
|
||||||
|
funksjonssett + sanntidsmekanisme, ADR-027; den autentiserte
|
||||||
|
individuell-leaderboard-beregningens kostnad/offentlig-trygghet;
|
||||||
|
eksisterende offentlig runde-leaderboard som designreferanse) FØR
|
||||||
|
bygging, se ARCHITECTURE_DECISIONS.md for ADR (kommer når hele
|
||||||
|
funksjonen er ferdig).
|
||||||
|
|
||||||
|
**Backend bygget og importverifisert (`app.main` boot-testet i
|
||||||
|
kjørende container), IKKE bygget/rullet ut ennå:**
|
||||||
|
- `PublicTournamentInfo` (`registration.py`) += `format_type` --
|
||||||
|
manglet før, landingssiden kunne ikke skille format offentlig.
|
||||||
|
- Ny `GET /public/tournaments/{id}/rounds` -- rundeliste med utledet
|
||||||
|
status (ikke startet/pågår/fullført; ingen statuskolonne finnes på
|
||||||
|
`tournament_round` selv, utledes fra `tournament_round_score`, samme
|
||||||
|
prinsipp som den eksisterende interne "dagens runde"-logikken).
|
||||||
|
- Ny `GET /public/tournaments/{id}/individual-leaderboard` --
|
||||||
|
gjenbruker `_compute_individual_standings` UENDRET (samme billige
|
||||||
|
4-spørrings-beregning som det autentiserte leaderboardet), fjerner
|
||||||
|
kun `player_id`. HCP-indeks lagt til bevisst (bruker valgte "ja,
|
||||||
|
vis HCP" da spurt eksplisitt -- samme presedens som frittstående
|
||||||
|
runders offentlige leaderboard allerede har).
|
||||||
|
- Sanntid: `broadcast_live_update(tournament_id)` koblet inn i
|
||||||
|
`_recompute_round_score` (individual_tournaments.py) -- kjøres etter
|
||||||
|
HVER hull-innsending uansett scoringsmetode, samme "delt funksjon
|
||||||
|
kringkaster selv"-mønster som Cup-formatets
|
||||||
|
`recompute_and_cache_match_state` (ADR-027). Gjenbruker SAMME
|
||||||
|
WebSocket-kanal (`/ws/public/tournaments/{id}/live}`) Cup-siden
|
||||||
|
allerede åpner -- ingen ny WS-infrastruktur.
|
||||||
|
- `public-tournament.tsx` sin "Program"-seksjon (viste før alltid en
|
||||||
|
tom Cup-økt-liste for individuelle turneringer) viser nå en
|
||||||
|
rundeliste med "Pågår nå" (samme fylte Radio-ikon-merke som
|
||||||
|
`watch-round.tsx`)/"Fullført"-merker.
|
||||||
|
|
||||||
|
**Gjenstår:** selve "Følg live"-siden sitt UI for individuelle
|
||||||
|
turneringer -- V0-prompt skrevet og sendt (rundevelger + leaderboard
|
||||||
|
med golfscore-formspråk fra DESIGN_SYSTEM.md, HCP synlig, mobil
|
||||||
|
først), venter på eksport. `/t/[id]/live` sin format-forgrening
|
||||||
|
(Cup uendret, individuell til den nye siden) kobles inn når
|
||||||
|
komponenten finnes.
|
||||||
|
|
||||||
|
`tsc --noEmit` rent, 55/55 vitest, ingen migrasjon (ingen nye
|
||||||
|
kolonner -- status utledes, lagres ikke).
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ from handicap_engine import (
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from ..realtime import broadcast_live_update
|
||||||
from ..hole_history import (
|
from ..hole_history import (
|
||||||
HoleHistoryOut,
|
HoleHistoryOut,
|
||||||
hole_history_for_user,
|
hole_history_for_user,
|
||||||
|
|
@ -1094,6 +1095,7 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
|
||||||
ctx = await conn.fetchrow(
|
ctx = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
||||||
|
tr.tournament_id::text AS tournament_id,
|
||||||
trp.tournament_participant_id::text AS tournament_participant_id,
|
trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
trp.playing_handicap
|
trp.playing_handicap
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
|
|
@ -1147,6 +1149,12 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
|
||||||
net_total,
|
net_total,
|
||||||
stableford_points,
|
stableford_points,
|
||||||
)
|
)
|
||||||
|
# Sanntid (2026-08-21, samme mønster som Cup-formatets
|
||||||
|
# recompute_and_cache_match_state, ADR-027) -- gjenbruker SAMME
|
||||||
|
# WebSocket-kanal (/ws/public/tournaments/{id}/live) den offentlige
|
||||||
|
# Cup-siden allerede lytter på. Ingen ny WS-infrastruktur; kun et
|
||||||
|
# "noe endret, hent på nytt"-signal, ikke selve dataene.
|
||||||
|
await broadcast_live_update(ctx["tournament_id"])
|
||||||
|
|
||||||
|
|
||||||
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
|
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
|
||||||
|
|
|
||||||
|
|
@ -28,6 +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 .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
|
||||||
|
|
@ -128,6 +129,12 @@ class PublicTournamentInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
organization_name: str
|
organization_name: str
|
||||||
status: str
|
status: str
|
||||||
|
# 2026-08-21: manglet her -- landingssiden/"Følg live"-lenken kunne
|
||||||
|
# derfor ikke skille individuell fra Cup-format offentlig, og rutet
|
||||||
|
# ALLTID til Cup-sidens /t/{id}/live (som feilet stille for en
|
||||||
|
# individuell turnering, se ARCHITECTURE_DECISIONS.md/CHANGELOG.md
|
||||||
|
# samme dag).
|
||||||
|
format_type: str
|
||||||
visibility: str
|
visibility: str
|
||||||
description: str | None
|
description: str | None
|
||||||
start_date: date | None
|
start_date: date | None
|
||||||
|
|
@ -151,7 +158,7 @@ async def get_public_tournament(
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
||||||
t.visibility, t.description, t.hero_image_key, t.join_code,
|
t.format_type::text, t.visibility, t.description, t.hero_image_key, t.join_code,
|
||||||
t.start_date, t.end_date, t.registration_deadline,
|
t.start_date, t.end_date, t.registration_deadline,
|
||||||
t.registration_capacity,
|
t.registration_capacity,
|
||||||
(SELECT count(*)::int FROM tournament_registration tr
|
(SELECT count(*)::int FROM tournament_registration tr
|
||||||
|
|
@ -182,6 +189,7 @@ async def get_public_tournament(
|
||||||
name=row["name"],
|
name=row["name"],
|
||||||
organization_name=row["organization_name"],
|
organization_name=row["organization_name"],
|
||||||
status=row["status"],
|
status=row["status"],
|
||||||
|
format_type=row["format_type"],
|
||||||
visibility=row["visibility"],
|
visibility=row["visibility"],
|
||||||
description=row["description"],
|
description=row["description"],
|
||||||
start_date=row["start_date"],
|
start_date=row["start_date"],
|
||||||
|
|
@ -221,6 +229,177 @@ async def get_public_sessions(
|
||||||
return await _fetch_sessions(conn, tournament_id)
|
return await _fetch_sessions(conn, tournament_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Individuelle turneringer, offentlig "Følg live" (2026-08-21) ---------
|
||||||
|
#
|
||||||
|
# Cup-formatets tilskuer-rolle over har eksistert siden ADR-026 -- denne
|
||||||
|
# siden manglet helt for individuelle turneringer (bevisst utsatt,
|
||||||
|
# FEATURE_BACKLOG.md "Flaggturnering Del B", 2026-08-14: "egen, større
|
||||||
|
# oppgave hvis/når etterspurt"). Samme synlighets-/kode-mønster som resten
|
||||||
|
# av filen, ingen ny mekanisme.
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTournamentRound(BaseModel):
|
||||||
|
id: str
|
||||||
|
sequence: int
|
||||||
|
name: str
|
||||||
|
course_name: str
|
||||||
|
scheduled_at: datetime | None
|
||||||
|
# Utledet (ingen status-kolonne finnes på tournament_round selv) --
|
||||||
|
# samme "har noen påbegynt et hull ennå"-logikk som
|
||||||
|
# _attach_stroke_play_columns sin today_sequence-utregning bruker
|
||||||
|
# internt, generalisert til å gjelde HVER runde (ikke bare den seneste)
|
||||||
|
# og lagt til en "alle ferdig"-gren for "completed".
|
||||||
|
status: str # "not_started" | "in_progress" | "completed"
|
||||||
|
|
||||||
|
|
||||||
|
_HOLES_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tournament_id}/rounds", response_model=list[PublicTournamentRound])
|
||||||
|
async def get_public_rounds(
|
||||||
|
tournament_id: str,
|
||||||
|
code: str | None = None,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> list[PublicTournamentRound]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
rounds = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tr.id::text, tr.sequence, tr.name, tr.hole_config::text,
|
||||||
|
c.name AS course_name, tr.scheduled_at
|
||||||
|
FROM tournament_round tr
|
||||||
|
JOIN course c ON c.id = tr.course_id
|
||||||
|
WHERE tr.tournament_id = $1
|
||||||
|
ORDER BY tr.sequence
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
progress = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT trs.tournament_round_id::text,
|
||||||
|
count(*) FILTER (WHERE trs.holes_played > 0) AS started,
|
||||||
|
count(*) FILTER (WHERE trs.holes_played >= h.hole_count) AS completed,
|
||||||
|
count(*) AS total
|
||||||
|
FROM tournament_round_score trs
|
||||||
|
JOIN tournament_round tr ON tr.id = trs.tournament_round_id
|
||||||
|
JOIN LATERAL (SELECT count(*) AS hole_count FROM hole WHERE hole.course_id = tr.course_id) h ON true
|
||||||
|
WHERE tr.tournament_id = $1
|
||||||
|
GROUP BY trs.tournament_round_id
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
progress_by_round = {r["tournament_round_id"]: r for r in progress}
|
||||||
|
|
||||||
|
def status_for(round_id: str) -> str:
|
||||||
|
p = progress_by_round.get(round_id)
|
||||||
|
if p is None or p["started"] == 0:
|
||||||
|
return "not_started"
|
||||||
|
if p["completed"] == p["total"] and p["total"] > 0:
|
||||||
|
return "completed"
|
||||||
|
return "in_progress"
|
||||||
|
|
||||||
|
return [
|
||||||
|
PublicTournamentRound(
|
||||||
|
id=r["id"],
|
||||||
|
sequence=r["sequence"],
|
||||||
|
name=r["name"] or f"Runde {r['sequence']}",
|
||||||
|
course_name=r["course_name"],
|
||||||
|
scheduled_at=r["scheduled_at"],
|
||||||
|
status=status_for(r["id"]),
|
||||||
|
)
|
||||||
|
for r in rounds
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PublicLeaderboardEntry(BaseModel):
|
||||||
|
tournament_participant_id: str
|
||||||
|
player_name: str
|
||||||
|
rounds_played: int
|
||||||
|
gross_total: int | None
|
||||||
|
net_total: int | None
|
||||||
|
stableford_total: int | None
|
||||||
|
copenhagen_total: int | None
|
||||||
|
bbb_total: int | None
|
||||||
|
class_id: str | None
|
||||||
|
class_name: str | None
|
||||||
|
position: str | None
|
||||||
|
is_leader: bool
|
||||||
|
today_label: str | None
|
||||||
|
thru_label: str | None
|
||||||
|
total_label: str | None
|
||||||
|
rank: int | None
|
||||||
|
cut: bool
|
||||||
|
status: str
|
||||||
|
eclectic_total: int | None
|
||||||
|
handicap_index: float | None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tournament_id}/individual-leaderboard", response_model=list[PublicLeaderboardEntry]
|
||||||
|
)
|
||||||
|
async def get_public_individual_leaderboard(
|
||||||
|
tournament_id: str,
|
||||||
|
code: str | None = None,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> list[PublicLeaderboardEntry]:
|
||||||
|
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)
|
||||||
|
|
||||||
|
_, entries = await _compute_individual_standings(conn, tournament_id)
|
||||||
|
|
||||||
|
# HCP-indeks hentes separat (bevisst valgt synlig her, jf. samme
|
||||||
|
# presedens som frittstående runders offentlige leaderboard) --
|
||||||
|
# LeaderboardEntry selv har den ikke, kun tournament_participant.
|
||||||
|
hcp_rows = await conn.fetch(
|
||||||
|
"SELECT id::text, handicap_index_snapshot FROM tournament_participant WHERE tournament_id = $1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
hcp_by_participant = {r["id"]: r["handicap_index_snapshot"] for r in hcp_rows}
|
||||||
|
|
||||||
|
return [
|
||||||
|
PublicLeaderboardEntry(
|
||||||
|
tournament_participant_id=e.tournament_participant_id,
|
||||||
|
player_name=e.player_name,
|
||||||
|
rounds_played=e.rounds_played,
|
||||||
|
gross_total=e.gross_total,
|
||||||
|
net_total=e.net_total,
|
||||||
|
stableford_total=e.stableford_total,
|
||||||
|
copenhagen_total=e.copenhagen_total,
|
||||||
|
bbb_total=e.bbb_total,
|
||||||
|
class_id=e.class_id,
|
||||||
|
class_name=e.class_name,
|
||||||
|
position=e.position,
|
||||||
|
is_leader=e.is_leader,
|
||||||
|
today_label=e.today_label,
|
||||||
|
thru_label=e.thru_label,
|
||||||
|
total_label=e.total_label,
|
||||||
|
rank=e.rank,
|
||||||
|
cut=e.cut,
|
||||||
|
status=e.status,
|
||||||
|
eclectic_total=e.eclectic_total,
|
||||||
|
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
|
||||||
|
if hcp_by_participant.get(e.tournament_participant_id) is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# --- 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
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Radio,
|
||||||
Send,
|
Send,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
|
@ -71,6 +72,7 @@ type ApiTournamentInfo = {
|
||||||
name: string
|
name: string
|
||||||
organization_name: string
|
organization_name: string
|
||||||
status: string
|
status: string
|
||||||
|
format_type: string
|
||||||
visibility: string
|
visibility: string
|
||||||
description: string | null
|
description: string | null
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
|
|
@ -90,6 +92,19 @@ type ApiSession = {
|
||||||
scheduled_at: string | null
|
scheduled_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Individuell turnering sitt "Program"-motstykke (2026-08-21) -- sessions
|
||||||
|
// er et rent Cup-format-konsept (`session`-tabellen), individuelle
|
||||||
|
// turneringer bruker `tournament_round` i stedet. Egen type/fetch/seksjon,
|
||||||
|
// ikke tvunget inn i Session-formen over.
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
sequence: number
|
||||||
|
name: string
|
||||||
|
course_name: string
|
||||||
|
scheduled_at: string | null
|
||||||
|
status: "not_started" | "in_progress" | "completed"
|
||||||
|
}
|
||||||
|
|
||||||
function mapOutcome(status: string): Outcome {
|
function mapOutcome(status: string): Outcome {
|
||||||
return status === "waitlisted" ? "waitlist" : (status as Outcome)
|
return status === "waitlisted" ? "waitlist" : (status as Outcome)
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +134,7 @@ function formatDateRange(start?: Date, end?: Date) {
|
||||||
export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||||
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
|
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
|
||||||
const [sessions, setSessions] = useState<Session[]>([])
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
|
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accessError, setAccessError] = useState<string | null>(null)
|
const [accessError, setAccessError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -147,6 +163,15 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setInfo(data)
|
setInfo(data)
|
||||||
|
|
||||||
|
if (data.format_type === "individual") {
|
||||||
|
const roundsRes = await fetch(`/public/tournaments/${tournamentId}/rounds${codeParam}`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (roundsRes.ok) {
|
||||||
|
const roundData: ApiRound[] = await roundsRes.json()
|
||||||
|
if (!cancelled) setRounds(roundData)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
|
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
})
|
})
|
||||||
|
|
@ -163,6 +188,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setAccessError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
|
if (!cancelled) setAccessError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -256,7 +282,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
|
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Program sessions={t.sessions} />
|
{info.format_type === "individual" ? <ProgramRounds rounds={rounds} /> : <Program sessions={t.sessions} />}
|
||||||
|
|
||||||
{info.registration_open ? (
|
{info.registration_open ? (
|
||||||
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
|
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
|
||||||
|
|
@ -664,6 +690,53 @@ function Program({ sessions }: { sessions: Session[] }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Individuell turnering sitt "Program" (2026-08-21) -- runder i stedet for
|
||||||
|
// økter. Ingen tom-tilstand-tekst manglet her fra start (i motsetning til
|
||||||
|
// Program over) siden dette er nytt.
|
||||||
|
function ProgramRounds({ rounds }: { rounds: ApiRound[] }) {
|
||||||
|
if (rounds.length === 0) {
|
||||||
|
return (
|
||||||
|
<section aria-label="Program" className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Program</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Ingen runder satt opp ennå.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section aria-label="Program" className="flex flex-col gap-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Program</h2>
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
||||||
|
{rounds.map((r) => {
|
||||||
|
const date = r.scheduled_at ? DATE_FMT.format(new Date(r.scheduled_at)) : null
|
||||||
|
return (
|
||||||
|
<li key={r.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3.5 last:border-b-0">
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-semibold text-foreground">{r.name}</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{r.course_name}
|
||||||
|
{date ? ` · ${date}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{r.status === "in_progress" && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
||||||
|
<Radio aria-hidden="true" className="size-3 animate-pulse" />
|
||||||
|
Pågår nå
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.status === "completed" && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||||||
|
<CheckCircle2 aria-hidden="true" className="size-3" />
|
||||||
|
Fullført
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Sponsors --------------------------------------------------------------
|
// --- Sponsors --------------------------------------------------------------
|
||||||
|
|
||||||
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue