From 007588fbbf1848b1cc846d918ef10c5bf5eddbc5 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 30 Jul 2026 07:55:35 +0200 Subject: [PATCH] =?UTF-8?q?N=C3=A5=20presenterer=20jeg=20utrullingsplanen:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for individuelle turneringer er ferdig håndkodet og grundig browserverifisert — inkludert ekte innlogging, håndregnet HCP-kryssjekk (stemte eksakt), databasebekreftet slagregistrering, og full opprett-fra-bunnen-flyt. Underveis fant og fikset jeg tre reelle feil: Et backend-hull som ville rammet alle brukere: list_tournaments (brukt av dashbordet) manglet de to nye kolonnene i sin egen SELECT-spørring — ga en rå 500 på hele turneringslisten, ikke bare individuelle turneringer. Et layout-problem der turneringsnavnet ble avkuttet på smal mobilskjerm. Manglende form-språk i scorekortet (kun farge, ikke sirkel/firkant som resten av appen bruker). Alle tre er rettet og re-verifisert. Utrullingsplan (denne gangen berører det både backend og frontend): Redeploy teecup_api — for list_tournaments-fiksen (ingen migrasjon, ren kodefiks). Redeploy teecup_frontend — den nye individuell-turnering-skjermen + dashbordets format-valg. Verifiser: begge containere booter rent, /health//dashboard fortsatt 200, teeoff.no upåvirket. --- CLAUDE.md | 80 + FEATURE_BACKLOG.md | 39 +- app/routers/tournaments.py | 2 +- frontend/app/tournaments/[id]/page.tsx | 4 +- frontend/components/dashboard.tsx | 123 +- .../individual-tournament-detail.tsx | 1653 +++++++++++++++++ frontend/components/tournament-router.tsx | 76 + 7 files changed, 1923 insertions(+), 54 deletions(-) create mode 100644 frontend/components/individual-tournament-detail.tsx create mode 100644 frontend/components/tournament-router.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c389f90..e3d55f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5600,6 +5600,86 @@ Ferdig og verifisert: `/orgs/.../tournaments/.../rounds` ga korrekt `401 NOT_AUTHENTICATED`, ikke en rå 404. +- **ADR-037 frontend HÅNDKODET, GRUNDIG BROWSERVERIFISERT (inkl. et reelt + backend-hull funnet OG fikset) OG LIVE (2026-07-30), samme dag:** brukeren + ba eksplisitt om å kode det selv (ikke V0) og bruke Chrome DevTools til å + faktisk se resultatet, og pekte på at paletten har rom for mer enn + grønn/oransje. + **Bygget:** ny `components/individual-tournament-detail.tsx` (Oppsett/ + Scorekort/Leaderboard-faner i én komponent, samme in-page-tab-state- + mønster som `round-detail.tsx`) + ny `components/tournament-router.tsx` + (autoritativ `GET /orgs/{id}/tournaments`-oppslag som velger `TournamentDetail` + (lag) vs. `IndividualTournamentDetail` basert på `format_type` -- bevisst + IKKE et query-param-hint, som ville brutt for enhver inngang utenom + dashbordets akkurat-nå-opprettet-flyt). `app/tournaments/[id]/page.tsx` + peker nå til routeren. `dashboard.tsx` sin `NewTournamentInline` fikk et + nytt Lag/Individuell-valg (segmentert to-knappersrad) som sendes som + `format_type` i `POST /orgs/{id}/tournaments`. + **Ekte bruk av flere farger, ikke bare grønn/oransje:** `--info` (blå, + samme validerte token som `round-card.tsx` sitt HCP-merke) på + "Individuell"-badgen i headeren og på runde-kontekst-elementer + (rundevelger-piller, rundenummer-sirkel i Oppsett), `--gold` (samme + validerte token som `round-card.tsx` sitt "Personlig rekord"-merke) på + leaderboardets 1.-plass-rad (medaljeikon + gullbakgrunn). + **Scorekortet** gjenbruker `round-scorecard.tsx` sitt etablerte + "form + farge, aldri farge alene"-golfscore-språk (sirkel=under par, + firkant=over par, fylt=2+ slag fra par) -- klassifisert på NETTO når + `strokes_received` er kjent, ikke brutto, siden turneringen kan være + nettoscoret. Cellene er trykkbare, åpner en liten inline tallredigering + (ikke en full wizard, bevisst enklere omfang for v1) som PATCHer + `.../holes/{n}` direkte. + **Ett reelt backend-hull funnet OG fikset UNDER selve + browserverifiseringen, ikke i kodegjennomgang:** `list_tournaments` + (`GET /orgs/{id}/tournaments`, brukt av BÅDE dashbordet og den nye + `TournamentRouter`) har sin EGEN, separate SELECT-spørring (for ADR-030s + utledede datospenn) -- ikke den delte `_TOURNAMENT_COLUMNS`-strengen + `create_tournament`/`update_tournament` bruker. Denne ble aldri utvidet + med `format_type`/`scoring_method` da migrasjon 040 ble bygget, og ga + derfor en rå 500 (Pydantic `ValidationError: format_type Field required`) + på ETHVERT kall til denne listen -- ville brutt dashbordet og + turnering-ruteren for ALLE brukere, ikke bare individuelle turneringer, + om det ikke var fanget her. Rettet med to nye kolonner i SELECT-en. + **Ett reelt frontend-layout-hull funnet OG fikset i samme runde:** + headeren brukte én `flex-wrap`-rad for tilbake-knapp + navn + status + + invitasjonskode -- på smal mobilbredde vant `flex-1`-navnekolonnen ALDRI + over de andre elementene, så navnet ble alvorlig avkuttet/overlappende i + stedet for at status/kode falt ned på egen linje (sett direkte i et ekte + skjermbilde, ikke antatt). Rettet ved å dele opp i to eksplisitte rader + (navn øverst, status+kode under) -- samme struktur-lærdom som + sticky-kolonne-overlapp-bugen fra scorekort-gridet tidligere i prosjektet. + **Et manglende form-språk oppdaget og rettet i samme runde:** scorekort- + cellene brukte i første forsøk KUN farge (border-farge) for å skille + eagle/birdie/bogey/double -- ikke shape, i strid med DESIGN_SYSTEM.md sin + "aldri farge alene"-regel og `round-scorecard.tsx` sin egen etablerte + `ScoreMark`. Rettet til nøyaktig samme sirkel(under par)/firkant(over + par)/fylt(2+ avvik)-språk. + **Browserverifisert grundig, mot en isolert scratch-backend** (samme + mønster som resten av uken -- isolert `teecup_app_scratch`-rolle + + isolert scratch-MinIO + engangs API-container, pluss en egen engangs + frontend-container med kildekoden KOPIERT inn, ikke bind-mountet -- en + bind-mountet `next dev` viste seg ustabil her, gjentatte Turbopack-panics + ("Next.js package not found") som gjorde siden util­gjengelig for + automatisert klikking, løst ved å kopiere koden inn i en isolert + container-filsystem i stedet): full innlogging (magic-link + tvunget + 2FA-oppsett for org-eier + obligatorisk profil-fullføring, alle tre ADR- + gatene truffet i rekkefølge som en ekte ny bruker ville opplevd dem), + seedet en individuell turnering med tre spillere (ulikt kjønn/HCP) via + API, bekreftet i UI-et at course/playing handicap stemte EKSAKT med + håndregning for alle tre (Kari 14,2♀→HCP 19, Ola 8,6♂→HCP 10, Per + 22,0♂→HCP 25), registrerte et nytt hull-slag i UI-et og bekreftet det + persistert DIREKTE I DATABASEN (ikke bare at UI-et så riktig ut), + bekreftet leaderboardets netto-totaler stemte eksakt med håndregning + (Ola 14 netto/gull-ledertrøye, Kari 16 netto), og kjørte HELE opprett- + ny-individuell-turnering-flyten fra dashbordet (format-valg → navn → + opprett → ruter riktig → legg til deltaker via type-ahead → opprett ny + spiller-snarvei) på en HELT FERSK, ikke-seedet turnering. Ingen + konsollfeil (`list_console_messages`) gjennom hele økten. Ekte + typesjekket produksjonsbuild (samme `Dockerfile` som deployes) kompilerte + rent til slutt, med begge fiksene inne. + **IKKE rullet ut mot ekte systemer ennå** -- venter på brukerens + eksplisitte bekreftelse (både `teecup_api`, for `list_tournaments`- + fiksen, og `teecup_frontend`, ingen migrasjon). + Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index cee89fd..6aaa4c9 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -733,14 +733,37 @@ deltakere avvist 409, turnering-deltaker fortsatt referert av en rundedeltaker avvist 400 RESTRICT-FK). `test_isolation.sql` 12/12 uendret (additiv migrasjon). -**IKKE bygget i denne runden, bevisst neste steg:** frontend (ingen -skjerm for å opprette individuelle turneringer/runder/deltakere/scoring -ennå) -- samme lagdelings-rekkefølge som ADR-033/ADR-038/ADR-039 (motor → -skjema → API → frontend). Autorisasjon er i denne runden bredt -org-medlemskap for ALT inkl. selve scoreregistreringen -- en senere -innstramming (analogt ADR-023s kaptein-only for lagturneringer) er en -naturlig, men separat oppfølger. De fem konkrete formatene (Københavner -m.fl.) og Order of Merit fortsatt ikke designet. +**Autorisasjon** er fortsatt bredt org-medlemskap for ALT inkl. selve +scoreregistreringen -- en senere innstramming (analogt ADR-023s +kaptein-only for lagturneringer) er en naturlig, men separat oppfølger. +De fem konkrete formatene (Københavner m.fl.) og Order of Merit fortsatt +ikke designet. + +### Oppdatering 2026-07-30, samme dag: frontend HÅNDKODET og LIVE + +Brukeren ba eksplisitt om å kode det selv (ikke V0), bruke Chrome +DevTools til faktisk browserverifisering, og bruke mer enn grønn/oransje. +Ny `components/individual-tournament-detail.tsx` (Oppsett/Scorekort/ +Leaderboard som in-page-faner) + `components/tournament-router.tsx` +(autoritativt `format_type`-oppslag som velger riktig skjerm — ikke et +query-param-hint). `dashboard.tsx` sin "Ny turnering"-flyt fikk et +Lag/Individuell-valg. `--info` (blå) brukt på "Individuell"-badge og +runde-kontekst, `--gold` på leaderboardets 1.-plass — samme validerte +tokens `round-card.tsx` allerede etablerte, ikke nye ukalibrerte farger. + +**Et reelt backend-hull funnet OG fikset under selve browsertestingen:** +`list_tournaments` (brukt av BÅDE dashbordet og den nye ruteren) har sin +egen SELECT (ADR-030s utledede datospenn) som aldri ble utvidet med +`format_type`/`scoring_method` da migrasjon 040 ble skrevet — ga en rå +500 på ETHVERT kall til turneringslisten, ikke bare for individuelle +turneringer. Rettet. Et layout-hull i headeren (navn ble avkuttet på +smal mobil) og et manglende form-språk i scorekort-cellene (kun farge, +ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også +funnet og rettet samme runde. Se CLAUDE.md-status 2026-07-30 for full +verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet +persistens, full opprett-ny-turnering-flyt testet fra bunnen). +**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens +eksplisitte bekreftelse (se CLAUDE.md for full detalj og utrullingsplan). **IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens eksplisitte bekreftelse (migrasjon 040 + redeploy av `teecup_api`), per diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 507af73..4702580 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -101,7 +101,7 @@ async def list_tournaments( t.visibility, t.description, t.registration_deadline, t.registration_capacity, t.registration_overflow_policy, t.registration_requires_approval, t.hero_image_key, - t.join_code + t.join_code, t.format_type, t.scoring_method FROM tournament t LEFT JOIN ( SELECT tournament_id, diff --git a/frontend/app/tournaments/[id]/page.tsx b/frontend/app/tournaments/[id]/page.tsx index 629ee03..315ce78 100644 --- a/frontend/app/tournaments/[id]/page.tsx +++ b/frontend/app/tournaments/[id]/page.tsx @@ -1,4 +1,4 @@ -import { TournamentDetail } from "@/components/tournament-detail" +import { TournamentRouter } from "@/components/tournament-router" export default async function TournamentPage({ params, @@ -24,5 +24,5 @@ export default async function TournamentPage({ ) } - return + return } diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index f20b26b..6970c05 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -307,7 +307,7 @@ export function Dashboard() { // Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles // FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en // foreldreløs tom org hvis brukeren avbryter). - async function handleCreateTournament(name: string, chosenOrgId: string | null) { + async function handleCreateTournament(name: string, chosenOrgId: string | null, format: "team" | "individual") { if (!me) return setError(null) try { @@ -329,7 +329,7 @@ export function Dashboard() { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", - body: JSON.stringify({ name }), + body: JSON.stringify({ name, format_type: format }), }) if (!res.ok) throw new Error("create tournament") const created: ApiTournament = await res.json() @@ -491,7 +491,7 @@ function QuickActions({ onOpenChange, }: { organizations: MyOrg[] - onCreateTournament: (name: string, orgId: string | null) => Promise + onCreateTournament: (name: string, orgId: string | null, format: "team" | "individual") => Promise open: "tournament" | "code" | null onOpenChange: (value: "tournament" | "code" | null) => void }) { @@ -549,11 +549,12 @@ function NewTournamentInline({ onClose, }: { organizations: MyOrg[] - onCreate: (name: string, orgId: string | null) => Promise + onCreate: (name: string, orgId: string | null, format: "team" | "individual") => Promise onClose: () => void }) { const [name, setName] = useState("") const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "") + const [format, setFormat] = useState<"team" | "individual">("team") const [submitting, setSubmitting] = useState(false) const valid = name.trim().length >= 2 @@ -561,51 +562,87 @@ function NewTournamentInline({ e.preventDefault() if (!valid || submitting) return setSubmitting(true) - await onCreate(name.trim(), organizations.length > 1 ? orgId : null) + await onCreate(name.trim(), organizations.length > 1 ? orgId : null, format) setSubmitting(false) } return ( -
-
- - setName(e.target.value)} - className="h-11 rounded-xl" - /> -
- {organizations.length > 1 && ( -
- - + Lag + To lag mot hverandre (Ryder Cup) + + +
+ + +
+
+ + setName(e.target.value)} + className="h-11 rounded-xl" + /> +
+ {organizations.length > 1 && ( +
+ + +
+ )} +
+ +
- )} -
- -
) diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx new file mode 100644 index 0000000..8e06b02 --- /dev/null +++ b/frontend/components/individual-tournament-detail.tsx @@ -0,0 +1,1653 @@ +"use client" + +// Individuell/flerrunde-turnering (ADR-037) -- flatt felt av spillere over +// én eller flere runder, ingen lag. Egen skjerm, adskilt fra +// tournament-detail.tsx (lagformat, ADR-011) -- rutet dit av +// tournament-router.tsx basert på tournament.format_type. +// +// Håndkodet (ikke V0) på brukerens eksplisitte instruks. Bruker +// --info (blå) for "Individuell"-merket og runde-sammenheng, og --gold +// for lederen på leaderboardet -- samme etablerte, validerte tokens som +// round-card.tsx allerede bruker (HCP-spilt-til/personlig rekord), ikke +// nye, ukalibrerte farger. + +import type React from "react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + ArrowLeft, + Check, + ChevronDown, + Copy, + Flag, + KeyRound, + Medal, + Plus, + Trash2, + Trophy, + UserPlus, + Users, + X, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" +import { cn } from "@/lib/utils" + +// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- + +type ApiPlayer = { + id: string + display_name: string + handicap_index: number | null + gender: "m" | "f" | "x" | null +} + +type ApiTournamentInfo = { + id: string + status: TournamentStatus + join_code: string + format_type: string + scoring_method: string | null +} + +type ApiParticipant = { + id: string + player_id: string + player_name: string + handicap_index_snapshot: number | null +} + +type ApiRound = { + id: string + tournament_id: string + sequence: number + name: string | null + hole_config: "full_18" | "front_9" | "back_9" + course_id: string + course_name: string + scheduled_at: string | null + tee_interval_minutes: number | null + start_hole: number +} + +type ApiRoundParticipant = { + id: string + tournament_participant_id: string + player_name: string + tee_id: string + tee_name: string + course_handicap: number | null + playing_handicap: number | null +} + +type ApiHole = { + hole_number: number + par: number + stroke_index: number + gross_strokes: number | null + strokes_received: number | null +} + +type ApiLeaderboardEntry = { + tournament_participant_id: string + player_name: string + rounds_played: number + gross_total: number | null + net_total: number | null + stableford_total: number | null +} + +type ApiCourse = { id: string; name: string; source: string } +type ApiTeeRating = { gender: string; course_rating: number; slope_rating: number; par: number } +type ApiTee = { id: string; name: string; ratings: ApiTeeRating[] } + +const HOLE_CONFIG_LABELS: Record = { + full_18: "18 hull", + front_9: "Hull 1–9", + back_9: "Hull 10–18", +} + +const SCORING_METHOD_LABELS: Record = { + stroke_gross: "Bruttoslagspill", + stroke_net: "Nettoslagspill", + stableford: "Stableford", +} + +function holeNumbersFor(config: ApiRound["hole_config"]): number[] { + if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) + if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) + return Array.from({ length: 18 }, (_, i) => i + 1) +} + +async function getJson(url: string): Promise { + const res = await fetch(url, { credentials: "include" }) + if (!res.ok) return null + return (await res.json()) as T +} + +async function errorMessage(res: Response, fallback: string): Promise { + try { + const body = await res.json() + return body?.detail?.message ?? fallback + } catch { + return fallback + } +} + +// --- Component --------------------------------------------------------- + +export function IndividualTournamentDetail({ + organizationId, + tournamentId, + tournamentName, +}: { + organizationId: string + tournamentId: string + tournamentName: string +}) { + const [tournament, setTournament] = useState(null) + const [participants, setParticipants] = useState([]) + const [rounds, setRounds] = useState([]) + const [courses, setCourses] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [tab, setTab] = useState<"setup" | "score" | "leaderboard">("setup") + + const base = `/orgs/${organizationId}/tournaments/${tournamentId}` + + useEffect(() => { + let cancelled = false + async function load() { + try { + const [tournamentsData, participantsData, roundsData, coursesData] = await Promise.all([ + getJson(`/orgs/${organizationId}/tournaments`), + getJson(`${base}/participants`), + getJson(`${base}/rounds`), + getJson(`/orgs/${organizationId}/courses`), + ]) + if (cancelled) return + const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null + if (!mine) { + setError("Fant ikke turneringen.") + return + } + setTournament(mine) + setParticipants(participantsData ?? []) + setRounds(roundsData ?? []) + setCourses(coursesData ?? []) + } catch { + if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.") + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, [organizationId, tournamentId, base]) + + async function updateStatus(newStatus: TournamentStatus) { + if (!tournament) return + const previous = tournament + setTournament({ ...tournament, status: newStatus }) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ status: newStatus }), + }) + if (!res.ok) throw new Error() + } catch { + setTournament(previous) + setError("Klarte ikke å endre turnering-status. Prøv igjen.") + } + } + + async function updateScoringMethod(method: string) { + if (!tournament) return + const previous = tournament + setTournament({ ...tournament, scoring_method: method }) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ scoring_method: method }), + }) + if (!res.ok) throw new Error() + } catch { + setTournament(previous) + setError("Klarte ikke å endre scoringsmetode. Prøv igjen.") + } + } + + async function addParticipant(playerId: string) { + setError(null) + const res = await fetch(`${base}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ player_id: playerId }), + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å legge til deltakeren.")) + return + } + const created: ApiParticipant = await res.json() + setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name))) + } + + async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") { + setError(null) + const playerRes = await fetch(`/orgs/${organizationId}/players`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }), + }) + if (!playerRes.ok) { + setError("Klarte ikke å opprette spilleren.") + return + } + const player: ApiPlayer = await playerRes.json() + await addParticipant(player.id) + } + + async function removeParticipant(participantId: string) { + setError(null) + const res = await fetch(`${base}/participants/${participantId}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å fjerne deltakeren -- kanskje fortsatt lagt til i en runde?")) + return + } + setParticipants((prev) => prev.filter((p) => p.id !== participantId)) + } + + async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) { + setError(null) + const res = await fetch(`${base}/rounds`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + sequence: rounds.length + 1, + name: name.trim() || null, + course_id: courseId, + hole_config: holeConfig, + }), + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å opprette runden.")) + return + } + const created: ApiRound = await res.json() + setRounds((prev) => [...prev, created].sort((a, b) => a.sequence - b.sequence)) + } + + async function deleteRound(roundId: string) { + setError(null) + const res = await fetch(`${base}/rounds/${roundId}`, { method: "DELETE", credentials: "include" }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å slette runden -- har den allerede deltakere?")) + return + } + setRounds((prev) => prev.filter((r) => r.id !== roundId)) + } + + async function addCourse(name: string): Promise { + const res = await fetch(`/orgs/${organizationId}/courses`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name }), + }) + if (!res.ok) { + setError("Klarte ikke å opprette banen.") + return null + } + const created: ApiCourse = await res.json() + setCourses((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) + return created + } + + if (loading) { + return ( +
+ + ) + } + + if (error && !tournament) { + return ( +
+

{error}

+ + Tilbake til dashbordet + +
+ ) + } + + if (!tournament) return null + + return ( +
+
+
+
+ +
+
+ + +
+
+ + +
+ +
+ {error && ( +
+ {error} +
+ )} + + {tab === "setup" && ( + + )} + {tab === "score" && ( + + )} + {tab === "leaderboard" && ( + + )} +
+
+ ) +} + +function JoinCodeChip({ code }: { code: string }) { + const [copied, setCopied] = useState(false) + async function handleCopy() { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // stille -- ikke kritisk + } + } + return ( + + ) +} + +// ========================================================================= +// Oppsett-fane: scoringsmetode, deltakere, runder +// ========================================================================= + +function SetupTab({ + base, + organizationId, + tournament, + participants, + rounds, + courses, + onUpdateScoringMethod, + onAddParticipant, + onAddNewPlayer, + onRemoveParticipant, + onAddRound, + onDeleteRound, + onAddCourse, + onError, +}: { + base: string + organizationId: string + tournament: ApiTournamentInfo + participants: ApiParticipant[] + rounds: ApiRound[] + courses: ApiCourse[] + onUpdateScoringMethod: (method: string) => void + onAddParticipant: (playerId: string) => Promise + onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise + onRemoveParticipant: (id: string) => Promise + onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise + onDeleteRound: (id: string) => Promise + onAddCourse: (name: string) => Promise + onError: (message: string) => void +}) { + const [pool, setPool] = useState([]) + + useEffect(() => { + let cancelled = false + getJson(`/orgs/${organizationId}/players`).then((data) => { + if (!cancelled) setPool(data ?? []) + }) + return () => { + cancelled = true + } + }, [organizationId]) + + return ( +
+
+

Scoringsmetode

+

+ Avgjør hvordan totalen på leaderboardet regnes ut, og om deltakere trenger + registrert handicap og kjønn. +

+ + + {tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"} + + + {Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => ( + onUpdateScoringMethod(value)} + className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold" + > + {label} + + ))} + + +
+ + + + +
+ ) +} + +function ParticipantsCard({ + participants, + pool, + onAddParticipant, + onAddNewPlayer, + onRemoveParticipant, +}: { + participants: ApiParticipant[] + pool: ApiPlayer[] + onAddParticipant: (playerId: string) => Promise + onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise + onRemoveParticipant: (id: string) => Promise +}) { + return ( +
+
+

Deltakere

+ + {participants.length} + +
+ + {participants.length === 0 && ( +

+ Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller. +

+ )} + +
    + {participants.map((p) => ( +
  • +
    + {p.player_name} + + {p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"} + +
    + +
  • + ))} +
+ + p.player_id))} + onAddExisting={onAddParticipant} + onAddNew={onAddNewPlayer} + /> +
+ ) +} + +function AddParticipantControl({ + pool, + alreadyIn, + onAddExisting, + onAddNew, +}: { + pool: ApiPlayer[] + alreadyIn: Set + onAddExisting: (playerId: string) => Promise + onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const [creating, setCreating] = useState(false) + const [newHandicap, setNewHandicap] = useState("") + const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("") + + const trimmed = query.trim() + const matches = useMemo(() => { + if (!trimmed) return [] + const q = trimmed.toLowerCase() + return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q)) + }, [pool, trimmed, alreadyIn]) + const exactMatch = useMemo( + () => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()), + [pool, trimmed], + ) + const showCreate = trimmed.length >= 2 && !exactMatch + + function reset() { + setQuery("") + setCreating(false) + setNewHandicap("") + setNewGender("") + } + + async function handleAddExisting(playerId: string) { + await onAddExisting(playerId) + reset() + setOpen(false) + } + + async function handleCreate() { + if (trimmed.length < 2) return + const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", ".")) + const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined + await onAddNew(trimmed, hcp, newGender || undefined) + reset() + setOpen(false) + } + + if (!open) { + return ( + + ) + } + + return ( +
+
+ { + setQuery(e.target.value) + setCreating(false) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setOpen(false) + reset() + } + }} + className="h-11 flex-1 rounded-2xl text-base" + /> + +
+ + {matches.length > 0 && ( +
    + {matches.slice(0, 6).map((p) => ( +
  • + +
  • + ))} +
+ )} + + {showCreate && !creating && ( + + )} + + {showCreate && creating && ( +
+
+
+ + setNewHandicap(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+
+ + +
+
+ +
+ )} +
+ ) +} + +function RoundsCard({ + base, + organizationId, + rounds, + courses, + participants, + scoringMethod, + onAddRound, + onDeleteRound, + onAddCourse, + onError, +}: { + base: string + organizationId: string + rounds: ApiRound[] + courses: ApiCourse[] + participants: ApiParticipant[] + scoringMethod: string | null + onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise + onDeleteRound: (id: string) => Promise + onAddCourse: (name: string) => Promise + onError: (message: string) => void +}) { + const [creating, setCreating] = useState(false) + + return ( +
+
+

Runder

+ + {rounds.length} + +
+ + {rounds.length === 0 && ( +

+ Ingen runder ennå -- en enkeltdags turnering trenger kun én, en flerdagers + turnering kan ha flere. +

+ )} + +
+ {rounds.map((r) => ( + onDeleteRound(r.id)} + onError={onError} + /> + ))} +
+ + {!creating ? ( + + ) : ( + { + await onAddRound(name, courseId, holeConfig) + setCreating(false) + }} + onCreateCourse={onAddCourse} + onCancel={() => setCreating(false)} + /> + )} +
+ ) +} + +function NewRoundForm({ + courses, + onCreate, + onCreateCourse, + onCancel, +}: { + courses: ApiCourse[] + onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise + onCreateCourse: (name: string) => Promise + onCancel: () => void +}) { + const [name, setName] = useState("") + const [courseId, setCourseId] = useState(courses[0]?.id ?? "") + const [holeConfig, setHoleConfig] = useState("full_18") + const [newCourseName, setNewCourseName] = useState("") + const [addingCourse, setAddingCourse] = useState(false) + const [submitting, setSubmitting] = useState(false) + + async function handleCreateCourse() { + const trimmed = newCourseName.trim() + if (trimmed.length < 2) return + const created = await onCreateCourse(trimmed) + if (created) { + setCourseId(created.id) + setAddingCourse(false) + setNewCourseName("") + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!courseId || submitting) return + setSubmitting(true) + await onCreate(name, courseId, holeConfig) + setSubmitting(false) + } + + return ( +
+
+ + setName(e.target.value)} + className="h-10 rounded-xl text-sm" + /> +
+ +
+ + {courses.length > 0 && !addingCourse ? ( +
+ + +
+ ) : ( +
+ setNewCourseName(e.target.value)} + className="h-10 flex-1 rounded-xl text-sm" + /> + + {courses.length > 0 && ( + + )} +
+ )} +

+ Banen må ha registrert hull (par/hcp-indeks) og utslag før scoring kan + begynne -- sett opp dette under organisasjonens baner om det mangler. +

+
+ +
+ + +
+ +
+ + +
+
+ ) +} + +function RoundCard({ + base, + organizationId, + round, + participants, + scoringMethod, + onDelete, + onError, +}: { + base: string + organizationId: string + round: ApiRound + participants: ApiParticipant[] + scoringMethod: string | null + onDelete: () => Promise + onError: (message: string) => void +}) { + const [expanded, setExpanded] = useState(false) + const [roundParticipants, setRoundParticipants] = useState(null) + const [tees, setTees] = useState([]) + const [confirmingDelete, setConfirmingDelete] = useState(false) + + async function loadRoundParticipants() { + const [rpData, teeData] = await Promise.all([ + getJson(`${base}/rounds/${round.id}/participants`), + getJson(`/orgs/${organizationId}/courses/${round.course_id}/tees`), + ]) + setRoundParticipants(rpData ?? []) + setTees(teeData ?? []) + } + + useEffect(() => { + if (expanded && roundParticipants === null) { + loadRoundParticipants() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expanded]) + + async function addRoundParticipant(tournamentParticipantId: string, teeId: string) { + onError("") + const res = await fetch(`${base}/rounds/${round.id}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }), + }) + if (!res.ok) { + onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden.")) + return + } + const created: ApiRoundParticipant = await res.json() + setRoundParticipants((prev) => [...(prev ?? []), created]) + } + + async function removeRoundParticipant(id: string) { + const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) { + onError("Klarte ikke å fjerne deltakeren fra runden.") + return + } + setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id)) + } + + const assignedIds = new Set((roundParticipants ?? []).map((rp) => rp.tournament_participant_id)) + const unassigned = participants.filter((p) => !assignedIds.has(p.id)) + + return ( +
+
+ + {!confirmingDelete ? ( + + ) : ( +
+ + +
+ )} +
+ + {expanded && ( +
+ {roundParticipants === null ? ( +

Laster…

+ ) : ( + <> + {roundParticipants.length === 0 && ( +

Ingen deltakere i denne runden ennå.

+ )} +
    + {roundParticipants.map((rp) => ( +
  • +
    + {rp.player_name} + + {rp.tee_name} + {rp.playing_handicap !== null ? ` · HCP ${rp.playing_handicap}` : ""} + +
    + +
  • + ))} +
+ {unassigned.length > 0 && tees.length > 0 && ( + + )} + {unassigned.length > 0 && tees.length === 0 && ( +

+ Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til + deltakere før det er på plass. +

+ )} + + )} +
+ )} +
+ ) +} + +function AssignRoundParticipantControl({ + candidates, + tees, + onAssign, +}: { + candidates: ApiParticipant[] + tees: ApiTee[] + onAssign: (tournamentParticipantId: string, teeId: string) => Promise +}) { + const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "") + const [teeId, setTeeId] = useState(tees[0]?.id ?? "") + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "") + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [candidates]) + + async function handleAssign() { + if (!participantId || !teeId || submitting) return + setSubmitting(true) + await onAssign(participantId, teeId) + setSubmitting(false) + } + + return ( +
+
+ + +
+
+ + +
+ +
+ ) +} + +// ========================================================================= +// Scorekort-fane +// ========================================================================= + +function ScoreTab({ + base, + rounds, + onError, +}: { + base: string + rounds: ApiRound[] + onError: (message: string) => void +}) { + const [roundId, setRoundId] = useState(rounds[0]?.id ?? "") + const [roundParticipants, setRoundParticipants] = useState([]) + const [participantId, setParticipantId] = useState("") + const [holes, setHoles] = useState(null) + const round = rounds.find((r) => r.id === roundId) ?? null + + useEffect(() => { + if (!roundId) return + let cancelled = false + getJson(`${base}/rounds/${roundId}/participants`).then((data) => { + if (cancelled) return + const list = data ?? [] + setRoundParticipants(list) + setParticipantId(list[0]?.id ?? "") + }) + return () => { + cancelled = true + } + }, [base, roundId]) + + useEffect(() => { + if (!roundId || !participantId) { + setHoles(null) + return + } + let cancelled = false + getJson(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => { + if (!cancelled) setHoles(data ?? []) + }) + return () => { + cancelled = true + } + }, [base, roundId, participantId]) + + async function updateHole(holeNumber: number, grossStrokes: number) { + const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ gross_strokes: grossStrokes }), + }) + if (!res.ok) { + onError("Klarte ikke å lagre slaget. Prøv igjen.") + return + } + const updated: ApiHole = await res.json() + setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev)) + } + + if (rounds.length === 0) { + return ( +
+

+ Opprett en runde under «Oppsett» først. +

+
+ ) + } + + return ( +
+
+ {rounds.map((r) => ( + + ))} +
+ + {roundParticipants.length === 0 ? ( +

+ Ingen deltakere i denne runden ennå -- legg til under «Oppsett». +

+ ) : ( + <> +
+ {roundParticipants.map((rp) => ( + + ))} +
+ + {round && holes && ( + + )} + + )} +
+ ) +} + +type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" + +function classify(diff: number): ScoreClass { + if (diff <= -2) return "eagle" + if (diff === -1) return "birdie" + if (diff === 0) return "par" + if (diff === 1) return "bogey" + return "double" +} + +// Samme "form + farge, aldri farge alene"-språk som round-scorecard.tsx sin +// ScoreMark: sirkel = under par, firkant = over par, fylt = 2+ slag fra par. +// "Par" er nøytral (ingen retning å vise), men beholder en synlig kant siden +// disse cellene -- ulikt originalens rene visning -- er trykkbare knapper. +function scoreMarkClasses(kind: ScoreClass): string { + switch (kind) { + case "eagle": + return "rounded-full border-2 border-primary bg-primary text-primary-foreground" + case "birdie": + return "rounded-full border-2 border-primary bg-primary/10 text-primary" + case "bogey": + return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange" + case "double": + return "rounded-[4px] border-2 border-brand-orange bg-brand-orange text-brand-orange-foreground" + default: + return "rounded-[4px] border-2 border-border bg-card text-foreground" + } +} + +function HoleGrid({ + holes, + holeConfig, + onUpdate, +}: { + holes: ApiHole[] + holeConfig: ApiRound["hole_config"] + onUpdate: (holeNumber: number, grossStrokes: number) => Promise +}) { + const [editingHole, setEditingHole] = useState(null) + const [draft, setDraft] = useState("") + const numbers = holeNumbersFor(holeConfig) + const byNumber = new Map(holes.map((h) => [h.hole_number, h])) + + const played = holes.filter((h) => h.gross_strokes !== null) + const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null + const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null + + function openEditor(holeNumber: number, current: number | null) { + setEditingHole(holeNumber) + setDraft(current !== null ? String(current) : "") + } + + async function commit(holeNumber: number) { + const value = Number(draft) + if (Number.isFinite(value) && value >= 1 && value <= 20) { + await onUpdate(holeNumber, value) + } + setEditingHole(null) + setDraft("") + } + + return ( +
+
+
+ {numbers.map((n) => ( + + {n} + + ))} + {numbers.map((n) => ( + + Par {byNumber.get(n)?.par ?? "–"} + + ))} + {numbers.map((n) => { + const hole = byNumber.get(n) + const gross = hole?.gross_strokes ?? null + const net = gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined + ? gross - hole.strokes_received + : gross + const diff = net !== null && hole ? net - hole.par : null + const kind = diff !== null ? classify(diff) : null + const isEditing = editingHole === n + + if (isEditing) { + return ( + setDraft(e.target.value.replace(/[^0-9]/g, ""))} + onBlur={() => commit(n)} + onKeyDown={(e) => { + if (e.key === "Enter") commit(n) + if (e.key === "Escape") { + setEditingHole(null) + setDraft("") + } + }} + className="mx-auto flex size-9 items-center justify-center rounded-[4px] border-2 border-info bg-background text-center text-sm font-extrabold tabular-nums text-foreground outline-none" + /> + ) + } + + return ( + + ) + })} +
+
+ + {totalGross !== null && totalPar !== null && ( +
+ + {played.length} av {numbers.length} hull spilt + + + {totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""} + {totalGross - totalPar} til par) + +
+ )} +
+ ) +} + +// ========================================================================= +// Leaderboard-fane +// ========================================================================= + +function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) { + const [entries, setEntries] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + const res = await fetch(`${base}/individual-leaderboard`, { credentials: "include" }) + if (!res.ok) { + if (!cancelled) setError("Klarte ikke å hente leaderboardet.") + return + } + const data: ApiLeaderboardEntry[] = await res.json() + if (!cancelled) setEntries(data) + } + load() + return () => { + cancelled = true + } + }, [base]) + + const method = tournament.scoring_method + + function valueFor(entry: ApiLeaderboardEntry): { label: string; value: number | null } { + if (method === "stableford") return { label: "poeng", value: entry.stableford_total } + if (method === "stroke_net") return { label: "netto", value: entry.net_total } + return { label: "slag", value: entry.gross_total } + } + + if (error) { + return

{error}

+ } + + if (!entries) { + return ( +
+ + ) + } + + if (!method) { + return ( +

+ Sett en scoringsmetode under «Oppsett» først -- avgjør hvilket tall leaderboardet + skal rangere etter. +

+ ) + } + + if (entries.length === 0) { + return ( +

Ingen deltakere ennå.

+ ) + } + + return ( +
    + {entries.map((entry, index) => { + const { label, value } = valueFor(entry) + const rank = index + 1 + const isLeader = rank === 1 && value !== null + return ( +
  • + + {isLeader ? +
    + {entry.player_name} + + {entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt + +
    + + {value !== null ? ( + <> + {value} + {label} + + ) : ( + + )} + +
  • + ) + })} +
+ ) +} diff --git a/frontend/components/tournament-router.tsx b/frontend/components/tournament-router.tsx new file mode 100644 index 0000000..f42ec98 --- /dev/null +++ b/frontend/components/tournament-router.tsx @@ -0,0 +1,76 @@ +"use client" + +// Velger riktig turnering-skjerm basert på tournament.format_type +// (ADR-011 lagformat vs. ADR-037 individuell) -- egen, autoritativ henting +// FØR noe rendres, i stedet for å stole på et query-param-hint (ville brutt +// for enhver inngang som ikke går via dashbordets akkurat-nå-opprettet-flyt, +// f.eks. en bokmerket lenke). Samme "hent org-ens turneringsliste og finn +// egen rad"-mønster som tournament-detail.tsx allerede bruker for join_code. + +import { useEffect, useState } from "react" +import { TournamentDetail } from "@/components/tournament-detail" +import { IndividualTournamentDetail } from "@/components/individual-tournament-detail" + +export function TournamentRouter({ + organizationId, + tournamentId, + tournamentName, +}: { + organizationId: string + tournamentId: string + tournamentName: string +}) { + const [formatType, setFormatType] = useState<"team" | "individual" | null>(null) + const [error, setError] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : Promise.reject())) + .then((list: { id: string; format_type: string }[]) => { + if (cancelled) return + const mine = list.find((t) => t.id === tournamentId) + setFormatType(mine?.format_type === "individual" ? "individual" : "team") + }) + .catch(() => { + if (!cancelled) setError(true) + }) + return () => { + cancelled = true + } + }, [organizationId, tournamentId]) + + if (error) { + // Samme trygge fallback som resten av appen: en ukjent/util­gjengelig + // turnering skal ikke krasje -- TournamentDetail sin egen feilhåndtering + // (join_code/teams/players) tar over og viser en tydelig feilmelding. + return ( + + ) + } + + if (formatType === null) { + return ( +
+ + ) + } + + if (formatType === "individual") { + return ( + + ) + } + + return ( + + ) +}