diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9b062..3538944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14108,3 +14108,73 @@ Neste steg: trenger spillere lagt til på nytt via "Spillere"/"Grupper og startliste" -- de gamle deltaker-radene forsvant da Klubbmesterskap 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). diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 51ec918..793d554 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -68,6 +68,7 @@ from handicap_engine import ( from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors +from ..realtime import broadcast_live_update from ..hole_history import ( HoleHistoryOut, 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( """ 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.playing_handicap FROM tournament_round_participant trp @@ -1147,6 +1149,12 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str, net_total, 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: diff --git a/app/routers/registration.py b/app/routers/registration.py index 5b0e8f4..0fc3ae0 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -28,6 +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 .matches import MatchOut, fetch_matches from .scoring import Scorecard, fetch_scorecard from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard @@ -128,6 +129,12 @@ class PublicTournamentInfo(BaseModel): name: str organization_name: 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 description: str | None start_date: date | None @@ -151,7 +158,7 @@ async def get_public_tournament( row = await conn.fetchrow( """ 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.registration_capacity, (SELECT count(*)::int FROM tournament_registration tr @@ -182,6 +189,7 @@ async def get_public_tournament( name=row["name"], organization_name=row["organization_name"], status=row["status"], + format_type=row["format_type"], visibility=row["visibility"], description=row["description"], start_date=row["start_date"], @@ -221,6 +229,177 @@ async def get_public_sessions( 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 ------------- # # Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx index f0ddf0e..36483cb 100644 --- a/frontend/components/public-tournament.tsx +++ b/frontend/components/public-tournament.tsx @@ -15,6 +15,7 @@ import { ImagePlus, Lock, MessageSquare, + Radio, Send, ShieldAlert, Trophy, @@ -71,6 +72,7 @@ type ApiTournamentInfo = { name: string organization_name: string status: string + format_type: string visibility: string description: string | null start_date: string | null @@ -90,6 +92,19 @@ type ApiSession = { 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 { 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 }) { const [info, setInfo] = useState(null) const [sessions, setSessions] = useState([]) + const [rounds, setRounds] = useState([]) const [loading, setLoading] = useState(true) const [accessError, setAccessError] = useState(null) @@ -147,20 +163,30 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string; if (cancelled) return setInfo(data) - const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, { - credentials: "include", - }) - if (sessionsRes.ok) { - const sessionData: ApiSession[] = await sessionsRes.json() - if (!cancelled) { - setSessions( - sessionData.map((s) => ({ - id: s.id, - name: s.name, - format: s.format, - time: s.scheduled_at ?? undefined, - })), - ) + 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}`, { + credentials: "include", + }) + if (sessionsRes.ok) { + const sessionData: ApiSession[] = await sessionsRes.json() + if (!cancelled) { + setSessions( + sessionData.map((s) => ({ + id: s.id, + name: s.name, + format: s.format, + time: s.scheduled_at ?? undefined, + })), + ) + } } } } catch { @@ -256,7 +282,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;