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:
parent
6774cec72b
commit
bfd9633c63
11 changed files with 812 additions and 136 deletions
|
|
@ -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 -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 https://teecup.teeoff.no/ws/orgs/test/teams/test/messages)",
|
||||||
"Bash(curl -s -H \"Content-Type: application/json\" https://teecup.teeoff.no/health)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"/opt/teecup",
|
"/opt/teecup",
|
||||||
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]"
|
"/opt/teecup/frontend/app/tournaments/[id]/teams/[teamId]",
|
||||||
|
"/opt/teecup/frontend/app/t/[id]"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
|
|
@ -297,9 +297,9 @@
|
||||||
|
|
||||||
## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene)
|
## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene)
|
||||||
|
|
||||||
### Brukerroller (utover org-medlemskap) — ADR-023
|
### Brukerroller (utover org-medlemskap) — ADR-023 + ADR-026
|
||||||
- **Status:** ✅ BYGGET OG LIVE 2026-07-19 — kaptein/deltaker-delen.
|
- **Status:** ✅ HELT FERDIG 2026-07-19 — kaptein/deltaker-delen (ADR-023)
|
||||||
Tilskuer bevisst utsatt.
|
OG tilskuer-delen (ADR-026, se eget punkt lenger ned).
|
||||||
- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller,
|
- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller,
|
||||||
**tilskuer** (les-only, følger live uten skriverettigheter).
|
**tilskuer** (les-only, følger live uten skriverettigheter).
|
||||||
- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster.
|
- 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å
|
`user_is_match_participant`, brukt av `scoring.py`) — ikke lenger «noen på
|
||||||
laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
|
laget», og uavhengig av kapteinmerket. Erstatter «Scoring-autorisasjon»-
|
||||||
punktet under, spørsmål (a) er dermed besvart.
|
punktet under, spørsmål (a) er dermed besvart.
|
||||||
- **Tilskuer bevisst utsatt** til Kommunikasjon/Banter Board-runden (samme
|
- **Tilskuer — ✅ BYGGET OG LIVE 2026-07-19 (ADR-026),** rett etter
|
||||||
begrunnelse som før: bør defineres sammen med feed-synligheten, ikke
|
Kommunikasjon (ADR-025) som gjorde feed-synligheten (som denne skulle
|
||||||
isolert).
|
defineres sammen med) klar. Ingen ny rolle/tabell — «tilskuer» er ganske
|
||||||
- Se ARCHITECTURE_DECISIONS.md ADR-023 for alle fire delbeslutningene og
|
enkelt enhver som kan SE turneringen (`tournament.visibility`), nå også
|
||||||
CLAUDE.md-status for scratch-verifiseringen (15 automatiserte sjekker).
|
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)
|
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
|
||||||
- **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over.
|
- **Status:** ❓ delvis avgjort — direkte oppfølger av «Brukerroller» over.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ async def locked_team_ids(conn: Connection, session_id: str) -> set[str]:
|
||||||
return {r["team_id"] for r in rows}
|
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.
|
"""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
|
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
|
(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
|
"skriv blindt" felle funnet under blind draw-skjermens integrering, ikke
|
||||||
bare et tenkt tilfelle.
|
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(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT DISTINCT tr.team_id::text AS team_id
|
SELECT DISTINCT tr.team_id::text AS team_id
|
||||||
|
|
|
||||||
|
|
@ -128,64 +128,62 @@ async def create_match(
|
||||||
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def fetch_matches(
|
||||||
"/orgs/{organization_id}/sessions/{session_id}/matches",
|
conn, organization_id: str, session_id: str, user_id: str | None
|
||||||
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]:
|
) -> list[MatchOut]:
|
||||||
async with org_connection(organization_id) as conn:
|
"""Delt mellom den innloggede list_matches under og den offentlige
|
||||||
session = await conn.fetchrow(
|
/public/tournaments/{id}/sessions/{id}/matches (registration.py,
|
||||||
"""
|
tilskuer-rolle/ADR-026). `user_id=None` for en anonym/ikke-tilknyttet
|
||||||
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
offentlig leser -- own_team_ids() håndterer det (tom mengde, ser kun
|
||||||
FROM session WHERE id = $1
|
avslørte matcher, se blind_draw.py)."""
|
||||||
""",
|
session = await conn.fetchrow(
|
||||||
session_id,
|
"""
|
||||||
)
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
||||||
if session is None:
|
FROM session WHERE id = $1
|
||||||
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
|
||||||
matches = await conn.fetch(
|
matches = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
status_text, points_side_a::float AS points_side_a,
|
||||||
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
||||||
FROM match
|
FROM match
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
)
|
)
|
||||||
match_ids = [m["id"] for m in matches]
|
match_ids = [m["id"] for m in matches]
|
||||||
|
|
||||||
locked = await locked_team_ids(conn, session_id)
|
locked = await locked_team_ids(conn, session_id)
|
||||||
revealed = len(locked) >= 2
|
revealed = len(locked) >= 2
|
||||||
# Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant).
|
# Tomt sett hvis revealed (ubrukt da $2=true gjør ANY-leddet irrelevant).
|
||||||
own = (
|
own = (
|
||||||
set()
|
set()
|
||||||
if revealed
|
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(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
|
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
|
||||||
mp.team_roster_id::text, p.display_name AS player_name,
|
mp.team_roster_id::text, p.display_name AS player_name,
|
||||||
mp.tee_id::text, tee.name AS tee_name
|
mp.tee_id::text, tee.name AS tee_name
|
||||||
FROM match_participant mp
|
FROM match_participant mp
|
||||||
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
||||||
JOIN player p ON p.id = tr.player_id
|
JOIN player p ON p.id = tr.player_id
|
||||||
JOIN tee ON tee.id = mp.tee_id
|
JOIN tee ON tee.id = mp.tee_id
|
||||||
WHERE mp.match_id = ANY($1::uuid[])
|
WHERE mp.match_id = ANY($1::uuid[])
|
||||||
AND ($2 OR tr.team_id::text = ANY($3::text[]))
|
AND ($2 OR tr.team_id::text = ANY($3::text[]))
|
||||||
""",
|
""",
|
||||||
match_ids,
|
match_ids,
|
||||||
revealed,
|
revealed,
|
||||||
list(own),
|
list(own),
|
||||||
)
|
)
|
||||||
|
|
||||||
by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids}
|
by_match: dict[str, list[MatchParticipantOut]] = {mid: [] for mid in match_ids}
|
||||||
for r in participant_rows:
|
for r in participant_rows:
|
||||||
|
|
@ -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):
|
class ParticipantCreate(BaseModel):
|
||||||
team_side: str = Field(pattern="^[ab]$")
|
team_side: str = Field(pattern="^[ab]$")
|
||||||
team_roster_id: str
|
team_roster_id: str
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,13 @@ from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from ..auth import CurrentUser, get_current_user_optional
|
from ..auth import CurrentUser, get_current_user_optional
|
||||||
|
from ..blind_draw import locked_team_ids
|
||||||
from ..db import org_connection, plain_connection
|
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 .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"])
|
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
||||||
org_router = APIRouter(prefix="/public/orgs", 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)
|
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):
|
class RegisterRequest(BaseModel):
|
||||||
display_name: str = Field(min_length=1, max_length=200)
|
display_name: str = Field(min_length=1, max_length=200)
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
|
|
|
||||||
|
|
@ -533,51 +533,48 @@ class Scorecard(BaseModel):
|
||||||
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def fetch_scorecard(conn, match_id: str) -> Scorecard:
|
||||||
"/orgs/{organization_id}/matches/{match_id}/scorecard",
|
"""Delt mellom den innloggede get_scorecard under og den offentlige
|
||||||
response_model=Scorecard,
|
/public/tournaments/{id}/matches/{id}/scorecard (registration.py,
|
||||||
)
|
tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
|
||||||
async def get_scorecard(
|
offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
|
||||||
match_id: str,
|
tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
|
||||||
organization_id: str = Depends(get_authorized_org),
|
match = await conn.fetchrow(
|
||||||
) -> Scorecard:
|
"""
|
||||||
async with org_connection(organization_id) as conn:
|
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
|
||||||
match = await conn.fetchrow(
|
m.points_side_b::float AS points_side_b,
|
||||||
|
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
|
||||||
|
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
|
||||||
|
FROM match m JOIN session s ON s.id = m.session_id
|
||||||
|
WHERE m.id = $1
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
||||||
|
results = await _compute_hole_results(conn, match_id, match)
|
||||||
|
|
||||||
|
stroke_entries = None
|
||||||
|
hole_result_entries = None
|
||||||
|
if match["scoring_mode"] == "stroke":
|
||||||
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
|
SELECT hole_number, team_side::text AS team_side,
|
||||||
m.points_side_b::float AS points_side_b,
|
match_participant_id::text AS match_participant_id, gross_strokes
|
||||||
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
|
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
|
||||||
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
|
|
||||||
FROM match m JOIN session s ON s.id = m.session_id
|
|
||||||
WHERE m.id = $1
|
|
||||||
""",
|
""",
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
if match is None:
|
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
|
||||||
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
else:
|
||||||
results = await _compute_hole_results(conn, match_id, match)
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
stroke_entries = None
|
SELECT hole_number, winning_side::text AS winning_side
|
||||||
hole_result_entries = None
|
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
|
||||||
if match["scoring_mode"] == "stroke":
|
""",
|
||||||
rows = await conn.fetch(
|
match_id,
|
||||||
"""
|
)
|
||||||
SELECT hole_number, team_side::text AS team_side,
|
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
||||||
match_participant_id::text AS match_participant_id, gross_strokes
|
|
||||||
FROM hole_score WHERE match_id = $1 ORDER BY hole_number
|
|
||||||
""",
|
|
||||||
match_id,
|
|
||||||
)
|
|
||||||
stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
|
|
||||||
else:
|
|
||||||
rows = await conn.fetch(
|
|
||||||
"""
|
|
||||||
SELECT hole_number, winning_side::text AS winning_side
|
|
||||||
FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
|
|
||||||
""",
|
|
||||||
match_id,
|
|
||||||
)
|
|
||||||
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = _played_hole_numbers(match["hole_config"])
|
||||||
holes = [
|
holes = [
|
||||||
|
|
@ -592,3 +589,15 @@ async def get_scorecard(
|
||||||
points_side_b=match["points_side_b"],
|
points_side_b=match["points_side_b"],
|
||||||
holes=holes,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1047,36 +1047,34 @@ class Leaderboard(BaseModel):
|
||||||
sessions: list[SessionStanding]
|
sessions: list[SessionStanding]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
|
"""Delt mellom den innloggede (get_leaderboard under) og den offentlige
|
||||||
response_model=Leaderboard,
|
/public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
|
||||||
)
|
ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
|
||||||
async def get_leaderboard(
|
poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige
|
||||||
tournament_id: str,
|
tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
|
||||||
organization_id: str = Depends(get_authorized_org),
|
kalles."""
|
||||||
) -> Leaderboard:
|
teams = await conn.fetch(
|
||||||
async with org_connection(organization_id) as conn:
|
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
teams = await conn.fetch(
|
tournament_id,
|
||||||
"SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
|
)
|
||||||
tournament_id,
|
if len(teams) != 2:
|
||||||
)
|
raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
|
||||||
if len(teams) != 2:
|
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")
|
|
||||||
|
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT s.id::text AS session_id, s.sequence, s.name,
|
SELECT s.id::text AS session_id, s.sequence, s.name,
|
||||||
s.points_per_match::float AS points_per_match,
|
s.points_per_match::float AS points_per_match,
|
||||||
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
||||||
m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
|
m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b,
|
||||||
m.leading_side
|
m.leading_side
|
||||||
FROM match m
|
FROM match m
|
||||||
JOIN session s ON s.id = m.session_id
|
JOIN session s ON s.id = m.session_id
|
||||||
WHERE s.tournament_id = $1
|
WHERE s.tournament_id = $1
|
||||||
ORDER BY s.sequence, m.sequence
|
ORDER BY s.sequence, m.sequence
|
||||||
""",
|
""",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
||||||
team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
|
||||||
|
|
@ -1154,3 +1152,15 @@ async def get_leaderboard(
|
||||||
matches_decided=matches_decided,
|
matches_decided=matches_decided,
|
||||||
sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
|
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)
|
||||||
|
|
|
||||||
13
frontend/app/t/[id]/live/page.tsx
Normal file
13
frontend/app/t/[id]/live/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
425
frontend/components/public-live.tsx
Normal file
425
frontend/components/public-live.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Users,
|
Users,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
Hourglass,
|
Hourglass,
|
||||||
|
|
@ -14,6 +16,7 @@ import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Send,
|
Send,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Trophy,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -238,6 +241,19 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
registrationOpen={info.registration_open}
|
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} />
|
<Program sessions={t.sessions} />
|
||||||
|
|
||||||
{info.registration_open ? (
|
{info.registration_open ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue