Nå presenterer jeg utrullingsplanen:

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.
This commit is contained in:
Erol Haagenrud 2026-07-30 07:55:35 +02:00
parent e5d2705080
commit 007588fbbf
7 changed files with 1923 additions and 54 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 <TournamentDetail organizationId={org} tournamentId={id} tournamentName={tournamentName} />
return <TournamentRouter organizationId={org} tournamentId={id} tournamentName={tournamentName} />
}

View file

@ -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<void>
onCreateTournament: (name: string, orgId: string | null, format: "team" | "individual") => Promise<void>
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<void>
onCreate: (name: string, orgId: string | null, format: "team" | "individual") => Promise<void>
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-tournament-name" className="text-sm font-semibold">
Navn turnering
</Label>
<Input
id="new-tournament-name"
autoFocus
placeholder="F.eks. Sommercup 2026"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
{organizations.length > 1 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-tournament-org" className="text-sm font-semibold">
Organisasjon
</Label>
<select
id="new-tournament-org"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none sm:w-48"
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-semibold text-foreground">Type turnering</span>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setFormat("team")}
aria-pressed={format === "team"}
className={cn(
"flex min-h-11 flex-col items-start gap-0.5 rounded-xl border px-3 py-2 text-left transition-colors",
format === "team"
? "border-primary bg-primary/10"
: "border-border bg-background hover:bg-accent/50",
)}
>
{organizations.map((o) => (
<option key={o.organization_id} value={o.organization_id}>
{o.name}
</option>
))}
</select>
<span className="text-sm font-bold text-foreground">Lag</span>
<span className="text-xs font-medium text-muted-foreground">To lag mot hverandre (Ryder Cup)</span>
</button>
<button
type="button"
onClick={() => setFormat("individual")}
aria-pressed={format === "individual"}
className={cn(
"flex min-h-11 flex-col items-start gap-0.5 rounded-xl border px-3 py-2 text-left transition-colors",
format === "individual"
? "border-info bg-info/10"
: "border-border bg-background hover:bg-accent/50",
)}
>
<span className="text-sm font-bold text-foreground">Individuell</span>
<span className="text-xs font-medium text-muted-foreground">Flatt felt, én eller flere runder</span>
</button>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-tournament-name" className="text-sm font-semibold">
Navn turnering
</Label>
<Input
id="new-tournament-name"
autoFocus
placeholder="F.eks. Sommercup 2026"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
{organizations.length > 1 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-tournament-org" className="text-sm font-semibold">
Organisasjon
</Label>
<select
id="new-tournament-org"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none sm:w-48"
>
{organizations.map((o) => (
<option key={o.organization_id} value={o.organization_id}>
{o.name}
</option>
))}
</select>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={!valid || submitting} className="h-11 rounded-xl font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={!valid || submitting} className="h-11 rounded-xl font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
</form>
)

File diff suppressed because it is too large Load diff

View file

@ -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 (
<TournamentDetail organizationId={organizationId} tournamentId={tournamentId} tournamentName={tournamentName} />
)
}
if (formatType === null) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (formatType === "individual") {
return (
<IndividualTournamentDetail
organizationId={organizationId}
tournamentId={tournamentId}
tournamentName={tournamentName}
/>
)
}
return (
<TournamentDetail organizationId={organizationId} tournamentId={tournamentId} tournamentName={tournamentName} />
)
}