diff --git a/069_flag_plant.sql b/069_flag_plant.sql new file mode 100644 index 0000000..db7b2c9 --- /dev/null +++ b/069_flag_plant.sql @@ -0,0 +1,66 @@ +-- ===================================================================== +-- TeeCup — Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, +-- frittstående runder). Se ARCHITECTURE_DECISIONS.md for ADR-tillegget. +-- +-- To nye, uavhengige tabeller: +-- +-- 1. round_participant_flag_plant -- posisjonen der spilleren gikk tom +-- for slag. ETT aktivt flagg per deltaker (UNIQUE på round_ +-- participant_id) -- re-planting er slett+opprett-på-nytt, samme +-- "aldri en delvis UPDATE"-prinsipp som round_shot (migrasjon 060). +-- `lap`/`hole_number` er hvilket hull/runde-nummer flagget faktisk +-- tilhører (beregnet server-side fra registrert scoredata, aldri +-- stolt blindt på fra klienten -- se app/routers/rounds.py). +-- +-- 2. round_hole_flag_overflow -- scoreføring for "runde 2+" (spilleren +-- fullførte 18 hull MED slag igjen, fortsetter play_order på nytt). +-- BEVISST en helt egen tabell, IKKE en lap-kolonne på selve +-- round_hole -- den tabellen er delt av samtlige ni eksisterende +-- spilleformater og lest av et stort antall eksisterende spørringer +-- (scorekort/statistikk/HCP-differensial) som alle antar ≤18 rader +-- per deltaker. En isolert tabell holder ALL risiko for denne +-- reelt sjeldne (krever uvanlig sjenerøst slagbudsjett) funksjonen +-- innenfor selve flagg-funksjonen, uten å røre noe annet format. +-- +-- Eierskap/tilgang: samme non-RLS plain_connection() + app-lag- +-- autorisasjon som resten av frittstående-rundesystemet (ADR-033 +-- Beslutning A) -- speiler round_shot presist. +-- ===================================================================== +\set ON_ERROR_STOP on + +CREATE TABLE round_participant_flag_plant ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + round_participant_id uuid NOT NULL UNIQUE + REFERENCES round_participant(id) ON DELETE CASCADE, + lap smallint NOT NULL DEFAULT 1 CHECK (lap >= 1), + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90), + lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180), + -- Landet flagget PÅ selve greenen -- avstand til hullet (cm-presisjon, + -- meter+cm slås sammen til én cm-verdi her, splittes kun i UI-et) + -- brukes til å rangere spillere som gikk tom på SAMME hull mot + -- hverandre (jo nærmere koppen, jo lengre har spilleren kommet). + on_green boolean NOT NULL DEFAULT false, + distance_to_pin_cm integer CHECK (distance_to_pin_cm >= 0), + CHECK (on_green OR distance_to_pin_cm IS NULL), + planted_by_user_id uuid NOT NULL REFERENCES app_user(id), + planted_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE round_hole_flag_overflow ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + round_participant_id uuid NOT NULL + REFERENCES round_participant(id) ON DELETE CASCADE, + lap smallint NOT NULL CHECK (lap >= 2), + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + score smallint CHECK (score > 0), + played boolean NOT NULL DEFAULT false, + UNIQUE (round_participant_id, lap, hole_number) +); + +CREATE INDEX round_hole_flag_overflow_participant_idx + ON round_hole_flag_overflow (round_participant_id); + +-- INGEN RLS -- samme plain_connection()-mønster som round_shot/round_hole. +GRANT SELECT, INSERT, DELETE ON round_participant_flag_plant TO teecup_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON round_hole_flag_overflow TO teecup_app; diff --git a/070_flag_plant_individual_tournament.sql b/070_flag_plant_individual_tournament.sql new file mode 100644 index 0000000..734d992 --- /dev/null +++ b/070_flag_plant_individual_tournament.sql @@ -0,0 +1,64 @@ +-- ===================================================================== +-- TeeCup — Flaggturnering: GPS-flaggplanting + runde 2+, org- +-- individuelle turneringer (migrasjon 070). Speiler migrasjon 069 +-- (frittstående runder) presist, samme begrunnelse for hvorfor +-- overflow-scoring er en egen tabell og ikke en lap-kolonne på +-- tournament_round_hole (delt av alle formater, mange eksisterende +-- spørringer antar ≤18 rader per deltaker). +-- +-- Ulikt 069: disse tabellene ER org-scopet og RLS-beskyttet (samme +-- org_isolation-policy-mønster som resten av tournament_round-familien, +-- migrasjon 040). +-- ===================================================================== +\set ON_ERROR_STOP on + +CREATE TABLE tournament_round_participant_flag_plant ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_round_participant_id uuid NOT NULL UNIQUE, + lap smallint NOT NULL DEFAULT 1 CHECK (lap >= 1), + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90), + lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180), + on_green boolean NOT NULL DEFAULT false, + distance_to_pin_cm integer CHECK (distance_to_pin_cm >= 0), + CHECK (on_green OR distance_to_pin_cm IS NULL), + planted_by_user_id uuid NOT NULL REFERENCES app_user(id), + planted_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, tournament_round_participant_id) + REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE +); + +CREATE TABLE tournament_round_hole_flag_overflow ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_round_participant_id uuid NOT NULL, + lap smallint NOT NULL CHECK (lap >= 2), + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + gross_strokes smallint CHECK (gross_strokes BETWEEN 1 AND 20), + played boolean NOT NULL DEFAULT false, + FOREIGN KEY (organization_id, tournament_round_participant_id) + REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE, + UNIQUE (tournament_round_participant_id, lap, hole_number) +); + +CREATE INDEX ON tournament_round_participant_flag_plant (organization_id, tournament_round_participant_id); +CREATE INDEX ON tournament_round_hole_flag_overflow (organization_id, tournament_round_participant_id); + +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'tournament_round_participant_flag_plant', + 'tournament_round_hole_flag_overflow' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t); + EXECUTE format($p$ + CREATE POLICY org_isolation ON %I + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); + $p$, t); + END LOOP; +END $$; diff --git a/071_flag_map_visibility.sql b/071_flag_map_visibility.sql new file mode 100644 index 0000000..a1e071d --- /dev/null +++ b/071_flag_map_visibility.sql @@ -0,0 +1,21 @@ +-- ===================================================================== +-- TeeCup — Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", +-- se ARCHITECTURE_DECISIONS.md ADR-067 for full begrunnelse). +-- +-- Én boolean per hjem, standard AV (samme "skjult til avslørt"-prinsipp +-- som visibility_mode/blind draw) -- ingen, verken medspillere eller +-- tilskuere, ser andres flagg før eieren (frittstående) eller et +-- org-medlem (org-turnering) eksplisitt slår den på. En spiller ser +-- alltid SITT EGET flagg uansett bryter-status (håndtert i app-laget, +-- se app/routers/rounds.py::_build_flag_map og +-- app/routers/individual_tournaments.py::get_flag_map). +-- +-- Endres via eksisterende PATCH-endepunkter (PATCH /rounds/{id} eier-only, +-- PATCH /orgs/{id}/tournaments/{id} ethvert org-medlem) -- ingen nye +-- skrive-endepunkter, kun nye felt på eksisterende modeller. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE round ADD COLUMN flag_map_visible boolean NOT NULL DEFAULT false; +ALTER TABLE tournament ADD COLUMN flag_map_visible boolean NOT NULL DEFAULT false; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 37ce38e..b6ac889 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -6510,6 +6510,212 @@ gang. --- +## ADR-066: Flaggturnering -- GPS-flaggplanting + runde 2+-scoreføring ("Del A") — 2026-08-14 + +Bruker ba om en tredelt utvidelse av Flaggturnering: (A) spilleren +"planter flagget" via GPS der slagbudsjettet tar slutt, (B) +turneringsledelsen kan slå av/på en kartoversikt over ALLE plantede +flagg (satellittfoto), synlig for spillere og tilskuere, (C) nytt format +Eclectic (brutto/netto/Stableford, beste resultat per hull på tvers av +en turnerings runder). **Kun Del A er bygget i denne runden** -- Del +B/C er fullt spesifisert i en godkjent plan, men ingen kode skrevet +ennå. + +Bekreftet med bruker (AskUserQuestion, to runder): +1. Flagg-GPS bygges for BEGGE hjem -- frittstående runder OG + org-individuelle turneringer (Flaggturnering finnes i begge, selv om + nærmeste presedens -- ADR-048 slag-for-slag-GPS -- kun fantes for + frittstående runder). +2. **Full runde 2-scoreføring**, ikke bare en visuell markering -- + spilleren som fullfører 18 hull MED slag igjen fortsetter reelt inn i + "runde 2" på samme play_order. +3. **Selvkorrigerende, interaktiv flyt** (brukerens eget forslag, + foretrukket fremfor mitt opprinnelige forslag om en passiv + påminnelse): spilleren trykker "Plant flagget" når som helst; appen + spør "Hullet du ut på hull N?" (N = første hull uten registrert score + i spillerekkefølgen). JA → flagget var prematurt, spilleren må føre + inn scoren for hull N og trykke "Plant flagget" på nytt fra neste + utslag (ingen innsending skjer). NEI → dette ER flagg-punktet, GPS + fanges, og appen spør om spilleren landet på green (i så fall: + avstand i meter+cm, som direkte påvirker resultatlisten -- jo nærmere + koppen, jo lengre har spilleren kommet blant andre som gikk tom på + samme hull). **Backend validerer UANSETT server-side** mot faktisk + registrert scoredata ("stol aldri blindt på klienten") -- klientflyten + er en UX-guide, ikke autoritativ. + +**Datamodell -- to nye, HELT ISOLERTE tabellpar** (migrasjon 069 +frittstående, 070 org, se filenes egne headerkommentarer for full +kolonneliste): `round_participant_flag_plant`/`tournament_round_ +participant_flag_plant` (posisjon der spilleren gikk tom for slag, UNIQUE +på deltaker-id -- re-planting er slett+opprett-på-nytt, samme prinsipp +som `round_shot`) og `round_hole_flag_overflow`/`tournament_round_hole_ +flag_overflow` (scoreføring for runde 2+). **Bevisst IKKE en `lap`- +kolonne på selve `round_hole`/`tournament_round_hole`** -- de tabellene +er delt av samtlige ni eksisterende formater og lest av et stort antall +spørringer (scorekort/statistikk/HCP-differensial) som alle antar ≤18 +rader per deltaker; en delt utvidelse hadde krevd en revisjon av HVER +eksisterende spørring for å unngå at runde-2-rader lekker inn uventet +andre steder. En helt egen tabell holder all ny risiko innenfor selve +flagg-funksjonen. Org-tabellene er RLS-beskyttet, samme +`org_isolation`-mønster som migrasjon 040; frittstående-tabellene har +ingen RLS (samme `plain_connection()`-presedens som resten av +frittstående-rundesystemet). + +**Motor:** ny, ren `flag_lap_and_hole(holes_completed, play_order) -> +(lap, hole_number)`-hjelpefunksjon i `handicap_engine.py`, rett etter +eksisterende `flag_result()`/`FlagResult`. Selve `flag_result()` viste +seg IKKE å trenge endring -- kalleren konkatenerer bare lap 1s fulle +gross_strokes-sekvens med lap 2s (kun hvis lap 1 var fullført) til én +flat liste før kallet; `holes_completed` blir naturlig en telling som +kan overstige 18. 5 nye tester i `test_handicap_engine.py` (budsjett +brukt opp midt i lap 2, budsjett akkurat nok til begge laps uten +plantbart punkt, lap/hull-oversettelse ved ikke-standard start_hole, +m.fl.) -- full suite 122/122. + +**API (speilende ruter i `rounds.py`/`individual_tournaments.py`):** +`POST/GET/DELETE .../flag-plant`, `GET/PUT .../flag-overflow/{lap}/ +holes/{hole_number}`. Server beregner FAKTISK "hull i gang" fra +eksisterende scoredata (`_flag_current_position`/`_flag_current_ +position_org`) og avviser (`VALIDATION_FAILED`, 400) ethvert +plant-/overflow-forsøk som ikke stemmer -- selv om frontend allerede +guider brukeren dit. **To ulike autorisasjonsmodeller bevisst +beholdt, ikke slått sammen:** frittstående runder er flight-styrt +(eier ELLER lenket medspiller kan handle for enhver deltaker, samme som +`update_hole`); org-turneringer er selv-only (`user_is_own_tournament_ +participant`, samme som den filens `update_hole`). + +**Frontend -- ny UI-overflate, bygget via V0 (standingregel i +CLAUDE.md/minne).** Skrev en detaljert V0-prompt for `FlagPlantSheet` +(GPS auto-fanget → "Hullet du ut?" Ja/Nei → evt. "Landet du på green?" ++ meter/cm → bekreftelse), sendt til bruker. Bygde parallelt en tydelig +MERKET midlertidig håndkodet versjon (samme props-kontrakt) for å kunne +verifisere hele backend+integrasjonen uten å vente. **V0-eksporten (zip +11) kom tilbake samme dag** og erstattet den midlertidige versjonen i +BÅDE `round-detail.tsx` (`FlagPlantSheet`) og +`individual-tournament-detail.tsx` (`FlagPlantSheetOrg`, egen kopi per +filens konvensjon -- fikk i tillegg `expectedLap`-prop lagt til +kallstedet, som den midlertidige org-varianten manglet). Selvstendig +GPS-håndtering (ingen kartkomponent -- det er "Del B"), server-avvisning +vist som vedvarende inline-feil (ikke en forsvinnende toast). + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (46/46, inkl. 15 nye flagg-plant- +tester i to nye testfiler). Egen scratch-database + scratch +`teecup_api`-container (port 18000) + lokal `next dev` (port 13000): +ende-til-ende-flyt for BEGGE hjem, inkl. server-side avvisning av et +for-tidlig plantingsforsøk og full runde 2-scoreføring, geolocation +emulert via Chrome DevTools MCP (måtte omgå at `emulate()` kun setter +koordinater, ikke selve Permissions API-tilstanden -- løst med +`navigate_page({type:"reload", initScript: ...})` som overstyrer +`navigator.geolocation` direkte). Lys+mørk bekreftet, lap 2 sin +`(runde {lap})`-visuelle markering bekreftet live. Etter V0-swap: +`tsc --noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet +ned igjen etterpå -- ingen av dette har rørt ekte `teecup_db`. + +**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte `teecup_db` +som `teeoff_admin` (`teecup_app` mangler bevisst `CREATE`-rett på schema +public, se ADR-002-begrunnelsen i `002_roles_and_grants.sql` -- migrasjoner +kjøres alltid av admin-rollen, aldri av runtime-rollen), etter eksplisitt +bekreftelse fra bruker. Alle fire tabeller verifisert direkte i skjemaet +etterpå (kolonner/constraints/RLS-policy som forventet), deretter +`docker compose build teecup_api teecup_frontend && up -d` -- begge +containere startet rent, ingen feil i logg. Del B (kartoversikt + +synlighetsbryter) og Del C (Eclectic) gjenstår, egne +scratch-verifiserings-/bekreftelsesrunder per CLAUDE.md/plan-disiplinen. + +--- + +## ADR-067: Flaggturnering -- kartoversikt over plantede flagg + synlighetsbryter ("Del B") — 2026-08-14 + +Andre del av den tredelte Flaggturnering-utvidelsen (se ADR-066 for Del A +og full kontekst). Turneringsledelsen (frittstående: eier: org: +ethvert medlem) kan slå av/på en satellittkartoversikt som viser ALLE +deltakeres plantede flagg samtidig -- standard AV, samme "skjult til +avslørt"-filosofi som `visibility_mode`/blind draw. En spiller ser +ALLTID sitt eget flagg uansett bryter-status; det er kun ANDRES flagg +bryteren styrer. + +**Avklart med bruker underveis (AskUserQuestion):** org-individuelle +turneringer har INGEN offentlig tilskuer-side i det hele tatt ennå +(`/t/[id]/live` er bygget utelukkende for lagturneringer -- team_a/ +team_b/matcher, ingen `format_type`-gren for individuelt). Å bygge en +slik side fra bunnen for å kunne vise kartet der også ville vært en HELT +EGEN, mye større oppgave enn selve kartfunksjonen. Bruker valgte +(anbefalt alternativ) å AVGRENSE Del B sin org-side til org-medlemmer/ +deltakere (samme tilgangsnivå som det eksisterende `flag-result`- +endepunktet) -- INGEN ny offentlig spectator-infrastruktur bygget her. +Frittstående runder FÅR full tilskuerstøtte, siden `/watch/[id]` og +`/public/rounds/*`-mønsteret allerede finnes og fungerer likt innlogget +som anonymt. + +**Datamodell (migrasjon 071):** to enkle boolean-kolonner, `round. +flag_map_visible` og `tournament.flag_map_visible`, begge `NOT NULL +DEFAULT false`. Ingen nye tabeller -- gjenbruker flagg-plant-tabellene +fra migrasjon 069/070 (ADR-066) direkte, kun ETT nytt felt per hjem for +selve synlighets-bryteren. + +**API:** ingen nye skrive-endepunkter for selve bryteren -- kun nye +felt på de allerede eksisterende PATCH-modellene (`RoundUpdate. +flag_map_visible`, eier-only via `_get_owned_round_or_404`; +`TournamentUpdate.flag_map_visible`, ethvert org-medlem via +`get_authorized_org` -- begge gjenbruker sine filers eksisterende PATCH- +handlere UENDRET, siden begge allerede var generiske "kun de feltene som +faktisk sendes" whitelist-drevne). Nytt LESE-endepunkt per hjem: `GET +/rounds/{id}/flag-map` (delt `_build_flag_map()`-hjelper, gjenbrukt av +BÅDE den autentiserte ruten og `GET /public/rounds/{id}/flag-map` for +tilskuere -- samme datauttrekk, ulik tilgangssjekk via +`_get_accessible_round_or_404` vs. `_get_viewable_round_or_404`) og `GET +/orgs/{id}/tournaments/{id}/rounds/{id}/flag-map` (kun autentisert, +`get_authorized_org`, se avgrensningen over). Responsen har alltid +`visible_to_all` + `flags`-listen -- ALLE deltakeres flagg når bryteren +er PÅ, ELLERS kun requesterens eget (0 eller 1 rad). + +**Frontend -- nok en ny UI-overflate via V0.** Skrev en detaljert prompt +for `flag-map-overview.tsx` (FØRSTE flerpunkts-Mapbox-kart i appen -- +alt annet Mapbox-innhold viser ett punkt om gangen), sendt til bruker. +Bygde parallelt en tydelig MERKET midlertidig håndkodet versjon (samme +props-kontrakt: `flags: FlagMapEntry[]`, `visibleToAll: boolean`, ren +UI uten egne nettverkskall) for å kunne verifisere hele funksjonen uten +å vente. Wired inn via tre tynne, selvstendige "seksjon"-komponenter +(egen fetch + `next/dynamic(..., {ssr:false})`-lasting av selve kartet, +samme mønster som `NassauPanel`): `FlagMapSection` +(`round-detail.tsx`, Administrer-fanen), `FlagMapSectionOrg` +(`individual-tournament-detail.tsx`, Score-fanen) og `WatchFlagMap` +(`watch-round.tsx`, tilskuer-siden -- viser ingenting i det hele tatt +når bryteren er av OG tilskueren ikke har noe eget flagg, i stedet for +en tom/forvirrende boks). Kartet skiller lap 1 fra lap 2+ med BÅDE farge +OG en tekst-badge ("R{lap}") på selve markøren, aldri farge alene +(fargeblindhet-hensynet i CLAUDE.md sin tilgjengelighetsregel), pluss en +alltid synlig tekst-legende. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (50/50, 4 nye flag-map-tester i de +samme to testfilene som ADR-066 -- "kun eget flagg vises når bryteren er +av"/"alle flagg vises når den er på", begge hjem). `tsc --noEmit` rent + +45/45 vitest etter frontend-endringene. Egen scratch-database + scratch +`teecup_api`-container (port 18000, kodendringer live-mountet inn i +containeren for å unngå gjenoppbygging ved hver iterasjon) + lokal `next +dev` (port 13000): sådde to komplette scenarioer direkte via +`tests/conftest.py` sine hjelpefunksjoner (ikke gjenimplementert) -- +frittstående runde og org-turnering, hver med to spillere og ett flagg +plantet hver (én lap 1, én lap 2 med `on_green`+avstand). Bekreftet i +ekte nettleser: bryteren av → kun eget flagg + "Kun ditt eget flagg er +synlig"-banner; bryteren på → begge flagg, korrekt lap 2-merking, popup +med spillernavn/hull/runde/avstand ved trykk; samme for org-siden; +tilskuer-siden (`/watch/[id]`) viser begge flagg når bryteren er på, +uten å kreve innlogging. Lys+mørk bekreftet. Mapbox sin URL-restrikterte +offentlige token krevde samme `Referer`-header-omgåelse via Chrome +DevTools MCP som tidligere kartverifiseringer i denne loggen (se punkt +51 i CHANGELOG.md). Scratch-stacken (database, rolle, container, MinIO- +scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/ +`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen. + +**IKKE rullet ut ennå** -- migrasjon 071 venter på eksplisitt +bekreftelse fra bruker før noe kjøres mot ekte `teecup_db`. Del C +(Eclectic) gjenstår. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ab7ee..ecb9536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11067,3 +11067,110 @@ Neste steg: under hver rettelse er beholdt uendret, ikke slettet. `Sist oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet tilsvarende. Ingen kodeendring, ren dokumentasjonsopprydding. + +82. **Flaggturnering "Del A": GPS-flaggplanting + runde 2+-scoreføring + (ADR-066) — 2026-08-14.** Første av tre bedt-om utvidelser + (Del A GPS-planting/runde 2, Del B kartoversikt+synlighetsbryter, + Del C Eclectic-format) -- kun Del A bygget denne runden, se ADR-066 + for full begrunnelse/avklaringer og plan-filens spesifikasjon av + Del B/C. + + Motor: ny `flag_lap_and_hole()`-hjelpefunksjon i + `handicap_engine.py` (`flag_result()` selv trengte ingen endring -- + kalleren konkatenerer lap 1+lap 2s gross_strokes til én flat liste + før kallet). 5 nye tester, full suite 122/122. + + Migrasjon 069 (`round_participant_flag_plant` + + `round_hole_flag_overflow`, frittstående) og 070 (samme par, + org-individuelle turneringer, RLS-beskyttet) -- to HELT ISOLERTE + tabellpar, bevisst IKKE en `lap`-kolonne på delte + `round_hole`/`tournament_round_hole` (se ADR-066 for + blindsone-begrunnelsen). + + API: speilende `flag-plant`/`flag-overflow`-endepunkter i + `rounds.py` og `individual_tournaments.py`. Server beregner faktisk + "hull i gang" fra registrert scoredata og avviser + (`VALIDATION_FAILED`) ethvert forsøk som ikke stemmer -- klientens + "Hullet du ut?"-flyt er kun en UX-guide. To ulike, bevisst beholdte + autorisasjonsmodeller: flight-styrt (frittstående) vs. selv-only + (org), speiler hver fils eksisterende `update_hole`-mønster. + + Frontend: `FlagPlantSheet`/`FlagPlantSheetOrg` -- ny UI-overflate, + derfor bygget via en Claude-skrevet V0-prompt (sendt til bruker), + med en tydelig MERKET midlertidig håndkodet versjon (samme + props-kontrakt) i mellomtiden for å kunne verifisere hele + backend-flyten uten å vente på eksporten. **V0-eksporten (zip 11) + kom tilbake samme dag** og erstattet den midlertidige versjonen i + begge filer (org-varianten fikk i tillegg `expectedLap`-prop lagt + til kallstedet, som den manglet før). + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh` (46/46, 15 nye tester i to nye + testfiler). Egen scratch-database + scratch `teecup_api`-container + (port 18000) + lokal `next dev` (port 13000): ende-til-ende for + BEGGE hjem inkl. server-side avvisning av for-tidlig planting og + full runde 2-scoreføring, geolocation emulert via Chrome DevTools + MCP (måtte omgå at `emulate()` kun setter koordinater, ikke + Permissions API-tilstanden -- løst med `navigate_page({type: + "reload", initScript: ...})`), lys+mørk bekreftet, lap 2 sin + `(runde {lap})`-markering bekreftet live. Etter V0-swap: `tsc + --noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet + ned igjen etterpå. + + **Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte + `teecup_db` som `teeoff_admin` (`teecup_app` har bevisst ingen + `CREATE`-rett på schema public, jf. ADR-002 -- migrasjoner kjøres av + admin-rollen, aldri runtime-rollen), etter eksplisitt bekreftelse fra + bruker. Alle fire nye tabeller verifisert direkte i skjemaet + etterpå. `docker compose build teecup_api teecup_frontend && up -d` + -- begge containere startet rent (`Application startup complete.` + henholdsvis `✓ Ready`), ingen feil i logg. + +83. **Flaggturnering "Del B": kartoversikt over plantede flagg + + synlighetsbryter (ADR-067) — 2026-08-14.** Andre av tre bedt-om + utvidelser (se punkt 82/ADR-066 for Del A). Underveis avdekket: + org-individuelle turneringer har INGEN offentlig tilskuer-side i det + hele tatt (`/t/[id]/live` er rendyrket for lagturneringer) -- + avklart med bruker via AskUserQuestion, som valgte å avgrense Del B + sin org-side til org-medlemmer/deltakere fremfor å bygge en helt ny + spectator-infrastruktur bare for kartet. Frittstående runder fikk + full tilskuerstøtte (gjenbruker eksisterende `/watch/[id]`/ + `/public/rounds/*`-mønster). + + Migrasjon 071: to boolean-kolonner (`round.flag_map_visible`, + `tournament.flag_map_visible`, begge default false) -- ingen nye + tabeller, gjenbruker flagg-plant-tabellene fra migrasjon 069/070 + direkte. Bryteren endres via de allerede eksisterende PATCH- + endepunktene (kun nye felt på whitelist-drevne modeller, ingen ny + handler-kode). Nytt leseendepunkt per hjem (`GET .../flag-map`), + delt `_build_flag_map()`-hjelper mellom autentisert og offentlig + variant -- alle flagg når bryteren er PÅ, ellers kun requesterens + eget. + + Frontend: enda en ny UI-overflate via V0-prompt (`flag-map- + overview.tsx`, FØRSTE flerpunkts-Mapbox-kart i appen), midlertidig + håndkodet versjon bygget parallelt for å unngå å vente. Tre tynne + "seksjon"-wrappere (`FlagMapSection`/`FlagMapSectionOrg`/ + `WatchFlagMap`) i de tre relevante skjermene, samme + "egen fetch + next/dynamic(ssr:false)"-mønster som `NassauPanel`. + Lap 1/2+ skilles med BÅDE farge OG en "R{lap}"-tekstbadge på selve + markøren (aldri farge alene), pluss alltid synlig tekst-legende. + + **Verifisert:** `python3 -m py_compile` + full + `./scripts/run_backend_tests.sh` (50/50, 4 nye tester). `tsc + --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch + `teecup_api`-container (port 18000, live-mountet kode for rask + iterasjon) + lokal `next dev` (port 13000): to fulle scenarioer + (frittstående + org, hver med to spillere, ett lap 1- og ett lap + 2-flagg med on-green-avstand) sådd via `tests/conftest.py` sine + hjelpefunksjoner. Bekreftet i ekte nettleser: bryter av/på for begge + hjem, korrekt "kun eget flagg"-banner, lap 2-merking, popup-innhold + (navn/hull/runde/avstand), tilskuer-siden viser begge flagg uten + innlogging når bryteren er på. Lys+mørk bekreftet. Samme `Referer`- + header-omgåelse for Mapbox sin URL-restrikterte token som tidligere + (se punkt 51). Scratch-stacken (db/rolle/container/MinIO-bucket) + fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/ + `teecup_frontend` urørt. + + **IKKE rullet ut ennå** -- migrasjon 071 venter på eksplisitt + bekreftelse fra bruker. Del C (Eclectic) gjenstår. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index f8dab4c..3099380 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -573,10 +573,28 @@ alle åtte formater" lenger ned i denne filen — `flag_result`-motoren, hull-granularitet). **GPS-avhengigheten er også borte** — GPS/ avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling, - ADR-064 rangefinder til grønn) — men selve GPS-posisjon-ved-siste-slag- - ideen spesifikt for Flaggturnering er fortsatt ikke designet/bygget - (stale-opprydding 2026-08-14: kun avhengigheten er avklart, ikke - funksjonen selv). + ADR-064 rangefinder til grønn). + + **✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14), + "Del A" av en tredelt utvidelse** (kartoversikt "Del B" og nytt format + Eclectic "Del C" gjenstår, se egne punkter): "Plant flagget"-knapp + + guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt. + "Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot + andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG + org-individuelle turneringer). Full runde 2+-scoreføring bygget for + spillere med uvanlig gunstig slagbudsjett (isolert overflow-tabell, + rører ikke delt `round_hole`/`tournament_round_hole`-infrastruktur). + UI-sheeten er en Claude-skrevet V0-eksport (zip 11), ikke håndkodet. + Migrasjon 069 (frittstående)/070 (org) — se ADR-066 for full detalj, + CHANGELOG.md for byggelogg/verifisering. + + **✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på- + bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg + på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. Kun + Eclectic "Del C" gjenstår nå. Org-individuelle turneringers offentlige + tilskuervisning er BEVISST utenfor omfang her (finnes ikke i det hele + tatt ennå — `/t/[id]/live` er lagturnerings-only — egen, større + oppgave hvis/når etterspurt). Migrasjon 071 — se ADR-067. - **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på: diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 947190c..6db8bf5 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -1403,3 +1403,370 @@ async def flag_round_result( ) results.sort(key=lambda r: -r.holes_completed) return results + + +# --------------------------------------------------------------------------- +# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14). +# Speiler rounds.py sin frittstående-variant presist -- se der for full +# begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap, +# aldri stolt blindt på fra klienten). Ulikt frittstående runder er +# scoreregistrering her SELV-only (user_is_own_tournament_participant), +# samme presedens som update_hole over -- ikke flight-styrt. +# --------------------------------------------------------------------------- + +async def _flag_current_position_org( + conn, tournament_round_participant_id: str, hole_config: str +) -> tuple[int, int]: + """Org-ekvivalent av rounds.py sin _flag_current_position -- samme + logikk, men leser tournament_round_hole/tournament_round_hole_flag_ + overflow, og play_order kommer fra played_hole_numbers(hole_config) + (ingen start_hole-rotasjon på org-siden).""" + play_order = played_hole_numbers(hole_config) + lap1_rows = await conn.fetch( + "SELECT hole_number FROM tournament_round_hole " + "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL", + tournament_round_participant_id, + ) + lap1_played = {r["hole_number"] for r in lap1_rows} + for h in play_order: + if h not in lap1_played: + return 1, h + lap = 2 + while True: + overflow_rows = await conn.fetch( + "SELECT hole_number FROM tournament_round_hole_flag_overflow " + "WHERE tournament_round_participant_id = $1 AND lap = $2 AND played", + tournament_round_participant_id, lap, + ) + overflow_played = {r["hole_number"] for r in overflow_rows} + for h in play_order: + if h not in overflow_played: + return lap, h + lap += 1 + + +class FlagPlantIn(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + hole_number: int = Field(ge=1, le=18) + lap: int = Field(ge=1) + on_green: bool = False + distance_to_pin_cm: int | None = Field(default=None, ge=0) + + +class FlagPlantOut(BaseModel): + id: str + lap: int + hole_number: int + lat: float + lng: float + on_green: bool + distance_to_pin_cm: int | None + planted_by_user_id: str + planted_at: str + + +def _flag_plant_out(row) -> FlagPlantOut: + return FlagPlantOut( + id=row["id"], lap=row["lap"], hole_number=row["hole_number"], + lat=row["lat"], lng=row["lng"], on_green=row["on_green"], + distance_to_pin_cm=row["distance_to_pin_cm"], + planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(), + ) + + +async def _get_round_participant_or_404(conn, tournament_id: str, round_id: str, round_participant_id: str): + round_row = await conn.fetchrow( + """ + SELECT tr.hole_config::text AS hole_config, t.scoring_method + FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id + WHERE tr.id = $1 AND tr.tournament_id = $2 + """, + round_id, tournament_id, + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if round_row["scoring_method"] != "flag": + raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.") + rp = await conn.fetchrow( + "SELECT id, tournament_participant_id::text AS tournament_participant_id " + "FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2", + round_participant_id, round_id, + ) + if rp is None: + raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.") + return round_row, rp + + +async def _require_own_tournament_participant(conn, organization_id: str, tournament_participant_id: str, user_id: str) -> None: + if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user_id): + raise app_error( + 403, "NOT_TOURNAMENT_PARTICIPANT", + "Du kan kun plante/registrere flagg for deg selv (eller være organisasjonsadministrator).", + ) + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/flag-plant", + response_model=FlagPlantOut, +) +async def plant_flag( + tournament_id: str, + round_id: str, + round_participant_id: str, + body: FlagPlantIn, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> FlagPlantOut: + if body.on_green and body.distance_to_pin_cm is None: + raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.") + if not body.on_green and body.distance_to_pin_cm is not None: + raise app_error( + 400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green." + ) + async with org_connection(organization_id) as conn: + round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) + await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) + + actual_lap, actual_hole = await _flag_current_position_org( + conn, round_participant_id, round_row["hole_config"] + ) + if (body.lap, body.hole_number) != (actual_lap, actual_hole): + raise app_error( + 400, "VALIDATION_FAILED", + f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), " + f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.", + ) + + async with conn.transaction(): + await conn.execute( + "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1", + round_participant_id, + ) + row = await conn.fetchrow( + """ + INSERT INTO tournament_round_participant_flag_plant + (organization_id, tournament_round_participant_id, lap, hole_number, lat, lng, + on_green, distance_to_pin_cm, planted_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, + planted_by_user_id::text AS planted_by_user_id, planted_at + """, + organization_id, round_participant_id, body.lap, body.hole_number, body.lat, body.lng, + body.on_green, body.distance_to_pin_cm, user.user_id, + ) + return _flag_plant_out(row) + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/flag-plant", + response_model=FlagPlantOut | None, +) +async def get_flag_plant( + tournament_id: str, + round_id: str, + round_participant_id: str, + organization_id: str = Depends(get_authorized_org), +) -> FlagPlantOut | None: + async with org_connection(organization_id) as conn: + await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) + row = await conn.fetchrow( + """ + SELECT id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, + planted_by_user_id::text AS planted_by_user_id, planted_at + FROM tournament_round_participant_flag_plant + WHERE tournament_round_participant_id = $1 + """, + round_participant_id, + ) + return _flag_plant_out(row) if row is not None else None + + +@router.delete( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/flag-plant", + status_code=204, +) +async def unplant_flag( + tournament_id: str, + round_id: str, + round_participant_id: str, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> None: + async with org_connection(organization_id) as conn: + _, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) + await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) + await conn.execute( + "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1", + round_participant_id, + ) + + +class FlagOverflowHoleIn(BaseModel): + gross_strokes: int = Field(gt=0, le=20) + + +class FlagOverflowHoleOut(BaseModel): + lap: int + hole_number: int + gross_strokes: int | None + played: bool + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/flag-overflow", + response_model=list[FlagOverflowHoleOut], +) +async def list_flag_overflow_holes( + tournament_id: str, + round_id: str, + round_participant_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[FlagOverflowHoleOut]: + async with org_connection(organization_id) as conn: + await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) + rows = await conn.fetch( + "SELECT lap, hole_number, gross_strokes, played FROM tournament_round_hole_flag_overflow " + "WHERE tournament_round_participant_id = $1 ORDER BY lap, hole_number", + round_participant_id, + ) + return [FlagOverflowHoleOut(**dict(r)) for r in rows] + + +@router.put( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/participants/{round_participant_id}/flag-overflow/{lap}/holes/{hole_number}", + response_model=FlagOverflowHoleOut, +) +async def update_flag_overflow_hole( + tournament_id: str, + round_id: str, + round_participant_id: str, + lap: int, + hole_number: int, + body: FlagOverflowHoleIn, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> FlagOverflowHoleOut: + if lap < 2: + raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.") + if not (1 <= hole_number <= 18): + raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.") + async with org_connection(organization_id) as conn: + round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id) + await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id) + + actual_lap, _ = await _flag_current_position_org(conn, round_participant_id, round_row["hole_config"]) + if lap > actual_lap: + raise app_error( + 400, "VALIDATION_FAILED", + f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.", + ) + + row = await conn.fetchrow( + """ + INSERT INTO tournament_round_hole_flag_overflow + (organization_id, tournament_round_participant_id, lap, hole_number, gross_strokes, played) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (tournament_round_participant_id, lap, hole_number) + DO UPDATE SET gross_strokes = $5, played = true + RETURNING lap, hole_number, gross_strokes, played + """, + organization_id, round_participant_id, lap, hole_number, body.gross_strokes, + ) + return FlagOverflowHoleOut(**dict(row)) + + +# --------------------------------------------------------------------------- +# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- speiler +# rounds.py sin frittstående-variant presist. Styrt av tournament.flag_map_ +# visible (ethvert org-medlem kan slå av/på via update_tournament, +# tournaments.py -- se TournamentUpdate.flag_map_visible). Bevisst KUN +# org-medlem-/deltaker-tilgang (samme presedens som flag_round_result over) +# -- ingen offentlig tilskuer-gren, individuelle turneringer har ingen +# offentlig spectator-side i det hele tatt ennå (ulikt frittstående +# rundevisning), se ADR-067 for avklaringen. +# --------------------------------------------------------------------------- + +class FlagMapEntryOut(BaseModel): + participant_id: str + display_name: str + lap: int + hole_number: int + lat: float + lng: float + on_green: bool + distance_to_pin_cm: int | None + planted_at: str + + +class FlagMapOut(BaseModel): + visible_to_all: bool + flags: list[FlagMapEntryOut] + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-map", + response_model=FlagMapOut, +) +async def get_flag_map( + tournament_id: str, + round_id: str, + organization_id: str = Depends(get_authorized_org), + user: CurrentUser = Depends(get_current_user), +) -> FlagMapOut: + async with org_connection(organization_id) as conn: + round_row = await conn.fetchrow( + """ + SELECT t.flag_map_visible + FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id + WHERE tr.id = $1 AND tr.tournament_id = $2 + """, + round_id, + tournament_id, + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + visible_to_all = round_row["flag_map_visible"] + + base_query = """ + SELECT trpfp.tournament_round_participant_id::text AS participant_id, + p.display_name AS display_name, + trpfp.lap, trpfp.hole_number, trpfp.lat, trpfp.lng, + trpfp.on_green, trpfp.distance_to_pin_cm, trpfp.planted_at + FROM tournament_round_participant_flag_plant trpfp + JOIN tournament_round_participant trp ON trp.id = trpfp.tournament_round_participant_id + 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 + """ + if visible_to_all: + rows = await conn.fetch(base_query, round_id) + else: + rows = await conn.fetch( + base_query + " AND p.user_id = $2", + round_id, + user.user_id, + ) + + return FlagMapOut( + visible_to_all=visible_to_all, + flags=[ + FlagMapEntryOut( + participant_id=r["participant_id"], + display_name=r["display_name"], + lap=r["lap"], + hole_number=r["hole_number"], + lat=r["lat"], + lng=r["lng"], + on_green=r["on_green"], + distance_to_pin_cm=r["distance_to_pin_cm"], + planted_at=r["planted_at"].isoformat(), + ) + for r in rows + ], + ) diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 0b90872..468f251 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -1468,6 +1468,8 @@ class RoundOut(BaseModel): flight_group_id: str | None # HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard. allowance_override: dict | None + # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). + flag_map_visible: bool async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: @@ -1477,7 +1479,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling, started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id, bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct, - allowance_override::text AS allowance_override + allowance_override::text AS allowance_override, flag_map_visible FROM round WHERE id = $1 """, round_id, @@ -1602,6 +1604,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: visible_categories=[r["category"] for r in visible_category_rows], flight_group_id=round_row["flight_group_id"], allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None, + flag_map_visible=round_row["flag_map_visible"], ) @@ -2636,6 +2639,11 @@ class RoundUpdate(BaseModel): # KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) -- # samme "None = ikke rør"-konvensjon som resten av denne modellen. scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100) + # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067) + # -- eier-only (samme tilgang som resten av denne PATCH-en). Standard AV + # (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw); + # None = ikke rør, samme konvensjon som resten av modellen her. + flag_map_visible: bool | None = None @router.patch("/rounds/{round_id}", response_model=RoundOut) @@ -2755,6 +2763,10 @@ async def update_round( await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at) if body.visibility_mode is not None: await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode) + if body.flag_map_visible is not None: + await conn.execute( + "UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible + ) if body.flight_group_id is not None: await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id) if body.visible_categories is not None: @@ -5508,6 +5520,353 @@ async def update_hole( return RoundHoleOut(**dict(row), strokes_received=strokes_received) +# --------------------------------------------------------------------------- +# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14). +# Spilleren markerer nøyaktig posisjon der slagbudsjettet ble brukt opp. +# Hvilket hull/lap flagget hører til beregnes ALLTID server-side fra +# faktisk registrert scoredata (aldri stolt blindt på fra klienten, som kun +# guider brukeren dit via en "Hullet du ut?"-flyt) -- se +# _flag_current_position under. round_hole_flag_overflow er en BEVISST +# egen, isolert tabell for runde 2+-scoreføring, ikke en lap-kolonne på +# selve round_hole (delt av alle ni formater, se 069_flag_plant.sql). +# --------------------------------------------------------------------------- + +async def _flag_current_position( + conn, participant_id: str, start_hole: int, holes_planned: int +) -> tuple[int, int]: + """Første hull UTEN registrert score, i spillerekkefølge, på tvers av + laps -- dette ER "der spilleren fysisk er" akkurat nå, uavhengig av om + slagbudsjettet TEORETISK er brukt opp (appen hindrer aldri videre + scoreføring forbi budsjettet, se flag_result() sin egen "v1- + granularitet"-kommentar i handicap_engine.py). Lap 1 leses fra + round_hole, lap 2+ fra round_hole_flag_overflow -- avsluttes så snart + en lap uten NOEN registrerte hull nås (garantert terminering, ingen + uendelig løkke, siden en lap kun kan nås ved at forrige er fullt + scoreført).""" + play_order = _round_hole_order(start_hole, holes_planned) + lap1_rows = await conn.fetch( + "SELECT hole_number FROM round_hole WHERE round_participant_id = $1 AND played", + participant_id, + ) + lap1_played = {r["hole_number"] for r in lap1_rows} + for h in play_order: + if h not in lap1_played: + return 1, h + lap = 2 + while True: + overflow_rows = await conn.fetch( + "SELECT hole_number FROM round_hole_flag_overflow WHERE round_participant_id = $1 AND lap = $2 AND played", + participant_id, lap, + ) + overflow_played = {r["hole_number"] for r in overflow_rows} + for h in play_order: + if h not in overflow_played: + return lap, h + lap += 1 + + +class FlagPlantIn(BaseModel): + lat: float = Field(ge=-90, le=90) + lng: float = Field(ge=-180, le=180) + hole_number: int = Field(ge=1, le=18) + lap: int = Field(ge=1) + on_green: bool = False + # Meter+cm slås sammen til én cm-verdi her (splittes kun i UI-et) -- + # brukes til å rangere spillere som gikk tom på SAMME hull mot + # hverandre (jo nærmere koppen, jo lengre har spilleren kommet). + distance_to_pin_cm: int | None = Field(default=None, ge=0) + + +class FlagPlantOut(BaseModel): + id: str + lap: int + hole_number: int + lat: float + lng: float + on_green: bool + distance_to_pin_cm: int | None + planted_by_user_id: str + planted_at: str + + +def _flag_plant_out(row: asyncpg.Record) -> FlagPlantOut: + return FlagPlantOut( + id=row["id"], lap=row["lap"], hole_number=row["hole_number"], + lat=row["lat"], lng=row["lng"], on_green=row["on_green"], + distance_to_pin_cm=row["distance_to_pin_cm"], + planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(), + ) + + +@router.post( + "/rounds/{round_id}/participants/{participant_id}/flag-plant", + response_model=FlagPlantOut, +) +async def plant_flag( + round_id: str, + participant_id: str, + body: FlagPlantIn, + user: CurrentUser = Depends(get_current_user), +) -> FlagPlantOut: + if body.on_green and body.distance_to_pin_cm is None: + raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.") + if not body.on_green and body.distance_to_pin_cm is not None: + raise app_error( + 400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green." + ) + async with plain_connection() as conn: + # Samme flight-styrte tilgang som update_hole -- en lenket + # medspiller kan plante flagget for HELE flighten, ikke bare seg selv. + await _get_accessible_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow( + "SELECT start_hole, holes_planned, play_format FROM round WHERE id = $1", round_id + ) + if round_row["play_format"] != "flag": + raise app_error(400, "VALIDATION_FAILED", "Flagg kan kun plantes i en flaggturnering.") + participant_exists = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id + ) + if not participant_exists: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + + actual_lap, actual_hole = await _flag_current_position( + conn, participant_id, round_row["start_hole"], round_row["holes_planned"] + ) + if (body.lap, body.hole_number) != (actual_lap, actual_hole): + raise app_error( + 400, "VALIDATION_FAILED", + f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), " + f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.", + ) + + async with conn.transaction(): + # Re-planting: slett og opprett på nytt, samme prinsipp som + # round_shot -- aldri en delvis UPDATE av et allerede plantet flagg. + await conn.execute( + "DELETE FROM round_participant_flag_plant WHERE round_participant_id = $1", participant_id + ) + row = await conn.fetchrow( + """ + INSERT INTO round_participant_flag_plant + (round_participant_id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, planted_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, + planted_by_user_id::text AS planted_by_user_id, planted_at + """, + participant_id, body.lap, body.hole_number, body.lat, body.lng, + body.on_green, body.distance_to_pin_cm, user.user_id, + ) + await broadcast_round_update(round_id) + return _flag_plant_out(row) + + +@router.get( + "/rounds/{round_id}/participants/{participant_id}/flag-plant", + response_model=FlagPlantOut | None, +) +async def get_flag_plant( + round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user) +) -> FlagPlantOut | None: + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + row = await conn.fetchrow( + """ + SELECT fp.id::text AS id, fp.lap, fp.hole_number, fp.lat, fp.lng, fp.on_green, + fp.distance_to_pin_cm, fp.planted_by_user_id::text AS planted_by_user_id, fp.planted_at + FROM round_participant_flag_plant fp + JOIN round_participant rp ON rp.id = fp.round_participant_id + WHERE fp.round_participant_id = $1 AND rp.round_id = $2 + """, + participant_id, round_id, + ) + return _flag_plant_out(row) if row is not None else None + + +@router.delete("/rounds/{round_id}/participants/{participant_id}/flag-plant", status_code=204) +async def unplant_flag( + round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user) +) -> None: + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + await conn.execute( + """ + DELETE FROM round_participant_flag_plant + WHERE round_participant_id = $1 + AND EXISTS (SELECT 1 FROM round_participant rp WHERE rp.id = $1 AND rp.round_id = $2) + """, + participant_id, round_id, + ) + await broadcast_round_update(round_id) + + +class FlagOverflowHoleIn(BaseModel): + score: int = Field(gt=0, le=20) + + +class FlagOverflowHoleOut(BaseModel): + lap: int + hole_number: int + score: int | None + played: bool + + +@router.get( + "/rounds/{round_id}/participants/{participant_id}/flag-overflow", + response_model=list[FlagOverflowHoleOut], +) +async def list_flag_overflow_holes( + round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user) +) -> list[FlagOverflowHoleOut]: + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + rows = await conn.fetch( + """ + SELECT o.lap, o.hole_number, o.score, o.played + FROM round_hole_flag_overflow o + JOIN round_participant rp ON rp.id = o.round_participant_id + WHERE o.round_participant_id = $1 AND rp.round_id = $2 + ORDER BY o.lap, o.hole_number + """, + participant_id, round_id, + ) + return [FlagOverflowHoleOut(**dict(r)) for r in rows] + + +@router.put( + "/rounds/{round_id}/participants/{participant_id}/flag-overflow/{lap}/holes/{hole_number}", + response_model=FlagOverflowHoleOut, +) +async def update_flag_overflow_hole( + round_id: str, + participant_id: str, + lap: int, + hole_number: int, + body: FlagOverflowHoleIn, + user: CurrentUser = Depends(get_current_user), +) -> FlagOverflowHoleOut: + if lap < 2: + raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.") + if not (1 <= hole_number <= 18): + raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.") + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow( + "SELECT start_hole, holes_planned, play_format FROM round WHERE id = $1", round_id + ) + if round_row["play_format"] != "flag": + raise app_error(400, "VALIDATION_FAILED", "Runde 2 finnes kun i en flaggturnering.") + participant_exists = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id + ) + if not participant_exists: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + + # Kan ikke score-føre en lap FØR forrige lap faktisk er fullført -- + # gjenbruker samme "hvor er spilleren egentlig"-beregning som + # selve flaggplantingen, for å unngå å duplisere fullførings-logikken. + actual_lap, _ = await _flag_current_position( + conn, participant_id, round_row["start_hole"], round_row["holes_planned"] + ) + if lap > actual_lap: + raise app_error( + 400, "VALIDATION_FAILED", + f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.", + ) + + row = await conn.fetchrow( + """ + INSERT INTO round_hole_flag_overflow (round_participant_id, lap, hole_number, score, played) + VALUES ($1, $2, $3, $4, true) + ON CONFLICT (round_participant_id, lap, hole_number) + DO UPDATE SET score = $4, played = true + RETURNING lap, hole_number, score, played + """, + participant_id, lap, hole_number, body.score, + ) + await broadcast_round_update(round_id) + return FlagOverflowHoleOut(**dict(row)) + + +# --------------------------------------------------------------------------- +# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- alle +# plantede flagg for runden, på satellittkart. Styrt av round.flag_map_ +# visible (eier-only bryter, se RoundUpdate.flag_map_visible over): PÅ +# viser samtlige deltakeres flagg, AV viser kun spørrerens eget (samme +# "skjult til avslørt"-prinsipp som visibility_mode/blind draw). Delt +# mellom den autentiserte ruten (eier/medspiller) og den offentlige +# tilskuer-ruten (/public/rounds/{id}/flag-map, se lenger ned) -- samme +# datauttrekk, ulik tilgangssjekk. +# --------------------------------------------------------------------------- + +class FlagMapEntryOut(BaseModel): + participant_id: str + display_name: str + lap: int + hole_number: int + lat: float + lng: float + on_green: bool + distance_to_pin_cm: int | None + planted_at: str + + +class FlagMapOut(BaseModel): + visible_to_all: bool + flags: list[FlagMapEntryOut] + + +async def _build_flag_map(conn, round_id: str, viewer_user_id: str | None) -> FlagMapOut: + round_row = await conn.fetchrow("SELECT flag_map_visible FROM round WHERE id = $1", round_id) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + visible_to_all = round_row["flag_map_visible"] + + base_query = """ + SELECT rpfp.round_participant_id::text AS participant_id, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name, + rpfp.lap, rpfp.hole_number, rpfp.lat, rpfp.lng, + rpfp.on_green, rpfp.distance_to_pin_cm, rpfp.planted_at + FROM round_participant_flag_plant rpfp + JOIN round_participant rp ON rp.id = rpfp.round_participant_id + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """ + if visible_to_all: + rows = await conn.fetch(base_query, round_id) + elif viewer_user_id is not None: + rows = await conn.fetch(base_query + " AND rp.user_id = $2", round_id, viewer_user_id) + else: + rows = [] + + return FlagMapOut( + visible_to_all=visible_to_all, + flags=[ + FlagMapEntryOut( + participant_id=r["participant_id"], + display_name=r["display_name"], + lap=r["lap"], + hole_number=r["hole_number"], + lat=r["lat"], + lng=r["lng"], + on_green=r["on_green"], + distance_to_pin_cm=r["distance_to_pin_cm"], + planted_at=r["planted_at"].isoformat(), + ) + for r in rows + ], + ) + + +@router.get("/rounds/{round_id}/flag-map", response_model=FlagMapOut) +async def get_flag_map( + round_id: str, + user: CurrentUser = Depends(get_current_user), +) -> FlagMapOut: + async with plain_connection() as conn: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + return await _build_flag_map(conn, round_id, user.user_id) + + # --------------------------------------------------------------------------- # Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra # round_hole sine faste hull-aggregater over. round_shot kjenner kun @@ -6375,6 +6734,16 @@ async def get_public_format_result( return await _build_format_result(conn, round_id) +@router.get("/public/rounds/{round_id}/flag-map", response_model=FlagMapOut) +async def get_public_flag_map( + round_id: str, + user: CurrentUser | None = Depends(get_current_user_optional), +) -> FlagMapOut: + async with plain_connection() as conn: + await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None) + return await _build_flag_map(conn, round_id, user.user_id if user else None) + + async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None): """Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme "avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp), diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index bddbdc1..fe568d3 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -59,7 +59,7 @@ _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 + join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible """ # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). @@ -88,6 +88,8 @@ class Tournament(BaseModel): format_type: str scoring_method: str | None bbb_sweep_bonus_enabled: bool + # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). + flag_map_visible: bool def _tournament_from_row(row) -> Tournament: @@ -119,7 +121,8 @@ async def list_tournaments( t.visibility, t.description, t.registration_deadline, 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.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, + t.flag_map_visible FROM tournament t LEFT JOIN ( SELECT tournament_id, @@ -189,6 +192,11 @@ class TournamentUpdate(BaseModel): registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$") registration_requires_approval: bool | None = None bbb_sweep_bonus_enabled: bool | None = None + # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067) + # -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se + # get_authorized_org under). Standard AV, samme "skjult til avslørt"- + # filosofi som visibility. + flag_map_visible: bool | None = None # 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/flag-map-overview.tsx b/frontend/components/flag-map-overview.tsx new file mode 100644 index 0000000..db527eb --- /dev/null +++ b/frontend/components/flag-map-overview.tsx @@ -0,0 +1,157 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import mapboxgl from "mapbox-gl" +import "mapbox-gl/dist/mapbox-gl.css" + +export type FlagMapEntry = { + participantId: string + displayName: string + lap: number + holeNumber: number + lat: number + lng: number + onGreen: boolean + distanceToPinCm: number | null +} + +export type FlagMapOverviewProps = { + flags: FlagMapEntry[] + visibleToAll: boolean +} + +// ADR-067-flagg-tillegget ("Del B", 2026-08-14) -- MIDLERTIDIG, enkel +// kartkomponent inntil V0-eksporten (prompt sendt til bruker) er klar. +// Samme props-kontrakt -- når eksporten kommer, byttes kun komponentens +// INNMAT, kalleren skal ikke måtte endres. Re-initialiserer hele kartet +// når `flags` endrer referanse (enkel, men ikke det mest effisiente -- +// akseptert forenkling for den midlertidige versjonen). +export function FlagMapOverview({ flags, visibleToAll }: FlagMapOverviewProps) { + const containerRef = useRef(null) + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (flags.length === 0) return + const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN + const container = containerRef.current + if (!container) return + if (!token) { + setError("Kartet er ikke tilgjengelig akkurat nå.") + return + } + + setLoaded(false) + setError(null) + mapboxgl.accessToken = token + const map = new mapboxgl.Map({ + container, + style: "mapbox://styles/mapbox/satellite-v9", + center: [flags[0].lng, flags[0].lat], + zoom: 16, + attributionControl: false, + }) + + map.on("load", () => { + setLoaded(true) + if (flags.length > 1) { + const bounds = new mapboxgl.LngLatBounds([flags[0].lng, flags[0].lat], [flags[0].lng, flags[0].lat]) + for (const f of flags) bounds.extend([f.lng, f.lat]) + map.fitBounds(bounds, { padding: 80, maxZoom: 18, duration: 0 }) + } + }) + map.on("error", () => setError("Kunne ikke laste kartet.")) + + const markers: mapboxgl.Marker[] = [] + for (const f of flags) { + const el = document.createElement("div") + el.className = + "flex size-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-extrabold text-white shadow-md" + el.style.backgroundColor = f.lap > 1 ? "#7c3aed" : "#ff5a1f" + el.textContent = f.lap > 1 ? `R${f.lap}` : "⚑" + + const popupHtml = ` +
+ ${f.displayName}
+ Hull ${f.holeNumber}${f.lap > 1 ? ` · runde ${f.lap}` : ""} + ${ + f.onGreen && f.distanceToPinCm !== null + ? `
${Math.floor(f.distanceToPinCm / 100)} m ${f.distanceToPinCm % 100} cm fra hullet` + : "" + } +
+ ` + const marker = new mapboxgl.Marker({ element: el }) + .setLngLat([f.lng, f.lat]) + .setPopup(new mapboxgl.Popup({ offset: 16 }).setHTML(popupHtml)) + .addTo(map) + markers.push(marker) + } + + return () => { + markers.forEach((m) => m.remove()) + map.remove() + } + }, [flags]) + + if (flags.length === 0) { + return ( +
+

+ {visibleToAll ? "Ingen flagg plantet ennå" : "Du har ikke plantet flagget ennå"} +

+
+ ) + } + + return ( +
+
+ + {!loaded && !error ? ( +
+
+
+

Laster kart …

+
+
+ ) : null} + + {error ? ( +
+

+ {error} +

+
+ ) : null} + + {!visibleToAll ? ( +
+

+ Kun ditt eget flagg er synlig +

+
+ ) : null} + +
+
+ + + + +
+
+
+ ) +} + +export default FlagMapOverview diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index a6f3535..a080171 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -12,15 +12,20 @@ // nye, ukalibrerte farger. import type React from "react" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" +import dynamic from "next/dynamic" +import type { FlagMapEntry } from "@/components/flag-map-overview" import { ArrowLeft, Check, ChevronDown, + ChevronLeft, Copy, + Crosshair, Flag, KeyRound, + MapPin, Medal, Plus, Trash2, @@ -61,6 +66,8 @@ type ApiTournamentInfo = { scoring_method: string | null // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). bbb_sweep_bonus_enabled: boolean + // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). + flag_map_visible: boolean } type ApiParticipant = { @@ -151,6 +158,55 @@ type ApiFlagResult = { strokes_remaining: number } +// GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14) -- speiler +// rounds.py sin frittstående-variant (round-detail.tsx), se der for full +// begrunnelse. Duplisert her (ikke importert på tvers av de to store +// sidefilene) -- samme selvstendighets-konvensjon som round-scorecard.tsx +// allerede følger for sine egne typer/hjelpefunksjoner. +type ApiFlagPlant = { + id: string + lap: number + hole_number: number + lat: number + lng: number + on_green: boolean + distance_to_pin_cm: number | null + planted_by_user_id: string + planted_at: string +} + +type ApiFlagOverflowHole = { + lap: number + hole_number: number + gross_strokes: number | null + played: boolean +} + +function playedHoleNumbersOrg(holeConfig: "full_18" | "front_9" | "back_9"): number[] { + if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) + if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) + return Array.from({ length: 18 }, (_, i) => i + 1) +} + +function computeFlagCurrentPositionOrg( + playOrder: number[], + holes: ApiHole[] | null, + overflowHoles: ApiFlagOverflowHole[], +): { lap: number; holeNumber: number } { + const lap1Played = new Set((holes ?? []).filter((h) => h.gross_strokes !== null).map((h) => h.hole_number)) + for (const h of playOrder) { + if (!lap1Played.has(h)) return { lap: 1, holeNumber: h } + } + let lap = 2 + while (true) { + const lapPlayed = new Set(overflowHoles.filter((h) => h.lap === lap && h.played).map((h) => h.hole_number)) + for (const h of playOrder) { + if (!lapPlayed.has(h)) return { lap, holeNumber: h } + } + lap += 1 + } +} + type ApiBBBHole = { hole_number: number bingo_participant_id: string | null @@ -308,6 +364,24 @@ export function IndividualTournamentDetail({ } } + async function updateFlagMapVisible(visible: boolean) { + if (!tournament) return + const previous = tournament + setTournament({ ...tournament, flag_map_visible: visible }) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ flag_map_visible: visible }), + }) + if (!res.ok) throw new Error() + } catch { + setTournament(previous) + setError("Klarte ikke å endre kartoversikt-innstillingen. Prøv igjen.") + } + } + async function addParticipant(playerId: string) { setError(null) const res = await fetch(`${base}/participants`, { @@ -550,6 +624,7 @@ export function IndividualTournamentDetail({ classes={classes} onUpdateScoringMethod={updateScoringMethod} onUpdateBbbSweepBonus={updateBbbSweepBonus} + onUpdateFlagMapVisible={updateFlagMapVisible} onAddParticipant={addParticipant} onAddNewPlayer={addNewPlayerAndParticipant} onRemoveParticipant={removeParticipant} @@ -620,6 +695,7 @@ function SetupTab({ classes, onUpdateScoringMethod, onUpdateBbbSweepBonus, + onUpdateFlagMapVisible, onAddParticipant, onAddNewPlayer, onRemoveParticipant, @@ -641,6 +717,7 @@ function SetupTab({ classes: ApiTournamentClass[] onUpdateScoringMethod: (method: string) => void onUpdateBbbSweepBonus: (enabled: boolean) => void + onUpdateFlagMapVisible: (visible: boolean) => void onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise onRemoveParticipant: (id: string) => Promise @@ -706,6 +783,22 @@ function SetupTab({ )} + {tournament.scoring_method === "flag" && ( + + )} )} + + {/* GPS-flaggplanting + runde 2+ (2026-08-14) -- for DEN VALGTE + deltakeren (samme selv-only autorisasjon som selve + scoreføringen over, ingen vits å vise for andre enn den man + faktisk fører for). */} + {round && scoringMethod === "flag" && roundParticipants.find((rp) => rp.id === participantId) && ( + rp.id === participantId)!} + refreshTick={refreshTick} + onChanged={() => setRefreshTick((n) => n + 1)} + /> + )} + + {/* Kartoversikt over flagg (migrasjon 071, "Del B", ADR-067) -- + kun for org-medlem/deltaker (ingen offentlig tilskuer-gren + for individuelle turneringer, se ADR-067). */} + {round && scoringMethod === "flag" && ( + + )} )}
@@ -1962,6 +2076,621 @@ function BBBRoundPanel({ ) } +// --- GPS-flaggplanting + runde 2+ (2026-08-14) ------------------------------- +// V0-eksport (zip 11), samme komponent som round-detail.tsx sin +// FlagPlantSheet -- egen kopi her (fil-lokalt konvensjon, se +// FlagPlantSheet i round-detail.tsx for samme innmat/kommentar). + +function FlagPlantSheetOrg({ + open, + onClose, + expectedHoleNumber, + expectedLap, + onConfirm, +}: { + open: boolean + onClose: () => void + expectedHoleNumber: number + expectedLap: number + onConfirm: (result: { + lat: number + lng: number + onGreen: boolean + distanceToPinCm: number | null + }) => Promise<{ ok: true } | { ok: false; message: string }> +}) { + const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps") + const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading") + const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null) + + // Step 2 "Ja" reveals a dead-end guidance card (no submission). + const [holedOutYes, setHoledOutYes] = useState(false) + + // Step 3 answer + distance inputs (kept as strings for clean input UX). + const [onGreen, setOnGreen] = useState(null) + const [meters, setMeters] = useState("") + const [centimeters, setCentimeters] = useState("") + + const [submitting, setSubmitting] = useState(false) + const [serverError, setServerError] = useState(null) + const [confirmedSummary, setConfirmedSummary] = useState<{ + onGreen: boolean + distanceToPinCm: number | null + } | null>(null) + + // Reset everything each time the sheet is (re)opened. + useEffect(() => { + if (!open) return + setStep("gps") + setGeoStatus("loading") + setPosition(null) + setHoledOutYes(false) + setOnGreen(null) + setMeters("") + setCentimeters("") + setSubmitting(false) + setServerError(null) + setConfirmedSummary(null) + }, [open]) + + // Step 1 — automatic GPS acquisition. Runs on entering the gps step. + const geoRequested = useRef(false) + useEffect(() => { + if (!open || step !== "gps") { + geoRequested.current = false + return + } + if (geoRequested.current) return + geoRequested.current = true + + if (typeof navigator === "undefined" || !navigator.geolocation) { + setGeoStatus("error") + return + } + setGeoStatus("loading") + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }) + setStep("holedOut") + }, + () => setGeoStatus("error"), + { enableHighAccuracy: true, timeout: 10000 }, + ) + }, [open, step]) + + function retryGeo() { + geoRequested.current = false + setGeoStatus("loading") + if (typeof navigator === "undefined" || !navigator.geolocation) { + setGeoStatus("error") + return + } + geoRequested.current = true + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }) + setStep("holedOut") + }, + () => setGeoStatus("error"), + { enableHighAccuracy: true, timeout: 10000 }, + ) + } + + if (!open) return null + + // ---- validation for the on-green distance -------------------------------- + const metersNum = meters === "" ? null : Number(meters) + const cmNum = centimeters === "" ? null : Number(centimeters) + const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0 + const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99 + const greenInputValid = metersValid && cmValid + + const canConfirm = onGreen === false || (onGreen === true && greenInputValid) + + async function handleConfirm() { + if (!position || onGreen === null || submitting) return + const distanceToPinCm = + onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null + + setSubmitting(true) + setServerError(null) + try { + const res = await onConfirm({ + lat: position.lat, + lng: position.lng, + onGreen, + distanceToPinCm, + }) + if (res.ok) { + setConfirmedSummary({ onGreen, distanceToPinCm }) + setStep("done") + } else { + setServerError(res.message) + } + } catch { + setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.") + } finally { + setSubmitting(false) + } + } + + function goBack() { + setServerError(null) + if (step === "green") { + setOnGreen(null) + setMeters("") + setCentimeters("") + setStep("holedOut") + } + } + + const showBack = step === "green" + + return ( +
+ {/* Header */} +
+
+ {showBack ? ( + + ) : null} +
+ + +
+

Plant flagget

+

+ {`Hull ${expectedHoleNumber} · runde ${expectedLap}`} +

+
+
+
+ + +
+ + {/* Main */} +
+ {/* Step 1 — GPS */} + {step === "gps" ? ( +
+ {geoStatus === "loading" ? ( + <> +
+

Finner posisjonen din …

+

+ Stå der du gikk tom for slag mens vi henter GPS-posisjonen. +

+ + ) : ( +
+

+ Fant ikke posisjonen din. Sjekk at posisjonstjenester er på for + TeeCup, og prøv igjen. +

+ +
+ )} +
+ ) : null} + + {/* Step 2 — Holed out on this hole? */} + {step === "holedOut" ? ( +
+

+ {`Hullet du ut på hull ${expectedHoleNumber}?`} +

+

+ Er hullet fullført, må scoren føres inn før du kan plante flagget. +

+ +
+ + +
+ + {/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */} + {holedOutYes ? ( +
+

+ {`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`} +

+ +
+ ) : null} +
+ ) : null} + + {/* Step 3 — On the green? + distance */} + {step === "green" ? ( +
+

Landet du på green?

+ +
+ + +
+ + {onGreen === true ? ( +
+

+ Dette avgjør rekkefølgen mot andre spillere som gikk tom på + samme hull. +

+
+
+ + setMeters(e.target.value.replace(/[^0-9]/g, ""))} + aria-invalid={meters !== "" && !metersValid} + className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + placeholder="0" + /> +
+
+ + setCentimeters(e.target.value.replace(/[^0-9]/g, ""))} + aria-invalid={centimeters !== "" && !cmValid} + className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + placeholder="0" + /> +
+
+ {centimeters !== "" && !cmValid ? ( +

+ Centimeter må være mellom 0 og 99. +

+ ) : null} +
+ ) : null} + + {serverError ? ( +

+ {serverError} +

+ ) : null} +
+ ) : null} + + {/* Step 4 — Success */} + {step === "done" && confirmedSummary ? ( +
+ + +

Flagget er plantet!

+
+ + + {confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? ( + + {`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${ + confirmedSummary.distanceToPinCm % 100 + } cm fra hullet`} + + ) : null} +
+
+ ) : null} +
+ + {/* Footer — contextual primary action */} + {step === "green" ? ( +
+ +
+ ) : null} + + {step === "done" ? ( +
+ +
+ ) : null} +
+ ) +} + +function FlagPlantPanel({ + base, + round, + participant, + refreshTick, + onChanged, +}: { + base: string + round: ApiRound + participant: ApiRoundParticipant + refreshTick: number + onChanged: () => void +}) { + const playOrder = useMemo(() => playedHoleNumbersOrg(round.hole_config), [round.hole_config]) + const [holes, setHoles] = useState(null) + const [plant, setPlant] = useState(null) + const [overflow, setOverflow] = useState([]) + const [sheetOpen, setSheetOpen] = useState(false) + const [draft, setDraft] = useState>({}) + const [overflowError, setOverflowError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + let cancelled = false + Promise.all([ + getJson(`${base}/rounds/${round.id}/participants/${participant.id}/holes`), + getJson(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`), + getJson(`${base}/rounds/${round.id}/participants/${participant.id}/flag-overflow`), + ]).then(([h, p, o]) => { + if (cancelled) return + setHoles(h) + setPlant(p) + setOverflow(o ?? []) + }) + return () => { + cancelled = true + } + }, [base, round.id, participant.id, refreshTick]) + + const currentPosition = computeFlagCurrentPositionOrg(playOrder, holes, overflow) + + async function handleUnplant() { + setBusy(true) + await fetch(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`, { + method: "DELETE", + credentials: "include", + }) + setBusy(false) + setPlant(null) + onChanged() + } + + async function submitOverflowHole(holeNumber: number) { + const raw = draft[holeNumber] + const score = parseInt(raw ?? "", 10) + if (!raw || Number.isNaN(score) || score <= 0) return + setOverflowError(null) + const res = await fetch( + `${base}/rounds/${round.id}/participants/${participant.id}/flag-overflow/${currentPosition.lap}/holes/${holeNumber}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ gross_strokes: score }), + }, + ) + if (!res.ok) { + const body = await res.json().catch(() => null) + setOverflowError(body?.detail?.message ?? "Klarte ikke å registrere score.") + return + } + const updated: ApiFlagOverflowHole = await res.json() + setOverflow((prev) => [...prev.filter((h) => !(h.lap === updated.lap && h.hole_number === updated.hole_number)), updated]) + setDraft((prev) => ({ ...prev, [holeNumber]: "" })) + onChanged() + } + + if (!holes) return null + const byOverflowHole = new Map(overflow.filter((h) => h.lap === currentPosition.lap).map((h) => [h.hole_number, h])) + + return ( +
+ GPS-flaggplanting -- {participant.player_name} +
+ {plant ? ( + <> + + + + + ) : ( + + )} +
+ + {currentPosition.lap >= 2 && ( +
+ Runde {currentPosition.lap} -- før inn slag per hull +
+ {playOrder.map((h) => { + const existing = byOverflowHole.get(h) + return ( +
+ Hull {h} + {existing?.played ? ( + + {existing.gross_strokes} + + ) : ( + setDraft((prev) => ({ ...prev, [h]: e.target.value }))} + onBlur={() => submitOverflowHole(h)} + className="h-9 w-14 text-center" + /> + )} +
+ ) + })} +
+ {overflowError &&

{overflowError}

} +
+ )} + + setSheetOpen(false)} + expectedHoleNumber={currentPosition.holeNumber} + expectedLap={currentPosition.lap} + onConfirm={async (result) => { + const res = await fetch(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + lat: result.lat, lng: result.lng, + hole_number: currentPosition.holeNumber, lap: currentPosition.lap, + on_green: result.onGreen, distance_to_pin_cm: result.distanceToPinCm, + }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + return { ok: false, message: body?.detail?.message ?? "Klarte ikke å plante flagget." } + } + const updated: ApiFlagPlant = await res.json() + setPlant(updated) + onChanged() + return { ok: true } + }} + /> +
+ ) +} + // --- Flaggturnering -- per-runde-resultat (2026-07-30) ---------------------- function FlagRoundPanel({ @@ -2014,6 +2743,74 @@ function FlagRoundPanel({ ) } +// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -------- +// Samme "egen fetch + refreshTick"-mønster som FlagRoundPanel over. Ingen +// offentlig tilskuer-gren her (org-medlem/deltaker-only, se ADR-067 -- +// individuelle turneringer har ingen offentlig spectator-side ennå). + +const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false }) + +type ApiFlagMapOrg = { visible_to_all: boolean; flags: FlagMapEntry[] } + +function FlagMapSectionOrg({ + base, + round, + refreshTick, +}: { + base: string + round: ApiRound + refreshTick: number +}) { + const [data, setData] = useState(null) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + let cancelled = false + getJson<{ visible_to_all: boolean; flags: Array> }>( + `${base}/rounds/${round.id}/flag-map`, + ).then((json) => { + if (cancelled || !json) return + setData({ + visible_to_all: json.visible_to_all, + flags: json.flags.map((f) => ({ + participantId: f.participant_id as string, + displayName: f.display_name as string, + lap: f.lap as number, + holeNumber: f.hole_number as number, + lat: f.lat as number, + lng: f.lng as number, + onGreen: f.on_green as boolean, + distanceToPinCm: f.distance_to_pin_cm as number | null, + })), + }) + }) + return () => { + cancelled = true + } + }, [base, round.id, refreshTick]) + + if (!data) return null + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ) +} + type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" function classify(diff: number): ScoreClass { diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index f94bdd7..4491b23 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -11,9 +11,11 @@ import type React from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import Link from "next/link" +import dynamic from "next/dynamic" import { useRouter, useSearchParams } from "next/navigation" import { RoundPageShell } from "@/components/round-page-shell" import { RoundMessages } from "@/components/round-messages" +import type { FlagMapEntry } from "@/components/flag-map-overview" import { ArrowDown, ArrowLeft, @@ -26,7 +28,9 @@ import { Trash2, ChevronLeft, ChevronRight, + Crosshair, Flag, + MapPin, Minus, Plus, RefreshCw, @@ -248,6 +252,8 @@ type ApiRound = { visible_categories: string[] // Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde. flight_group_id: string | null + // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). + flag_map_visible: boolean } // Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx @@ -455,6 +461,11 @@ export function RoundDetail({ roundId }: { roundId: string }) { const [conflictNotice, setConflictNotice] = useState(null) const [activePlayerId, setActivePlayerId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) + // Flaggturnering: GPS-flaggplanting + runde 2+ (2026-08-14) -- null i + // flagPlantByParticipant betyr "lastet, men ikke plantet ennå" (skilt fra + // "ikke lastet", som er fraværet av nøkkelen). + const [flagPlantByParticipant, setFlagPlantByParticipant] = useState>({}) + const [flagOverflowByParticipant, setFlagOverflowByParticipant] = useState>({}) // Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et // knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun // for eieren selv (gjester har ingen profil å hente dette fra). @@ -640,6 +651,93 @@ export function RoundDetail({ roundId }: { roundId: string }) { setFormatResultRefreshTick((n) => n + 1) } + // Flaggturnering (2026-08-14) -- laster flagg-status + runde 2-hull for + // ALLE deltakere (samme "ikke bare aktiv spiller"-begrunnelse som + // holesByParticipant under), kun for play_format="flag". + useEffect(() => { + if (!round || round.play_format !== "flag") { + setFlagPlantByParticipant({}) + setFlagOverflowByParticipant({}) + return + } + let cancelled = false + async function loadAll() { + for (const p of round!.participants) { + const [plantRes, overflowRes] = await Promise.all([ + fetch(`/rounds/${roundId}/participants/${p.id}/flag-plant`, { credentials: "include" }), + fetch(`/rounds/${roundId}/participants/${p.id}/flag-overflow`, { credentials: "include" }), + ]) + if (cancelled) return + const plant: ApiFlagPlant | null = plantRes.ok ? await plantRes.json() : null + const overflow: ApiFlagOverflowHole[] = overflowRes.ok ? await overflowRes.json() : [] + setFlagPlantByParticipant((prev) => ({ ...prev, [p.id]: plant })) + setFlagOverflowByParticipant((prev) => ({ ...prev, [p.id]: overflow })) + } + } + void loadAll() + return () => { + cancelled = true + } + }, [roundId, round?.play_format, round?.participants.length, formatResultRefreshTick]) + + async function plantFlag( + participantId: string, + body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null }, + ): Promise<{ ok: true } | { ok: false; message: string }> { + const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }) + if (!res.ok) { + const errBody = await res.json().catch(() => null) + return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å plante flagget. Prøv igjen." } + } + const updated: ApiFlagPlant = await res.json() + setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: updated })) + setFormatResultRefreshTick((n) => n + 1) + return { ok: true } + } + + async function unplantFlag(participantId: string): Promise { + const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) return + setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: null })) + setFormatResultRefreshTick((n) => n + 1) + } + + async function updateFlagOverflowHole( + participantId: string, lap: number, holeNumber: number, score: number, + ): Promise<{ ok: true } | { ok: false; message: string }> { + const res = await fetch( + `/rounds/${roundId}/participants/${participantId}/flag-overflow/${lap}/holes/${holeNumber}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ score }), + }, + ) + if (!res.ok) { + const errBody = await res.json().catch(() => null) + return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å registrere score. Prøv igjen." } + } + const updated: ApiFlagOverflowHole = await res.json() + setFlagOverflowByParticipant((prev) => ({ + ...prev, + [participantId]: [ + ...(prev[participantId] ?? []).filter((h) => !(h.lap === updated.lap && h.hole_number === updated.hole_number)), + updated, + ], + })) + setFormatResultRefreshTick((n) => n + 1) + return { ok: true } + } + useEffect(() => { let cancelled = false fetch("/auth/me", { credentials: "include" }) @@ -1417,6 +1515,14 @@ export function RoundDetail({ roundId }: { roundId: string }) { (ADR-039) -- avledet ved lesing, aldri lagret. */} + {/* Flaggturnering: kartoversikt (migrasjon 071, "Del B", + ADR-067) -- kun for eier/medspiller (samme tilgang som + resten av denne siden), av/på-bryteren styrer om ALLE + flagg eller kun spørrerens eget vises (se komponenten). */} + {round.play_format === "flag" && ( + + )} + {/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer (front9/back9/overall) av det samme to-sidede formatet, kun for 18-hulls runder. Ren tilleggsvisning, ikke et eget @@ -1675,6 +1781,12 @@ export function RoundDetail({ roundId }: { roundId: string }) { onOpenEntry={openPlayerOrTeamSideEntry} onPatchParticipant={patchParticipant} onSendScorecard={sendScorecard} + playFormat={round.play_format} + flagPlantByParticipant={flagPlantByParticipant} + flagOverflowByParticipant={flagOverflowByParticipant} + onPlantFlag={plantFlag} + onUnplantFlag={unplantFlag} + onSubmitFlagOverflowHole={updateFlagOverflowHole} />
@@ -3109,6 +3221,50 @@ type ApiBBBHole = { bongo_participant_id: string | null } +// Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14). +type ApiFlagPlant = { + id: string + lap: number + hole_number: number + lat: number + lng: number + on_green: boolean + distance_to_pin_cm: number | null + planted_by_user_id: string + planted_at: string +} + +type ApiFlagOverflowHole = { + lap: number + hole_number: number + score: number | null + played: boolean +} + +// Speiler _flag_current_position (app/routers/rounds.py) presist -- brukt +// KUN til å vise riktig "hull N"/"lap L" i UI-et FØR innsending; serveren +// er alltid den autoritative kilden og validerer på nytt (se plantFlag). +function computeFlagCurrentPosition( + holeOrder: number[], + holes: ApiHole[] | undefined, + overflowHoles: ApiFlagOverflowHole[] | undefined, +): { lap: number; holeNumber: number } { + const lap1Played = new Set((holes ?? []).filter((h) => h.played).map((h) => h.hole_number)) + for (const h of holeOrder) { + if (!lap1Played.has(h)) return { lap: 1, holeNumber: h } + } + let lap = 2 + while (true) { + const lapPlayed = new Set( + (overflowHoles ?? []).filter((h) => h.lap === lap && h.played).map((h) => h.hole_number), + ) + for (const h of holeOrder) { + if (!lapPlayed.has(h)) return { lap, holeNumber: h } + } + lap += 1 + } +} + type ApiFormatResult = { play_format: string ready: boolean @@ -3813,6 +3969,69 @@ function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; fo ) } +// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -------- +// Selvstendig, samme "egen fetch + refreshTick"-mønster som NassauPanel over. +// Rendres kun i Administrer-fanen (samme sted som leaderboard-forhåndsvisning/ +// Nassau) -- kun synlig for eier/medspiller (samme tilgang som resten av +// Score-siden), ikke tilskuere (de har sin egen /watch/[id]-visning, se +// egen komponent der). + +const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false }) + +type ApiFlagMap = { visible_to_all: boolean; flags: FlagMapEntry[] } + +function FlagMapSection({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) { + const [data, setData] = useState(null) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/rounds/${roundId}/flag-map`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((json: { visible_to_all: boolean; flags: Array> } | null) => { + if (cancelled || !json) return + setData({ + visible_to_all: json.visible_to_all, + flags: json.flags.map((f) => ({ + participantId: f.participant_id as string, + displayName: f.display_name as string, + lap: f.lap as number, + holeNumber: f.hole_number as number, + lat: f.lat as number, + lng: f.lng as number, + onGreen: f.on_green as boolean, + distanceToPinCm: f.distance_to_pin_cm as number | null, + })), + }) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [roundId, formatResultRefreshTick]) + + if (!data) return null + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ) +} + // --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) ----------------- // Tre uavhengige kategorier (først på green/nærmest hull/først i hull), // manuelt registrert av den som fører score -- ikke GPS/live-sporet. Én @@ -4564,6 +4783,8 @@ function EditRoundPanel({ round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private", ) const [visibleCategories, setVisibleCategories] = useState(round.visible_categories) + // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067). + const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible) const [saving, setSaving] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false) const [error, setError] = useState(null) @@ -4579,7 +4800,8 @@ function EditRoundPanel({ (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) || playFormat !== currentEditablePlayFormat || visibilityMode !== round.visibility_mode || - (visibilityMode === "friends" && !sameCategories) + (visibilityMode === "friends" && !sameCategories) || + flagMapVisible !== round.flag_map_visible async function saveMetadata() { setSaving(true) @@ -4599,6 +4821,7 @@ function EditRoundPanel({ if (playFormat !== currentEditablePlayFormat) body.play_format = playFormat if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories + if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible const result = await onPatch(body) if (!result.ok) setError(result.message) setSaving(false) @@ -4752,6 +4975,43 @@ function EditRoundPanel({ )}
+ {/* Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", + ADR-067) -- kun relevant for spilleformatet Flaggturnering. + Standard AV -- ingen ser hverandres flagg før eieren slår + dette på (samme "skjult til avslørt"-prinsipp som Synlighet + over). Spilleren ser alltid sitt eget flagg uansett. */} + {playFormat === "flag" && ( +
+ Kartoversikt over flagg +
+ {( + [ + { value: false, label: "Av" }, + { value: true, label: "På" }, + ] + ).map((opt) => ( + + ))} +
+

+ Når kartoversikten er PÅ, kan alle spillere se hvor alle andre har plantet flagget. Av som standard. +

+
+ )} + {!isCompleted && ( <>
@@ -5613,6 +5873,583 @@ function ParticipantCompletionActions({ ) } +// ADR-066-flagg-tillegget (2026-08-14) -- V0-eksport (zip 11), integrert +// mot samme props-kontrakt som den midlertidige versjonen den erstatter. +// Ren UI: komponenten eier kun steg-/geo-/inputtilstand og gjør aldri +// nettverkskall selv -- kalleren gir `onConfirm`, som gjør det faktiske +// API-kallet og kan AVVISE forsøket (f.eks. hvis registrert scoredata +// sier spilleren fortsatt er på et annet hull). Avvisning vises som en +// vedvarende inline-feilmelding, ikke en forsvinnende toast. +function FlagPlantSheet({ + open, + onClose, + expectedHoleNumber, + expectedLap, + onConfirm, +}: { + open: boolean + onClose: () => void + expectedHoleNumber: number + expectedLap: number + onConfirm: (result: { + lat: number + lng: number + onGreen: boolean + distanceToPinCm: number | null + }) => Promise<{ ok: true } | { ok: false; message: string }> +}) { + const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps") + const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading") + const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null) + + // Step 2 "Ja" reveals a dead-end guidance card (no submission). + const [holedOutYes, setHoledOutYes] = useState(false) + + // Step 3 answer + distance inputs (kept as strings for clean input UX). + const [onGreen, setOnGreen] = useState(null) + const [meters, setMeters] = useState("") + const [centimeters, setCentimeters] = useState("") + + const [submitting, setSubmitting] = useState(false) + const [serverError, setServerError] = useState(null) + const [confirmedSummary, setConfirmedSummary] = useState<{ + onGreen: boolean + distanceToPinCm: number | null + } | null>(null) + + // Reset everything each time the sheet is (re)opened. + useEffect(() => { + if (!open) return + setStep("gps") + setGeoStatus("loading") + setPosition(null) + setHoledOutYes(false) + setOnGreen(null) + setMeters("") + setCentimeters("") + setSubmitting(false) + setServerError(null) + setConfirmedSummary(null) + }, [open]) + + // Step 1 — automatic GPS acquisition. Runs on entering the gps step. + const geoRequested = useRef(false) + useEffect(() => { + if (!open || step !== "gps") { + geoRequested.current = false + return + } + if (geoRequested.current) return + geoRequested.current = true + + if (typeof navigator === "undefined" || !navigator.geolocation) { + setGeoStatus("error") + return + } + setGeoStatus("loading") + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }) + setStep("holedOut") + }, + () => setGeoStatus("error"), + { enableHighAccuracy: true, timeout: 10000 }, + ) + }, [open, step]) + + function retryGeo() { + geoRequested.current = false + setGeoStatus("loading") + if (typeof navigator === "undefined" || !navigator.geolocation) { + setGeoStatus("error") + return + } + geoRequested.current = true + navigator.geolocation.getCurrentPosition( + (pos) => { + setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }) + setStep("holedOut") + }, + () => setGeoStatus("error"), + { enableHighAccuracy: true, timeout: 10000 }, + ) + } + + if (!open) return null + + // ---- validation for the on-green distance -------------------------------- + const metersNum = meters === "" ? null : Number(meters) + const cmNum = centimeters === "" ? null : Number(centimeters) + const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0 + const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99 + const greenInputValid = metersValid && cmValid + + const canConfirm = onGreen === false || (onGreen === true && greenInputValid) + + async function handleConfirm() { + if (!position || onGreen === null || submitting) return + const distanceToPinCm = + onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null + + setSubmitting(true) + setServerError(null) + try { + const res = await onConfirm({ + lat: position.lat, + lng: position.lng, + onGreen, + distanceToPinCm, + }) + if (res.ok) { + setConfirmedSummary({ onGreen, distanceToPinCm }) + setStep("done") + } else { + setServerError(res.message) + } + } catch { + setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.") + } finally { + setSubmitting(false) + } + } + + function goBack() { + setServerError(null) + if (step === "green") { + setOnGreen(null) + setMeters("") + setCentimeters("") + setStep("holedOut") + } + } + + const showBack = step === "green" + + return ( +
+ {/* Header */} +
+
+ {showBack ? ( + + ) : null} +
+ + +
+

Plant flagget

+

+ {`Hull ${expectedHoleNumber} · runde ${expectedLap}`} +

+
+
+
+ + +
+ + {/* Main */} +
+ {/* Step 1 — GPS */} + {step === "gps" ? ( +
+ {geoStatus === "loading" ? ( + <> +
+

Finner posisjonen din …

+

+ Stå der du gikk tom for slag mens vi henter GPS-posisjonen. +

+ + ) : ( +
+

+ Fant ikke posisjonen din. Sjekk at posisjonstjenester er på for + TeeCup, og prøv igjen. +

+ +
+ )} +
+ ) : null} + + {/* Step 2 — Holed out on this hole? */} + {step === "holedOut" ? ( +
+

+ {`Hullet du ut på hull ${expectedHoleNumber}?`} +

+

+ Er hullet fullført, må scoren føres inn før du kan plante flagget. +

+ +
+ + +
+ + {/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */} + {holedOutYes ? ( +
+

+ {`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`} +

+ +
+ ) : null} +
+ ) : null} + + {/* Step 3 — On the green? + distance */} + {step === "green" ? ( +
+

Landet du på green?

+ +
+ + +
+ + {onGreen === true ? ( +
+

+ Dette avgjør rekkefølgen mot andre spillere som gikk tom på + samme hull. +

+
+
+ + setMeters(e.target.value.replace(/[^0-9]/g, ""))} + aria-invalid={meters !== "" && !metersValid} + className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + placeholder="0" + /> +
+
+ + setCentimeters(e.target.value.replace(/[^0-9]/g, ""))} + aria-invalid={centimeters !== "" && !cmValid} + className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + placeholder="0" + /> +
+
+ {centimeters !== "" && !cmValid ? ( +

+ Centimeter må være mellom 0 og 99. +

+ ) : null} +
+ ) : null} + + {serverError ? ( +

+ {serverError} +

+ ) : null} +
+ ) : null} + + {/* Step 4 — Success */} + {step === "done" && confirmedSummary ? ( +
+ + +

Flagget er plantet!

+
+ + + {confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? ( + + {`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${ + confirmedSummary.distanceToPinCm % 100 + } cm fra hullet`} + + ) : null} +
+
+ ) : null} +
+ + {/* Footer — contextual primary action */} + {step === "green" ? ( +
+ +
+ ) : null} + + {step === "done" ? ( +
+ +
+ ) : null} +
+ ) +} + +// Flaggturnering: viser plantet-status + "Plant flagget"-knapp for én +// spiller (2026-08-14). Samme visuelle rad-mønster som +// ParticipantCompletionActions over. +function FlagStatusRow({ + player, + plant, + currentPosition, + readOnly, + onOpenSheet, + onUnplant, +}: { + player: Player + plant: ApiFlagPlant | null | undefined + currentPosition: { lap: number; holeNumber: number } + readOnly: boolean + onOpenSheet: () => void + onUnplant: () => Promise +}) { + const [busy, setBusy] = useState(false) + async function handleUnplant() { + setBusy(true) + await onUnplant() + setBusy(false) + } + return ( +
+ {plant ? ( + <> + + + {!readOnly && ( + + )} + + ) : ( + !readOnly && ( + + ) + )} +
+ ) +} + +// Runde 2+ scoreføring (2026-08-14) -- vises kun når spilleren faktisk har +// fullført lap 1 (currentPosition.lap > 1) og mangler ikke bygd +// ScoringWizard-flyt for dette -- flaggformatet er allerede kun +// hull-granularitet (rå slagtall), en enkel tallinput-liste er +// proporsjonal, ikke en full ny veiviser-flyt. +function FlagOverflowPanel({ + player, + holeOrder, + overflowHoles, + currentPosition, + readOnly, + onSubmitHole, +}: { + player: Player + holeOrder: number[] + overflowHoles: ApiFlagOverflowHole[] + currentPosition: { lap: number; holeNumber: number } + readOnly: boolean + onSubmitHole: (lap: number, holeNumber: number, score: number) => Promise<{ ok: true } | { ok: false; message: string }> +}) { + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + if (currentPosition.lap < 2 && overflowHoles.length === 0) return null + + const lap = currentPosition.lap + const byHole = new Map(overflowHoles.filter((h) => h.lap === lap).map((h) => [h.hole_number, h])) + + async function submit(holeNumber: number) { + const raw = draft[holeNumber] + const score = parseInt(raw ?? "", 10) + if (!raw || Number.isNaN(score) || score <= 0) return + setError(null) + const result = await onSubmitHole(lap, holeNumber, score) + if (!result.ok) setError(result.message) + else setDraft((prev) => ({ ...prev, [holeNumber]: "" })) + } + + return ( +
+ + {player.name} spiller runde {lap} -- før inn slag per hull + +
+ {holeOrder.map((h) => { + const existing = byHole.get(h) + return ( +
+ Hull {h} + {existing?.played ? ( + + {existing.score} + + ) : !readOnly ? ( + setDraft((prev) => ({ ...prev, [h]: e.target.value }))} + onBlur={() => submit(h)} + className="h-9 w-14 text-center" + /> + ) : ( + + )} +
+ ) + })} +
+ {error &&

{error}

} +
+ ) +} + function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null { if (!list || numbers.length === 0) return null const played = numbers @@ -5655,6 +6492,12 @@ function PlayerHoleCards({ onOpenEntry, onPatchParticipant, onSendScorecard, + playFormat, + flagPlantByParticipant, + flagOverflowByParticipant, + onPlantFlag, + onUnplantFlag, + onSubmitFlagOverflowHole, }: { roundId: string players: Player[] @@ -5673,7 +6516,19 @@ function PlayerHoleCards({ onOpenEntry: (playerId: string, holeNumber: number) => void onPatchParticipant: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }> + playFormat: string + flagPlantByParticipant: Record + flagOverflowByParticipant: Record + onPlantFlag: ( + participantId: string, + body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null }, + ) => Promise<{ ok: true } | { ok: false; message: string }> + onUnplantFlag: (participantId: string) => Promise + onSubmitFlagOverflowHole: ( + participantId: string, lap: number, holeNumber: number, score: number, + ) => Promise<{ ok: true } | { ok: false; message: string }> }) { + const [flagSheetParticipantId, setFlagSheetParticipantId] = useState(null) const holeMeta: Record = {} for (const list of Object.values(holesByParticipant)) { for (const h of list) { @@ -5939,6 +6794,47 @@ function PlayerHoleCards({ onPatch={onPatchParticipant} onSendScorecard={onSendScorecard} /> + {playFormat === "flag" && (() => { + const currentPosition = computeFlagCurrentPosition( + holeOrder, holesByParticipant[player.id], flagOverflowByParticipant[player.id], + ) + return ( + <> + setFlagSheetParticipantId(player.id)} + onUnplant={() => onUnplantFlag(player.id)} + /> + onSubmitFlagOverflowHole(player.id, lap, holeNumber, score)} + /> + {flagSheetParticipantId === player.id && ( + setFlagSheetParticipantId(null)} + expectedHoleNumber={currentPosition.holeNumber} + expectedLap={currentPosition.lap} + onConfirm={async (result) => { + const res = await onPlantFlag(player.id, { + lat: result.lat, lng: result.lng, + hole_number: currentPosition.holeNumber, lap: currentPosition.lap, + on_green: result.onGreen, distance_to_pin_cm: result.distanceToPinCm, + }) + return res + }} + /> + )} + + ) + })()}
) diff --git a/frontend/components/watch-round.tsx b/frontend/components/watch-round.tsx index d2844f4..3d32b74 100644 --- a/frontend/components/watch-round.tsx +++ b/frontend/components/watch-round.tsx @@ -15,10 +15,77 @@ import { useEffect, useState } from "react" import Link from "next/link" +import dynamic from "next/dynamic" import { Radio, Trophy } from "lucide-react" import { WatchTabs } from "@/components/watch-tabs" import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players" import { RoundMessages } from "@/components/round-messages" +import type { FlagMapEntry } from "@/components/flag-map-overview" + +const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false }) + +// Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- +// tilskuer-varianten, mot /public/rounds/{id}/flag-map. Tilgang er +// allerede gatet av _get_viewable_round_or_404 (samme regel som resten +// av denne siden, styrt av round.visibility_mode) -- selve INNHOLDET +// (alle flagg vs. ingenting) styres i tillegg av round.flag_map_visible +// server-side (`visible_to_all` i responsen), en tilskuer har jo ikke +// noe "eget flagg" å falle tilbake til når bryteren er av. +type ApiFlagMapPublic = { visible_to_all: boolean; flags: FlagMapEntry[] } + +function WatchFlagMap({ roundId, refreshKey }: { roundId: string; refreshKey: number }) { + const [data, setData] = useState(null) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + let cancelled = false + fetch(`/public/rounds/${roundId}/flag-map`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((json: { visible_to_all: boolean; flags: Array> } | null) => { + if (cancelled || !json) return + setData({ + visible_to_all: json.visible_to_all, + flags: json.flags.map((f) => ({ + participantId: f.participant_id as string, + displayName: f.display_name as string, + lap: f.lap as number, + holeNumber: f.hole_number as number, + lat: f.lat as number, + lng: f.lng as number, + onGreen: f.on_green as boolean, + distanceToPinCm: f.distance_to_pin_cm as number | null, + })), + }) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [roundId, refreshKey]) + + // Ingen synlig seksjon i det hele tatt når bryteren er av OG tilskueren + // (naturligvis) ikke har noe eget flagg -- ikke en tom/forvirrende boks. + if (!data || (!data.visible_to_all && data.flags.length === 0)) return null + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ) +} type ApiSide = { id: string; label: string | null } @@ -192,6 +259,8 @@ export function WatchRound({ roundId }: { roundId: string }) { flightGroup={flightGroup} /> + {round.play_format === "flag" && } + {/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes aldri her, kun på round-detail.tsx sin egen "Score"-fane. En person runden er delt med hadde dermed ingen vei til å se eller diff --git a/handicap_engine.py b/handicap_engine.py index e11139c..ff20650 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -906,6 +906,28 @@ def flag_result(gross_strokes: Sequence[int], total_strokes_budget: int) -> Flag return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining) +def flag_lap_and_hole(holes_completed: int, play_order: Sequence[int]) -> tuple[int, int]: + """Runde 2+-utvidelse (2026-08-14, GPS-flaggplanting) -- "fortsettelse på + hull 19+" er nå støttet: går budsjettet IKKE tomt innen 18 hull, spilles + play_order på nytt fra begynnelsen med gjenværende slag (lap 2, lap 3, + ...). `flag_result()` selv trenger INGEN endring for dette -- den er + allerede lap-agnostisk, kalleren limer ganske enkelt sammen lap 1 sin + FULLE 18-lange prefiks med lap 2 sin (kun hvis lap 1 faktisk ble + fullført) osv. før den kalles, og `holes_completed` blir da naturlig + et tall som kan overstige `len(play_order)`. + + Denne funksjonen oversetter et slikt flatt `holes_completed`-tall + (fra `flag_result()`) til (lap, hullnummer) -- lap 1 = `play_order` + som den er, lap 2 = samme rekkefølge på nytt, osv. Brukes til å + beregne hvilket hull en spiller reelt er "i gang med" (der flagget + skal plantes), og til server-side å validere at et innsendt + flagg-plant-forsøk faktisk stemmer med registrert scoredata.""" + n = len(play_order) + lap = holes_completed // n + 1 + hole_index_in_lap = holes_completed % n + return lap, play_order[hole_index_in_lap] + + # --------------------------------------------------------------------------- # Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse, # bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle diff --git a/test_handicap_engine.py b/test_handicap_engine.py index 8d1e625..89874ec 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -43,6 +43,7 @@ from handicap_engine import ( compute_bbb, FlagResult, flag_result, + flag_lap_and_hole, shamble_hole_score, money_ball_hole_score, high_low_high_points_for_hole, @@ -901,6 +902,53 @@ def test_flag_result_no_holes_played_yet(): assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90) +# --- Runde 2+ (lap-fortsettelse, 2026-08-14) -------------------------------- + +def test_flag_result_runs_out_mid_lap_two(): + # Lap 1 fullført (18 hull, par 4 hver = 72), budsjett 100 -> 28 igjen inn + # i lap 2. Lap 2: hull 1 (5) -> 23, hull 2 (4) -> 19, hull 3 krever 30 + # (umulig i praksis, men beviser bare at flag_result() selv ikke bryr + # seg om laps -- kalleren limer bare sammen sekvensen). + lap1 = [4] * 18 + lap2_prefix = [5, 4, 30] + result = flag_result(lap1 + lap2_prefix, total_strokes_budget=100) + assert result == FlagResult(holes_completed=20, ran_out=True, strokes_remaining=19) + + +def test_flag_result_completes_two_full_laps_with_budget_left(): + lap1 = [4] * 18 + lap2 = [4] * 18 + result = flag_result(lap1 + lap2, total_strokes_budget=200) + assert result == FlagResult(holes_completed=36, ran_out=False, strokes_remaining=56) + + +def test_flag_lap_and_hole_within_lap_one_start_hole_one(): + play_order = list(range(1, 19)) + assert flag_lap_and_hole(0, play_order) == (1, 1) + assert flag_lap_and_hole(2, play_order) == (1, 3) + assert flag_lap_and_hole(17, play_order) == (1, 18) + + +def test_flag_lap_and_hole_wraps_into_lap_two(): + play_order = list(range(1, 19)) + # 18 fullførte hull = akkurat ferdig med lap 1 -- neste er lap 2, hull 1. + assert flag_lap_and_hole(18, play_order) == (2, 1) + assert flag_lap_and_hole(20, play_order) == (2, 3) + assert flag_lap_and_hole(35, play_order) == (2, 18) + assert flag_lap_and_hole(36, play_order) == (3, 1) + + +def test_flag_lap_and_hole_respects_non_default_start_hole(): + # start_hole=10 -> play_order er 10,11,...,18,1,2,...,9 (samme sirkulære + # rekkefølge round-detail.tsx/rounds.py allerede bruker andre steder). + play_order = [10, 11, 12, 13, 14, 15, 16, 17, 18, 1, 2, 3, 4, 5, 6, 7, 8, 9] + assert flag_lap_and_hole(0, play_order) == (1, 10) + assert flag_lap_and_hole(8, play_order) == (1, 18) + assert flag_lap_and_hole(9, play_order) == (1, 1) + # Lap 2 gjentar SAMME spillerekkefølge, ikke fysisk hull 1. + assert flag_lap_and_hole(18, play_order) == (2, 10) + + # --------------------------------------------------------------------------- # Shamble (2026-07-30) # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 3fc87c0..7d843ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -250,3 +250,64 @@ async def create_match_participant(organization_id: str, match_id: str, team_sid participant_id, organization_id, match_id, team_side, team_roster_id, tee_id, ) return participant_id + + +# --------------------------------------------------------------------------- +# Individuelle/flerrunde-turneringer (ADR-037, migrasjon 040) -- brukt av +# test_flag_plant_individual_tournament.py. Samme org_connection()-mønster +# som resten av org-turnering-kjeden over. +# --------------------------------------------------------------------------- + +async def create_org_hole(organization_id: str, course_id: str, hole_number: int, par: int = 4, + stroke_index: int | None = None, **overrides) -> str: + hole_id = overrides.get("id", new_id()) + stroke_index = stroke_index if stroke_index is not None else hole_number + async with app_db.org_connection(organization_id) as conn: + await conn.execute( + "INSERT INTO hole (id, organization_id, course_id, hole_number, par, stroke_index) " + "VALUES ($1, $2, $3, $4, $5, $6)", + hole_id, organization_id, course_id, hole_number, par, stroke_index, + ) + return hole_id + + +async def create_tournament_round(organization_id: str, tournament_id: str, course_id: str, + **overrides) -> str: + round_id = overrides.get("id", new_id()) + sequence = overrides.get("sequence", 1) + hole_config = overrides.get("hole_config", "full_18") + async with app_db.org_connection(organization_id) as conn: + await conn.execute( + "INSERT INTO tournament_round (id, organization_id, tournament_id, sequence, course_id, hole_config) " + "VALUES ($1, $2, $3, $4, $5, $6)", + round_id, organization_id, tournament_id, sequence, course_id, hole_config, + ) + return round_id + + +async def create_tournament_participant(organization_id: str, tournament_id: str, player_id: str, + **overrides) -> str: + participant_id = overrides.get("id", new_id()) + async with app_db.org_connection(organization_id) as conn: + await conn.execute( + "INSERT INTO tournament_participant (id, organization_id, tournament_id, player_id) " + "VALUES ($1, $2, $3, $4)", + participant_id, organization_id, tournament_id, player_id, + ) + return participant_id + + +async def create_tournament_round_participant(organization_id: str, tournament_round_id: str, + tournament_participant_id: str, tee_id: str, + **overrides) -> str: + round_participant_id = overrides.get("id", new_id()) + course_handicap = overrides.get("course_handicap", 10) + async with app_db.org_connection(organization_id) as conn: + await conn.execute( + "INSERT INTO tournament_round_participant " + "(id, organization_id, tournament_round_id, tournament_participant_id, tee_id, course_handicap) " + "VALUES ($1, $2, $3, $4, $5, $6)", + round_participant_id, organization_id, tournament_round_id, tournament_participant_id, + tee_id, course_handicap, + ) + return round_participant_id diff --git a/tests/test_flag_plant.py b/tests/test_flag_plant.py new file mode 100644 index 0000000..5af6522 --- /dev/null +++ b/tests/test_flag_plant.py @@ -0,0 +1,259 @@ +""" +Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14). +Kaller de faktiske router-funksjonene i rounds.py direkte, samme mønster +som test_concurrency_version_check.py -- ikke en gjenimplementering. + +Kun frittstående runder (rounds.py) dekkes her -- individual_tournaments.py +sin org-speilende variant mangler conftest-hjelpere for tournament_round- +familien og er i stedet manuelt browserverifisert (se CHANGELOG.md). +""" + +import pytest +from fastapi import HTTPException + +from app import db as app_db +from app.auth import CurrentUser +from app.routers.rounds import ( + HoleUpdate, + FlagPlantIn, + FlagOverflowHoleIn, + get_flag_map, + get_flag_plant, + list_flag_overflow_holes, + plant_flag, + unplant_flag, + update_flag_overflow_hole, + update_hole, +) + +from tests.conftest import create_hole, create_participant, create_round, create_user + + +async def _setup_flag_round(holes_planned: int = 18, start_hole: int = 1): + async with app_db.plain_connection() as conn: + owner_id = await create_user() + round_id = await create_round(conn, owner_id) + await conn.execute( + "UPDATE round SET play_format = 'flag', holes_planned = $2, start_hole = $3 WHERE id = $1", + round_id, holes_planned, start_hole, + ) + participant_id = await create_participant( + conn, round_id, user_id=owner_id, is_owner=True, course_handicap_snapshot=10, + ) + for n in range(1, 19): + await create_hole(conn, participant_id, hole_number=n, par=4, stroke_index=n) + return owner_id, round_id, participant_id + + +async def _play_hole(round_id: str, participant_id: str, hole_number: int, score: int, user: CurrentUser): + await update_hole(round_id, participant_id, hole_number, HoleUpdate(score=score, played=True), user=user) + + +async def test_plant_flag_succeeds_on_first_unplayed_hole(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + await _play_hole(round_id, participant_id, 1, 5, user) + await _play_hole(round_id, participant_id, 2, 4, user) + + result = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=3, lap=1, on_green=False), + user=user, + ) + assert result.lap == 1 + assert result.hole_number == 3 + assert result.on_green is False + assert result.distance_to_pin_cm is None + + +async def test_plant_flag_rejects_mismatched_hole(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + await _play_hole(round_id, participant_id, 1, 5, user) + # Faktisk posisjon er hull 2 (kun hull 1 spilt) -- forsøker å plante på hull 5. + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=5, lap=1, on_green=False), + user=user, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "VALIDATION_FAILED" + + +async def test_plant_flag_on_green_requires_distance(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=True), + user=user, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "VALIDATION_FAILED" + + +async def test_plant_flag_on_green_with_distance_succeeds(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + result = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=True, distance_to_pin_cm=340), + user=user, + ) + assert result.on_green is True + assert result.distance_to_pin_cm == 340 + + +async def test_replanting_overwrites_not_duplicates(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + first = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + user=user, + ) + await _play_hole(round_id, participant_id, 1, 5, user) + second = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=60.0, lng=10.8, hole_number=2, lap=1, on_green=False), + user=user, + ) + assert second.id != first.id + current = await get_flag_plant(round_id, participant_id, user=user) + assert current.hole_number == 2 + + +async def test_unplant_removes_flag(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + user=user, + ) + await unplant_flag(round_id, participant_id, user=user) + assert await get_flag_plant(round_id, participant_id, user=user) is None + + +async def test_get_flag_plant_none_when_never_planted(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + assert await get_flag_plant(round_id, participant_id, user=user) is None + + +async def test_overflow_hole_rejected_before_lap_one_complete(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + # Kun 5 av 18 hull spilt i lap 1 -- lap 2 skal avvises. + for n in range(1, 6): + await _play_hole(round_id, participant_id, n, 4, user) + with pytest.raises(HTTPException) as exc_info: + await update_flag_overflow_hole( + round_id, participant_id, 2, 1, FlagOverflowHoleIn(score=4), user=user, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "VALIDATION_FAILED" + + +async def test_full_lap_two_flow(pool): + owner_id, round_id, participant_id = await _setup_flag_round() + user = CurrentUser(user_id=owner_id) + for n in range(1, 19): + await _play_hole(round_id, participant_id, n, 4, user) + + # Forsøk på å plante i lap 1 avvises nå -- spilleren har allerede + # fullført alle 18 hull i lap 1, faktisk posisjon er lap 2, hull 1. + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + user=user, + ) + assert exc_info.value.status_code == 400 + + overflow_hole = await update_flag_overflow_hole( + round_id, participant_id, 2, 1, FlagOverflowHoleIn(score=5), user=user, + ) + assert overflow_hole.lap == 2 + assert overflow_hole.hole_number == 1 + assert overflow_hole.score == 5 + + holes = await list_flag_overflow_holes(round_id, participant_id, user=user) + assert len(holes) == 1 + assert holes[0].hole_number == 1 + + # Nå kan flagget faktisk plantes riktig i lap 2, hull 2. + lap2_plant = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=2, lap=2, on_green=False), + user=user, + ) + assert lap2_plant.lap == 2 + assert lap2_plant.hole_number == 2 + + +async def test_flag_plant_respects_non_default_start_hole(pool): + owner_id, round_id, participant_id = await _setup_flag_round(start_hole=10) + user = CurrentUser(user_id=owner_id) + # Spillerekkefølgen er 10,11,...,18,1,...,9 -- ingen hull spilt ennå, + # så flagget skal kun kunne plantes på hull 10 (ikke fysisk hull 1). + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + user=user, + ) + assert exc_info.value.status_code == 400 + result = await plant_flag( + round_id, participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=10, lap=1, on_green=False), + user=user, + ) + assert result.hole_number == 10 + + +async def test_flag_map_shows_only_own_flag_when_switch_off(pool): + owner_id, round_id, owner_participant_id = await _setup_flag_round() + other_user_id = await create_user() + async with app_db.plain_connection() as conn: + other_participant_id = await create_participant(conn, round_id, user_id=other_user_id, is_owner=False) + owner = CurrentUser(user_id=owner_id) + other = CurrentUser(user_id=other_user_id) + + await plant_flag( + round_id, owner_participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), user=owner, + ) + await plant_flag( + round_id, other_participant_id, + FlagPlantIn(lat=60.0, lng=10.8, hole_number=1, lap=1, on_green=False), user=other, + ) + + result = await get_flag_map(round_id, user=owner) + assert result.visible_to_all is False + assert [f.participant_id for f in result.flags] == [owner_participant_id] + + +async def test_flag_map_shows_all_flags_when_switch_on(pool): + owner_id, round_id, owner_participant_id = await _setup_flag_round() + other_user_id = await create_user() + async with app_db.plain_connection() as conn: + other_participant_id = await create_participant(conn, round_id, user_id=other_user_id, is_owner=False) + await conn.execute("UPDATE round SET flag_map_visible = true WHERE id = $1", round_id) + owner = CurrentUser(user_id=owner_id) + other = CurrentUser(user_id=other_user_id) + + await plant_flag( + round_id, owner_participant_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), user=owner, + ) + await plant_flag( + round_id, other_participant_id, + FlagPlantIn(lat=60.0, lng=10.8, hole_number=1, lap=1, on_green=False), user=other, + ) + + result = await get_flag_map(round_id, user=other) + assert result.visible_to_all is True + assert {f.participant_id for f in result.flags} == {owner_participant_id, other_participant_id} diff --git a/tests/test_flag_plant_individual_tournament.py b/tests/test_flag_plant_individual_tournament.py new file mode 100644 index 0000000..cd99cfc --- /dev/null +++ b/tests/test_flag_plant_individual_tournament.py @@ -0,0 +1,226 @@ +""" +Flaggturnering: GPS-flaggplanting + runde 2+, org-individuelle turneringer +(migrasjon 070, 2026-08-14) -- speiler test_flag_plant.py (frittstående +runder) presist, men gjennom individual_tournaments.py sin self-only +autorisasjon (user_is_own_tournament_participant), ikke rounds.py sin +flight-styrte modell. + +Kaller de faktiske router-funksjonene direkte (samme mønster som +test_scoring_concurrency.py) -- organization_id sendes som vanlig +funksjonsargument her, ikke via FastAPIs Depends (som kun løses når +appen faktisk kjører via HTTP). +""" + +import pytest +from fastapi import HTTPException + +from app.auth import CurrentUser +from app.routers.individual_tournaments import ( + FlagOverflowHoleIn, + FlagPlantIn, + HoleUpdate, + get_flag_map, + get_flag_plant, + list_flag_overflow_holes, + plant_flag, + unplant_flag, + update_flag_overflow_hole, + update_hole, +) + +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, +) + + +async def _setup_flag_tournament(): + org_id = await create_org() + user_id = await create_user() + await add_membership(org_id, user_id, role="owner") + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + 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) + + import app.db as app_db + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE tournament SET format_type = 'individual', scoring_method = 'flag' WHERE id = $1", + tournament_id, + ) + + tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id) + player_id = await create_player(org_id, user_id=user_id) + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id) + return org_id, user_id, tournament_id, tournament_round_id, rp_id + + +async def _play_hole(tournament_id, round_id, rp_id, hole_number, score, org_id, user): + await update_hole( + tournament_id, round_id, rp_id, hole_number, + HoleUpdate(gross_strokes=score), + organization_id=org_id, user=user, + ) + + +async def test_plant_flag_succeeds_on_first_unplayed_hole(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + user = CurrentUser(user_id=user_id) + await _play_hole(tournament_id, round_id, rp_id, 1, 5, org_id, user) + + result = await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=2, lap=1, on_green=False), + organization_id=org_id, user=user, + ) + assert result.lap == 1 + assert result.hole_number == 2 + + +async def test_plant_flag_rejects_non_owning_player(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + stranger_id = await create_user() + await add_membership(org_id, stranger_id, role="member") + stranger = CurrentUser(user_id=stranger_id) + + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=stranger, + ) + assert exc_info.value.status_code == 403 + assert exc_info.value.detail["code"] == "NOT_TOURNAMENT_PARTICIPANT" + + +async def test_plant_flag_rejects_mismatched_hole(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + user = CurrentUser(user_id=user_id) + with pytest.raises(HTTPException) as exc_info: + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=5, lap=1, on_green=False), + organization_id=org_id, user=user, + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.detail["code"] == "VALIDATION_FAILED" + + +async def test_unplant_and_replant(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + user = CurrentUser(user_id=user_id) + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=True, distance_to_pin_cm=120), + organization_id=org_id, user=user, + ) + current = await get_flag_plant(tournament_id, round_id, rp_id, organization_id=org_id) + assert current is not None and current.distance_to_pin_cm == 120 + + await unplant_flag(tournament_id, round_id, rp_id, organization_id=org_id, user=user) + assert await get_flag_plant(tournament_id, round_id, rp_id, organization_id=org_id) is None + + +async def test_full_lap_two_flow(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + user = CurrentUser(user_id=user_id) + for n in range(1, 19): + await _play_hole(tournament_id, round_id, rp_id, n, 4, org_id, user) + + with pytest.raises(HTTPException): + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=user, + ) + + overflow_hole = await update_flag_overflow_hole( + tournament_id, round_id, rp_id, 2, 1, + FlagOverflowHoleIn(gross_strokes=5), organization_id=org_id, user=user, + ) + assert overflow_hole.lap == 2 + assert overflow_hole.gross_strokes == 5 + + holes = await list_flag_overflow_holes(tournament_id, round_id, rp_id, organization_id=org_id) + assert len(holes) == 1 + + lap2_plant = await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=2, lap=2, on_green=False), + organization_id=org_id, user=user, + ) + assert lap2_plant.lap == 2 + assert lap2_plant.hole_number == 2 + + +async def test_flag_map_shows_only_own_flag_when_switch_off(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + other_user_id = await create_user() + await add_membership(org_id, other_user_id, role="member") + other_player_id = await create_player(org_id, user_id=other_user_id) + other_tp_id = await create_tournament_participant(org_id, tournament_id, other_player_id) + + import app.db as app_db + async with app_db.org_connection(org_id) as conn: + tee_id = await conn.fetchval("SELECT tee_id FROM tournament_round_participant WHERE id = $1", rp_id) + other_rp_id = await create_tournament_round_participant(org_id, round_id, other_tp_id, tee_id) + + user = CurrentUser(user_id=user_id) + other = CurrentUser(user_id=other_user_id) + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=user, + ) + await plant_flag( + tournament_id, round_id, other_rp_id, + FlagPlantIn(lat=60.0, lng=10.8, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=other, + ) + + result = await get_flag_map(tournament_id, round_id, organization_id=org_id, user=user) + assert result.visible_to_all is False + assert [f.participant_id for f in result.flags] == [rp_id] + + +async def test_flag_map_shows_all_flags_when_switch_on(pool): + org_id, user_id, tournament_id, round_id, rp_id = await _setup_flag_tournament() + other_user_id = await create_user() + await add_membership(org_id, other_user_id, role="member") + other_player_id = await create_player(org_id, user_id=other_user_id) + other_tp_id = await create_tournament_participant(org_id, tournament_id, other_player_id) + + import app.db as app_db + async with app_db.org_connection(org_id) as conn: + tee_id = await conn.fetchval("SELECT tee_id FROM tournament_round_participant WHERE id = $1", rp_id) + await conn.execute("UPDATE tournament SET flag_map_visible = true WHERE id = $1", tournament_id) + other_rp_id = await create_tournament_round_participant(org_id, round_id, other_tp_id, tee_id) + + user = CurrentUser(user_id=user_id) + other = CurrentUser(user_id=other_user_id) + await plant_flag( + tournament_id, round_id, rp_id, + FlagPlantIn(lat=59.9, lng=10.7, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=user, + ) + await plant_flag( + tournament_id, round_id, other_rp_id, + FlagPlantIn(lat=60.0, lng=10.8, hole_number=1, lap=1, on_green=False), + organization_id=org_id, user=other, + ) + + result = await get_flag_map(tournament_id, round_id, organization_id=org_id, user=other) + assert result.visible_to_all is True + assert {f.participant_id for f in result.flags} == {rp_id, other_rp_id}