From c5ab6a18f274f4fa9205e4fa38e650a326659f09 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 17:00:44 +0200 Subject: [PATCH] Integrer TournamentSettingsCard (V0-eksport) i Oppsett-fanen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navn/datoer/synlighet/beskrivelse/påmelding -- rent innbytte mot props-kontrakten. Fant og fikset samme tidssone-bugklasse som ADR-088 proaktivt denne gangen (mønstergjenkjenning) før den nådde en nettleser. --- CHANGELOG.md | 43 ++ .../individual-tournament-detail.tsx | 91 ++++ .../components/tournament-settings-card.tsx | 438 ++++++++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 frontend/components/tournament-settings-card.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 238af9b..0d9fc45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12768,3 +12768,46 @@ Neste steg: **Rullet ut:** venter på bekreftelse (ren backend-tillegg, ingen migrasjon -- kolonnene fantes allerede). + +119. **TournamentSettingsCard integrert (V0-eksport, samme dag) -- + 2026-08-18.** Punkt 118 sin V0-prompt kjørt av bruker, eksport + lastet opp SAMME dag (samme `Temp-uploads/tee-cup-login-screen.zip`- + filnavn som leaderboardet i punkt 112 -- overskrevet, ikke et nytt + navn). Eksporten traff props-kontrakten + (`TournamentSettingsValues`/`TournamentSettingsCard`) helt eksakt -- + rent innbytte. Fjernet ubrukt `Lock`-ikonimport og V0 sin egen + demo-`export default` (prosjektkonvensjon: ingen komponent i denne + kodebasen har en default-eksport). + + Orkestrering lagt til i `individual-tournament-detail.tsx` + (`updateSettings`, kortet rendres øverst i Oppsett-fanen, over + Scoringsmetode) -- kortet kaster sin egen feil ved mislykket lagring + (viser den selv), i stedet for det vanlige "fang og vis global + feil"-mønstret de andre PATCH-hjelperne bruker. `ApiTournamentInfo` + utvidet med `name`/`start_date`/`end_date`/`visibility`/ + `description`/`registration_*` -- feltene kom allerede med i API- + responsen, bare ikke typet på frontend før nå. + + **Samme tidssone-klasse bug som ADR-088 funnet og fikset PROAKTIVT + denne gangen** (mønstergjenkjenning fra forrige runde, ikke en ny + scratch-oppdagelse): `` for + påmeldingsfrist ville fått nøyaktig samme UTC-mistolkning som + utslagsgruppenes klokkeslett hvis rå strenger ble sendt/vist direkte + -- `toDatetimeLocalValue`/`fromDatetimeLocalValue`-konvertering lagt + til FØR noe traff en nettleser, ikke funnet i etterkant. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lettere + verifisering enn utslagsgruppene (ADR-088) -- ingen full scratch- + runde denne gangen: en midlertidig lokal `/settingspreviewtmp`-side + (kun i `next dev`, ALDRI committet) rendret kortet med realistiske + mock-verdier, bekreftet radiogruppe-semantikk, at "Når fullt" + faktisk deaktiveres (med forklaring) når maks-antall tømmes, at + "Lagre" korrekt aktiveres/deaktiveres med endringer, og at selve + lagre-runden fullfører og nullstiller "ulagret"-tilstanden. Vurdert + som lavere restrisiko enn utslagsgruppene siden komponentens egen + logikk er enklere (ett objekt, ett lagre-kall, ingen multi-entitet- + tilstandskoreografi) -- selve felt-til-felt-kartleggingen er + likevel IKKE browser-klikket-gjennom mot en ekte backend ennå, kun + lest/resonnert over kode + typesjekk. + + **Rullet ut:** venter på bekreftelse (ren frontend, ingen migrasjon). diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index f4807be..47ccfa2 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -43,6 +43,7 @@ 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 { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card" import { DropdownMenu, DropdownMenuContent, @@ -103,10 +104,22 @@ type NewPlayerInput = { type ApiTournamentInfo = { id: string + name: string status: TournamentStatus join_code: string format_type: string scoring_method: string | null + // Turneringsinnstillinger (2026-08-18) -- eksisterte alle på backend + // (fullt fungerende, ekte selvregistrerings-logikk i registration.py) + // uten noen UI noe sted til å sette dem, før TournamentSettingsCard. + start_date: string | null + end_date: string | null + visibility: string + description: string | null + registration_deadline: string | null + registration_capacity: number | null + registration_overflow_policy: string + registration_requires_approval: boolean // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). bbb_sweep_bonus_enabled: boolean // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). @@ -563,6 +576,45 @@ export function IndividualTournamentDetail({ } } + // Turneringsinnstillinger (2026-08-18, TournamentSettingsCard -- + // V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN + // lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her + // (til forskjell fra de andre optimistiske PATCH-hjelperne over), la + // den kaste videre slik komponenten forventer. + async function updateSettings(next: TournamentSettingsValues) { + if (!tournament) return + const registrationDeadlineIso = fromDatetimeLocalValue(next.registrationDeadline) + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + name: next.name, + start_date: next.startDate, + end_date: next.endDate, + visibility: next.visibility, + description: next.description || null, + registration_deadline: registrationDeadlineIso, + registration_capacity: next.registrationCapacity, + registration_overflow_policy: next.registrationOverflowPolicy, + registration_requires_approval: next.registrationRequiresApproval, + }), + }) + if (!res.ok) throw new Error("Klarte ikke å lagre innstillingene.") + setTournament({ + ...tournament, + name: next.name, + start_date: next.startDate, + end_date: next.endDate, + visibility: next.visibility, + description: next.description || null, + registration_deadline: registrationDeadlineIso, + registration_capacity: next.registrationCapacity, + registration_overflow_policy: next.registrationOverflowPolicy, + registration_requires_approval: next.registrationRequiresApproval, + }) + } + // MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE // automatisk ved fullspilt runde) -- idempotent på serveren, men likevel // en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra @@ -904,6 +956,7 @@ export function IndividualTournamentDetail({ onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateCutConfig={updateCutConfig} onUpdateTiebreakConfig={updateTiebreakConfig} + onUpdateSettings={updateSettings} onApplyCut={applyCut} onAddParticipant={addParticipant} onAddNewPlayer={addNewPlayerAndParticipant} @@ -967,6 +1020,26 @@ function JoinCodeChip({ code }: { code: string }) { // Oppsett-fane: scoringsmetode, deltakere, runder // ========================================================================= +// krever "YYYY-MM-DDTHH:mm" (ingen sekunder/ +// tidssone) -- backend (Pydantic datetime) serialiserer/forventer full ISO +// med tidssone. Samme konverteringsbehov -- og samme tidssone-bug hvis det +// hoppes over -- som allerede funnet og fikset i round-groups-panel.tsx +// (ADR-088): new Date(...).toISOString() FØR sending, nettleserens egen +// lokal-tolkning ved visning. +function toDatetimeLocalValue(iso: string | null): string | null { + if (!iso) return null + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return null + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function fromDatetimeLocalValue(value: string | null): string | null { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + function SetupTab({ base, organizationId, @@ -981,6 +1054,7 @@ function SetupTab({ onUpdateCutConfig, onApplyCut, onUpdateTiebreakConfig, + onUpdateSettings, onAddParticipant, onAddNewPlayer, onImportComplete, @@ -1008,6 +1082,7 @@ function SetupTab({ onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise onApplyCut: () => Promise onUpdateTiebreakConfig: (winner: string, field: string) => Promise + onUpdateSettings: (next: TournamentSettingsValues) => Promise onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (input: NewPlayerInput) => Promise onImportComplete: () => Promise @@ -1042,8 +1117,24 @@ function SetupTab({ } }, [organizationId]) + const settingsValues: TournamentSettingsValues = { + name: tournament.name, + startDate: tournament.start_date, + endDate: tournament.end_date, + visibility: (tournament.visibility as TournamentSettingsValues["visibility"]) ?? "org", + description: tournament.description ?? "", + registrationDeadline: toDatetimeLocalValue(tournament.registration_deadline), + registrationCapacity: tournament.registration_capacity, + registrationOverflowPolicy: + (tournament.registration_overflow_policy as TournamentSettingsValues["registrationOverflowPolicy"]) ?? + "waitlist", + registrationRequiresApproval: tournament.registration_requires_approval, + } + return (
+ +

Scoringsmetode

diff --git a/frontend/components/tournament-settings-card.tsx b/frontend/components/tournament-settings-card.tsx new file mode 100644 index 0000000..5e9139d --- /dev/null +++ b/frontend/components/tournament-settings-card.tsx @@ -0,0 +1,438 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { + Building2, + Check, + Globe, + Loader2, + TriangleAlert, + Users, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" + +export type TournamentSettingsValues = { + name: string + startDate: string | null // "YYYY-MM-DD", or null if not set + endDate: string | null // "YYYY-MM-DD", or null if not set + visibility: "public" | "org" | "participants" + description: string // "" if empty, never null + registrationDeadline: string | null // ISO datetime ("YYYY-MM-DDTHH:mm"), or null + registrationCapacity: number | null // null = uncapped + registrationOverflowPolicy: "waitlist" | "closed" // only meaningful once a capacity is set + registrationRequiresApproval: boolean +} + +const VISIBILITY_OPTIONS: { + value: TournamentSettingsValues["visibility"] + label: string + description: string + icon: typeof Globe +}[] = [ + { + value: "public", + label: "Offentlig", + description: "Hvem som helst kan finne turneringen.", + icon: Globe, + }, + { + value: "org", + label: "Organisasjonen", + description: "Bare medlemmer av organisasjonen din.", + icon: Building2, + }, + { + value: "participants", + label: "Kun deltakere", + description: "Bare de som allerede er påmeldt.", + icon: Users, + }, +] + +const inputBase = + "h-11 w-full rounded-xl border border-border bg-background px-3 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50" + +export function TournamentSettingsCard({ + values, + onSave, +}: { + values: TournamentSettingsValues + onSave: (next: TournamentSettingsValues) => Promise +}) { + const [draft, setDraft] = useState(values) + const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") + const [errorMessage, setErrorMessage] = useState(null) + + // Only re-sync the draft from props when the SAVED values genuinely change + // (e.g. after a real save round-trip), never on an incidental parent + // re-render that hands us a new object with identical contents. + const lastSyncedRef = useRef(JSON.stringify(values)) + useEffect(() => { + const serialized = JSON.stringify(values) + if (serialized !== lastSyncedRef.current) { + lastSyncedRef.current = serialized + setDraft(values) + } + }, [values]) + + const savedFadeRef = useRef | null>(null) + useEffect(() => { + return () => { + if (savedFadeRef.current) clearTimeout(savedFadeRef.current) + } + }, []) + + function patch( + key: K, + value: TournamentSettingsValues[K], + ) { + setDraft((prev) => ({ ...prev, [key]: value })) + // Any edit clears a lingering "saved"/"error" indicator. + setStatus((s) => (s === "saving" ? s : "idle")) + setErrorMessage(null) + } + + const nameEmpty = draft.name.trim() === "" + const dirty = JSON.stringify(draft) !== JSON.stringify(values) + const capacitySet = draft.registrationCapacity != null + const saving = status === "saving" + const canSave = dirty && !nameEmpty && !saving + + async function handleSave() { + if (!canSave) return + setStatus("saving") + setErrorMessage(null) + try { + // Normalize: trim the name; overflow policy is irrelevant without a cap. + const next: TournamentSettingsValues = { + ...draft, + name: draft.name.trim(), + } + await onSave(next) + lastSyncedRef.current = JSON.stringify(next) + setDraft(next) + setStatus("saved") + if (savedFadeRef.current) clearTimeout(savedFadeRef.current) + savedFadeRef.current = setTimeout(() => setStatus("idle"), 2600) + } catch (err) { + setStatus("error") + setErrorMessage( + err instanceof Error ? err.message : "Kunne ikke lagre. Prøv igjen.", + ) + } + } + + return ( +

+
+

+ Grunnleggende +

+

+ Navn, datoer, synlighet og påmelding for turneringen. +

+
+ +
+ {/* Navn — spans full width */} +
+ + patch("name", e.target.value)} + aria-required="true" + aria-invalid={nameEmpty} + aria-describedby={nameEmpty ? "tsc-name-error" : undefined} + className="h-11 rounded-xl text-base" + placeholder="F.eks. Klubbmesterskapet 2026" + /> + {nameEmpty && ( +

+ Navn er påkrevd. +

+ )} +
+ + {/* Datoer — one connected range control */} +
+ Datoer +
+
+ + patch("startDate", e.target.value || null)} + className={inputBase} + /> +
+ +
+ + patch("endDate", e.target.value || null)} + className={inputBase} + /> +
+
+
+ + {/* Synlighet — real radio group, spans full width */} +
+ Synlighet +
+ {VISIBILITY_OPTIONS.map((o) => { + const selected = draft.visibility === o.value + const Icon = o.icon + return ( + + ) + })} +
+
+ + {/* Beskrivelse — spans full width */} +
+ +