Alt er ferdig og live. Kort oppsummering av hele runden:

Backend (ADR-020):

tournament.join_code — kort, unik kode generert automatisk, overstyrer synlighet
match.leading_side — cachet ledende side, oppdateres ved hvert hull
Leaderboardet fikk projected_points — hva stillingen blir om pågående matcher holder seg
Ny migrasjon 011, kjørt mot ekte teecup_db, test_isolation.sql 12/12
Frontend:

Login-skjermen har nå et kode-felt som tar deg rett til turneringen, uten innlogging
Invitasjonskoden vises i turnering-detalj med kopier-knapp
Leaderboardet har to fargesegmenterte barer øverst — faktisk stilling og projisert stilling
Matchlisten i blind draw er fargekodet etter hvem som leder
Underveis oppsto en reell hendelse: en feilformulert kommando eksponerte teeoff_db sitt superbrukerpassord. Det ble flagget umiddelbart, du valgte å rotere det, og det ble gjort trygt uten at verdien noensinne ble vist på nytt — bekreftet med rene logger og 200 på både teeoff.no og teecup.teeoff.no etterpå.

Alt er verifisert i scratch før utrulling, typesjekket build kjørt, og .md-filene (CLAUDE.md, FEATURE_BACKLOG.md, ARCHITECTURE_DECISIONS.md) er oppdatert. Jeg kommuniserer på norsk videre i dette prosjektet.
This commit is contained in:
Erol Haagenrud 2026-07-19 09:33:34 +02:00
parent 8821bbd448
commit 317e24a800
9 changed files with 467 additions and 45 deletions

View file

@ -276,7 +276,8 @@
"Bash(grep -v '^$')", "Bash(grep -v '^$')",
"Bash(grep -rl \"POSTGRES_PASSWORD\" /opt/teeoff --include='*.yml' --include='*.env*' 2>/dev/null)", "Bash(grep -rl \"POSTGRES_PASSWORD\" /opt/teeoff --include='*.yml' --include='*.env*' 2>/dev/null)",
"Bash(grep -n \"^ [a-zA-Z0-9_-]*:$\" /opt/teeoff/docker-compose.prod.yml)", "Bash(grep -n \"^ [a-zA-Z0-9_-]*:$\" /opt/teeoff/docker-compose.prod.yml)",
"Bash(set -e)" "Bash(set -e)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/dashboard)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

140
CLAUDE.md
View file

@ -1068,15 +1068,139 @@ Ferdig og verifisert:
ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no` ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no`
upåvirket. upåvirket.
- **Invitasjonskode + ledende side + projisert stilling, BACKEND LIVE
(2026-07-19, ADR-020):** brukeren reiste tre relaterte hull rett etter at
"bygg i rekkefølgen ting brukes"-serien var ferdig: (1) ingen vei inn til
en turnering for en spiller som bare har fått muntlig beskjed, (2)
leaderboardet viser kun faktisk opptjente poeng, ikke hva stillingen ville
blitt om pågående matcher holder seg, (3) ingen fargekoding i matchlister
for hvem som leder. Full ADR-020 skrevet (4 delbeslutninger, se
ARCHITECTURE_DECISIONS.md) — nøkkelbeslutning bekreftet eksplisitt av
bruker FØR bygging: en invitasjonskode OVERSTYRER `tournament.visibility`
helt (koden ER selve invitasjonen, ikke en snarvei som fortsatt krever
eksisterende tilgang).
Ny migrasjon `011_join_code_and_leading_side.sql`: `tournament.join_code`
(6 tegn, alfabet uten 0/O/1/I, globalt unikt, backfylt for eksisterende
rader), `match.leading_side` (cachet fortegn av `MatchState.lead`, samme
mønster som `status_text`/`points_side_a/b`), fjerde
`SECURITY DEFINER`-bro `public_tournament_by_code()` (etter
`public_tournament_org` 007, `link_player_by_email` 008,
`public_org_by_slug` 009).
**Backend bygget:** `create_tournament` genererer koden (retry-løkke ved
kollisjon, astronomisk usannsynlig med 33^6 kombinasjoner). Ny
`GET /public/tournaments/by-code/{code}` (MÅ registreres FØR
`/{tournament_id}` i routeren, ellers tolkes "by-code" som en ugyldig
UUID). `GET /public/tournaments/{id}`, `GET .../sessions` og
`POST .../register` godtar alle en valgfri `code`-parameter som — når den
matcher — hopper `_check_visibility()` helt over.
`recompute_and_cache_match_state` (scoring.py) cacher nå `leading_side`
ved HVER hull-innsending, ikke bare ved avgjørelse. Leaderboard-
endepunktet fikk `projected_points`/`projected_points_by_team`:
avgjorte matcher bidrar likt til faktisk og projisert, ikke-avgjorte gir
hele `points_per_match` til `leading_side` (delt 0,5/0,5 ved "AS"/ikke
startet) — speiler hvordan Ryder Cup-TV-dekning viser "hvis det sluttet
nå".
**Reell hendelse underveis, håndtert transparent (ikke skjult):** en
feilformulert `docker exec teeoff_db env | grep -i POSTGRES`-kommando
(ment å liste variabelNAVN) fanget opp `POSTGRES_PASSWORD` sin VERDI også,
siden selve nøkkelnavnet matchet søkemønsteret — eksponerte `teeoff_db`
sitt superbruker-passord (`teeoff_admin`) i verktøyresultatet. Alvorligere
enn de to tidligere passord-hendelsene i prosjektet siden dette er
superbrukeren for HELE den delte Postgres-klyngen (teeoff OG teecup), ikke
en enkelt tjeneste-credential. Flagget til bruker umiddelbart, som valgte
å rotere. Rotert trygt UTEN å noensinne re-eksponere gammel ELLER ny verdi
i noe synlig kommandoresultat: `ALTER ROLE` kjørt via lokal
Unix-socket-`trust`-auth (bekreftet ved å lese `pg_hba.conf`, ingen
hemmelighet involvert i den sjekken) — krevde altså IKKE det gamle
passordet i det hele tatt. Nytt passord generert med `openssl rand -hex
32` (hex, ikke base64 — unngår SAMME klasse URL-enkodings-felle som
`TEECUP_DATABASE_URL`-hendelsen tidligere, siden verdien også ligger i en
`postgres://`-DSN). `/opt/teeoff/.env` sine TO forekomster
(`POSTGRES_PASSWORD` og `DATABASE_URL`) oppdatert med `sed`-mønstre som
ALDRI leser/skriver ut den gamle verdien. `teeoff_api`/`teeoff_worker`
service-nøklene i `docker-compose.prod.yml` viste seg å hete `api`/
`worker` (ikke `teeoff_api`/`teeoff_worker` — det er kun
`container_name`), samme "service-nøkkel ≠ container-navn"-fallgruve som
nettverksalias-hendelsen fra containeriseringsrunden. `docker compose up
-d --force-recreate api worker` gjenskapte OGSÅ `teeoff_db` selv (ikke
eksplisitt navngitt) — Compose oppdager konfigurasjonsendring
(`${POSTGRES_PASSWORD}` i `db`-tjenestens egen `environment:`) og
gjenskaper uansett hvilke tjenester som ble navngitt. Verifisert grundig
ETTERPÅ: `teeoff_db`-loggen viste "Skipping initialization" (datavolum
urørt, ikke reinitialisert) + ren oppstart, `teeoff_api`/`teeoff_worker`
ren oppstart uten en eneste feil-/auth-/passord-linje i hele loggen,
`teeoff.no` OG `teecup.teeoff.no/health` begge `200` etterpå.
**Scratch-verifisert grundig** (fersk `teecup_scratch` 001→011,
isolert `teecup_app_scratch`-rolle, isolert scratch-MinIO, engangs
API-container): `test_isolation.sql` 12/12 (måtte først rettes — tre
`INSERT INTO tournament`-steder i selve testfilen predaterte
`join_code` og traff den nye NOT NULL-constrainten, rettet med
dummy-koder). Full ende-til-ende-runde: to turneringer opprettet, ulike
koder bekreftet; `by-code`-oppslag bekreftet for kjent OG ukjent kode
(404); anonym lesing av en `org`-synlig turnering BLOKKERT uten kode
(403 NOT_VISIBLE), TILLATT med riktig kode (inkl. case-insensitivt),
FORTSATT blokkert med feil kode; samme mønster bekreftet for
`POST .../register`. Full hull-for-hull-simulering av en singel-match
(hole_result-modus): `leading_side`/`status_text` fulgte hverandre
eksakt gjennom "1 UP (A)" → "AS" (leading_side=null) → avgjort "9&7
(A)", leaderboardets `projected_points` traff nøyaktig 1.0/0.0 mens A
ledet, 0.5/0.5 ved "AS", og ble likt `points`/`projected_points` (begge
1.0/0.0) etter avgjørelse.
**Rullet ut mot ekte `teecup_db` 2026-07-19**, bruker bekreftet
eksplisitt: migrasjon 011 kjørt (eneste eksisterende turnering fikk
automatisk generert kode), `test_isolation.sql` fortsatt 12/12, kun
`teecup_api` redeployet (ingen frontend-endring i denne del-runden),
`teeoff.no` upåvirket.
**Frontend fullført samme dag, egen del-runde:** `login-form.tsx` fikk et
eget kode-modus (`JoinByCode`) — «Har du en invitasjonskode?»-lenke bytter
ut e-post-skjemaet, slår opp `/public/tournaments/by-code/{code}` og
navigerer til `/t/{id}?code=...` med Next sin `useRouter`. `code`
query-param tres gjennom hele veien: `app/t/[id]/page.tsx` leser
`searchParams`, `public-tournament.tsx` sender den med på BÅDE
info-/sessions-lesingen og selve `POST .../register` (ikke bare det
første oppslaget som tok deg dit).
`tournament-detail.tsx` viser koden i en egen kopier-chip i headeren —
ingen enkelt-turnering-`GET` fantes, så komponenten henter i stedet hele
org-ens turneringsliste (som allerede bærer `join_code`) og finner egen
rad, i stedet for å legge til et nytt endepunkt kun for dette.
`tournament-leaderboard.tsx` fikk en ny `SegmentedBar`-komponent — ETT
fargesegmentert rektangel per bar (ikke tall side om side), proporsjonalt
med hvert lags poeng, 50/50 nøytralt ved 0-0. To slike bares rett under
headeren: "Stilling nå" (faktisk) og "Projisert (hvis pågående matcher
holder seg)" — den EKSISTERENDE store tall-scoreboarden beholdt uendret
lenger ned som detaljvisning.
`session-blind-draw.tsx` sin `RevealedView`: matchkortet får nå en farget
toppkant (leaderens `team.color`) og en `status_text`-chip (fylt farge
ved avgjort match m/ konfetti-ikon, lys tone ved pågående) i stedet for
kun klokkeslettet; `RevealSide` for ledende side får en svak fargetonet
bakgrunn. `leading_side`/`status_text`/`points_side_a/b` lagt til
`ApiMatch`-typen (var der allerede i API-et, bare ikke konsumert
frontend-siden før nå).
**Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile`
som deployes, ikke dev-server) kompilerte rent, alle 10 ruter listet.
Rullet ut (kun `teecup_frontend`, ingen backend-endring i denne delen),
`teecup.teeoff.no/dashboard` og `/` → 200, `teeoff.no` upåvirket. Ekte
smoke-test i produksjon: `by-code`-oppslag for "De Gamle er Eldst" sin
faktiske kode ga riktig turnering-id, `/t/{id}?code=...` ga 200.
**ADR-020 er dermed helt ferdig** (backend + frontend, alle fire
del-ønsker: kode-basert oppdagelse, kode-felt på login, projisert
stilling, fargekoding av matcher) — bortsett fra kode-regenerering, som
er bevisst utsatt (se FEATURE_BACKLOG.md).
**Reell driftshendelse underveis** (mellom backend- og frontend-delen,
under scratch-oppsett): en feilformulert `grep -i POSTGRES`-kommando
eksponerte `teeoff_db` sitt superbruker-passord ved et uhell. Flagget
umiddelbart, brukeren valgte å rotere — se detaljene under
backend-avsnittet over for hele hendelsen og hvordan roteringen ble
gjennomført uten å noensinne re-eksponere gammel eller ny verdi.
Neste steg: Neste steg:
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. 1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
2. Kommunikasjon (chat/feed) — ikke startet. 2. Kommunikasjon (chat/feed) — ikke startet.
3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon, 3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon,
sanntid/WebSockets vs. polling, tilskuer-rolle) — bør avklares i egne, sanntid/WebSockets vs. polling, tilskuer-rolle, kode-regenerering for
dedikerte runder når det er naturlig. ADR-020) — bør avklares i egne, dedikerte runder når det er naturlig.
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver 4. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
gang — diff mot live-treet i et scratch-område før noe pakkes ut over FORVENT en full re-eksport hver gang — diff mot live-treet i et
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
ikke støtter ennå FØR integrering. V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. integrering.
4. Kommunikasjon (chat/feed) — ikke startet.

View file

@ -646,6 +646,28 @@ sammenslåing eller rekkefølge-endring i en senere runde.
--- ---
## Invitasjonskode, ledende side og projisert stilling — ADR-020
Reist av brukeren 2026-07-19, rett etter at kamp-play-flyten var komplett.
Full design i ARCHITECTURE_DECISIONS.md ADR-020.
| Del | Status | Notat |
|---|---|---|
| Kort invitasjonskode per turnering, overstyrer visibility | ✅ backend | `tournament.join_code` (migrasjon 011), genereres automatisk ved opprettelse. `GET /public/tournaments/by-code/{code}` + kode-bypass i `registration.py`. Løser "muntlig invitert spiller finner ikke turneringen"-hullet. |
| `match.leading_side` (strukturert, ikke tekst-parsing) | ✅ backend | Cachet i `recompute_and_cache_match_state` ved hver hull-innsending, eksponert på `MatchOut`. |
| Projisert stilling (hvis pågående matcher holder seg) | ✅ backend | `TeamStanding.projected_points` + `SessionStanding.projected_points_by_team` i leaderboard-endepunktet. Ikke-avgjorte matcher gir full poengsum til `leading_side`, delt 0,5/0,5 ved "AS". |
| Login-skjerm: kode-felt, tar deg direkte til turneringen | ✅ LIVE 2026-07-19 | `login-form.tsx` sin `JoinByCode`. `code` tres gjennom `/t/[id]` sin lesing OG registrering. |
| Turnering-detalj: vis invitasjonskode (kopier-knapp) | ✅ LIVE 2026-07-19 | `tournament-detail.tsx` sin `JoinCodeChip` — henter fra org-ens turneringsliste, ikke et nytt endepunkt. |
| Leaderboard: to stillingsbarer (faktisk + projisert) | ✅ LIVE 2026-07-19 | `tournament-leaderboard.tsx` sin `SegmentedBar` — fargesegmentert rektangel, ikke tall side om side. Eksisterende tall-scoreboard beholdt som detaljvisning. |
| Fargekoding av matchlister etter ledende lag | ✅ LIVE 2026-07-19 | `session-blind-draw.tsx` sin `RevealedView`/`RevealSide` — farget toppkant + status-chip, bruker `leading_side` + eksisterende `team.color`, ingen ny fargemodell. |
| Kode-regenerering (ved lekket kode) | 💤 bevisst utsatt | Ikke bygget denne runden — ingen organisator-vei til å bytte ut en kode ennå. Egen sak hvis etterspurt. |
**ADR-020 er dermed helt ferdig** — backend + frontend, alle fire
del-ønsker levert samme dag. Se CLAUDE.md-status for full runde inkl. en
reell (og transparent håndtert) passord-eksponeringshendelse underveis.
---
## UX / frontend (senere fase) ## UX / frontend (senere fase)
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp

View file

@ -44,9 +44,12 @@ export async function generateMetadata({
export default async function PublicTournamentPage({ export default async function PublicTournamentPage({
params, params,
searchParams,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
searchParams: Promise<{ code?: string }>
}) { }) {
const { id } = await params const { id } = await params
return <PublicTournament tournamentId={id} /> const { code } = await searchParams
return <PublicTournament tournamentId={id} code={code} />
} }

View file

@ -2,7 +2,8 @@
import type React from "react" import type React from "react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Mail, ArrowLeft, CheckCircle2 } from "lucide-react" import { useRouter } from "next/navigation"
import { Mail, ArrowLeft, CheckCircle2, KeyRound } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -14,6 +15,7 @@ function isValidEmail(value: string) {
} }
export function LoginForm() { export function LoginForm() {
const [mode, setMode] = useState<"email" | "code">("email")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
@ -83,6 +85,8 @@ export function LoginForm() {
onResend={handleResend} onResend={handleResend}
onReset={handleReset} onReset={handleReset}
/> />
) : mode === "code" ? (
<JoinByCode onBack={() => setMode("email")} />
) : ( ) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate> <form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -133,12 +137,109 @@ export function LoginForm() {
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty"> <p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen passord. Vi sender deg en sikker lenke e-post. Ingen passord. Vi sender deg en sikker lenke e-post.
</p> </p>
<button
type="button"
onClick={() => setMode("code")}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<KeyRound aria-hidden="true" className="size-4" />
Har du en invitasjonskode? rett til turneringen
</button>
</form> </form>
)} )}
</div> </div>
) )
} }
// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort,
// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens
// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). ---------
function JoinByCode({ onBack }: { onBack: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = code.trim()
if (!trimmed || loading) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
return
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch {
setError("Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="join-code" className="text-sm font-semibold">
Invitasjonskode
</Label>
<div className="relative">
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="join-code"
type="text"
autoCapitalize="characters"
autoComplete="off"
autoFocus
placeholder="F.eks. RAPWAR"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="h-14 rounded-2xl pl-11 text-base tracking-widest"
/>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{loading ? "Sjekker …" : "Gå til turnering"}
</Button>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for
å se turneringen eller melde deg .
</p>
<button
type="button"
onClick={onBack}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Tilbake til innlogging
</button>
</form>
)
}
function ConfirmationState({ function ConfirmationState({
email, email,
cooldown, cooldown,

View file

@ -107,17 +107,22 @@ function formatDateRange(start?: Date, end?: Date) {
// --- Root ------------------------------------------------------------------ // --- Root ------------------------------------------------------------------
export function PublicTournament({ tournamentId }: { tournamentId: string }) { export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
const [info, setInfo] = useState<ApiTournamentInfo | null>(null) const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
const [sessions, setSessions] = useState<Session[]>([]) const [sessions, setSessions] = useState<Session[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null) const [accessError, setAccessError] = useState<string | null>(null)
// ADR-020: en gyldig invitasjonskode overstyrer turneringens synlighet --
// føres med på BÅDE lesingen og registreringen (RegistrationCard under),
// ikke bare det første oppslaget som tok deg hit fra login-skjermet.
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const res = await fetch(`/public/tournaments/${tournamentId}`, { credentials: "include" }) const res = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) { if (res.status === 403 || res.status === 404) {
const body = await res.json().catch(() => null) const body = await res.json().catch(() => null)
if (!cancelled) { if (!cancelled) {
@ -133,7 +138,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
if (cancelled) return if (cancelled) return
setInfo(data) setInfo(data)
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions`, { const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
credentials: "include", credentials: "include",
}) })
if (sessionsRes.ok) { if (sessionsRes.ok) {
@ -159,7 +164,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [tournamentId]) }, [tournamentId, codeParam])
if (loading) { if (loading) {
return ( return (
@ -232,7 +237,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
<Program sessions={t.sessions} /> <Program sessions={t.sessions} />
{info.registration_open ? ( {info.registration_open ? (
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} /> <RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
) : ( ) : (
<ClosedCard /> <ClosedCard />
)} )}
@ -435,9 +440,11 @@ const GENDER_TO_API: Record<string, string> = { kvinne: "f", mann: "m", annet: "
function RegistrationCard({ function RegistrationCard({
tournamentId, tournamentId,
tournamentName, tournamentName,
code,
}: { }: {
tournamentId: string tournamentId: string
tournamentName: string tournamentName: string
code?: string
}) { }) {
const [name, setName] = useState("") const [name, setName] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
@ -491,6 +498,7 @@ function RegistrationCard({
gender: GENDER_TO_API[optional.gender], gender: GENDER_TO_API[optional.gender],
handicap_index: hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined, handicap_index: hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined,
consent, consent,
code: code || undefined,
}), }),
}) })
if (!res.ok) { if (!res.ok) {

View file

@ -57,6 +57,10 @@ type ApiMatch = {
sequence: number sequence: number
team_a_id: string team_a_id: string
team_b_id: string team_b_id: string
status_text: string | null
points_side_a: number | null
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null tee_time: string | null
participants: ApiParticipant[] participants: ApiParticipant[]
} }
@ -699,33 +703,66 @@ function RevealedView({
}) { }) {
return ( return (
<div className="mt-6 flex flex-col gap-3"> <div className="mt-6 flex flex-col gap-3">
{matches.map((match) => ( {matches.map((match) => {
// Fargekoding etter ledende lag (ADR-020) -- umiddelbar visuell
// status i stedet for kun tekst, samme mønster som TV-dekning av
// Ryder Cup. Ingen farge når leading_side er null (ikke startet/AS).
const leadingTeam =
match.leading_side === "a" ? teams[0] : match.leading_side === "b" ? teams[1] : null
const leadColor = leadingTeam?.color ?? null
const decided = match.points_side_a !== null
return (
<Link <Link
key={match.id} key={match.id}
href={scorecardHrefFor(match.id)} href={scorecardHrefFor(match.id)}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5" className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground"> <span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
{match.sequence} {match.sequence}
</span> </span>
{match.status_text ? (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide",
decided ? "text-white" : "text-foreground",
)}
style={{ backgroundColor: leadColor ? `${leadColor}${decided ? "" : "26"}` : undefined }}
>
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" /> <Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"} {match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span> </span>
)}
</div> </div>
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2 border-t border-border pt-3 sm:gap-3"> <div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2 border-t border-border pt-3 sm:gap-3">
<RevealSide team={teams[0]} participants={match.participants.filter((p) => p.team_side === "a")} align="left" /> <RevealSide
team={teams[0]}
participants={match.participants.filter((p) => p.team_side === "a")}
align="left"
leading={match.leading_side === "a"}
/>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide text-secondary-foreground"> <span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide text-secondary-foreground">
vs vs
</span> </span>
</div> </div>
<RevealSide team={teams[1]} participants={match.participants.filter((p) => p.team_side === "b")} align="right" /> <RevealSide
team={teams[1]}
participants={match.participants.filter((p) => p.team_side === "b")}
align="right"
leading={match.leading_side === "b"}
/>
</div> </div>
</Link> </Link>
))} )
})}
</div> </div>
) )
} }
@ -734,17 +771,22 @@ function RevealSide({
team, team,
participants, participants,
align, align,
leading,
}: { }: {
team: ApiTeam team: ApiTeam
participants: ApiParticipant[] participants: ApiParticipant[]
align: "left" | "right" align: "left" | "right"
leading: boolean
}) { }) {
const right = align === "right" const right = align === "right"
const color = team.color ?? "#64748b" const color = team.color ?? "#64748b"
return ( return (
<div <div
className={cn("flex flex-col gap-2 rounded-2xl bg-background/50 p-3", right ? "items-end text-right" : "items-start text-left")} className={cn("flex flex-col gap-2 rounded-2xl bg-background/50 p-3", right ? "items-end text-right" : "items-start text-left")}
style={right ? { borderRightWidth: 4, borderRightColor: color } : { borderLeftWidth: 4, borderLeftColor: color }} style={{
...(right ? { borderRightWidth: 4, borderRightColor: color } : { borderLeftWidth: 4, borderLeftColor: color }),
backgroundColor: leading ? `${color}14` : undefined,
}}
> >
<div className={cn("flex items-center gap-2", right && "flex-row-reverse")}> <div className={cn("flex items-center gap-2", right && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} /> <span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />

View file

@ -6,6 +6,8 @@ import Link from "next/link"
import { import {
ArrowLeft, ArrowLeft,
Check, Check,
Copy,
KeyRound,
MoreVertical, MoreVertical,
Plus, Plus,
Star, Star,
@ -72,6 +74,7 @@ export function TournamentDetail({
}) { }) {
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null]) const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
const [joinCode, setJoinCode] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -79,16 +82,25 @@ export function TournamentDetail({
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const [teamsRes, poolRes] = await Promise.all([ const [teamsRes, poolRes, tournamentsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
credentials: "include", credentials: "include",
}), }),
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }), fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
]) ])
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
const teamsData: { id: string; name: string; color: string | null }[] = const teamsData: { id: string; name: string; color: string | null }[] =
await teamsRes.json() await teamsRes.json()
const poolData: ApiPlayer[] = await poolRes.json() const poolData: ApiPlayer[] = await poolRes.json()
// Ingen enkelt-turnering-GET finnes ennå -- listen bærer allerede
// join_code (ADR-020), så vi finner raden herfra i stedet for å
// legge til et nytt endepunkt kun for dette.
if (tournamentsRes.ok) {
const tournamentsData: { id: string; join_code: string }[] = await tournamentsRes.json()
const mine = tournamentsData.find((t) => t.id === tournamentId)
if (mine) setJoinCode(mine.join_code)
}
const withRosters = await Promise.all( const withRosters = await Promise.all(
teamsData.map(async (t) => { teamsData.map(async (t) => {
@ -235,7 +247,7 @@ export function TournamentDetail({
> >
<ArrowLeft aria-hidden="true" className="size-5" /> <ArrowLeft aria-hidden="true" className="size-5" />
</Link> </Link>
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering Turnering
</span> </span>
@ -243,6 +255,7 @@ export function TournamentDetail({
{tournamentName} {tournamentName}
</h1> </h1>
</div> </div>
{joinCode && <JoinCodeChip code={joinCode} />}
</div> </div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner"> <nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
@ -318,6 +331,43 @@ export function TournamentDetail({
) )
} }
// --- Invitasjonskode-chip (ADR-020) -----------------------------------------
// Vises til organisator slik at koden kan gis muntlig/på en lapp til
// spillere som ellers ikke ville funnet turneringen. Overstyrer
// tournament.visibility når den brukes -- se login-skjermets kode-felt.
function JoinCodeChip({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Utilgjengelig clipboard-API (f.eks. usikker kontekst) -- koden er
// uansett synlig i chipen, bare uten kopier-snarveien.
}
}
return (
<button
type="button"
onClick={handleCopy}
title="Del denne koden med spillere som skal finne turneringen uten lenke"
className="flex shrink-0 items-center gap-1.5 rounded-xl border border-border bg-secondary/60 px-3 py-2 text-sm font-bold tracking-widest text-foreground transition-colors hover:bg-secondary"
>
<KeyRound aria-hidden="true" className="size-3.5 text-muted-foreground" />
{code}
{copied ? (
<Check aria-hidden="true" className="size-3.5 text-primary" />
) : (
<Copy aria-hidden="true" className="size-3.5 text-muted-foreground" />
)}
</button>
)
}
// --- Create team (State A) ------------------------------------------------- // --- Create team (State A) -------------------------------------------------
function CreateTeamCard({ function CreateTeamCard({

View file

@ -12,6 +12,9 @@ type ApiTeamStanding = {
team_name: string team_name: string
color: string | null color: string | null
points: number points: number
// ADR-020: hva stillingen ville blitt om pågående, ikke-avgjorte matcher
// holder seg som de står nå.
projected_points: number
} }
type ApiSessionStanding = { type ApiSessionStanding = {
@ -19,6 +22,7 @@ type ApiSessionStanding = {
sequence: number sequence: number
name: string | null name: string | null
points_by_team: Record<string, number> points_by_team: Record<string, number>
projected_points_by_team: Record<string, number>
matches_total: number matches_total: number
matches_decided: number matches_decided: number
} }
@ -154,7 +158,25 @@ export function TournamentLeaderboard({
teamA.points === teamB.points ? null : teamA.points > teamB.points ? teamA.team_id : teamB.team_id teamA.points === teamB.points ? null : teamA.points > teamB.points ? teamA.team_id : teamB.team_id
return ( return (
<> <>
<div className="flex flex-col gap-3">
<SegmentedBar
label="Stilling nå"
teamA={teamA}
teamB={teamB}
pointsA={teamA.points}
pointsB={teamB.points}
/>
<SegmentedBar
label="Projisert (hvis pågående matcher holder seg)"
teamA={teamA}
teamB={teamB}
pointsA={teamA.projected_points}
pointsB={teamB.projected_points}
/>
</div>
<div className="mt-4">
<Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} /> <Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} />
</div>
<p className="mt-3 text-center text-sm font-medium text-muted-foreground"> <p className="mt-3 text-center text-sm font-medium text-muted-foreground">
{board.matches_decided} av {board.matches_total} matcher avgjort {board.matches_decided} av {board.matches_total} matcher avgjort
</p> </p>
@ -178,6 +200,55 @@ export function TournamentLeaderboard({
) )
} }
// --- Segmentert bar (to per skjerm: faktisk + projisert, ADR-020) ----------
// Ett rektangel delt i to fargede segmenter proporsjonalt med hvert lags
// poeng -- viser umiddelbart hvem som leder, ikke bare tall side om side.
// Ved 0-0 (ingenting avgjort/projisert ennå) splittes 50/50 nøytralt.
function SegmentedBar({
label,
teamA,
teamB,
pointsA,
pointsB,
}: {
label: string
teamA: ApiTeamStanding
teamB: ApiTeamStanding
pointsA: number
pointsB: number
}) {
const total = pointsA + pointsB
const widthA = total > 0 ? (pointsA / total) * 100 : 50
const widthB = 100 - widthA
const colorA = teamA.color ?? "#64748b"
const colorB = teamB.color ?? "#64748b"
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 h-11 w-full overflow-hidden rounded-2xl shadow-sm shadow-black/5">
<div
className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300"
style={{ width: `${widthA}%`, backgroundColor: colorA }}
>
{widthA > 12 && formatPoints(pointsA)}
</div>
<div
className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300"
style={{ width: `${widthB}%`, backgroundColor: colorB }}
>
{widthB > 12 && formatPoints(pointsB)}
</div>
</div>
<div className="flex items-center justify-between text-xs font-medium text-muted-foreground">
<span className="truncate">{teamA.team_name}</span>
<span className="truncate">{teamB.team_name}</span>
</div>
</div>
)
}
// --- Scoreboard ------------------------------------------------------------ // --- Scoreboard ------------------------------------------------------------
function Scoreboard({ function Scoreboard({