Update Todos

Refaktorere get_leaderboard til delt fetch_leaderboard()

Gjøre own_team_ids null-sikker for anonyme/uinnloggede tilskuere

Refaktorere list_matches og get_scorecard til delte funksjoner

Legge til offentlige leaderboard/matches/scorecard-endepunkter i registration.py

Skrive ADR-026 (tilskuer-rolle)

Scratch-verifisere backend

Bygge frontend: offentlig live-visning (leaderboard + matcher + scorekort) på /t/[id]/live

Ekte typesjekket frontend-build

Deploy etter bekreftelse + oppdatere .md-filer
Backend og frontend er scratch-verifisert (16 automatiserte sjekker, inkl. en presis test av tenant-vs-sti-sikkerhetssjekken). Ingen migrasjon denne runden — ren backend-refaktorering + nye endepunkter + ny /t/[id]/live-side. Ingen Caddy-endring nødvendig (dekkes av eksisterende /public/*-proxy)
This commit is contained in:
Erol Haagenrud 2026-07-19 22:51:07 +02:00
parent 6774cec72b
commit bfd9633c63
11 changed files with 812 additions and 136 deletions

View file

@ -292,13 +292,18 @@
"Bash(curl -s -o /dev/null -w \"ws-route \\(uten upgrade-header\\): %{http_code}\\\\n\" https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)",
"Bash(curl -s https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)",
"Bash(curl -s -H \"Content-Type: application/json\" https://teecup.teeoff.no/health)",
"Bash(docker run --rm python:3.12-slim bash -c 'pip install -q websockets 2>/dev/null; python3 -c \" *)"
"Bash(docker run --rm python:3.12-slim bash -c 'pip install -q websockets 2>/dev/null; python3 -c \" *)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/tournaments.py'\\).read\\(\\)\\)\")",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/matches.py'\\).read\\(\\)\\)\")",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.py'\\).read\\(\\)\\)\")",
"Bash(mkdir -p /opt/teecup/frontend/app/t/\\\\[id\\\\]/live)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
"/tmp",
"/opt/teecup",
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]"
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]",
"/opt/teecup/frontend/app/t/[id]"
]
}
}

View file

@ -1101,6 +1101,64 @@ infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
---
## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort
Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
som nå finnes).
**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
trenivå-visibility + `get_current_user_optional`-mønster som all annen
offentlig lesing, gjenbrukt helt uendret.
**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
visibility-sjekk.
**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
kun leaderboard+matchliste som opprinnelig anbefalt).
**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
`None`-bruker.
**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
denne sjekken kunne noen med tilgang til én offentlig turnering i en
organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
(inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
samme klasse hull som ADR-018 Beslutning B advarte om generelt
("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
inneholde registrerte hull før reveal, selv om det ikke er normal flyt).
**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (kun nye endepunkter +
refaktorering av eksisterende spørringer til delte funksjoner). Se
CLAUDE.md-status for scratch-verifisering og utrulling.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

View file

@ -297,9 +297,9 @@
## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene)
### Brukerroller (utover org-medlemskap) — ADR-023
- **Status:**BYGGET OG LIVE 2026-07-19 — kaptein/deltaker-delen.
Tilskuer bevisst utsatt.
### Brukerroller (utover org-medlemskap) — ADR-023 + ADR-026
- **Status:**HELT FERDIG 2026-07-19 — kaptein/deltaker-delen (ADR-023)
OG tilskuer-delen (ADR-026, se eget punkt lenger ned).
- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller,
**tilskuer** (les-only, følger live uten skriverettigheter).
- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster.
@ -323,11 +323,24 @@
`user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på
laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
punktet under, spørsmål (a) er dermed besvart.
- **Tilskuer bevisst utsatt** til Kommunikasjon/Banter Board-runden (samme
begrunnelse som før: bør defineres sammen med feed-synligheten, ikke
isolert).
- Se ARCHITECTURE_DECISIONS.md ADR-023 for alle fire delbeslutningene og
CLAUDE.md-status for scratch-verifiseringen (15 automatiserte sjekker).
- **Tilskuer — ✅ BYGGET OG LIVE 2026-07-19 (ADR-026),** rett etter
Kommunikasjon (ADR-025) som gjorde feed-synligheten (som denne skulle
defineres sammen med) klar. Ingen ny rolle/tabell — «tilskuer» er ganske
enkelt enhver som kan SE turneringen (`tournament.visibility`), nå også
for LEADERBOARD, MATCH-LISTE og FULLT SCOREKORT (hull-for-hull), ikke bare
info-siden/programtider som før (`GET /public/tournaments/{id}/
leaderboard`, `.../sessions/{id}/matches`, `.../matches/{id}/scorecard`,
alle i `registration.py`, gjenbruker eksisterende
`fetch_leaderboard`/`fetch_matches`/`fetch_scorecard`). Match-listen arver
blind draw-skjuling (ADR-013) automatisk via `own_team_ids()` gjort
null-sikker for en anonym leser (tom mengde = kun avslørte matcher).
Scorekortet har en eksplisitt reveal-sjekk i tillegg. Ny `/t/[id]/live`-
side (leaderboard-bar, øktliste, matcher, hull-for-hull-scorekort),
lenket fra hovedsiden. Bruker valgte å bygge fullt scorekort med i denne
runden (utover opprinnelig anbefaling om kun leaderboard+matchliste).
- Se ARCHITECTURE_DECISIONS.md ADR-023 (kaptein/deltaker) og ADR-026
(tilskuer) for alle delbeslutningene, og CLAUDE.md-status for
scratch-verifiseringen (15 + 16 automatiserte sjekker).
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over.

View file

@ -20,7 +20,9 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]:
return {r["team_id"] for r in rows}
async def own_team_ids(conn: Connection, organization_id: str, tournament_id: str, user_id: str) -> set[str]:
async def own_team_ids(
conn: Connection, organization_id: str, tournament_id: str, user_id: str | None
) -> set[str]:
"""Hvilke lag brukeren FÅR SE deltakerne til, før begge lag har låst.
To uavhengige veier inn, samme par som team_authz.user_may_act_for_team
@ -30,7 +32,15 @@ async def own_team_ids(conn: Connection, organization_id: str, tournament_id: st
(team_authz gir dem det) men aldri SE dem igjen før reveal -- en reell
"skriv blindt" felle funnet under blind draw-skjermens integrering, ikke
bare et tenkt tilfelle.
`user_id=None` (2026-07-19, tilskuer-rolle/ADR-026): en anonym eller
ikke-tilknyttet offentlig leser har per definisjon INGEN egne lag --
korrekt oppførsel er da tom mengde (ser kun avslørte matcher), ikke en
feil. Brukt av den offentlige match-listen (registration.py).
"""
if user_id is None:
return set()
rows = await conn.fetch(
"""
SELECT DISTINCT tr.team_id::text AS team_id

View file

@ -128,16 +128,14 @@ async def create_match(
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=list[MatchOut],
)
async def list_matches(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
async def fetch_matches(
conn, organization_id: str, session_id: str, user_id: str | None
) -> list[MatchOut]:
async with org_connection(organization_id) as conn:
"""Delt mellom den innloggede list_matches under og den offentlige
/public/tournaments/{id}/sessions/{id}/matches (registration.py,
tilskuer-rolle/ADR-026). `user_id=None` for en anonym/ikke-tilknyttet
offentlig leser -- own_team_ids() håndterer det (tom mengde, ser kun
avslørte matcher, se blind_draw.py)."""
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
@ -167,7 +165,7 @@ async def list_matches(
own = (
set()
if revealed
else await own_team_ids(conn, organization_id, session["tournament_id"], user.user_id)
else await own_team_ids(conn, organization_id, session["tournament_id"], user_id)
)
participant_rows = await conn.fetch(
@ -219,6 +217,19 @@ async def list_matches(
]
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/matches",
response_model=list[MatchOut],
)
async def list_matches(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[MatchOut]:
async with org_connection(organization_id) as conn:
return await fetch_matches(conn, organization_id, session_id, user.user_id)
class ParticipantCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
team_roster_id: str

View file

@ -23,10 +23,13 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_current_user_optional
from ..blind_draw import locked_team_ids
from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
from .. import storage
from .tournaments import SessionOut, _fetch_sessions
from .matches import MatchOut, fetch_matches
from .scoring import Scorecard, fetch_scorecard
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
org_router = APIRouter(prefix="/public/orgs", tags=["public-registration"])
@ -229,6 +232,109 @@ async def get_public_sessions(
return await _fetch_sessions(conn, tournament_id)
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
#
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
# mekanisme. Leaderboard har ingen spillernivå-data i det hele tatt (kun
# lag-id/-navn/-farge/poeng), så INGEN ekstra reveal-sjekk trengs der.
# Match-listen arver blind draw-skjulingen (ADR-013) automatisk via
# fetch_matches() sin egen own_team_ids()-håndtering (tom mengde for en
# anonym/uinnloggede leser -- ser bare avslørte matcher, akkurat som en
# tilfeldig org-medlem uten roster ville gjort). Scorekortet trenger en
# EKSPLISITT reveal-sjekk her (matchen kan i prinsippet ha registrerte hull
# før begge lag har låst -- ikke normal flyt, men ikke teknisk umulig).
@router.get("/{tournament_id}/leaderboard", response_model=Leaderboard)
async def get_public_leaderboard(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Leaderboard:
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)
return await fetch_leaderboard(conn, tournament_id)
@router.get(
"/{tournament_id}/sessions/{session_id}/matches", response_model=list[MatchOut]
)
async def get_public_matches(
tournament_id: str,
session_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[MatchOut]:
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)
# Sikkerhetskritisk: session_id må faktisk høre til DENNE turneringen
# -- org_connection() setter kun tenant-grensen (RLS), ikke at stiens
# to id-er faktisk henger sammen. Uten denne sjekken kunne noen med
# kode/tilgang til én offentlig turnering lese en HVILKEN SOM HELST
# økt i samme organisasjon (inkl. en privat en) ved å gjette id-en.
session_tournament = await conn.fetchval(
"SELECT tournament_id::text FROM session WHERE id = $1", session_id
)
if session_tournament != tournament_id:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke i denne turneringen.")
user_id = user.user_id if user is not None else None
return await fetch_matches(conn, organization_id, session_id, user_id)
@router.get("/{tournament_id}/matches/{match_id}/scorecard", response_model=Scorecard)
async def get_public_scorecard(
tournament_id: str,
match_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> Scorecard:
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)
# Samme tenant-vs-sti-konsistenssjekk som matchlisten over, PLUSS en
# eksplisitt reveal-sjekk (blind draw, ADR-013) -- scorekortet skal
# aldri kunne leses offentlig før laget selv har avslørt oppstillingen.
match_row = await conn.fetchrow(
"""
SELECT s.tournament_id::text AS tournament_id, m.session_id::text AS session_id
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match_row is None or match_row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke i denne turneringen.")
locked = await locked_team_ids(conn, match_row["session_id"])
if len(locked) < 2:
raise app_error(403, "NOT_VISIBLE", "Denne matchen er ikke avslørt ennå.")
return await fetch_scorecard(conn, match_id)
class RegisterRequest(BaseModel):
display_name: str = Field(min_length=1, max_length=200)
email: EmailStr | None = None

View file

@ -533,15 +533,12 @@ class Scorecard(BaseModel):
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Scorecard:
async with org_connection(organization_id) as conn:
async def fetch_scorecard(conn, match_id: str) -> Scorecard:
"""Delt mellom den innloggede get_scorecard under og den offentlige
/public/tournaments/{id}/matches/{id}/scorecard (registration.py,
tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
match = await conn.fetchrow(
"""
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
@ -592,3 +589,15 @@ async def get_scorecard(
points_side_b=match["points_side_b"],
holes=holes,
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/scorecard",
response_model=Scorecard,
)
async def get_scorecard(
match_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Scorecard:
async with org_connection(organization_id) as conn:
return await fetch_scorecard(conn, match_id)

View file

@ -1047,15 +1047,13 @@ class Leaderboard(BaseModel):
sessions: list[SessionStanding]
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
response_model=Leaderboard,
)
async def get_leaderboard(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Leaderboard:
async with org_connection(organization_id) as conn:
async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
"""Delt mellom den innloggede (get_leaderboard under) og den offentlige
/public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
poeng), ingen ekstra synlighetsfiltrering trengs utover den vanlige
tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
kalles."""
teams = await conn.fetch(
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
tournament_id,
@ -1154,3 +1152,15 @@ async def get_leaderboard(
matches_decided=matches_decided,
sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
)
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
response_model=Leaderboard,
)
async def get_leaderboard(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
) -> Leaderboard:
async with org_connection(organization_id) as conn:
return await fetch_leaderboard(conn, tournament_id)

View file

@ -0,0 +1,13 @@
import { PublicLive } from "@/components/public-live"
export default async function PublicLivePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ code?: string }>
}) {
const { id } = await params
const { code } = await searchParams
return <PublicLive tournamentId={id} code={code} />
}

View file

@ -0,0 +1,425 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- Typer (matcher app/routers/tournaments.py/matches.py/scoring.py) ------
type TeamStanding = {
team_id: string
team_name: string
color: string | null
points: number
projected_points: number
}
type SessionStanding = {
session_id: string
sequence: number
name: string | null
points_by_team: Record<string, number>
projected_points_by_team: Record<string, number>
matches_total: number
matches_decided: number
}
type Leaderboard = {
teams: TeamStanding[]
matches_total: number
matches_decided: number
sessions: SessionStanding[]
}
type ApiSession = {
id: string
sequence: number
name: string | null
format: string
scoring_mode: string
revealed: boolean
}
type ApiParticipant = { id: string; team_side: "a" | "b"; player_name: string; tee_name: string }
type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
status_text: string | null
leading_side: "a" | "b" | null
participants: ApiParticipant[]
}
type ApiScorecardHole = { hole_number: number; result: "a" | "b" | "halved" }
type ApiScorecard = {
match_id: string
status_text: string | null
holes: ApiScorecardHole[]
}
const FORMAT_LABELS: Record<string, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}
export function PublicLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null)
const [tournamentName, setTournamentName] = useState("")
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null)
const [sessions, setSessions] = useState<ApiSession[]>([])
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
useEffect(() => {
let cancelled = false
async function load() {
try {
const infoRes = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { credentials: "include" })
if (infoRes.status === 403 || infoRes.status === 404) {
const body = await infoRes.json().catch(() => null)
if (!cancelled) {
setAccessError(
body?.detail?.message ??
(infoRes.status === 404 ? "Turneringen finnes ikke." : "Du har ikke tilgang til denne turneringen."),
)
}
return
}
if (!infoRes.ok) throw new Error(`info: ${infoRes.status}`)
const info: { name: string } = await infoRes.json()
if (cancelled) return
setTournamentName(info.name)
const [lbRes, sessionsRes] = await Promise.all([
fetch(`/public/tournaments/${tournamentId}/leaderboard${codeParam}`, { credentials: "include" }),
fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, { credentials: "include" }),
])
if (lbRes.ok) {
const lb: Leaderboard = await lbRes.json()
if (!cancelled) setLeaderboard(lb)
}
if (sessionsRes.ok) {
const s: ApiSession[] = await sessionsRes.json()
if (!cancelled) setSessions(s.sort((a, b) => a.sequence - b.sequence))
}
} catch {
if (!cancelled) setAccessError("Klarte ikke å laste stillingen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [tournamentId, codeParam])
const backHref = `/t/${tournamentId}${code ? `?code=${encodeURIComponent(code)}` : ""}`
if (loading) {
return (
<div className="flex min-h-[100dvh] 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>
)
}
if (accessError) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
</div>
)
}
const teamsById = new Map(leaderboard?.teams.map((t) => [t.team_id, t]) ?? [])
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href={backHref}
aria-label="Tilbake til turneringssiden"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Følg live
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
</div>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
{leaderboard && leaderboard.teams.length === 2 && <LeaderboardCard leaderboard={leaderboard} />}
<section aria-label="Økter og matcher" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Matcher</h2>
{sessions.length === 0 && (
<p className="text-sm text-muted-foreground">Ingen økter opprettet ennå.</p>
)}
{sessions.map((s) => (
<SessionRow
key={s.id}
session={s}
tournamentId={tournamentId}
codeParam={codeParam}
teamsById={teamsById}
/>
))}
</section>
</div>
</main>
</div>
)
}
// --- Leaderboard -------------------------------------------------------------
function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) {
const [a, b] = leaderboard.teams
return (
<section
aria-label="Stilling"
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5"
>
<div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-primary" />
<h2 className="text-base font-bold text-foreground">Stilling</h2>
</div>
<StandingBar label="Nå" a={a} b={b} aValue={a.points} bValue={b.points} />
<StandingBar label="Projisert (hvis pågående matcher holder seg)" a={a} b={b} aValue={a.projected_points} bValue={b.projected_points} />
<p className="text-xs text-muted-foreground">
{leaderboard.matches_decided} av {leaderboard.matches_total} matcher avgjort
</p>
</section>
)
}
function StandingBar({
label,
a,
b,
aValue,
bValue,
}: {
label: string
a: TeamStanding
b: TeamStanding
aValue: number
bValue: number
}) {
const total = aValue + bValue
const aPct = total > 0 ? (aValue / total) * 100 : 50
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<div className="flex items-center justify-between text-sm font-bold text-foreground">
<span>{a.team_name} · {formatPoints(aValue)}</span>
<span>{formatPoints(bValue)} · {b.team_name}</span>
</div>
<div className="flex h-3 w-full overflow-hidden rounded-full bg-muted">
<div style={{ width: `${aPct}%`, backgroundColor: a.color ?? "#64748b" }} />
<div style={{ width: `${100 - aPct}%`, backgroundColor: b.color ?? "#64748b" }} />
</div>
</div>
)
}
// --- Session/match-liste -----------------------------------------------------
function SessionRow({
session,
tournamentId,
codeParam,
teamsById,
}: {
session: ApiSession
tournamentId: string
codeParam: string
teamsById: Map<string, TeamStanding>
}) {
const [open, setOpen] = useState(false)
const [matches, setMatches] = useState<ApiMatch[] | null>(null)
const [loadingMatches, setLoadingMatches] = useState(false)
const title = session.name?.trim() || `Økt ${session.sequence}`
async function toggle() {
const next = !open
setOpen(next)
if (next && matches === null) {
setLoadingMatches(true)
try {
const res = await fetch(
`/public/tournaments/${tournamentId}/sessions/${session.id}/matches${codeParam}`,
{ credentials: "include" },
)
if (res.ok) setMatches(await res.json())
} finally {
setLoadingMatches(false)
}
}
}
return (
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="flex w-full items-center justify-between gap-2 px-4 py-3.5 text-left"
>
<div className="flex flex-col">
<span className="font-semibold text-foreground">{title}</span>
<span className="text-xs text-muted-foreground">
{FORMAT_LABELS[session.format] ?? session.format}
{!session.revealed && " · ikke avslørt ennå"}
</span>
</div>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="flex flex-col gap-2 border-t border-border p-3">
{loadingMatches && (
<div className="flex justify-center py-4">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
)}
{matches?.length === 0 && <p className="px-1 py-2 text-sm text-muted-foreground">Ingen matcher opprettet ennå.</p>}
{matches?.map((m) => (
<MatchRow
key={m.id}
match={m}
tournamentId={tournamentId}
codeParam={codeParam}
teamA={teamsById.get(m.team_a_id)}
teamB={teamsById.get(m.team_b_id)}
/>
))}
</div>
)}
</div>
)
}
function MatchRow({
match,
tournamentId,
codeParam,
teamA,
teamB,
}: {
match: ApiMatch
tournamentId: string
codeParam: string
teamA?: TeamStanding
teamB?: TeamStanding
}) {
const [showScorecard, setShowScorecard] = useState(false)
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
const [loadingScorecard, setLoadingScorecard] = useState(false)
const leadingColor = match.leading_side === "a" ? teamA?.color : match.leading_side === "b" ? teamB?.color : undefined
async function toggleScorecard() {
const next = !showScorecard
setShowScorecard(next)
if (next && scorecard === null) {
setLoadingScorecard(true)
try {
const res = await fetch(
`/public/tournaments/${tournamentId}/matches/${match.id}/scorecard${codeParam}`,
{ credentials: "include" },
)
if (res.ok) setScorecard(await res.json())
} finally {
setLoadingScorecard(false)
}
}
}
return (
<div
className="overflow-hidden rounded-xl border border-border"
style={{ borderLeftWidth: 4, borderLeftColor: leadingColor ?? "transparent" }}
>
<button type="button" onClick={toggleScorecard} className="flex w-full flex-col gap-1.5 px-3 py-2.5 text-left">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">
{teamA?.team_name ?? "Lag A"} vs. {teamB?.team_name ?? "Lag B"}
</span>
{match.status_text && (
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-foreground">
{match.status_text}
</span>
)}
</div>
{match.participants.length > 0 && (
<span className="truncate text-xs text-muted-foreground">
{match.participants.map((p) => p.player_name).join(" · ")}
</span>
)}
</button>
{showScorecard && (
<div className="border-t border-border p-3">
{loadingScorecard ? (
<div className="flex justify-center py-3">
<div
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
) : scorecard && scorecard.holes.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{scorecard.holes.map((h) => {
const color = h.result === "a" ? teamA?.color : h.result === "b" ? teamB?.color : undefined
return (
<li
key={h.hole_number}
title={`Hull ${h.hole_number}`}
className="flex size-8 items-center justify-center rounded-lg text-xs font-bold text-primary-foreground"
style={{ backgroundColor: color ?? "var(--muted)" }}
>
<span className={color ? "" : "text-muted-foreground"}>{h.hole_number}</span>
</li>
)
})}
</ul>
) : (
<p className="text-xs text-muted-foreground">Ingen hull registrert ennå.</p>
)}
</div>
)}
</div>
)
}

View file

@ -2,10 +2,12 @@
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import {
Calendar,
Users,
ChevronDown,
ChevronRight,
CheckCircle2,
Clock3,
Hourglass,
@ -14,6 +16,7 @@ import {
MessageSquare,
Send,
ShieldAlert,
Trophy,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@ -238,6 +241,19 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
registrationOpen={info.registration_open}
/>
<Link
href={`/t/${tournamentId}/live${codeParam}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:bg-accent/40"
>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Trophy aria-hidden="true" className="size-5 text-primary" />
</div>
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
</div>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</Link>
<Program sessions={t.sessions} />
{info.registration_open ? (