Turneringsoppsett som steg-wizard (ADR-092)

Oppsett-fanen var én lang stablet side med alle kort etter hverandre.
Omgjort til 6 klikkbare steg med fri navigasjon, strukturelt inspirert
av Golf GameBooks steg-navigasjon (ikke visuelt -- egne clubhouse-
tokens). Grupper og startliste er det eneste genuint nye steget,
løfter Utslagsgrupper ut av å være gjemt inni hver enkelt runde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-18 20:18:47 +02:00
parent 6f25b57f90
commit 1867c65143
5 changed files with 425 additions and 128 deletions

View file

@ -9319,6 +9319,89 @@ allerede fungerer andre steder i appen).
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge. Rene logger, 200 OK.
## ADR-092: Turneringsoppsett som steg-wizard, strukturelt inspirert av Golf GameBook (2026-08-18)
Bruker viste skjermdumper av Golf GameBooks turneringsoppsett (steg-
navigasjon: Tournament Setup → Round Setup → Tournament Site → Branding
→ Entries → Players → Groups and Start List → Results and Options) og
spurte: "Jeg synes denne delen av TeeCup er veldig lite brukervennlig.
Kan du hjelpe meg å tenke gjennom dette?" -- TeeCups "Oppsett"-fane
(`SetupTab` i `individual-tournament-detail.tsx`) var én lang stablet
side med alle kort etter hverandre (`TournamentSettingsCard` →
Scoringsmetode → `RoundsCard``CutCard`/`TiebreakCard` →
`ClassesCard``ParticipantsCard`), i byggerekkefølge, ikke i en
rekkefølge en arrangør faktisk tenker gjennom oppsettet i. Bruker
svarte "Full wizard. Bring gjerne V0 inn", med ett eksplisitt forbehold
underveis: "Yes, men husk at vi ikke skal ende opp med et plagiat av
Golf GameBook."
**Struktur, ikke visuell kopi.** GameBook-skjermdumpene ble brukt
UTELUKKENDE som referanse for selve IA-mønsteret (steg i stedet for én
lang side), ikke som visuell mal -- V0-prompten instruerte eksplisitt
om å bruke TeeCups egne clubhouse-fargetoken/`rounded-2xl`-kortstil fra
`DESIGN_SYSTEM.md`, ingen grønnfarge/ikonsett/spacing hentet fra
GameBook. Dette var et rent IA-/navigasjonsproblem, ikke et manglende-
funksjonalitet-problem -- praktisk talt alle felt GameBook viser fantes
allerede og fungerte. Ingen backend-endring, ingen migrasjon.
**6 steg** (ikke GameBooks 8 -- "Tournament Site"/"Branding" dekkes
allerede av TeeCups egen frittstående Presentasjon-fane, "Entries" er
allerede en velfungerende inne-i-kortet-seksjon i `TournamentSettings
Card` som ikke var verdt å splitte ut av et nylig ferdigstilt kort),
hver drevet av et kort som allerede fantes og fungerte -- ingen av dem
trengte V0-rework: Turneringsoppsett (`TournamentSettingsCard`), Runder
og baner (`RoundsCard`), Klasser (`ClassesCard`), Spillere
(`ParticipantsCard`), Resultat og opsjoner (Scoringsmetode-seksjonen,
flyttet hit fra å stå løst øverst siden det er et resultatvalg ikke
identitet, + `CutCard` + `TiebreakCard`, samme betingede visning som
før).
**Grupper og startliste er det eneste genuint nye steget.**
`RoundGroupsPanel` var tidligere gjemt bak en "Utslagsgrupper"-knapp
inni hver enkelt `RoundCard` (måtte åpne runden først) -- direkte i
ånden til brukerens opprinnelige klage om at rundene har mye å ta tak i
mens org-oppsettet har lite. Løftet til et eget toppnivå-steg: ny
`RoundGroupsStepList`-komponent (V0-generert) lister alle runder
(sammenhengende liste-mønster, `divide-y`, ikke separate kort) med én
"Rediger grupper"-knapp per runde som åpner samme `RoundGroupsPanel`
uendret. Den gamle knappen inni `RoundCard` ble FJERNET (ikke bare
duplisert) -- to veier til samme handling ville vært akkurat den typen
duplisering wizarden var ment å rydde opp i.
**Fri navigasjon, ingen låsing.** GameBooks egne skjermdumper viser
faktisk grønne haker på ALLE 8 steg samtidig som "Tournament name"
sto tomt i opprettelses-flyten -- hakene er altså ikke ekte fullført-
validering, bare statisk wizard-pynt. Ny `TournamentSetupNav`
(V0-generert): alle 6 steg alltid klikkbare i vilkårlig rekkefølge,
ingen fullført-per-steg-logikk bygget (TeeCup er allerede fleksibelt på
oppsett-rekkefølge, og en slik validering ble aldri etterspurt). Steget
er ren `useState` inni `SetupTab`, ikke URL-drevet -- samme mønster som
de ytre fanene (Oppsett/Scorekort/Leaderboard/Presentasjon) allerede
bruker.
**Bredde-regresjon funnet og rettet under verifisering:** Oppsett-fanen
delte tidligere en `max-w-4xl` (896px) sidewrapper med Scorekort/
Presentasjon-fanene. Ved en lokal scratch-visning på 1440px viste det
seg at 6 steg-piller IKKE fikk plass side ved side i 896px -- akkurat
den "stor skjerm først"-egenskapen wizarden skulle innfri (jf. samme
mønster som leaderboardets `max-w-[110rem]`-unntak fra tidligere denne
dagen). Fikset med en egen `max-w-6xl`-unntaksbredde for `tab ===
"setup"` i den delte ternæren i `individual-tournament-detail.tsx`
sin `<main>`.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lokal scratch-
visning (`next dev` på port 3100, midlertidig `/setupwizardpreviewtmp`-
side, ALDRI committet, fjernet etter bruk): alle 6 steg klikkbare og
viser riktig innhold, Grupper-steget bekreftet å sende riktig runde-id
videre til panelet (tre varianter testet: dato+bane, kun bane, ingen av
delene), lys+mørk tema, smal skjerm (390px -- steg-raden scroller
sidelengs i stedet for å wrappe, som spesifisert, i stedet for feilende
layout).
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
frontend). `docker compose build teecup_frontend && up -d
teecup_frontend`, rene logger, 200 OK.
---
## Utviklingsplan (rekkefølge)

View file

@ -12878,3 +12878,39 @@ Neste steg:
**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 087 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge, rene logger, 200 OK.
123. **Turneringsoppsett som steg-wizard -- 2026-08-18 (ADR-092).**
Bruker viste GameBook-skjermdumper: "Jeg synes denne delen av
TeeCup er veldig lite brukervennlig ... Full wizard. Bring gjerne
V0 inn", med presisering underveis: "husk at vi ikke skal ende opp
med et plagiat av Golf GameBook." Oppsett-fanen (`SetupTab`) var én
lang stablet side -- omgjort til 6 klikkbare steg (Turnerings-
oppsett/Runder og baner/Klasser/Spillere/Grupper og startliste/
Resultat og opsjoner), fri navigasjon uten låsing eller fullført-
haker (GameBooks egne skjermdumper viste haker på alle steg selv
med tomt navnefelt -- ikke ekte validering). Kun to nye V0-
komponenter, GameBook brukt UTELUKKENDE som strukturelt forbilde
(steg-mønsteret), ikke visuelt -- begge bruker TeeCups egne
clubhouse-tokens fra `DESIGN_SYSTEM.md`: `tournament-setup-nav.tsx`
(steg-raden) og `round-groups-step-list.tsx` (ny topplassering for
"Utslagsgrupper", tidligere gjemt bak en knapp inni hver enkelt
runde -- den gamle knappen fjernet, ikke duplisert). Alle 6 stegs
faktiske innhold er allerede-eksisterende kort (TournamentSettings
Card/RoundsCard/ClassesCard/ParticipantsCard/CutCard/TiebreakCard),
kun flyttet til riktig steg -- ingen ny logikk, ingen backend-
endring, ingen migrasjon. Fant og fikset en bredde-regresjon
underveis: Oppsett delte en `max-w-4xl`-wrapper med Scorekort/
Presentasjon som gjorde at 6 steg-piller ikke fikk plass ved siden
av hverandre selv på en bred skjerm -- egen `max-w-6xl`-unntaksbredde
lagt til (samme mønster som leaderboardets `max-w-[110rem]`-unntak).
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lokal
scratch-visning (`next dev`, midlertidig previewside, ALDRI
committet, fjernet etter bruk): alle 6 steg klikkbare med riktig
innhold, Grupper-steget sender riktig runde-id videre (tre
varianter: dato+bane/kun bane/ingen av delene), lys+mørk, smal
skjerm (steg-raden scroller sidelengs i stedet for å wrappe/feile).
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
frontend). `docker compose build teecup_frontend && up -d
teecup_frontend`, rene logger, 200 OK.

View file

@ -29,9 +29,11 @@ import {
Crosshair,
Flag,
KeyRound,
Layers,
MapPin,
Medal,
Plus,
Settings2,
Trash2,
Trophy,
Upload,
@ -44,7 +46,9 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel"
import { RoundGroupsPanel } from "@/components/round-groups-panel"
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
import {
DropdownMenu,
DropdownMenuContent,
@ -991,11 +995,17 @@ export function IndividualTournamentDetail({
egen kommentar om "storskjerm uten skrolling"), men satt inni denne
delte 4xl-wrapperen (896px, samme wrapper som skjema-tunge Oppsett/
Presentasjon-fanene bruker) fikk den aldri sjansen til det -- felles
med resten av siden siden komponenten ble bygget/testet isolert. */}
med resten av siden siden komponenten ble bygget/testet isolert.
Oppsett-fanen fikk samme problem 2026-08-18 (ADR-092): 6 steg i
TournamentSetupNav får ikke plass side ved side i 896px, selv en
bred skjerm -- akkurat den "stor skjerm først"-regelen wizarden
skulle innfri. Egen, litt smalere unntaksbredde enn leaderboardets
110rem (dette er skjemainnhold, ikke en datatabell). */}
<main
className={cn(
"mx-auto w-full flex-1 px-5 py-6 sm:py-8",
tab === "leaderboard" ? "max-w-[110rem]" : "max-w-4xl",
tab === "leaderboard" ? "max-w-[110rem]" : tab === "setup" ? "max-w-6xl" : "max-w-4xl",
)}
>
{error && (
@ -1104,6 +1114,21 @@ function fromDatetimeLocalValue(value: string | null): string | null {
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
// Wizard-steg (ADR-092) -- GameBook-skjermdumper var strukturelt forbilde
// (steg i stedet for én lang stablet side), IKKE visuelt forbilde.
// Fri navigasjon -- ingen låsing/fullført-haker, se ADR-092: GameBooks
// egne skjermdumper viser haker på alle steg selv med tomt navnefelt,
// altså ikke ekte validering, og TeeCup er allerede fleksibelt på
// oppsett-rekkefølge.
const SETUP_STEPS: SetupStep[] = [
{ key: "basics", label: "Turneringsoppsett", icon: Settings2 },
{ key: "rounds", label: "Runder og baner", icon: Flag },
{ key: "classes", label: "Klasser", icon: Layers },
{ key: "players", label: "Spillere", icon: Users },
{ key: "groups", label: "Grupper og startliste", icon: Clock },
{ key: "results", label: "Resultat og opsjoner", icon: Trophy },
]
function SetupTab({
base,
organizationId,
@ -1171,6 +1196,8 @@ function SetupTab({
) => Promise<void>
onError: (message: string) => void
}) {
const [step, setStep] = useState<string>("basics")
const [groupsOpenFor, setGroupsOpenFor] = useState<string | null>(null)
const [pool, setPool] = useState<ApiPlayer[]>([])
async function refreshPool() {
@ -1202,10 +1229,84 @@ function SetupTab({
registrationRequiresApproval: tournament.registration_requires_approval,
}
return (
<div className="flex flex-col gap-6">
<TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />
const roundSummaries: RoundSummary[] = rounds.map((r) => ({
id: r.id,
name: r.name || `Runde ${r.sequence}`,
sequence: r.sequence,
courseName: r.course_name || null,
dateLabel: r.scheduled_at
? new Date(r.scheduled_at).toLocaleDateString("nb-NO", { day: "numeric", month: "short", year: "numeric" })
: null,
}))
const groupsOpenRound = rounds.find((r) => r.id === groupsOpenFor) ?? null
return (
<TournamentSetupNav steps={SETUP_STEPS} activeStep={step} onChange={setStep}>
{step === "basics" && <TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />}
{step === "rounds" && (
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
)}
{step === "classes" && (
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
)}
{step === "players" && (
<ParticipantsCard
organizationId={organizationId}
tournamentId={tournament.id}
participants={participants}
pool={pool}
classes={classes}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onImportComplete={async () => {
await Promise.all([refreshPool(), onImportComplete()])
}}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
onSetHandicap={onSetParticipantHandicap}
onSetStatus={onSetParticipantStatus}
/>
)}
{step === "groups" && (
<div className="flex flex-col gap-6">
<RoundGroupsStepList rounds={roundSummaries} onManageGroups={setGroupsOpenFor} />
{groupsOpenRound && (
<RoundGroupsPanel
base={base}
roundId={groupsOpenRound.id}
roundLabel={groupsOpenRound.name || `Runde ${groupsOpenRound.sequence}`}
onClose={() => setGroupsOpenFor(null)}
/>
)}
</div>
)}
{step === "results" && (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
@ -1263,21 +1364,6 @@ function SetupTab({
)}
</section>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
{tournament.scoring_method != null &&
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
<>
@ -1285,33 +1371,9 @@ function SetupTab({
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
</>
)}
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
<ParticipantsCard
organizationId={organizationId}
tournamentId={tournament.id}
participants={participants}
pool={pool}
classes={classes}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onImportComplete={async () => {
await Promise.all([refreshPool(), onImportComplete()])
}}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
onSetHandicap={onSetParticipantHandicap}
onSetStatus={onSetParticipantStatus}
/>
</div>
)}
</TournamentSetupNav>
)
}
@ -2603,7 +2665,6 @@ function RoundCard({
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
const [tees, setTees] = useState<ApiTee[]>([])
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [groupsOpen, setGroupsOpen] = useState(false)
async function loadRoundParticipants() {
const [rpData, teeData] = await Promise.all([
@ -2758,30 +2819,10 @@ function RoundCard({
deltakere før det er plass.
</p>
)}
{roundParticipants.length > 0 && (
<Button
type="button"
variant="secondary"
onClick={() => setGroupsOpen(true)}
className="h-10 w-full rounded-xl text-sm font-bold"
>
<Clock aria-hidden="true" className="size-4" />
Utslagsgrupper -- hvem spiller med hvem, klokka når
</Button>
)}
</>
)}
</div>
)}
{groupsOpen && (
<RoundGroupsPanel
base={base}
roundId={round.id}
roundLabel={round.name || `Runde ${round.sequence}`}
onClose={() => setGroupsOpen(false)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,66 @@
"use client"
import { Clock } from "lucide-react"
export type RoundSummary = {
id: string
name: string
sequence: number
courseName: string | null
dateLabel: string | null // already formatted, e.g. "20. sep 2026", or null if unscheduled
}
export type RoundGroupsStepListProps = {
rounds: RoundSummary[]
onManageGroups: (roundId: string) => void
}
export function RoundGroupsStepList({ rounds, onManageGroups }: RoundGroupsStepListProps) {
if (rounds.length === 0) {
return (
<div className="rounded-2xl border border-border bg-card p-6 shadow-md shadow-black/8">
<p className="text-pretty text-center text-sm leading-relaxed text-muted-foreground">
Ingen runder er lagt til ennå legg til runder under «Runder og baner» først.
</p>
</div>
)
}
return (
// Connected list: one container, rows divided by hairlines — not separate
// shadowed cards with gaps between them.
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<ul className="divide-y divide-border">
{rounds.map((round) => {
// Build the secondary "course · date" line from whatever is set.
const meta = [round.courseName, round.dateLabel].filter(Boolean).join(" · ")
return (
<li
key={round.id}
className="flex flex-wrap items-center justify-between gap-3 p-4 sm:p-5"
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Runde {round.sequence}
</span>
<span className="truncate text-base font-bold text-foreground">{round.name}</span>
<span className="truncate text-sm text-muted-foreground tabular-nums">
{meta === "" ? "Ingen bane eller dato satt ennå" : meta}
</span>
</div>
<button
type="button"
onClick={() => onManageGroups(round.id)}
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-xl border border-border bg-card px-4 py-2.5 text-sm font-bold text-foreground transition-all duration-200 ease-in-out hover:bg-accent/50 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Clock aria-hidden="true" className="size-5 shrink-0" />
<span>Rediger grupper</span>
</button>
</li>
)
})}
</ul>
</div>
)
}

View file

@ -0,0 +1,71 @@
"use client"
import type { LucideIcon } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
export type SetupStep = {
key: string
label: string
icon: LucideIcon
}
export type TournamentSetupNavProps = {
steps: SetupStep[]
activeStep: string
onChange: (key: string) => void
children?: ReactNode
}
export function TournamentSetupNav({
steps,
activeStep,
onChange,
children,
}: TournamentSetupNavProps) {
return (
<div className="flex flex-col gap-6">
<nav aria-label="Turneringsoppsett-steg">
{/*
Every step is always selectable in any order no gating, no completion
checkmarks, no step numbers. On wide screens all 6 pills share the row
equally; on narrow screens the row scrolls sideways rather than wrapping
or collapsing, so every labelled step stays reachable.
*/}
<div
role="tablist"
aria-orientation="horizontal"
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{steps.map((step) => {
const Icon = step.icon
const isActive = step.key === activeStep
return (
<button
key={step.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(step.key)}
className={cn(
"flex min-h-11 flex-1 shrink-0 snap-start items-center justify-center gap-2 rounded-2xl px-4 py-2.5",
"whitespace-nowrap text-sm font-bold transition-all duration-200 ease-in-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"active:scale-[0.98]",
isActive
? "bg-primary text-primary-foreground shadow-md shadow-black/8"
: "border border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<Icon aria-hidden="true" className="size-5 shrink-0" />
<span>{step.label}</span>
</button>
)
})}
</div>
</nav>
<div>{children}</div>
</div>
)
}