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:
parent
986e27ccd9
commit
9a9fcc84c0
8 changed files with 1165 additions and 1 deletions
71
084_tournament_round_groups.sql
Normal file
71
084_tournament_round_groups.sql
Normal 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);
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
|
||||||
|
|
@ -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 på plass.
|
deltakere før det er på 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
453
frontend/components/round-groups-panel.tsx
Normal file
453
frontend/components/round-groups-panel.tsx
Normal 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 så 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
164
tests/test_round_groups.py
Normal 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
|
||||||
Loading…
Reference in a new issue