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:
parent
8821bbd448
commit
317e24a800
9 changed files with 467 additions and 45 deletions
|
|
@ -276,7 +276,8 @@
|
|||
"Bash(grep -v '^$')",
|
||||
"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(set -e)"
|
||||
"Bash(set -e)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/dashboard)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
140
CLAUDE.md
140
CLAUDE.md
|
|
@ -1068,15 +1068,139 @@ Ferdig og verifisert:
|
|||
ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no`
|
||||
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
|
||||
RÅ `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:
|
||||
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
2. Kommunikasjon (chat/feed) — ikke startet.
|
||||
3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon,
|
||||
sanntid/WebSockets vs. polling, tilskuer-rolle) — 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
|
||||
gang — diff mot live-treet i et scratch-område før noe pakkes ut over
|
||||
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend
|
||||
ikke støtter ennå FØR integrering.
|
||||
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
4. Kommunikasjon (chat/feed) — ikke startet.
|
||||
sanntid/WebSockets vs. polling, tilskuer-rolle, kode-regenerering for
|
||||
ADR-020) — bør avklares i egne, dedikerte runder når det er naturlig.
|
||||
4. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
|
||||
FORVENT en full re-eksport hver gang — diff mot live-treet i et
|
||||
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
||||
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
||||
integrering.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
||||
|
|
|
|||
|
|
@ -44,9 +44,12 @@ export async function generateMetadata({
|
|||
|
||||
export default async function PublicTournamentPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ code?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
return <PublicTournament tournamentId={id} />
|
||||
const { code } = await searchParams
|
||||
return <PublicTournament tournamentId={id} code={code} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import type React 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 { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
|
@ -14,6 +15,7 @@ function isValidEmail(value: string) {
|
|||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const [mode, setMode] = useState<"email" | "code">("email")
|
||||
const [email, setEmail] = useState("")
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
|
@ -83,6 +85,8 @@ export function LoginForm() {
|
|||
onResend={handleResend}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
) : mode === "code" ? (
|
||||
<JoinByCode onBack={() => setMode("email")} />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||
<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">
|
||||
Ingen passord. Vi sender deg en sikker lenke på e-post.
|
||||
</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? Gå rett til turneringen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</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 på en lapp? Skriv den inn her — du trenger ikke logge inn for
|
||||
å se turneringen eller melde deg på.
|
||||
</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({
|
||||
email,
|
||||
cooldown,
|
||||
|
|
|
|||
|
|
@ -107,17 +107,22 @@ function formatDateRange(start?: Date, end?: Date) {
|
|||
|
||||
// --- Root ------------------------------------------------------------------
|
||||
|
||||
export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
||||
export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
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(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
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) {
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!cancelled) {
|
||||
|
|
@ -133,7 +138,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
|||
if (cancelled) return
|
||||
setInfo(data)
|
||||
|
||||
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions`, {
|
||||
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (sessionsRes.ok) {
|
||||
|
|
@ -159,7 +164,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [tournamentId])
|
||||
}, [tournamentId, codeParam])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -232,7 +237,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
|||
<Program sessions={t.sessions} />
|
||||
|
||||
{info.registration_open ? (
|
||||
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} />
|
||||
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
|
||||
) : (
|
||||
<ClosedCard />
|
||||
)}
|
||||
|
|
@ -435,9 +440,11 @@ const GENDER_TO_API: Record<string, string> = { kvinne: "f", mann: "m", annet: "
|
|||
function RegistrationCard({
|
||||
tournamentId,
|
||||
tournamentName,
|
||||
code,
|
||||
}: {
|
||||
tournamentId: string
|
||||
tournamentName: string
|
||||
code?: string
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
|
|
@ -491,6 +498,7 @@ function RegistrationCard({
|
|||
gender: GENDER_TO_API[optional.gender],
|
||||
handicap_index: hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined,
|
||||
consent,
|
||||
code: code || undefined,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ type ApiMatch = {
|
|||
sequence: number
|
||||
team_a_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
|
||||
participants: ApiParticipant[]
|
||||
}
|
||||
|
|
@ -699,33 +703,66 @@ function RevealedView({
|
|||
}) {
|
||||
return (
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{matches.map((match) => (
|
||||
<Link
|
||||
key={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"
|
||||
>
|
||||
<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">
|
||||
{match.sequence}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock aria-hidden="true" className="size-4" />
|
||||
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<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">
|
||||
vs
|
||||
{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
|
||||
key={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"
|
||||
style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined}
|
||||
>
|
||||
<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">
|
||||
{match.sequence}
|
||||
</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">
|
||||
<Clock aria-hidden="true" className="size-4" />
|
||||
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RevealSide team={teams[1]} participants={match.participants.filter((p) => p.team_side === "b")} align="right" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<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"
|
||||
leading={match.leading_side === "a"}
|
||||
/>
|
||||
<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">
|
||||
vs
|
||||
</span>
|
||||
</div>
|
||||
<RevealSide
|
||||
team={teams[1]}
|
||||
participants={match.participants.filter((p) => p.team_side === "b")}
|
||||
align="right"
|
||||
leading={match.leading_side === "b"}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -734,17 +771,22 @@ function RevealSide({
|
|||
team,
|
||||
participants,
|
||||
align,
|
||||
leading,
|
||||
}: {
|
||||
team: ApiTeam
|
||||
participants: ApiParticipant[]
|
||||
align: "left" | "right"
|
||||
leading: boolean
|
||||
}) {
|
||||
const right = align === "right"
|
||||
const color = team.color ?? "#64748b"
|
||||
return (
|
||||
<div
|
||||
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")}>
|
||||
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import Link from "next/link"
|
|||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Star,
|
||||
|
|
@ -72,6 +74,7 @@ export function TournamentDetail({
|
|||
}) {
|
||||
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
const [joinCode, setJoinCode] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -79,16 +82,25 @@ export function TournamentDetail({
|
|||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [teamsRes, poolRes] = await Promise.all([
|
||||
const [teamsRes, poolRes, tournamentsRes] = await Promise.all([
|
||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
||||
credentials: "include",
|
||||
}),
|
||||
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
|
||||
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
|
||||
])
|
||||
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
|
||||
const teamsData: { id: string; name: string; color: string | null }[] =
|
||||
await teamsRes.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(
|
||||
teamsData.map(async (t) => {
|
||||
|
|
@ -235,7 +247,7 @@ export function TournamentDetail({
|
|||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</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">
|
||||
Turnering
|
||||
</span>
|
||||
|
|
@ -243,6 +255,7 @@ export function TournamentDetail({
|
|||
{tournamentName}
|
||||
</h1>
|
||||
</div>
|
||||
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||
</div>
|
||||
|
||||
<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) -------------------------------------------------
|
||||
|
||||
function CreateTeamCard({
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ type ApiTeamStanding = {
|
|||
team_name: string
|
||||
color: string | null
|
||||
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 = {
|
||||
|
|
@ -19,6 +22,7 @@ type ApiSessionStanding = {
|
|||
sequence: number
|
||||
name: string | null
|
||||
points_by_team: Record<string, number>
|
||||
projected_points_by_team: Record<string, number>
|
||||
matches_total: 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
|
||||
return (
|
||||
<>
|
||||
<Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} />
|
||||
<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} />
|
||||
</div>
|
||||
<p className="mt-3 text-center text-sm font-medium text-muted-foreground">
|
||||
{board.matches_decided} av {board.matches_total} matcher avgjort
|
||||
</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 ------------------------------------------------------------
|
||||
|
||||
function Scoreboard({
|
||||
|
|
|
|||
Loading…
Reference in a new issue