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:
Erol Haagenrud 2026-07-19 21:51:05 +02:00
parent dd54a78ce3
commit 2e9c97c943
5 changed files with 139 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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