diff --git a/083_tournament_cut.sql b/083_tournament_cut.sql new file mode 100644 index 0000000..e8f04ff --- /dev/null +++ b/083_tournament_cut.sql @@ -0,0 +1,37 @@ +-- ===================================================================== +-- TeeCup — migrasjon 083 +-- Cut: topp N og delt plass, etter et valgt rundenummer, manuelt anvendt +-- ===================================================================== +-- Kun for individuelle turneringer (stroke_gross/stroke_net/stableford +-- -- samme sett som Augusta-resultattavlen, se ADR-*/CHANGELOG #104/112). +-- Bruker bekreftet design 2026-08-17/18: "Topp N og delt plass" (den +-- N-te plassen og alle uavgjort med den slipper gjennom), "etter et +-- bestemt rundenummer" (organisator velger selv hvilken runde cutten +-- gjelder fra), "vises i egen 'Cut'-seksjon, blokkert fra videre +-- scoring" -- og eksplisitt et MANUELT "Anvend cut"-trykk, IKKE +-- automatisk ved fullspilt runde (for å unngå at cutten slår inn for +-- tidlig mens noen fortsatt spiller den runden). +-- +-- cut_after_round/cut_size er KONFIGURASJON (satt i Oppsett, kan endres +-- fritt inntil cutten faktisk anvendes). cut_applied_at er null inntil +-- organisator trykker "Anvend cut" -- selve anvendelsen er et eksplisitt +-- API-kall (`app/routers/individual_tournaments.py`) som beregner +-- stillingen etter cut_after_round og setter tournament_participant.cut +-- = true for alle UTENFOR cut_size+uavgjort-grensen. Idempotent: kan +-- trykkes på nytt (f.eks. etter en rettelse) -- regner alt på nytt fra +-- bunnen av, ikke bare inkrementelt. +-- +-- tournament_participant.cut er IKKE nullable (false = fortsatt med, +-- samme "ingen regresjon for eksisterende data"-prinsipp som resten av +-- prosjektets nullable/default-valg). +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE tournament + ADD COLUMN cut_after_round smallint, + ADD COLUMN cut_size smallint CHECK (cut_size IS NULL OR cut_size > 0), + ADD COLUMN cut_applied_at timestamptz; + +ALTER TABLE tournament_participant + ADD COLUMN cut boolean NOT NULL DEFAULT false; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index ab6cf44..d3e1807 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -8902,6 +8902,111 @@ ned. --- +## ADR-087: Cut i individuelle turneringer -- topp N og delt plass, manuelt anvendt (2026-08-18) + +Bruker spurte om Cut var tenkt på ("Delt 18. plass eller bedre klarer +Cut'en"). Undersøkt FØR bygging: bekreftet at konseptet ikke fantes noe +sted -- verken i skjema, backend eller frontend. Design bekreftet med +bruker over to runder med spørsmål/svar: + +- **Topp N og delt plass.** Alle med samme til-par-tall som den N-te + plassen slipper gjennom -- IKKE et strengt kutt til nøyaktig N stykker. +- **Etter et organisator-valgt rundenummer.** Ikke fast (f.eks. "alltid + etter runde 1") -- fritt konfigurerbart per turnering. +- **Vises i egen "Cut"-seksjon på leaderboardet, blokkert fra videre + scoring.** Kuttede spillere skal IKKE bare vises dempet innimellom + resten -- egen, tydelig atskilt del av tabellen. +- **MANUELT anvendt, ikke automatisk.** Bruker eksplisitt: en "Anvend + cut"-knapp organisator trykker selv, IKKE noe som slår inn automatisk + idet runden er fullspilt -- unngår at cutten treffer feil mens noen + fortsatt er midt i runden. + +**Migrasjon 083:** `tournament.cut_after_round`/`cut_size`/ +`cut_applied_at` (ren KONFIGURASJON + tidsstempel for når cutten faktisk +ble anvendt -- de to første er frie å endre helt til organisator trykker +"Anvend cut"). `tournament_participant.cut boolean NOT NULL DEFAULT +false` (selve utfallet, satt i bulk av selve anvendelsen). + +**Backend (`app/routers/individual_tournaments.py`):** +- `_compute_individual_standings`/`_attach_stroke_play_columns` fikk et + nytt, valgfritt `max_sequence`-parameter -- lar standings beregnes KUN + ut fra runder t.o.m. et gitt rundenummer, uavhengig av hvor langt + turneringen faktisk har kommet siden (brukt av selve cut-beregningen + til å regne "stillingen som den var etter cut_after_round", ikke + dagens stilling). `LeaderboardEntry` fikk `rank: int | None` (rå + konkurranse-rangering uten "T"-prefiks, til numerisk cut-grense- + sammenligning) og `cut: bool`. +- Ny `POST .../apply-cut`: henter stillingen med `max_sequence= + cut_after_round`, finner cut-grensen som `rank` til deltakeren på + `cut_size`-plassen (uavgjorte på nøyaktig den rangeringen slipper + også gjennom -- "delt plass"), setter `tournament_participant.cut` i + ETT bulk-`UPDATE ... SET cut = (id = ANY($ids))` for HELE feltet + (idempotent -- regner alt på nytt fra bunnen hver gang, ikke + inkrementelt, så en rettelse i en tidligere runde og et nytt trykk gir + korrekt oppdatert resultat). Deltakere som ikke har spilt ETT eneste + hull gjennom cut-punktet kuttes alltid, uansett `cut_size`. +- `update_hole`: ny sperre -- en kuttet deltaker (`tournament_participant. + cut = true`) blokkeres (403) fra å registrere score i runder MED + høyere `sequence` enn `cut_after_round`. Runder T.O.M. cut-punktet + forblir fritt redigerbare (cutten låser ikke historikken, kun + fremtiden). +- `_compute_individual_standings` sin vanlige (ikke-cut-begrensede) + variant: kuttede deltakere sorteres ALLTID etter alle ikke-kuttede + (stabil sortering på `cut` alene, bevarer den allerede riktige til- + par-rekkefølgen innad i begge gruppene -- uten dette kunne en kuttet + spiller med et godt frosset tall endt opp foran en som fortsatt + spiller og nettopp har hatt en dårlig runde). `total_label` + overstyres til `"CUT"` -- gjenbruker STATUS-mekanismen WD/DQ/DNS/NR + allerede har i `stroke-play-leaderboard.tsx`. + +**Frontend:** +- `stroke-play-leaderboard.tsx` (liten, håndkodet tilføyelse til den + eksisterende V0-komponenten -- IKKE en ny UI-flate, se + [[feedback_frontend_via_v0]]): ny `CutLineRow`, en tydelig, tilgjengelig + skillelinje ("CUT -- spillerne under gikk ikke videre") satt inn RETT + FØR første rad med `totalLabel === "CUT"` -- komponenten hadde + allerede en kommentar fra V0-rundens prompt om at nettopp dette skulle + være enkelt å legge til senere ("A future 'cut line' divider row can be + inserted between any two FieldRows here without restructuring"). +- `individual-tournament-detail.tsx`: ny `CutCard` i Oppsett-fanen + (samme "vanlig innstillingskort"-mønster som Scoringsmetode/BBB-bonus/ + flaggkart-seksjonene rett over den -- alle allerede håndkodet, ikke + V0-eksportert, så dette er en tilføyelse i samme stil, ikke en ny + UI-flate). To tallfelt (rundenummer, antall som går videre) + "Lagre + innstilling", og en separat "Anvend cut"-knapp med `confirm()` (samme + føre-var-mønster som andre konsekvensfulle handlinger i appen, f.eks. + slett turnering) -- vises kun for brutto/netto/stableford-turneringer. + +**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 6 nye +tester i `tests/test_cut.py` (topp N + delt plass slipper en ekstra +spiller gjennom, idempotens, avvist uten konfigurasjon satt, kuttet +spiller blokkert i senere runde MEN fortsatt kan rette runden t.o.m. +cut-punktet, leaderboard-sortering+status) -- 126/126 grønt (opp fra +120), kjørt via `scripts/run_backend_tests.sh` (egen scratch-database, +rører aldri ekte `teecup_db`). Frontend: midlertidig lokal +`/cutlinepreviewtmp`-side (kun i `next dev`, ALDRI committet) rendret +`StrokePlayLeaderboard` med `MOCK_ROWS` utvidet med to nye "CUT"-rader -- +skjermbilde + a11y-snapshot bekreftet skillelinjen dukker opp nøyaktig +rett før første kuttede rad, med riktig R1-tall fortsatt synlig (kun +TODAY/HULL/R2+ blir dashet ut, samme mønster som WD/DQ). + +**IKKE fullt nettleser-verifisert denne runden:** selve `CutCard`- +skjemaet (Oppsett-fanen) er IKKE klikket gjennom i en ekte scratch- +nettleserøkt -- kun `tsc --noEmit` (feltnavnene i fetch-kallene matcher +`TournamentUpdate`/`apply_cut` strukturelt) og gjenbruk av et allerede +tre ganger fungerende, identisk PATCH-mønster (scoringsmetode/BBB-bonus/ +flaggkart, rett over i samme fil). Vurdert som lav nok gjenværende +risiko til å ikke rettferdiggjøre en full 2FA-innlogging-scratch-runde i +en allerede svært lang økt -- bør likevel klikkes gjennom manuelt (eller +i en fremtidig scratch-runde) før dette lener seg tungt på i en skarp +turnering. + +**Rullet ut:** venter på bekreftelse (migrasjon 083 + backend + frontend +-- egen bekreftelse for selve migrasjonen mot ekte `teecup_db`, se +CLAUDE.md sin sikkerhetsregel). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eeba39..0a125a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12632,3 +12632,36 @@ Neste steg: **Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build teecup_api teecup_frontend && up -d` for begge, rene logger, 200 OK. + +115. **Cut i individuelle turneringer -- 2026-08-18 (ADR-087).** Bruker: + "Sett i gang cut-funksjonen" (design bekreftet tidligere samme dag: + topp N og delt plass, etter et valgt rundenummer, egen "Cut"-seksjon + på leaderboardet, blokkert fra videre scoring). Ett gjenstående + teknisk valg avklart med bruker: cutten anvendes MANUELT (organisator + trykker "Anvend cut"), ikke automatisk ved fullspilt runde. + + Migrasjon 083 (`tournament.cut_after_round`/`cut_size`/ + `cut_applied_at`, `tournament_participant.cut`), ny `POST .../apply- + cut` (idempotent, regner alt på nytt fra bunnen hver gang), sperre i + `update_hole` mot score-registrering i runder etter cut-punktet for + kuttede deltakere (historikken t.o.m. cut-punktet forblir + redigerbar). Leaderboard: kuttede deltakere sorteres alltid sist + (stabil sortering, ikke basert på deres frosne til-par-tall alene), + `total_label="CUT"` gjenbruker den eksisterende WD/DQ-status- + mekanismen i `stroke-play-leaderboard.tsx`, pluss en ny synlig "CUT"- + skillelinje satt inn rett før første kuttede rad (nøyaktig det + V0-eksporten sin egen kommentar fra punkt 112 forutså plass til). Ny + "Cut"-kort i Oppsett-fanen (`individual-tournament-detail.tsx`), + håndkodet i samme stil som de tre andre allerede håndkodede + innstillingskortene rett over (scoringsmetode/BBB-bonus/flaggkart) -- + se ADR-087 for full detalj, inkl. hvorfor V0 ikke ble brukt her. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 6 nye + backend-tester i `tests/test_cut.py` -- 126/126 grønt (opp fra 120). + Skjermbilde-bekreftet cut-linje-skillelinjen. Se ADR-087 for full + verifiseringsdetalj, inkl. hva som IKKE ble browser-klikket-gjennom + denne runden (selve Oppsett-skjemaet -- lavt vurdert restrisiko, + gjenbruker et identisk, tre ganger allerede fungerende mønster). + + **Rullet ut:** venter på bekreftelse (migrasjon 083 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 f3e03d8..a0f6710 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -940,7 +940,8 @@ async def update_hole( async with org_connection(organization_id) as conn, translate_db_errors(): round_row = await conn.fetchrow( """ - SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method + SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, tr.sequence, + t.scoring_method, t.cut_after_round FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1 """, @@ -953,8 +954,9 @@ async def update_hole( rp = await conn.fetchrow( """ - SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id + SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut FROM tournament_round_participant trp + JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id WHERE trp.id = $1 AND trp.tournament_round_id = $2 """, round_participant_id, @@ -963,6 +965,19 @@ async def update_hole( if rp is None: raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") + # Cut (migrasjon 083, 2026-08-18): en kuttet deltaker kan fortsatt + # se/rette score i runder T.O.M. cut_after_round (historikken deres + # skal ikke låses), men er blokkert fra å registrere score i runder + # ETTER cut-punktet -- det er selve poenget med en cut. + if ( + rp["cut"] + and round_row["cut_after_round"] is not None + and round_row["sequence"] > round_row["cut_after_round"] + ): + raise app_error( + 403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden." + ) + if not await user_is_own_tournament_participant( conn, organization_id, rp["tournament_participant_id"], user.user_id ): @@ -1237,6 +1252,17 @@ class LeaderboardEntry(BaseModel): # (scheduled_at er nullable -- ikke alle turneringer setter det). next_tee_time: str | None = None next_start_hole: int | None = None + # Cut (migrasjon 083, 2026-08-18) -- KUN populert (og kun meningsfullt) + # for scoring_method i (stroke_gross, stroke_net, stableford), samme + # begrensning som resten av Augusta-resultattavlen. `rank` er den RÅ + # konkurranse-rangeringen (uten "T"-prefiks/uavgjort-tekst -- se + # `position` for visningsklar streng) -- brukt internt av apply_cut for + # å finne cut-grensen numerisk, men trygt å eksponere. `cut=True` når + # organisator har trykket "Anvend cut" og denne deltakeren falt utenfor + # cut_size+uavgjort-grensen (eller ikke hadde spilt noe gjennom + # cut_after_round i det hele tatt). + rank: int | None = None + cut: bool = False # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ @@ -1245,7 +1271,9 @@ class LeaderboardEntry(BaseModel): eclectic_holes: list[EclecticHoleCellOut] = [] -async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]: +async def _compute_individual_standings( + conn, tournament_id: str, max_sequence: int | None = None +) -> tuple[dict, list[LeaderboardEntry]]: """Delt mellom `individual_leaderboard` under (denne ENE turneringens egen visning) og Order of Merit sitt leaderboard-endepunkt (`order_of_merit.py`, som summerer resultater over FLERE lenkede @@ -1253,6 +1281,12 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, posisjon-/til-par-beregningen (inkl. den til-par-normaliserte rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`). + `max_sequence` (2026-08-18, Cut): når satt, regnes stillingen KUN ut + fra runder t.o.m. den sekvensen -- brukt av `apply_cut` til å beregne + "stillingen som den var etter cut_after_round", uavhengig av hvor + langt turneringen faktisk har kommet i mellomtiden. `None` (standard) + = alle runder, uendret oppførsel for den vanlige leaderboard-visningen. + Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng trenger `tournament_row["scoring_method"]` for å vite hvilket felt på hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) @@ -1269,6 +1303,7 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, """ SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id, p.display_name AS player_name, + tp.cut, count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, SUM(trs.gross_total)::int AS gross_total, SUM(trs.net_total)::int AS net_total, @@ -1278,12 +1313,17 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, tc.id::text AS class_id, tc.name AS class_name FROM tournament_participant tp JOIN player p ON p.id = tp.player_id - LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id + LEFT JOIN tournament_round_score trs + ON trs.tournament_participant_id = tp.id + AND ($2::smallint IS NULL OR trs.tournament_round_id IN ( + SELECT id FROM tournament_round WHERE tournament_id = $1 AND sequence <= $2 + )) LEFT JOIN tournament_class tc ON tc.id = tp.class_id WHERE tp.tournament_id = $1 GROUP BY tp.id, p.display_name, tc.id, tc.name """, tournament_id, + max_sequence, ) entries = [LeaderboardEntry(**dict(r)) for r in rows] @@ -1299,7 +1339,26 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran # deltakere med -5 til par over 27-36 hull, kun fordi rått # slagtall er mindre jo færre hull man har spilt. - await _attach_stroke_play_columns(conn, tournament_id, method, entries) + await _attach_stroke_play_columns(conn, tournament_id, method, entries, max_sequence=max_sequence) + # Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en + # egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på + # frossent til-par-tall alene (en kuttet spiller sin score endrer + # seg jo aldri mer, mens resten av feltet fortsetter å spille -- + # uten dette ville en kuttet spiller med et godt frosset tall kunne + # ende opp foran en som fortsatt spiller og har hatt en dårlig + # runde). `total_label` overstyres til "CUT" -- gjenbruker + # STATUS-mekanismen som WD/DQ/DNS/NR allerede har i + # stroke-play-leaderboard.tsx (dempet rad, egen badge), pluss en + # visuell skillelinje i komponenten mellom siste ikke-kuttede og + # første kuttede rad. `_attach_stroke_play_columns` har ALLEREDE + # sortert `entries` riktig internt (beste til-par først) -- en + # STABIL sortering på kun `cut` her bevarer den rekkefølgen innad i + # begge gruppene, ingen ny rangeringsnøkkel trengs. + entries.sort(key=lambda e: e.cut) + for e in entries: + if e.cut: + e.total_label = "CUT" + e.is_leader = False elif method == "copenhagen": # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) @@ -1334,6 +1393,80 @@ async def individual_leaderboard( return entries +class CutResult(BaseModel): + cut_after_round: int + cut_size: int + survivors: int + cut_count: int + cut_participant_ids: list[str] + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/apply-cut", + response_model=CutResult, +) +async def apply_cut( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> CutResult: + """Anvender cutten MANUELT (migrasjon 083, 2026-08-18) -- bruker + bekreftet eksplisitt: IKKE automatisk ved fullspilt runde, organisator + trykker selv en "Anvend cut"-knapp når de er klare (unngår at cutten + slår inn mens noen fortsatt er midt i runden). Topp `cut_size` OG + delt plass slipper gjennom -- alle med samme til-par-tall som + grenseplassen regnes som "innenfor". Deltakere som ikke har spilt ETT + eneste hull gjennom `cut_after_round` kuttes alltid, uansett `cut_size` + (ingen resultat å beskytte). + + IDEMPOTENT -- regner cut-linjen på nytt fra bunnen av HELE feltet hver + gang (aldri inkrementelt), så et nytt trykk etter en score-rettelse i + en tidligere runde gir riktig oppdatert resultat, ikke et lag oppå + forrige kjøring. Kan derfor trykkes flere ganger.""" + async with org_connection(organization_id) as conn, translate_db_errors(): + tournament = await conn.fetchrow( + "SELECT format_type, scoring_method, cut_after_round, cut_size FROM tournament WHERE id = $1", + tournament_id, + ) + if tournament is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + if tournament["format_type"] != "individual" or tournament["scoring_method"] not in ( + "stroke_gross", "stroke_net", "stableford" + ): + raise app_error( + 400, "VALIDATION_FAILED", "Cut er kun støttet for brutto-, netto- eller stableford-turneringer." + ) + cut_after_round = tournament["cut_after_round"] + cut_size = tournament["cut_size"] + if cut_after_round is None or cut_size is None: + raise app_error( + 400, "VALIDATION_FAILED", "Sett rundenummer og antall som går videre til cut før du anvender den." + ) + + _, entries = await _compute_individual_standings(conn, tournament_id, max_sequence=cut_after_round) + ranked = [e for e in entries if e.rank is not None] + ranked.sort(key=lambda e: e.rank) + if ranked and cut_size < len(ranked): + cutoff_rank = ranked[cut_size - 1].rank + else: + cutoff_rank = ranked[-1].rank if ranked else 0 + cut_ids = [e.tournament_participant_id for e in entries if e.rank is None or e.rank > cutoff_rank] + + await conn.execute( + "UPDATE tournament_participant SET cut = (id = ANY($2::uuid[])) WHERE tournament_id = $1", + tournament_id, + cut_ids, + ) + await conn.execute("UPDATE tournament SET cut_applied_at = now() WHERE id = $1", tournament_id) + + return CutResult( + cut_after_round=cut_after_round, + cut_size=cut_size, + survivors=len(entries) - len(cut_ids), + cut_count=len(cut_ids), + cut_participant_ids=cut_ids, + ) + + _HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9} @@ -1352,7 +1485,7 @@ def _to_par_tone(v: int) -> str: async def _attach_stroke_play_columns( - conn, tournament_id: str, method: str, entries: list[LeaderboardEntry] + conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None ) -> None: """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx): POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av @@ -1360,6 +1493,8 @@ async def _attach_stroke_play_columns( gross_total/net_total/stableford_total som ALLEREDE er summert riktig, legger kun til det som mangler: par-for-spilte-hull (for til-par-tall), per-runde nedbrytning, og posisjon-med-uavgjort-håndtering. + + `max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`. """ rounds_meta = await conn.fetch( """ @@ -1399,9 +1534,11 @@ async def _attach_stroke_play_columns( AND trh.gross_strokes IS NOT NULL ) pp ON true WHERE tr.tournament_id = $1 AND trs.holes_played > 0 + AND ($2::smallint IS NULL OR tr.sequence <= $2) ORDER BY tr.sequence """, tournament_id, + max_sequence, ) by_participant: dict[str, dict[int, object]] = {} @@ -1527,6 +1664,7 @@ async def _attach_stroke_play_columns( for k in range(i, j): entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) entries[k].is_leader = rank == 1 and not not_started_group + entries[k].rank = None if not_started_group else rank i = j diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 2323ad1..821e6b5 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -61,7 +61,8 @@ _TOURNAMENT_COLUMNS = """ id::text, name, status::text, start_date, end_date, visibility, description, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval, hero_image_key, - join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible + join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible, + cut_after_round, cut_size, cut_applied_at """ # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). @@ -92,6 +93,14 @@ class Tournament(BaseModel): bbb_sweep_bonus_enabled: bool # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). flag_map_visible: bool + # Cut (migrasjon 083, 2026-08-18) -- KUN meningsfullt for individuelle + # stroke play/net/stableford-turneringer, se app/routers/ + # individual_tournaments.py sin apply_cut. cut_applied_at er null inntil + # organisator faktisk trykker "Anvend cut" -- cut_after_round/cut_size + # alene er bare KONFIGURASJON, ikke en aktiv cut. + cut_after_round: int | None + cut_size: int | None + cut_applied_at: datetime | None def _tournament_from_row(row) -> Tournament: @@ -124,7 +133,7 @@ async def list_tournaments( t.registration_capacity, t.registration_overflow_policy, t.registration_requires_approval, t.hero_image_key, t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, - t.flag_map_visible + t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at FROM tournament t LEFT JOIN ( SELECT tournament_id, @@ -201,6 +210,12 @@ class TournamentUpdate(BaseModel): # get_authorized_org under). Standard AV, samme "skjult til avslørt"- # filosofi som visibility. flag_map_visible: bool | None = None + # Cut-KONFIGURASJON (migrasjon 083, 2026-08-18) -- fritt endre/nullstille + # helt til organisator faktisk trykker "Anvend cut" (se apply_cut i + # individual_tournaments.py, som setter cut_applied_at -- IKKE satt her, + # denne PATCH-en er kun for selve innstillingene). + cut_after_round: int | None = Field(default=None, ge=1) + cut_size: int | None = Field(default=None, gt=0) # hero_image_key er BEVISST IKKE med her -- den settes kun via # upload_hero_image under, aldri som en fritekst-streng klienten sender, # siden den nå alltid kommer fra en ekte, server-konvertert opplasting. diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 943f4d7..de39e5d 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -109,6 +109,12 @@ type ApiTournamentInfo = { bbb_sweep_bonus_enabled: boolean // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). flag_map_visible: boolean + // Cut (migrasjon 083, 2026-08-18) -- kun meningsfullt for brutto/netto/ + // stableford. cut_applied_at er null inntil organisator faktisk trykker + // "Anvend cut" -- cut_after_round/cut_size alene er bare konfigurasjon. + cut_after_round: number | null + cut_size: number | null + cut_applied_at: string | null } type ApiParticipant = { @@ -508,6 +514,56 @@ export function IndividualTournamentDetail({ } } + // Cut (migrasjon 083, 2026-08-18) -- ren KONFIGURASJON, fritt endre/ + // nullstille helt til "Anvend cut" faktisk trykkes (applyCut under). + async function updateCutConfig(afterRound: number | null, size: number | null) { + if (!tournament) return + const previous = tournament + setTournament({ ...tournament, cut_after_round: afterRound, cut_size: size }) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ cut_after_round: afterRound, cut_size: size }), + }) + if (!res.ok) throw new Error() + } catch { + setTournament(previous) + setError("Klarte ikke å lagre cut-innstillingene. Prøv igjen.") + } + } + + // MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE + // automatisk ved fullspilt runde) -- idempotent på serveren, men likevel + // en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra + // videre scoring), derfor samme confirm()-mønster som andre irreversible + // handlinger i appen (slett turnering osv.). + async function applyCut() { + if (!tournament) return + if ( + !confirm( + "Anvende cut nå? Spillere utenfor grensen blokkeres fra å registrere score i senere runder. Kan kjøres på nytt (regner alt på nytt), men bør ikke trykkes før alle er ferdige med runden.", + ) + ) { + return + } + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/apply-cut`, { + method: "POST", + credentials: "include", + }) + if (!res.ok) throw new Error() + const result: { survivors: number; cut_count: number } = await res.json() + setTournament({ ...tournament, cut_applied_at: new Date().toISOString() }) + await refreshParticipants() + setError(null) + alert(`Cut anvendt: ${result.survivors} går videre, ${result.cut_count} er kuttet.`) + } catch { + setError("Klarte ikke å anvende cutten. Prøv igjen.") + } + } + async function addParticipant(playerId: string) { setError(null) const res = await fetch(`${base}/participants`, { @@ -817,6 +873,8 @@ export function IndividualTournamentDetail({ onUpdateScoringMethod={updateScoringMethod} onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateFlagMapVisible={updateFlagMapVisible} + onUpdateCutConfig={updateCutConfig} + onApplyCut={applyCut} onAddParticipant={addParticipant} onAddNewPlayer={addNewPlayerAndParticipant} onImportComplete={refreshParticipants} @@ -890,6 +948,8 @@ function SetupTab({ onUpdateScoringMethod, onUpdateBbbSweepBonus, onUpdateFlagMapVisible, + onUpdateCutConfig, + onApplyCut, onAddParticipant, onAddNewPlayer, onImportComplete, @@ -914,6 +974,8 @@ function SetupTab({ onUpdateScoringMethod: (method: string) => void onUpdateBbbSweepBonus: (enabled: boolean) => void onUpdateFlagMapVisible: (visible: boolean) => void + onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise + onApplyCut: () => Promise onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (input: NewPlayerInput) => Promise onImportComplete: () => Promise @@ -1022,6 +1084,11 @@ function SetupTab({ onError={onError} /> + {tournament.scoring_method != null && + ["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && ( + + )} + Promise + onApplyCut: () => Promise +}) { + const [afterRound, setAfterRound] = useState(tournament.cut_after_round?.toString() ?? "") + const [size, setSize] = useState(tournament.cut_size?.toString() ?? "") + const [saving, setSaving] = useState(false) + const [applying, setApplying] = useState(false) + + const sortedRounds = [...rounds].sort((a, b) => a.sequence - b.sequence) + const dirty = afterRound !== (tournament.cut_after_round?.toString() ?? "") || size !== (tournament.cut_size?.toString() ?? "") + const configComplete = afterRound.trim() !== "" && size.trim() !== "" + + async function handleSave() { + setSaving(true) + await onUpdateConfig(afterRound.trim() ? Number(afterRound) : null, size.trim() ? Number(size) : null) + setSaving(false) + } + + async function handleApply() { + setApplying(true) + await onApplyCut() + setApplying(false) + } + + return ( +
+

Cut

+

+ Topp N og delt plass slipper videre etter valgt runde. Kuttede spillere vises i egen + seksjon på leaderboardet og blokkeres fra å registrere score i senere runder. + {sortedRounds.length > 0 && ( + <> Turneringen har {sortedRounds.length} {sortedRounds.length === 1 ? "runde" : "runder"} satt opp. + )} +

+ +
+ + + +
+ +
+

+ {tournament.cut_applied_at + ? `Cut anvendt ${new Date(tournament.cut_applied_at).toLocaleString("no-NO", { dateStyle: "medium", timeStyle: "short" })}. Kan anvendes på nytt hvis noe rettes.` + : "Cut er ikke anvendt ennå -- ingen spillere er blokkert."} +

+ +
+
+ ) +} + // Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin // ClassesCard for lagturnering-varianten (samme mønster, men uten // leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten). diff --git a/frontend/components/stroke-play-leaderboard.tsx b/frontend/components/stroke-play-leaderboard.tsx index 636fbf9..a4cd332 100644 --- a/frontend/components/stroke-play-leaderboard.tsx +++ b/frontend/components/stroke-play-leaderboard.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { Clock, Flag, Pause, Play, Trophy } from "lucide-react" import { cn } from "@/lib/utils" @@ -61,6 +61,8 @@ export const MOCK_ROWS: LeaderboardRow[] = [ { position: "–", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] }, { position: "-", playerName: "Julie Berget", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: "2026-08-19T11:10:00", nextStartHole: 1, rounds: [] }, { position: "-", playerName: "Selma Vang", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: null, nextStartHole: 10, rounds: [] }, + { position: "16", playerName: "Thea Foss", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 81)] }, + { position: "17", playerName: "Vilde Skog", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 84)] }, ] // --- Tone helpers ---------------------------------------------------------- @@ -343,11 +345,20 @@ export function StrokePlayLeaderboard({ )} - {/* A future "cut line" divider row can be inserted between any two - FieldRows here without restructuring. */} - {field.map((row, i) => ( - - ))} + {/* Cut-linje (2026-08-18): "CUT" er en egen, eksplisitt status + (skilt fra WD/DQ/DNS/NR, som fortsatt bare vises inline uten + divider) -- backend sorterer ALLTID kuttede spillere etter + alle fortsatt-med, så "første CUT-rad" er en entydig, + stabil grense å sette streken foran. */} + {(() => { + const firstCutIndex = field.findIndex((row) => row.totalLabel === "CUT") + return field.map((row, i) => ( + + {i === firstCutIndex && } + + + )) + })()} @@ -680,6 +691,29 @@ function RoundTd({ ) } +// --- Cut-linje --------------------------------------------------------- +// Egen seksjon-skillelinje mellom fortsatt-med-spillere og kuttede +// spillere (2026-08-18) -- bruker bekreftet eksplisitt at kuttede +// spillere skal "vises i egen 'Cut'-seksjon", ikke bare en dempet rad +// blant resten. Samme cellePadding/border-språk som resten av tabellen, +// ingen ny farge oppfunnet. + +function CutLineRow({ totalCols }: { totalCols: number }) { + return ( + + +
+
+ + + ) +} + // --- Not-applicable dash cell (status rows) -------------------------------- function DashTd({ bg, border }: { bg: string; border: string }) { diff --git a/tests/test_cut.py b/tests/test_cut.py new file mode 100644 index 0000000..4bb126b --- /dev/null +++ b/tests/test_cut.py @@ -0,0 +1,227 @@ +""" +Cut (migrasjon 083, 2026-08-18): topp N og delt plass, etter et +organisator-valgt rundenummer, MANUELT anvendt (bruker bekreftet +eksplisitt -- ikke automatisk ved fullspilt runde). Kaller de ekte +router-funksjonene direkte (samme mønster som test_order_of_merit.py). +""" + +import pytest +from fastapi import HTTPException + +from app.auth import CurrentUser +from app.routers.individual_tournaments import ( + HoleUpdate as TournamentHoleUpdate, + apply_cut, + individual_leaderboard, + update_hole as tournament_update_hole, +) +from app.routers.tournaments import TournamentUpdate, update_tournament + +from tests.conftest import ( + add_membership, + create_org, + create_org_hole, + create_course, + create_player, + create_tee, + create_tournament, + create_tournament_participant, + create_tournament_round, + create_tournament_round_participant, + create_user, +) + +import app.db as app_db + + +async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None: + user = CurrentUser(user_id=user_id) + for n, gross in enumerate(scores, start=1): + await tournament_update_hole( + tournament_id, round_id, rp_id, n, + TournamentHoleUpdate(gross_strokes=gross, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def _setup(org_id, owner_id, method="stroke_gross"): + tournament_id = await create_tournament(org_id, name="Cut-turnering") + course_id = await create_course(org_id, name="Cut Links") + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n) + 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 = $2 WHERE id = $1", + tournament_id, method, + ) + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2) + + players = {} + for name in ["P1", "P2", "P3", "P4", "P5"]: + player_id = await create_player(org_id, display_name=name) + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + players[name] = tp_id + + # Runde 1: P1 -4, P2/P3 uavgjort -2, P4 +3, P5 +8 (par 72 over 18 hull). + scores_r1 = { + "P1": [3, 3, 3, 3] + [4] * 14, # 68 -> -4 + "P2": [3, 3] + [4] * 16, # 70 -> -2 + "P3": [3, 3] + [4] * 16, # 70 -> -2 (uavgjort med P2) + "P4": [5, 5, 5] + [4] * 15, # 75 -> +3 + "P5": [5] * 8 + [4] * 10, # 80 -> +8 + } + rp1 = {} + for name, tp_id in players.items(): + rp1[name] = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id) + await _score_holes(tournament_id, round1_id, rp1[name], org_id, owner_id, scores_r1[name]) + + # P6 opprettes som deltaker, men spiller ALDRI runde 1 -- skal kuttes + # uansett cut_size (ingen resultat gjennom cut-punktet). + p6_player_id = await create_player(org_id, display_name="P6") + players["P6"] = await create_tournament_participant(org_id, tournament_id, p6_player_id) + + rp2 = {name: await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id) + for name, tp_id in players.items() if name != "P6"} + + return tournament_id, round1_id, round2_id, players, rp2 + + +async def _set_cut_config(org_id, tournament_id, cut_after_round, cut_size): + await update_tournament( + tournament_id, + TournamentUpdate(cut_after_round=cut_after_round, cut_size=cut_size), + organization_id=org_id, + ) + + +async def test_apply_cut_top_n_and_ties_lets_extra_player_through(pool): + """cut_size=2, men P2/P3 er uavgjort på grenseplassen -- begge skal + slippe gjennom ("delt plass"), så tre (ikke to) overlever i praksis.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id) + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2) + + result = await apply_cut(tournament_id, organization_id=org_id) + assert result.cut_after_round == 1 + assert result.cut_size == 2 + assert result.survivors == 3 # P1, P2, P3 (uavgjort holdt begge inne) + assert result.cut_count == 3 # P4, P5, P6 + + async with app_db.org_connection(org_id) as conn: + cut_flags = { + name: await conn.fetchval("SELECT cut FROM tournament_participant WHERE id = $1", tp_id) + for name, tp_id in players.items() + } + assert cut_flags["P1"] is False + assert cut_flags["P2"] is False + assert cut_flags["P3"] is False + assert cut_flags["P4"] is True + assert cut_flags["P5"] is True + assert cut_flags["P6"] is True # spilte aldri runde 1 -- kuttes uansett cut_size + + +async def test_apply_cut_is_idempotent(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id) + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2) + + first = await apply_cut(tournament_id, organization_id=org_id) + second = await apply_cut(tournament_id, organization_id=org_id) + assert first.cut_participant_ids == second.cut_participant_ids or ( + set(first.cut_participant_ids) == set(second.cut_participant_ids) + ) + assert second.survivors == first.survivors + assert second.cut_count == first.cut_count + + +async def test_apply_cut_requires_config_set_first(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, *_ = await _setup(org_id, owner_id) + + with pytest.raises(HTTPException) as exc_info: + await apply_cut(tournament_id, organization_id=org_id) + assert exc_info.value.status_code == 400 + + +async def test_cut_player_blocked_from_scoring_in_later_round(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id) + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2) + await apply_cut(tournament_id, organization_id=org_id) + + user = CurrentUser(user_id=owner_id) + # P4 er kuttet -- runde 2 (etter cut_after_round=1) skal avvises. + with pytest.raises(HTTPException) as exc_info: + await tournament_update_hole( + tournament_id, round2_id, rp2["P4"], 1, + TournamentHoleUpdate(gross_strokes=4, expected_version=None), + organization_id=org_id, user=user, + ) + assert exc_info.value.status_code == 403 + + # P1 overlevde cutten -- runde 2 skal fortsatt fungere som normalt. + result = await tournament_update_hole( + tournament_id, round2_id, rp2["P1"], 1, + TournamentHoleUpdate(gross_strokes=4, expected_version=None), + organization_id=org_id, user=user, + ) + assert result.gross_strokes == 4 + + +async def test_cut_player_can_still_edit_score_in_round_at_or_before_cut_point(pool): + """Cutten låser IKKE historikken -- en rettelse i runde 1 (t.o.m. + cut_after_round) skal fortsatt være mulig for en kuttet spiller.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id) + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2) + await apply_cut(tournament_id, organization_id=org_id) + + async with app_db.org_connection(org_id) as conn: + rp1_p4 = await conn.fetchval( + "SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + round1_id, players["P4"], + ) + user = CurrentUser(user_id=owner_id) + result = await tournament_update_hole( + tournament_id, round1_id, str(rp1_p4), 1, + TournamentHoleUpdate(gross_strokes=5, expected_version=1), + organization_id=org_id, user=user, + ) + assert result.gross_strokes == 5 + + +async def test_leaderboard_marks_cut_players_with_status_and_sorts_them_last(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, round1_id, round2_id, players, rp2 = await _setup(org_id, owner_id) + await _set_cut_config(org_id, tournament_id, cut_after_round=1, cut_size=2) + await apply_cut(tournament_id, organization_id=org_id) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + by_id = {e.tournament_participant_id: e for e in entries} + + assert by_id[players["P4"]].total_label == "CUT" + assert by_id[players["P4"]].cut is True + assert by_id[players["P4"]].is_leader is False + assert by_id[players["P1"]].total_label != "CUT" + assert by_id[players["P1"]].cut is False + + # Alle ikke-kuttede rader kommer FØR alle kuttede rader, uansett + # frossent til-par-tall. + cut_flags_in_order = [by_id[e.tournament_participant_id].cut for e in entries] + first_cut_index = cut_flags_in_order.index(True) + assert all(not c for c in cut_flags_in_order[:first_cut_index]) + assert all(c for c in cut_flags_in_order[first_cut_index:])