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)
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
OK), deretter `docker compose build teecup_api teecup_frontend &&
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 datetime import datetime, timedelta
from typing import Literal
import asyncpg
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
@ -89,7 +91,13 @@ class TournamentRoundCreate(BaseModel):
name: str | None = None
hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
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)
start_hole: int = Field(default=1, ge=1, le=18)
@ -173,6 +181,61 @@ async def create_round(
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(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
response_model=list[TournamentRound],
@ -602,6 +665,247 @@ async def remove_round_participant(
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)
# =====================================================================

View file

@ -23,6 +23,7 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Clock,
Copy,
Crosshair,
Flag,
@ -41,6 +42,7 @@ import { Button } from "@/components/ui/button"
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 {
DropdownMenu,
DropdownMenuContent,
@ -2176,6 +2178,7 @@ 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([
@ -2330,10 +2333,30 @@ 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,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)",
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)
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(
"SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id
) == 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):

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