From 9a9fcc84c0493832f5bb3cbcdb77bdbbcc5322ca Mon Sep 17 00:00:00 2001
From: Erol Haagenrud
Date: Tue, 18 Aug 2026 15:43:39 +0200
Subject: [PATCH] Utslagsgrupper for individuelle turneringer: hvem spiller med
hvem (ADR-088)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
084_tournament_round_groups.sql | 71 +++
ARCHITECTURE_DECISIONS.md | 93 ++++
CHANGELOG.md | 39 ++
app/routers/individual_tournaments.py | 306 +++++++++++-
.../individual-tournament-detail.tsx | 23 +
frontend/components/round-groups-panel.tsx | 453 ++++++++++++++++++
tests/test_delete_tournament.py | 17 +
tests/test_round_groups.py | 164 +++++++
8 files changed, 1165 insertions(+), 1 deletion(-)
create mode 100644 084_tournament_round_groups.sql
create mode 100644 frontend/components/round-groups-panel.tsx
create mode 100644 tests/test_round_groups.py
diff --git a/084_tournament_round_groups.sql b/084_tournament_round_groups.sql
new file mode 100644
index 0000000..8eb869d
--- /dev/null
+++ b/084_tournament_round_groups.sql
@@ -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);
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 56c2cf0..dd96e4a 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -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: `` 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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12d23bd..b7f2cae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py
index a0f6710..9d30497 100644
--- a/app/routers/individual_tournaments.py
+++ b/app/routers/individual_tournaments.py
@@ -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)
# =====================================================================
diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx
index de39e5d..53428a7 100644
--- a/frontend/components/individual-tournament-detail.tsx
+++ b/frontend/components/individual-tournament-detail.tsx
@@ -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(null)
const [tees, setTees] = useState([])
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 på plass.
)}
+ {roundParticipants.length > 0 && (
+
+ )}
>
)}
)}
+
+ {groupsOpen && (
+ setGroupsOpen(false)}
+ />
+ )}
)
}
diff --git a/frontend/components/round-groups-panel.tsx b/frontend/components/round-groups-panel.tsx
new file mode 100644
index 0000000..7e7b5e3
--- /dev/null
+++ b/frontend/components/round-groups-panel.tsx
@@ -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(null)
+ const [saving, setSaving] = useState(false)
+ const [savedMessage, setSavedMessage] = useState(null)
+
+ const [roundInfo, setRoundInfo] = useState(null)
+ const [scheduledAtInput, setScheduledAtInput] = useState("")
+ const [intervalInput, setIntervalInput] = useState("")
+
+ const [groups, setGroups] = useState([])
+ const [ungrouped, setUngrouped] = useState([])
+ 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)
+ // 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 (
+
+ )
+ }
+
+ return (
+
+
+
+
Utslagsgrupper
+
{roundLabel}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
Laster…
+ ) : (
+ <>
+
+
Første utslag og intervall
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
Grupper
+
+ Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
+
+
+
+
+
+
+
+
+
+ {groups.length === 0 && (
+
+ Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".
+