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:
parent
e5d2705080
commit
007588fbbf
7 changed files with 1923 additions and 54 deletions
80
CLAUDE.md
80
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 utilgjengelig 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +562,47 @@ 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">
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<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 på turnering
|
||||
|
|
@ -607,6 +643,7 @@ function NewTournamentInline({
|
|||
<X aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1653
frontend/components/individual-tournament-detail.tsx
Normal file
1653
frontend/components/individual-tournament-detail.tsx
Normal file
File diff suppressed because it is too large
Load diff
76
frontend/components/tournament-router.tsx
Normal file
76
frontend/components/tournament-router.tsx
Normal 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/utilgjengelig
|
||||
// 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} />
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue