Utslagsgrupper for individuelle turneringer: hvem spiller med hvem (ADR-088)

Tradisjonelt utslag (samme starthull, staggerte klokkeslett med fast
intervall), manuell gruppesammensetning med et forslag å justere, fast
gruppestørrelse. Fire ekte bugs funnet og fikset før utrulling via egen
scratch-verifisering (FK-korrupsjon, str/datetime-mismatch, tidssone,
React state). Migrasjon 084 lagt til, men IKKE anvendt mot ekte
teecup_db ennå -- venter på bekreftelse.
This commit is contained in:
Erol Haagenrud 2026-08-18 15:43:39 +02:00
parent 986e27ccd9
commit 9a9fcc84c0
8 changed files with 1165 additions and 1 deletions

View file

@ -0,0 +1,71 @@
-- =====================================================================
-- TeeCup — migrasjon 084
-- Utslagsgrupper for individuelle turneringer: hvem spiller med hvem,
-- klokka når
-- =====================================================================
-- Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille fra hvor, med
-- hvem, klokka når?" -- undersøkt FØR bygging: individuelle turneringer
-- hadde INGEN gruppe-/pairing-konsept i det hele tatt, kun ett delt
-- start_hole/scheduled_at/tee_interval_minutes for HELE runden
-- (tournament_round, migrasjon 040) -- ingen måte å si hvem som spiller
-- sammen, eller gi ulike grupper ulikt klokkeslett.
--
-- Design bekreftet med bruker 2026-08-18 (tre spørsmål):
-- - TRADISJONELT utslag: samme starthull for alle (tournament_round sitt
-- allerede eksisterende start_hole -- ingen endring der), staggerte
-- klokkeslett PER GRUPPE med fast intervall -- IKKE shotgun (ulike
-- hull samtidig).
-- - Gruppesammensetning er MANUELL, med et forslag å justere -- ikke
-- helt automatisk, ikke helt fra bunnen av.
-- - FAST gruppestørrelse (organisator velger tallet, f.eks. 4), ikke
-- fleksibelt antall per gruppe.
--
-- `tournament_round_group` speiler `session`/`match` sin allerede
-- etablerte klokkeslett-modell i matches.py (`_compute_tee_time`):
-- gruppens tee-tid er UTLEDET (tournament_round.scheduled_at +
-- (sequence-1)*tournament_round.tee_interval_minutes), med en valgfri
-- `tee_time_override` som vinner hvis satt (samme unntaks-mønster som
-- `match.tee_time_override` -- f.eks. én gruppe forsinket). INGEN nytt
-- starthull-felt her -- alle grupper i en runde bruker rundens EGNE
-- start_hole, per bekreftet "tradisjonelt"-valg.
--
-- `tournament_round_participant.tournament_round_group_id` er nullable
-- (ikke gruppert ennå = null, ingen regresjon for eksisterende runder/
-- deltakere). BEVISST INGEN `ON DELETE SET NULL` på FK-en, selv om det
-- først virket naturlig ("slett en gruppe -> deltakerne blir ugrupperte
-- igjen") -- funnet under egen scratch-testing: en SAMMENSATT FK
-- (organization_id, tournament_round_group_id) med ON DELETE SET NULL
-- setter BEGGE kolonnene til null ved kaskade, IKKE bare fremmednøkkel-
-- kolonnen -- det ville korrumpert organization_id (RLS sin tenant-
-- nøkkel, kan ALDRI være null) på deltaker-rader hver gang en gruppe
-- slettes. Appen (`save_round_groups`, individual_tournaments.py)
-- nullstiller derfor `tournament_round_group_id` EKSPLISITT i Python
-- FØR den sletter de gamle gruppe-radene -- samme "eksplisitt rekkefølge
-- fremfor å stole på kaskade-semantikk"-mønster som ADR-086 sin
-- turneringssletting allerede etablerte for en beslektet RESTRICT-FK-
-- diamant. Standard FK-oppførsel (RESTRICT) er dermed trygg: den
-- utløses aldri fra denne kodestien (kolonnen er allerede null før
-- selve DELETE-en kjører), og ville korrekt stoppet enhver FREMTIDIG
-- kodesti som glemte å nullstille først, i stedet for å stille
-- korrumpere data.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_group (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_id uuid NOT NULL,
sequence smallint NOT NULL,
tee_time_override timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_id)
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
UNIQUE (organization_id, id),
UNIQUE (tournament_round_id, sequence)
);
ALTER TABLE tournament_round_participant
ADD COLUMN tournament_round_group_id uuid,
ADD FOREIGN KEY (organization_id, tournament_round_group_id)
REFERENCES tournament_round_group(organization_id, id);

View file

@ -9007,6 +9007,99 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
--- ---
## ADR-088: Utslagsgrupper for individuelle turneringer -- hvem spiller med hvem, klokka når (2026-08-18)
Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille fra hvor, med
hvem, klokka når?" -- fulgt av skjermbilde av Oppsett-fanen som bekreftet
gapet: individuelle turneringer hadde INGEN gruppe-/pairing-konsept i
det hele tatt, kun ett-om-gangen "Deltaker + Utslag + Legg til". Da
bruker ble spurt om å vente på en egen scoping-runde (som Cut fikk):
"Jeg vil ha den nå, takk." Design avklart med tre spørsmål:
- **TRADISJONELT utslag** (samme starthull for alle -- rundens
allerede eksisterende `start_hole`, uendret), staggerte klokkeslett
PER GRUPPE med fast intervall -- IKKE shotgun (ulike hull samtidig),
selv om "hvor" i det opprinnelige spørsmålet kunne pekt den veien.
- **Gruppesammensetning MANUELL, med et forslag å justere** -- ikke
helautomatisk, ikke helt fra bunnen av.
- **FAST gruppestørrelse**, organisator velger tallet (f.eks. 4).
**Migrasjon 084:** ny `tournament_round_group` (sequence, valgfri
`tee_time_override`) + `tournament_round_participant.tournament_round_
group_id` (nullable). Gruppens tee-tid er UTLEDET (samme prinsipp som
`match.tee_time` i matches.py: `scheduled_at + (sequence-1)*tee_
interval_minutes`, override vinner hvis satt), ikke lagret direkte.
**Reell bug funnet og fikset UNDERVEIS, FØR utrulling** (begge via egen
scratch-verifisering, ingen av dem nådde produksjon):
1. Første forsøk ga `tournament_round_group_id`-FK-en `ON DELETE SET
NULL` -- viste seg at en SAMMENSATT FK (organization_id, ...) med
SET NULL setter BEGGE kolonnene til null ved kaskade, ikke bare
fremmednøkkelen. Ville korrumpert `organization_id` (RLS sin tenant-
nøkkel) på deltaker-rader hver gang en gruppe ble slettet. Fikset
som ADR-086 allerede etablerte mønsteret for: appen nullstiller
`tournament_round_group_id` EKSPLISITT i Python før sletting, FK-en
er vanlig RESTRICT (utløses aldri fra denne kodestien). Egen
regresjonstest lagt til `test_delete_tournament.py` som bekrefter
`delete_tournament` (ADR-086) fortsatt fungerer med en ekte gruppe
koblet på.
2. `TournamentRoundCreate`/`TournamentRoundUpdate.scheduled_at` var
typet `str`, ikke `datetime` -- asyncpg krever en ekte
`datetime`-instans for en `timestamptz`-kolonne, en rå streng feiler
med `TypeError`. Latent i `create_round` siden migrasjon 040 (aldri
truffet i produksjon fordi frontend-skjemaet aldri satte feltet ved
opprettelse), oppdaget når `update_round` (ny her) faktisk ble
testet med en verdi satt. Fikset ved å bruke `datetime`-typen
direkte (Pydantic parser ISO-tekst selv).
3. **Tidssone-bug i selve frontend-skjemaet**, funnet under browser-
scratch-verifisering: `<input type="datetime-local">` gir en
tidssoneløs streng ("organisatorens lokale klokkeslett"), som ble
sendt RÅTT til backend -- Postgres-sesjonen tolket den som UTC,
altså feil med hele tidssoneavviket (2 timer i testmiljøet). Fikset
i `round-groups-panel.tsx`: `new Date(inputVerdi).toISOString()`
FØR sending (nettleseren tolker den tidssoneløse strengen som lokal
tid, `.toISOString()` konverterer korrekt til UTC).
4. **Ekte React-state-bug** i drag/velg-om-gruppe-logikken: en flyttet
spiller forsvant SPORLØST (fjernet fra gammel gruppe, aldri lagt til
ny) -- årsak: en `let moved`-variabel ble satt INNI en
`setState`-oppdateringsfunksjon, så lest RETT ETTERPÅ i samme
funksjonskall. React kjører ikke oppdateringsfunksjoner umiddelbart
(de køes), så `moved` var fortsatt `null` når den ble lest. Fikset
ved å lese fra det allerede rendrede state-et SYNKRONT, FØR noen
`setState`-kall, og gjøre fjern+legg-til i én samlet oppdatering.
**Frontend:** ny fil `round-groups-panel.tsx` -- full-skjerm-overlegg
(samme mønster som `org-player-roster-panel.tsx`), åpnet fra en ny
"Utslagsgrupper"-knapp i `RoundCard` (`individual-tournament-detail.tsx`,
Oppsett-fanen). Bevisst HÅNDKODET av Claude, ikke sendt via V0 først --
bruker ba eksplisitt om funksjonen NÅ, samme avveining som `DuplicateStep`
(ADR/CHANGELOG #114): en avgrenset, funksjonell arbeidsflate, bygget med
de samme globale design-tokenene (border/bg-card/bg-primær) som resten
av appen, ikke forsøkt en ferdig-polert ny flaggskip-skjerm. Kan
poleres videre via V0 senere om ønskelig (se [[feedback_frontend_via_v0]]).
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 5 nye
tester i `tests/test_round_groups.py` (rundeplanlegging, forslag uten
lagring, full-erstatning ved re-lagring, ugrupperte forblir ugrupperte,
tee_time_override vinner over utledet tid) + 1 ny regresjonstest i
`test_delete_tournament.py` -- 131/131 grønt (opp fra 126), kjørt via
`scripts/run_backend_tests.sh` (egen scratch-database). Frontend: FULL
nettleser-scratch-verifisering denne gangen (database+API+next dev, ekte
passord-innlogging med `role='member'` for å unngå obligatorisk
2FA-omveien) -- 9 ekte deltakere, satte første utslag/intervall,
bekreftet riktig UTC-lagring OG riktig lokal tilbake-visning (10:00
rundtur), klikket "Foreslå grupper" (3 grupper à 4/4/1, riktige utledede
klokkeslett 10:00/10:10/10:20), flyttet en spiller til en annen gruppe
via nedtrekksmenyen (fanget opp state-buggen over), lagret, og bekreftet
den nøyaktige gruppesammensetningen direkte i databasen etterpå. Scratch-
stacken revet ned (database, rolle, API-container, MinIO-bucket, next
dev-prosess).
**Rullet ut:** venter på bekreftelse (migrasjon 084 mot ekte `teecup_db`
krever egen bekreftelse, se CLAUDE.md).
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12668,3 +12668,42 @@ Neste steg:
teecup_db -f 083_tournament_cut.sql`, begge `ALTER TABLE`-setningene teecup_db -f 083_tournament_cut.sql`, begge `ALTER TABLE`-setningene
OK), deretter `docker compose build teecup_api teecup_frontend && OK), deretter `docker compose build teecup_api teecup_frontend &&
up -d` for begge, rene logger, 200 OK. up -d` for begge, rene logger, 200 OK.
116. **Utslagsgrupper for individuelle turneringer -- 2026-08-18
(ADR-088).** Bruker: "Jeg må jo kunne bulksjekke hvem som skal
spille fra hvor, med hvem, klokka når?" Da tilbudt en scoping-runde
(som Cut fikk): "Jeg vil ha den nå, takk." Design: tradisjonelt
utslag (samme starthull, staggerte klokkeslett med fast intervall),
manuell gruppesammensetning med et forslag å justere, fast
gruppestørrelse.
Migrasjon 084 (ny `tournament_round_group` + `tournament_round_
participant.tournament_round_group_id`), ny `PATCH .../rounds/
{round_id}` (rundeplanlegging kunne tidligere KUN settes ved
opprettelse, aldri endres etterpå), `GET`/`POST .../groups/suggest`
/`PUT .../groups` for selve gruppene. Fire ekte bugs funnet og
fikset FØR utrulling (alle via egen scratch-verifisering, ingen
nådde produksjon) -- se ADR-088 for full detalj: (1) en sammensatt
FK med `ON DELETE SET NULL` som ville korrumpert `organization_id`
ved gruppesletting, (2) `scheduled_at` typet `str` i stedet for
`datetime` (latent siden migrasjon 040, aldri truffet før
`update_round` faktisk ble testet), (3) en tidssone-bug i selve
frontend-skjemaet (`datetime-local`-verdi sendt uten UTC-konvertering
-- 2 timer feil i testmiljøet), (4) en React-state-bug der en flyttet
spiller forsvant sporløst pga. en `setState`-oppdateringsfunksjon
lest synkront rett etterpå.
Ny fil `round-groups-panel.tsx`, håndkodet direkte (ikke sendt via
V0 først -- bruker ba om funksjonen nå, se ADR-088 for
resonnementet, samme avveining som `DuplicateStep` i punkt 114).
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 5 nye
backend-tester i `tests/test_round_groups.py` + 1 ny regresjonstest
i `test_delete_tournament.py` -- 131/131 grønt (opp fra 126). FULL
nettleser-scratch-verifisering (database+API+next dev, ekte
passord-innlogging) -- satte utslagstidspunkt, foreslo grupper,
flyttet en spiller manuelt, lagret, bekreftet nøyaktig
gruppesammensetning direkte i databasen. Scratch-stacken revet ned.
**Rullet ut:** venter på bekreftelse (migrasjon 084 mot ekte
`teecup_db` krever egen bekreftelse, se CLAUDE.md).

View file

@ -41,8 +41,10 @@ turneringer).
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta
from typing import Literal from typing import Literal
import asyncpg
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -89,7 +91,13 @@ class TournamentRoundCreate(BaseModel):
name: str | None = None name: str | None = None
hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$") hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
course_id: str course_id: str
scheduled_at: str | None = None # ISO 8601, tolkes av asyncpg/Pydantic ved behov # `datetime`, IKKE `str` -- Pydantic parser ISO 8601-tekst til en ekte
# datetime selv, som asyncpg krever for en timestamptz-kolonne (en rå
# streng feiler med TypeError ved skriving -- funnet 2026-08-18 da
# update_round under fikk akkurat samme felt og ble faktisk testet med
# en verdi satt, noe create_round aldri hadde blitt før siden
# frontend-skjemaet ikke satte dette feltet ved opprettelse).
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = Field(default=None, gt=0) tee_interval_minutes: int | None = Field(default=None, gt=0)
start_hole: int = Field(default=1, ge=1, le=18) start_hole: int = Field(default=1, ge=1, le=18)
@ -173,6 +181,61 @@ async def create_round(
return TournamentRound(**dict(row)) return TournamentRound(**dict(row))
class TournamentRoundUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(PATCH-semantikk via Pydantic sin exclude_unset). Lagt til 2026-08-18
(utslagsgrupper, migrasjon 084) -- runden hadde INGEN vei til å endre
scheduled_at/tee_interval_minutes/start_hole etter opprettelse
tidligere, kun ved selve INSERT-en (og `create_round`-skjemaet i
frontend satte dem aldri i utgangspunktet). Trengs for at organisator
faktisk skal kunne sette et første utslagstidspunkt + intervall FØR
utslagsgrupper gir noen mening (gruppenes klokkeslett er UTLEDET
herfra, se `_compute_group_tee_time`). course_id/hole_config er
BEVISST IKKE med -- mer strukturelle endringer enn dette skjemaet er
ment for, og ville krevd samme eclectic-samme-bane-sjekk som
create_round."""
name: str | None = None
scheduled_at: datetime | None = None # se TournamentRoundCreate for hvorfor datetime, ikke str
tee_interval_minutes: int | None = Field(default=None, gt=0)
start_hole: int | None = Field(default=None, ge=1, le=18)
@router.patch(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}",
response_model=TournamentRound,
)
async def update_round(
tournament_id: str,
round_id: str,
body: TournamentRoundUpdate,
organization_id: str = Depends(get_authorized_org),
) -> TournamentRound:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(round_id)
values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
WITH updated AS (
UPDATE tournament_round SET {', '.join(set_clauses)}
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
RETURNING *
)
SELECT {_ROUND_COLUMNS} FROM updated tr JOIN course c ON c.id = tr.course_id
""",
*values,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
return TournamentRound(**dict(row))
@router.get( @router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds", "/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
response_model=list[TournamentRound], response_model=list[TournamentRound],
@ -602,6 +665,247 @@ async def remove_round_participant(
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
# =====================================================================
# Utslagsgrupper (migrasjon 084, 2026-08-18) -- hvem spiller med hvem,
# klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille
# fra hvor, med hvem, klokka når?" Design bekreftet: TRADISJONELT utslag
# (samme starthull for alle, allerede rundens eget start_hole -- ingen
# endring der), staggerte klokkeslett per gruppe med FAST intervall
# (tournament_round.tee_interval_minutes), gruppesammensetning MANUELL
# MED et forslag å justere, FAST gruppestørrelse (organisator velger
# tallet). Se ADR-088.
# =====================================================================
def _compute_group_tee_time(
scheduled_at: datetime | None,
tee_interval_minutes: int | None,
sequence: int,
override: datetime | None,
) -> datetime | None:
"""Samme formel/filosofi som matches.py sin `_compute_tee_time` for
lagturneringers økter -- utledet, ikke lagret, med en valgfri
override som vinner (f.eks. én gruppe forsinket)."""
if override is not None:
return override
if scheduled_at is None or tee_interval_minutes is None:
return None
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
class RoundGroupParticipant(BaseModel):
round_participant_id: str
player_name: str
class RoundGroupOut(BaseModel):
# `id=None` for en FORESLÅTT (ikke lagret) gruppe -- se suggest_round_groups.
id: str | None
sequence: int
tee_time: str | None # utledet (eller override), ISO-tekst
tee_time_override: str | None
participants: list[RoundGroupParticipant]
class RoundGroupsOut(BaseModel):
groups: list[RoundGroupOut]
# Rundedeltakere som IKKE er i noen gruppe ennå (nylig lagt til runden,
# eller aldri gruppert) -- alltid vist separat, aldri stille utelatt.
ungrouped: list[RoundGroupParticipant]
async def _round_scheduling(conn, tournament_id: str, round_id: str) -> asyncpg.Record:
round_row = await conn.fetchrow(
"SELECT scheduled_at, tee_interval_minutes FROM tournament_round WHERE id = $1 AND tournament_id = $2",
round_id,
tournament_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
return round_row
async def _fetch_round_groups(conn, round_id: str, round_row) -> RoundGroupsOut:
group_rows = await conn.fetch(
"SELECT id::text, sequence, tee_time_override "
"FROM tournament_round_group WHERE tournament_round_id = $1 ORDER BY sequence",
round_id,
)
participant_rows = await conn.fetch(
"""
SELECT trp.id::text AS round_participant_id, p.display_name AS player_name,
trp.tournament_round_group_id::text AS group_id
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
WHERE trp.tournament_round_id = $1
ORDER BY p.display_name
""",
round_id,
)
by_group: dict[str, list[RoundGroupParticipant]] = {}
ungrouped: list[RoundGroupParticipant] = []
for r in participant_rows:
entry = RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"])
if r["group_id"] is None:
ungrouped.append(entry)
else:
by_group.setdefault(r["group_id"], []).append(entry)
groups = []
for g in group_rows:
tee_time = _compute_group_tee_time(
round_row["scheduled_at"], round_row["tee_interval_minutes"], g["sequence"], g["tee_time_override"]
)
groups.append(
RoundGroupOut(
id=g["id"],
sequence=g["sequence"],
tee_time=tee_time.isoformat() if tee_time is not None else None,
tee_time_override=g["tee_time_override"].isoformat() if g["tee_time_override"] else None,
participants=by_group.get(g["id"], []),
)
)
return RoundGroupsOut(groups=groups, ungrouped=ungrouped)
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups",
response_model=RoundGroupsOut,
)
async def list_round_groups(
tournament_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
) -> RoundGroupsOut:
async with org_connection(organization_id) as conn:
round_row = await _round_scheduling(conn, tournament_id, round_id)
return await _fetch_round_groups(conn, round_id, round_row)
class SuggestGroupsIn(BaseModel):
group_size: int = Field(ge=1, le=12)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups/suggest",
response_model=RoundGroupsOut,
)
async def suggest_round_groups(
tournament_id: str,
round_id: str,
body: SuggestGroupsIn,
organization_id: str = Depends(get_authorized_org),
) -> RoundGroupsOut:
"""Regner ut et FORSLAG (skriver INGENTING) -- organisator justerer i
frontend og lagrer med save_round_groups når de er fornøyd. Fordeler
ALLE rundens deltakere (uansett eksisterende gruppe) i rekkefølge
etter spillernavn, i biter av `group_size` -- enkel og forutsigbar
startfordeling, ikke ment å være den endelige (manuell justering med
forslag å justere fra var det bekreftede designet, ikke helautomatisk)."""
async with org_connection(organization_id) as conn:
round_row = await _round_scheduling(conn, tournament_id, round_id)
participant_rows = await conn.fetch(
"""
SELECT trp.id::text AS round_participant_id, p.display_name AS player_name
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
WHERE trp.tournament_round_id = $1
ORDER BY p.display_name
""",
round_id,
)
entries = [
RoundGroupParticipant(round_participant_id=r["round_participant_id"], player_name=r["player_name"])
for r in participant_rows
]
groups = []
for i in range(0, len(entries), body.group_size):
sequence = i // body.group_size + 1
chunk = entries[i : i + body.group_size]
tee_time = _compute_group_tee_time(
round_row["scheduled_at"], round_row["tee_interval_minutes"], sequence, None
)
groups.append(
RoundGroupOut(
id=None,
sequence=sequence,
tee_time=tee_time.isoformat() if tee_time is not None else None,
tee_time_override=None,
participants=chunk,
)
)
return RoundGroupsOut(groups=groups, ungrouped=[])
class RoundGroupIn(BaseModel):
sequence: int = Field(ge=1)
tee_time_override: datetime | None = None
round_participant_ids: list[str]
class SaveRoundGroupsIn(BaseModel):
groups: list[RoundGroupIn]
@router.put(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/groups",
response_model=RoundGroupsOut,
)
async def save_round_groups(
tournament_id: str,
round_id: str,
body: SaveRoundGroupsIn,
organization_id: str = Depends(get_authorized_org),
) -> RoundGroupsOut:
"""Erstatter HELE rundens gruppeoppsett i én transaksjon -- samme
"lagre hele brettet på nytt"-mønster som spillerimport-tabellen
(enklere og mer forutsigbart enn å diffe mot forrige lagring).
Rundedeltakere som ikke er nevnt i noen gruppe blir/forblir
ugrupperte (ikke fjernet fra runden -- kun gruppetilhørigheten
endres)."""
async with org_connection(organization_id) as conn, translate_db_errors():
round_row = await _round_scheduling(conn, tournament_id, round_id)
# Nullstiller koblingen EKSPLISITT før sletting -- se migrasjon 084
# sin kommentar for hvorfor FK-en bevisst IKKE har ON DELETE SET
# NULL (en sammensatt FK med SET NULL ville korrumpert
# organization_id, ikke bare selve gruppe-referansen).
await conn.execute(
"UPDATE tournament_round_participant SET tournament_round_group_id = NULL "
"WHERE tournament_round_id = $1",
round_id,
)
await conn.execute("DELETE FROM tournament_round_group WHERE tournament_round_id = $1", round_id)
for g in body.groups:
group_row = await conn.fetchrow(
"""
INSERT INTO tournament_round_group
(organization_id, tournament_round_id, sequence, tee_time_override)
VALUES ($1, $2, $3, $4)
RETURNING id
""",
organization_id,
round_id,
g.sequence,
g.tee_time_override,
)
if g.round_participant_ids:
await conn.execute(
"""
UPDATE tournament_round_participant
SET tournament_round_group_id = $1
WHERE id = ANY($2::uuid[]) AND tournament_round_id = $3
""",
group_row["id"],
g.round_participant_ids,
round_id,
)
return await _fetch_round_groups(conn, round_id, round_row)
# ===================================================================== # =====================================================================
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet) # Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
# ===================================================================== # =====================================================================

View file

@ -23,6 +23,7 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Clock,
Copy, Copy,
Crosshair, Crosshair,
Flag, Flag,
@ -41,6 +42,7 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel" import { PlayerImportPanel } from "@/components/player-import-panel"
import { RoundGroupsPanel } from "@/components/round-groups-panel"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -2176,6 +2178,7 @@ function RoundCard({
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null) const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
const [tees, setTees] = useState<ApiTee[]>([]) const [tees, setTees] = useState<ApiTee[]>([])
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const [groupsOpen, setGroupsOpen] = useState(false)
async function loadRoundParticipants() { async function loadRoundParticipants() {
const [rpData, teeData] = await Promise.all([ const [rpData, teeData] = await Promise.all([
@ -2330,10 +2333,30 @@ function RoundCard({
deltakere før det er plass. deltakere før det er plass.
</p> </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> </div>
)} )}
{groupsOpen && (
<RoundGroupsPanel
base={base}
roundId={round.id}
roundLabel={round.name || `Runde ${round.sequence}`}
onClose={() => setGroupsOpen(false)}
/>
)}
</div> </div>
) )
} }

View file

@ -0,0 +1,453 @@
"use client"
// Utslagsgrupper (2026-08-18) -- hvem spiller med hvem, klokka når, for
// én runde i en individuell turnering. Bruker: "Jeg må jo kunne
// bulksjekke hvem som skal spille fra hvor, med hvem, klokka når?"
// Design bekreftet: TRADISJONELT utslag (samme starthull for alle,
// rundens eget start_hole -- uendret her), staggerte klokkeslett per
// gruppe med fast intervall, MANUELL gruppesammensetning med et forslag
// å justere, FAST gruppestørrelse.
//
// Håndkodet direkte (ikke sendt via V0 først) -- bruker ba eksplisitt om
// funksjonen NÅ ("Jeg vil ha den nå, takk"), samme avveining som
// DuplicateStep i player-import-panel.tsx: en avgrenset, funksjonell
// arbeidsflate satt inn i en eksisterende flyt, bygget med de samme
// globale design-tokenene som resten av appen (border/bg-card/bg-primær
// osv.), ikke en forsøkt "ferdig polert" ny flaggskip-skjerm. Kan
// poleres videre via V0 senere om ønskelig.
import { useEffect, useState } from "react"
import { Clock, Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiGroupParticipant = {
round_participant_id: string
player_name: string
}
type ApiRoundGroup = {
id: string | null
sequence: number
tee_time: string | null
tee_time_override: string | null
participants: ApiGroupParticipant[]
}
type ApiRoundGroups = {
groups: ApiRoundGroup[]
ungrouped: ApiGroupParticipant[]
}
type ApiRoundInfo = {
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
}
function formatTeeTime(iso: string | null) {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
// datetime-local-inputet gir "YYYY-MM-DDTHH:mm" (ingen tidssone) -- server
// (Pydantic datetime-felt) godtar dette direkte, tolkes som lokal tid.
function toDatetimeLocalValue(iso: string | null) {
if (!iso) return ""
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ""
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())}`
}
const CONTROL =
"h-10 rounded-lg border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
export function RoundGroupsPanel({
base,
roundId,
roundLabel,
onClose,
}: {
base: string
roundId: string
roundLabel: string
onClose: () => void
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
const [scheduledAtInput, setScheduledAtInput] = useState("")
const [intervalInput, setIntervalInput] = useState("")
const [groups, setGroups] = useState<ApiRoundGroup[]>([])
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
const [groupSize, setGroupSize] = useState("4")
async function loadAll() {
setLoading(true)
setError(null)
try {
const [roundRes, groupsRes] = await Promise.all([
fetch(`${base}/rounds`, { credentials: "include" }),
fetch(`${base}/rounds/${roundId}/groups`, { credentials: "include" }),
])
if (!roundRes.ok || !groupsRes.ok) throw new Error()
const rounds: (ApiRoundInfo & { id: string })[] = await roundRes.json()
const thisRound = rounds.find((r) => r.id === roundId) ?? null
const groupsData: ApiRoundGroups = await groupsRes.json()
setRoundInfo(thisRound)
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
setGroups(groupsData.groups)
setUngrouped(groupsData.ungrouped)
} catch {
setError("Klarte ikke å laste utslagsgruppene. Prøv igjen.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
async function saveSchedule() {
setError(null)
// <input type="datetime-local"> gir en TIDSSONELØS streng som betyr
// "organisatorens lokale klokkeslett" (f.eks. "10:00" i Norge). new
// Date(...) på en slik streng tolkes av nettleseren som LOKAL tid --
// .toISOString() konverterer deretter til riktig UTC-tidspunkt FØR
// sending. Uten dette steget ville "10:00" blitt lagret som 10:00 UTC
// (feil med hele tidssoneavviket, f.eks. 2 timer for norsk sommertid)
// -- funnet under egen scratch-verifisering av akkurat denne skjermen.
const scheduledAtIso = scheduledAtInput ? new Date(scheduledAtInput).toISOString() : null
const res = await fetch(`${base}/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
scheduled_at: scheduledAtIso,
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
}),
})
if (!res.ok) {
setError("Klarte ikke å lagre utslagstidspunktet.")
return
}
await loadAll()
}
async function suggest() {
const size = Number(groupSize)
if (!size || size < 1) return
const hasExistingWork = groups.some((g) => g.participants.length > 0)
if (hasExistingWork && !confirm("Dette erstatter det du eventuelt allerede har justert her (ikke lagret ennå). Fortsette?")) {
return
}
setError(null)
const res = await fetch(`${base}/rounds/${roundId}/groups/suggest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ group_size: size }),
})
if (!res.ok) {
setError("Klarte ikke å beregne et forslag.")
return
}
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage(null)
}
function addEmptyGroup() {
const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1
setGroups((prev) => [...prev, { id: null, sequence: nextSequence, tee_time: null, tee_time_override: null, participants: [] }])
}
function removeGroup(sequence: number) {
const group = groups.find((g) => g.sequence === sequence)
if (!group) return
setUngrouped((prev) => [...prev, ...group.participants])
setGroups((prev) => prev.filter((g) => g.sequence !== sequence))
}
// VIKTIG: `moved` må leses SYNKRONT fra det gjeldende (allerede rendrede)
// state-et, IKKE inne i en setState-oppdateringsfunksjon -- React kjører
// ikke oppdateringsfunksjoner umiddelbart, så en `let moved` satt inni én
// slik funksjon er fortsatt `null` når den leses rett etterpå i samme
// kall. Funnet under egen scratch-verifisering: en flyttet spiller
// forsvant sporløst (fjernet fra den gamle gruppen, aldri lagt til den
// nye) fordi nettopp dette skjedde.
function moveParticipant(participantId: string, toSequence: number | "ungrouped") {
const moved =
groups.flatMap((g) => g.participants).find((p) => p.round_participant_id === participantId) ??
ungrouped.find((p) => p.round_participant_id === participantId)
if (!moved) return
setGroups((prev) =>
prev.map((g) => {
if (g.sequence === toSequence) {
const withoutMoved = g.participants.filter((p) => p.round_participant_id !== participantId)
return { ...g, participants: [...withoutMoved, moved] }
}
return { ...g, participants: g.participants.filter((p) => p.round_participant_id !== participantId) }
}),
)
setUngrouped((prev) => {
const withoutMoved = prev.filter((p) => p.round_participant_id !== participantId)
return toSequence === "ungrouped" ? [...withoutMoved, moved] : withoutMoved
})
}
async function save() {
setSaving(true)
setError(null)
setSavedMessage(null)
try {
const res = await fetch(`${base}/rounds/${roundId}/groups`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
groups: groups.map((g) => ({
sequence: g.sequence,
tee_time_override: null,
round_participant_ids: g.participants.map((p) => p.round_participant_id),
})),
}),
})
if (!res.ok) throw new Error()
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage("Lagret.")
} catch {
setError("Klarte ikke å lagre utslagsgruppene. Prøv igjen.")
} finally {
setSaving(false)
}
}
const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b)
function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) {
return (
<label className="flex items-center gap-2">
<span className="sr-only">Gruppe</span>
<select
value={currentSequence}
onChange={(e) => {
const v = e.target.value
moveParticipant(participantId, v === "ungrouped" ? "ungrouped" : Number(v))
}}
className={cn(CONTROL, "h-9 px-2 text-xs")}
>
<option value="ungrouped">Ugruppert</option>
{allSequences.map((seq) => (
<option key={seq} value={seq}>
Gruppe {seq}
</option>
))}
</select>
</label>
)
}
return (
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background px-5 py-4 sm:px-8">
<div className="flex flex-col">
<h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1>
<p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8">
{error && (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
)}
{loading ? (
<p className="text-sm font-medium text-muted-foreground">Laster</p>
) : (
<>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<h2 className="text-base font-bold text-foreground">Første utslag og intervall</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden),
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette
antall minutter senere, og videre.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Første utslag</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
<input
type="number"
min={1}
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="f.eks. 10"
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
</div>
</section>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Grupper</h2>
<p className="text-sm text-muted-foreground">
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
</p>
</div>
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
<input
type="number"
min={1}
max={12}
value={groupSize}
onChange={(e) => setGroupSize(e.target.value)}
className={cn(CONTROL, "w-20")}
/>
</label>
<button
type="button"
onClick={() => void suggest()}
className="inline-flex h-10 items-center justify-center rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Foreslå grupper
</button>
</div>
</div>
<div className="flex flex-col gap-3">
{groups.length === 0 && (
<p className="text-sm font-medium text-muted-foreground">
Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".
</p>
)}
{groups
.slice()
.sort((a, b) => a.sequence - b.sequence)
.map((g) => (
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{g.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
</span>
</div>
<button
type="button"
onClick={() => removeGroup(g.sequence)}
aria-label={`Fjern gruppe ${g.sequence}`}
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</div>
{g.participants.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">Ingen spillere i denne gruppen ennå.</p>
) : (
<ul className="flex flex-col gap-2">
{g.participants.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<GroupSelect participantId={p.round_participant_id} currentSequence={g.sequence} />
</li>
))}
</ul>
)}
</div>
))}
<button
type="button"
onClick={addEmptyGroup}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dashed border-border text-sm font-bold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Ny gruppe
</button>
</div>
<div className="border-t border-border pt-3">
<h3 className="mb-2 text-sm font-bold text-foreground">
Ugrupperte ({ungrouped.length})
</h3>
{ungrouped.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">Alle deltakere er i en gruppe.</p>
) : (
<ul className="flex flex-col gap-2">
{ungrouped.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
{allSequences.length > 0 ? (
<GroupSelect participantId={p.round_participant_id} currentSequence="ungrouped" />
) : (
<span className="text-xs text-muted-foreground">Opprett en gruppe først</span>
)}
</li>
))}
</ul>
)}
</div>
</section>
<div className="flex items-center justify-end gap-3 pb-6">
{savedMessage && <span className="text-sm font-bold text-tee-strong">{savedMessage}</span>}
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="inline-flex h-12 items-center justify-center rounded-xl bg-primary px-6 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre utslagsgrupper"}
</button>
</div>
</>
)}
</div>
</div>
)
}

View file

@ -100,6 +100,20 @@ async def test_delete_individual_tournament_with_real_rounds_and_scores(pool):
"hole_number, gross_strokes) VALUES ($1, $2, 1, 4)", "hole_number, gross_strokes) VALUES ($1, $2, 1, 4)",
org_id, rp_id, org_id, rp_id,
) )
# Utslagsgruppe (migrasjon 084) MED en deltaker faktisk koblet på --
# egen RESTRICT-FK (tournament_round_participant.tournament_round_
# group_id), samme klasse fallgruve som match_participant.team_
# roster_id over. Deltaker slettes FØR runden (og dermed gruppen,
# som kaskaderer fra runden) -- se delete_tournament sin kommentar.
group_id = await conn.fetchval(
"INSERT INTO tournament_round_group (organization_id, tournament_round_id, sequence) "
"VALUES ($1, $2, 1) RETURNING id",
org_id, round_id,
)
await conn.execute(
"UPDATE tournament_round_participant SET tournament_round_group_id = $1 WHERE id = $2",
group_id, rp_id,
)
user = CurrentUser(user_id=owner_id) user = CurrentUser(user_id=owner_id)
await delete_tournament(tournament_id, organization_id=org_id, user=user) await delete_tournament(tournament_id, organization_id=org_id, user=user)
@ -114,6 +128,9 @@ async def test_delete_individual_tournament_with_real_rounds_and_scores(pool):
assert await conn.fetchval( assert await conn.fetchval(
"SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id "SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id
) == 0 ) == 0
assert await conn.fetchval(
"SELECT count(*) FROM tournament_round_group WHERE tournament_round_id = $1", round_id
) == 0
async def test_delete_requires_org_admin_not_just_member(pool): async def test_delete_requires_org_admin_not_just_member(pool):

164
tests/test_round_groups.py Normal file
View file

@ -0,0 +1,164 @@
"""
Utslagsgrupper (migrasjon 084, 2026-08-18, ADR-088): hvem spiller med
hvem, klokka når -- for individuelle turneringer. Bruker: "Jeg må jo
kunne bulksjekke hvem som skal spille fra hvor, med hvem, klokka når?"
Design bekreftet: tradisjonelt utslag (samme starthull, staggerte
klokkeslett med fast intervall), manuell gruppesammensetning med et
forslag å justere, fast gruppestørrelse.
"""
from app.routers.individual_tournaments import (
RoundGroupIn,
SaveRoundGroupsIn,
SuggestGroupsIn,
TournamentRoundUpdate,
list_round_groups,
save_round_groups,
suggest_round_groups,
update_round,
)
from tests.conftest import (
create_org,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
)
import app.db as app_db
async def _setup(org_id, n_players=5):
tournament_id = await create_tournament(org_id, name="Gruppe-turnering")
course_id = await create_course(org_id, name="Gruppe Links")
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
rp_ids = []
for i in range(n_players):
player_id = await create_player(org_id, display_name=f"Spiller {i:02d}")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_ids.append(await create_tournament_round_participant(org_id, round_id, tp_id, tee_id))
return tournament_id, round_id, rp_ids
async def test_update_round_sets_scheduling_fields(pool):
org_id = await create_org()
tournament_id, round_id, _ = await _setup(org_id, n_players=1)
updated = await update_round(
tournament_id, round_id,
TournamentRoundUpdate(scheduled_at="2026-08-19T10:00:00", tee_interval_minutes=10, start_hole=1),
organization_id=org_id,
)
assert updated.tee_interval_minutes == 10
assert updated.start_hole == 1
assert "2026-08-19" in (updated.scheduled_at or "")
async def test_suggest_round_groups_chunks_by_size_and_computes_tee_times_without_saving(pool):
org_id = await create_org()
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=5)
await update_round(
tournament_id, round_id,
TournamentRoundUpdate(scheduled_at="2026-08-19T10:00:00", tee_interval_minutes=10),
organization_id=org_id,
)
suggestion = await suggest_round_groups(
tournament_id, round_id, SuggestGroupsIn(group_size=2), organization_id=org_id
)
assert [len(g.participants) for g in suggestion.groups] == [2, 2, 1]
assert [g.sequence for g in suggestion.groups] == [1, 2, 3]
assert suggestion.groups[0].tee_time is not None and "10:00" in suggestion.groups[0].tee_time
assert suggestion.groups[1].tee_time is not None and "10:10" in suggestion.groups[1].tee_time
assert suggestion.groups[2].tee_time is not None and "10:20" in suggestion.groups[2].tee_time
# Alle grupper i forslaget er usaved (id=None).
assert all(g.id is None for g in suggestion.groups)
# Ingenting skal faktisk ha blitt lagret av selve forslaget.
saved = await list_round_groups(tournament_id, round_id, organization_id=org_id)
assert saved.groups == []
assert len(saved.ungrouped) == 5
async def test_save_round_groups_persists_and_fully_replaces_previous(pool):
org_id = await create_org()
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=4)
await update_round(
tournament_id, round_id,
TournamentRoundUpdate(scheduled_at="2026-08-19T10:00:00", tee_interval_minutes=10),
organization_id=org_id,
)
first = await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[
RoundGroupIn(sequence=1, round_participant_ids=[rp_ids[0], rp_ids[1]]),
RoundGroupIn(sequence=2, round_participant_ids=[rp_ids[2], rp_ids[3]]),
]),
organization_id=org_id,
)
assert len(first.groups) == 2
assert len(first.ungrouped) == 0
# Lagre på nytt med en HELT ANNEN sammensetning -- skal erstatte, ikke legge til.
second = await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[
RoundGroupIn(sequence=1, round_participant_ids=[rp_ids[0], rp_ids[2], rp_ids[3]]),
]),
organization_id=org_id,
)
assert len(second.groups) == 1
assert len(second.groups[0].participants) == 3
assert len(second.ungrouped) == 1
assert second.ungrouped[0].round_participant_id == rp_ids[1]
async with app_db.org_connection(org_id) as conn:
group_count = await conn.fetchval(
"SELECT count(*) FROM tournament_round_group WHERE tournament_round_id = $1", round_id
)
assert group_count == 1 # den gamle gruppen faktisk slettet, ikke bare "løsrevet"
async def test_save_round_groups_leaves_unlisted_participants_ungrouped(pool):
org_id = await create_org()
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=3)
result = await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[RoundGroupIn(sequence=1, round_participant_ids=[rp_ids[0]])]),
organization_id=org_id,
)
assert len(result.groups) == 1
assert {p.round_participant_id for p in result.ungrouped} == {rp_ids[1], rp_ids[2]}
async def test_group_tee_time_override_wins_over_computed(pool):
org_id = await create_org()
tournament_id, round_id, rp_ids = await _setup(org_id, n_players=2)
await update_round(
tournament_id, round_id,
TournamentRoundUpdate(scheduled_at="2026-08-19T10:00:00", tee_interval_minutes=10),
organization_id=org_id,
)
result = await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[
RoundGroupIn(sequence=1, tee_time_override="2026-08-19T09:15:00", round_participant_ids=rp_ids),
]),
organization_id=org_id,
)
assert result.groups[0].tee_time is not None
assert "09:15" in result.groups[0].tee_time # override, IKKE den utledede 10:00