Update Todos
Legge til status i TournamentUpdate + enum-cast i update_tournament Scratch-verifisere status-oppdatering (enum-cast, PATCH-semantikk, validering) Bygge frontend-UI for å sette turnering-status Ekte typesjekket frontend-build Oppdatere .md-filer + deploy etter bekreftelse
This commit is contained in:
parent
dd54a78ce3
commit
2e9c97c943
5 changed files with 139 additions and 8 deletions
37
CLAUDE.md
37
CLAUDE.md
|
|
@ -1443,6 +1443,43 @@ Ferdig og verifisert:
|
|||
begge containere boot-et rent, `/health` og `/dashboard` → 200,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
- **Turnering-status via API, SCRATCH-VERIFISERT (2026-07-19):** brukeren
|
||||
valgte dette som neste steg etter walkover/konsesjon-runden (fikk velge
|
||||
mellom denne lille opprydningen og å starte Kommunikasjon-runden). `status`
|
||||
(draft/active/completed/archived) har ligget i skjemaet siden migrasjon
|
||||
001, men INGEN endepunkt kunne endre det — kun `INSERT`-defaulten `'draft'`
|
||||
fra `create_tournament`.
|
||||
**Bygget:** lagt til i `TournamentUpdate` (`app/routers/tournaments.py`),
|
||||
settes via det eksisterende generiske `PATCH /orgs/{id}/tournaments/{id}`
|
||||
(`exclude_unset`-mønsteret, ekte PATCH-semantikk uendret).
|
||||
**Reelt funn UNDER scratch-testing, ikke antatt riktig på forhånd:**
|
||||
`status` er -- ulikt `visibility` (som er ren `text`+`CHECK`) -- en EKTE
|
||||
Postgres ENUM-type (`tournament_status`). Den generiske
|
||||
`set_clauses`-byggeren (`f"{key} = ${i}"`) hadde derfor trengt et
|
||||
eksplisitt cast for at asyncpg sin ukjent-typede parameter skulle løses
|
||||
riktig mot en enum-kolonne -- lagt til en spesialsjekk (`::tournament_status`
|
||||
kun for `status`-nøkkelen) FØR jeg antok mekanismen "bare fungerer" fordi
|
||||
den gjør det for de andre feltene.
|
||||
Bevisst INGEN tilstandsmaskin/overgangsregler bygget (kan f.eks. gå fra
|
||||
`completed` tilbake til `draft` fritt) -- samme tillitsnivå som resten av
|
||||
appen, ikke etterspurt.
|
||||
**Frontend:** `tournament-status-badge.tsx` fikk en ny redigerbar
|
||||
`TournamentStatusPicker` (dropdown over de fire verdiene, optimistisk
|
||||
UI-oppdatering med rollback ved feil), koblet inn i `tournament-detail.tsx`
|
||||
sin header ved siden av invitasjonskode-chipen. Den eksisterende
|
||||
skrivebeskyttede `TournamentStatusBadge` (dashbordets kortliste) urørt.
|
||||
**Verifisert i scratch:** ny turnering får riktig default `draft`, PATCH
|
||||
til `active` bekreftet enum-castet faktisk løser problemet, PATCH med
|
||||
status+et annet felt samtidig fungerer, PATCH UTEN status-felt lar
|
||||
verdien stå urørt (regresjon på eksisterende PATCH-semantikk), ugyldig
|
||||
status-verdi avvist med 422 (Pydantic-mønster), `GET`-listen viser samme
|
||||
verdi etterpå. `test_isolation.sql` 12/12 uendret (ingen skjemaendring).
|
||||
Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
||||
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||||
begge containere boot-et rent, `/health` og `/dashboard` → 200,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
Neste steg:
|
||||
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
2. Kommunikasjon (chat/feed) — ikke startet.
|
||||
|
|
|
|||
|
|
@ -349,9 +349,7 @@
|
|||
talt hull avvist, GET scorecard fortsatt leselig, ikke-avgjorte matcher
|
||||
upåvirket. **Bevisst IKKE bygget:** ingen manuell «avslutt match før den
|
||||
er avgjort»-handling (det grenser mot walkover under, fortsatt utsatt).
|
||||
Turnering har et `status`-felt (draft/active/completed/archived) men
|
||||
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
|
||||
ferdig via API-et i dag (eget, senere punkt).
|
||||
Turnering-status kan nå settes via API — se eget punkt lenger ned.
|
||||
- **Walkover/konsesjon — ✅ BYGGET OG LIVE 2026-07-19 (ADR-024).** Løste det
|
||||
opprinnelige hullet: en side som aldri stiller nok spillere kan nå
|
||||
avsluttes ved at den TAPENDE siden (kaptein, eller org-admin) erklærer
|
||||
|
|
@ -364,6 +362,20 @@
|
|||
forblir urørt i scorekortet. Se ARCHITECTURE_DECISIONS.md ADR-024.
|
||||
Frontend: `session-scorecard.tsx` (gi opp én match) og
|
||||
`tournament-detail.tsx` (gi opp resten av turneringen, per lag).
|
||||
- **Turnering-status via API — ✅ BYGGET OG LIVE 2026-07-19.** `status` lagt
|
||||
til i `TournamentUpdate` (`app/routers/tournaments.py`), settes via det
|
||||
eksisterende `PATCH /orgs/{id}/tournaments/{id}` (ekte PATCH-semantikk
|
||||
uendret — et utelatt `status`-felt lar verdien stå urørt). `status` er,
|
||||
ulikt de andre PATCH-bare feltene, en EKTE Postgres ENUM-type
|
||||
(`tournament_status`, migrasjon 001) — den generiske SQL-byggeren i
|
||||
`update_tournament` fikk et eksplisitt `::tournament_status`-cast for
|
||||
akkurat dette feltet, funnet og løst FØR noe ble antatt riktig (verifisert
|
||||
i scratch at castet faktisk trengs). Ingen egen tilstandsmaskin/
|
||||
overgangsregler — enhver org-medlem med skrivetilgang kan sette hvilken
|
||||
som helst av de fire verdiene, samme tillitsnivå som resten av appen.
|
||||
Frontend: `tournament-status-badge.tsx` fikk en ny redigerbar
|
||||
`TournamentStatusPicker` (dropdown, optimistisk oppdatering med
|
||||
rollback ved feil), koblet inn i `tournament-detail.tsx` sin header.
|
||||
- **Avgjort 2026-07-16:**
|
||||
- **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk
|
||||
(ikke en handling noen utfører), så «hvem får låse» ble aldri et
|
||||
|
|
@ -371,8 +383,8 @@
|
|||
- **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
|
||||
matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
|
||||
korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok
|
||||
for v1? (c) skal turnering-status (draft/active/completed/archived) kunne
|
||||
settes via API?
|
||||
for v1? (c) ~~skal turnering-status kunne settes via API~~ ✅ avgjort/
|
||||
bygget 2026-07-19 (se over).
|
||||
|
||||
### Blind draw (skjult lagoppstilling)
|
||||
- **Status:** ✅ skjema (migrasjon 003, `lineup_lock`) + API bygget og verifisert
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ class TournamentUpdate(BaseModel):
|
|||
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
|
||||
|
||||
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
|
||||
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
||||
description: str | None = None
|
||||
registration_deadline: datetime | None = None
|
||||
|
|
@ -142,7 +143,14 @@ async def update_tournament(
|
|||
# Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne
|
||||
# feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren
|
||||
# sender inn.
|
||||
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||
# `status` er -- ulikt de andre feltene her -- en EKTE Postgres ENUM-type
|
||||
# (tournament_status, migrasjon 001), ikke text+CHECK. asyncpg sender
|
||||
# parameteren som ukjent/text; et eksplisitt cast trengs for at
|
||||
# tildelingen skal løses riktig.
|
||||
set_clauses = [
|
||||
f"{key} = ${i}::tournament_status" if key == "status" else f"{key} = ${i}"
|
||||
for i, key in enumerate(updates, start=1)
|
||||
]
|
||||
values = list(updates.values())
|
||||
values.append(tournament_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/players.py) -
|
||||
|
|
@ -76,6 +77,7 @@ export function TournamentDetail({
|
|||
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
const [joinCode, setJoinCode] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<TournamentStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -98,9 +100,13 @@ export function TournamentDetail({
|
|||
// join_code (ADR-020), så vi finner raden herfra i stedet for å
|
||||
// legge til et nytt endepunkt kun for dette.
|
||||
if (tournamentsRes.ok) {
|
||||
const tournamentsData: { id: string; join_code: string }[] = await tournamentsRes.json()
|
||||
const tournamentsData: { id: string; join_code: string; status: TournamentStatus }[] =
|
||||
await tournamentsRes.json()
|
||||
const mine = tournamentsData.find((t) => t.id === tournamentId)
|
||||
if (mine) setJoinCode(mine.join_code)
|
||||
if (mine) {
|
||||
setJoinCode(mine.join_code)
|
||||
setStatus(mine.status)
|
||||
}
|
||||
}
|
||||
|
||||
const withRosters = await Promise.all(
|
||||
|
|
@ -229,6 +235,24 @@ export function TournamentDetail({
|
|||
}
|
||||
}
|
||||
|
||||
async function updateStatus(newStatus: TournamentStatus) {
|
||||
setError(null)
|
||||
const previous = status
|
||||
setStatus(newStatus) // optimistisk -- rulles tilbake under ved feil
|
||||
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(`update status: ${res.status}`)
|
||||
} catch {
|
||||
setStatus(previous)
|
||||
setError("Klarte ikke å endre turnering-status. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
// Walkover/konsesjon på turnering-nivå (ADR-024): gir opp ALLE
|
||||
// ikke-avgjorte matcher laget har i turneringen, i én operasjon. Kun
|
||||
// kaptein for laget som gir seg (eller org-admin) får lov -- backend
|
||||
|
|
@ -283,6 +307,7 @@ export function TournamentDetail({
|
|||
{tournamentName}
|
||||
</h1>
|
||||
</div>
|
||||
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
|
||||
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
import { ChevronDown } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TournamentStatus = "draft" | "active" | "completed" | "archived"
|
||||
|
|
@ -42,3 +49,45 @@ export function TournamentStatusBadge({ status }: { status: TournamentStatus })
|
|||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Redigerbar variant -- lar organisator sette status via PATCH
|
||||
// /orgs/{id}/tournaments/{id} (ingen egen tilstandsmaskin/overgangsregler,
|
||||
// samme tillitsnivå som resten av appen). Brukt i tournament-detail.tsx sin
|
||||
// header; den skrivebeskyttede TournamentStatusBadge over brukes fortsatt i
|
||||
// dashbordets read-only kortliste.
|
||||
export function TournamentStatusPicker({
|
||||
status,
|
||||
onChange,
|
||||
}: {
|
||||
status: TournamentStatus
|
||||
onChange: (status: TournamentStatus) => void
|
||||
}) {
|
||||
const config = STATUS_CONFIG[status]
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Endre turnering-status"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold transition-opacity hover:opacity-80",
|
||||
config.badge,
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", config.dot)} aria-hidden="true" />
|
||||
{config.label}
|
||||
<ChevronDown aria-hidden="true" className="size-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-40 rounded-2xl p-1.5">
|
||||
{(Object.keys(STATUS_CONFIG) as TournamentStatus[]).map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s}
|
||||
onClick={() => onChange(s)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", STATUS_CONFIG[s].dot)} aria-hidden="true" />
|
||||
{STATUS_CONFIG[s].label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue