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". +

+ )} + {groups + .slice() + .sort((a, b) => a.sequence - b.sequence) + .map((g) => ( +
+
+
+ + {g.sequence} + + + +
+ +
+ {g.participants.length === 0 ? ( +

Ingen spillere i denne gruppen ennå.

+ ) : ( +
    + {g.participants.map((p) => ( +
  • + {p.player_name} + +
  • + ))} +
+ )} +
+ ))} + +
+ +
+

+ Ugrupperte ({ungrouped.length}) +

+ {ungrouped.length === 0 ? ( +

Alle deltakere er i en gruppe.

+ ) : ( +
    + {ungrouped.map((p) => ( +
  • + {p.player_name} + {allSequences.length > 0 ? ( + + ) : ( + Opprett en gruppe først + )} +
  • + ))} +
+ )} +
+
+ +
+ {savedMessage && {savedMessage}} + +
+ + )} +
+
+ ) +} diff --git a/tests/test_delete_tournament.py b/tests/test_delete_tournament.py index f9840ec..5c45665 100644 --- a/tests/test_delete_tournament.py +++ b/tests/test_delete_tournament.py @@ -100,6 +100,20 @@ async def test_delete_individual_tournament_with_real_rounds_and_scores(pool): "hole_number, gross_strokes) VALUES ($1, $2, 1, 4)", org_id, rp_id, ) + # Utslagsgruppe (migrasjon 084) MED en deltaker faktisk koblet på -- + # egen RESTRICT-FK (tournament_round_participant.tournament_round_ + # group_id), samme klasse fallgruve som match_participant.team_ + # roster_id over. Deltaker slettes FØR runden (og dermed gruppen, + # som kaskaderer fra runden) -- se delete_tournament sin kommentar. + group_id = await conn.fetchval( + "INSERT INTO tournament_round_group (organization_id, tournament_round_id, sequence) " + "VALUES ($1, $2, 1) RETURNING id", + org_id, round_id, + ) + await conn.execute( + "UPDATE tournament_round_participant SET tournament_round_group_id = $1 WHERE id = $2", + group_id, rp_id, + ) user = CurrentUser(user_id=owner_id) await delete_tournament(tournament_id, organization_id=org_id, user=user) @@ -114,6 +128,9 @@ async def test_delete_individual_tournament_with_real_rounds_and_scores(pool): assert await conn.fetchval( "SELECT count(*) FROM tournament_round_hole WHERE tournament_round_participant_id = $1", rp_id ) == 0 + assert await conn.fetchval( + "SELECT count(*) FROM tournament_round_group WHERE tournament_round_id = $1", round_id + ) == 0 async def test_delete_requires_org_admin_not_just_member(pool): diff --git a/tests/test_round_groups.py b/tests/test_round_groups.py new file mode 100644 index 0000000..60b6bd8 --- /dev/null +++ b/tests/test_round_groups.py @@ -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