diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 98d58c7..b0ba02a 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3538944..2a0f92d 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index e71cb44..c721f67 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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/ diff --git a/app/routers/registration.py b/app/routers/registration.py index 0fc3ae0..ca3ea11 100644 --- a/app/routers/registration.py +++ b/app/routers/registration.py @@ -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 diff --git a/frontend/app/t/[id]/live/page.tsx b/frontend/app/t/[id]/live/page.tsx index 05b3662..e2519d3 100644 --- a/frontend/app/t/[id]/live/page.tsx +++ b/frontend/app/t/[id]/live/page.tsx @@ -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 + } return } diff --git a/frontend/components/public-individual-live.tsx b/frontend/components/public-individual-live.tsx new file mode 100644 index 0000000..e5255e6 --- /dev/null +++ b/frontend/components/public-individual-live.tsx @@ -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 ( + + ) + } + if (tone === "even") { + return ( + + {label} + likt med par + + ) + } + if (tone == null) { + return {label} + } + + 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 ( + + {label} + {under ? " under par" : " over par"} + + ) +} + +function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) { + if (!position) { + return ( + + – + + ) + } + return ( + + {position} + + ) +} + +function StatusPill({ label }: { label: string }) { + return ( + + {label} + + ) +} + +function RoundStatusBadge({ status }: { status: RoundStatus }) { + if (status === "in_progress") { + return ( + + + ) + } + if (status === "completed") { + return ( + + + ) + } + return Ikke startet +} + +const STATUS_LABEL: Record, 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 ( +
+
+
{label}
+ {holes.map((h) => ( +
+ {h.holeNumber} +
+ ))} +
Sum
+
+
+
Par
+ {holes.map((h) => ( +
+ {h.par} +
+ ))} +
{parSum}
+
+
+
Deg
+ {holes.map((h) => + h.label ? ( + + ) : ( + + ), + )} +
{strokeSum || "–"}
+
+
+ ) +} + +function HoleGrid({ holes }: { holes: HoleResult[] }) { + if (holes.length === 0) { + return

Ingen hull registrert for denne runden ennå.

+ } + return ( +
+ + {holes.length > 9 && } +
+ ) +} + +/* ------------------------------------------------------------------ * + * 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(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 = ( + <> + + + {entry.player_name} + + {entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`} + {entry.handicap_index != null && entry.class_name ? " · " : ""} + {entry.class_name} + + + + + Thru + {entry.thru_label ?? "-"} + + + + I dag + {isStatus ? ( + + ) : ( + + )} + + + + Total + {isStatus || entry.cut ? ( + ]} /> + ) : ( + + )} + + +