Flaggturnering: GPS-flaggplanting (Del A) + kartoversikt (Del B)

Del A (ADR-066, migrasjon 069/070, allerede rullet ut mot teecup_db):
"Plant flagget" via GPS, med en interaktiv "Hullet du ut?"-bekreftelse
før posisjonen registreres, pluss full runde 2+-scoreføring for
spillere som fullfører 18 hull med slag igjen. Ny, isolert overflow-
tabell (rører ikke delt round_hole/tournament_round_hole). Bygget for
både frittstående runder og org-individuelle turneringer. UI-sheeten
er en Claude-skrevet V0-eksport (zip 11).

Del B (ADR-067, migrasjon 071, klar for utrulling): av/på-bryter
(standard AV) for en satellittkartoversikt over ALLE deltakeres
plantede flagg, lap 1 skilt fra lap 2+ med farge+tekstbadge. Frittstående
runder får full tilskuerstøtte (/watch/[id]); org-individuelle
turneringer er bevisst avgrenset til medlemmer/deltakere, siden ingen
offentlig spectator-side finnes for det turneringsformatet ennå.

Se ARCHITECTURE_DECISIONS.md (ADR-066/067) og CHANGELOG.md (punkt 82/83)
for full begrunnelse og verifiseringslogg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-14 10:38:54 +02:00
parent 25c6056e76
commit 25bdabe3ea
18 changed files with 3770 additions and 9 deletions

66
069_flag_plant.sql Normal file
View file

@ -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;

View file

@ -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 $$;

View file

@ -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;

View file

@ -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) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11067,3 +11067,110 @@ Neste steg:
under hver rettelse er beholdt uendret, ikke slettet. `Sist under hver rettelse er beholdt uendret, ikke slettet. `Sist
oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet
tilsvarende. Ingen kodeendring, ren dokumentasjonsopprydding. 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.

View file

@ -573,10 +573,28 @@
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren, alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
hull-granularitet). **GPS-avhengigheten er også borte** — GPS/ hull-granularitet). **GPS-avhengigheten er også borte** — GPS/
avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling, 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- ADR-064 rangefinder til grønn).
ideen spesifikt for Flaggturnering er fortsatt ikke designet/bygget
(stale-opprydding 2026-08-14: kun avhengigheten er avklart, ikke **✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
funksjonen selv). "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 - **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å: at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:

View file

@ -1403,3 +1403,370 @@ async def flag_round_result(
) )
results.sort(key=lambda r: -r.holes_completed) results.sort(key=lambda r: -r.holes_completed)
return results 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 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
],
)

View file

@ -1468,6 +1468,8 @@ class RoundOut(BaseModel):
flight_group_id: str | None flight_group_id: str | None
# HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard. # HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard.
allowance_override: dict | None 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: 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, 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, 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, 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 FROM round WHERE id = $1
""", """,
round_id, 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], visible_categories=[r["category"] for r in visible_category_rows],
flight_group_id=round_row["flight_group_id"], flight_group_id=round_row["flight_group_id"],
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None, 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) -- # KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
# samme "None = ikke rør"-konvensjon som resten av denne modellen. # samme "None = ikke rør"-konvensjon som resten av denne modellen.
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100) 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) @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) await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
if body.visibility_mode is not None: if body.visibility_mode is not None:
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode) 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: 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) 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: if body.visible_categories is not None:
@ -5508,6 +5520,353 @@ async def update_hole(
return RoundHoleOut(**dict(row), strokes_received=strokes_received) 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 , 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 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 # Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra
# round_hole sine faste hull-aggregater over. round_shot kjenner kun # 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) 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): async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
"""Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme """Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
"avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp), "avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),

View file

@ -59,7 +59,7 @@ _TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date, id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity, visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key, 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). # 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 format_type: str
scoring_method: str | None scoring_method: str | None
bbb_sweep_bonus_enabled: bool bbb_sweep_bonus_enabled: bool
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: bool
def _tournament_from_row(row) -> Tournament: def _tournament_from_row(row) -> Tournament:
@ -119,7 +121,8 @@ async def list_tournaments(
t.visibility, t.description, t.registration_deadline, t.visibility, t.description, t.registration_deadline,
t.registration_capacity, t.registration_overflow_policy, t.registration_capacity, t.registration_overflow_policy,
t.registration_requires_approval, t.hero_image_key, 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 FROM tournament t
LEFT JOIN ( LEFT JOIN (
SELECT tournament_id, SELECT tournament_id,
@ -189,6 +192,11 @@ class TournamentUpdate(BaseModel):
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$") registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
registration_requires_approval: bool | None = None registration_requires_approval: bool | None = None
bbb_sweep_bonus_enabled: 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 # hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender, # upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting. # siden den nå alltid kommer fra en ekte, server-konvertert opplasting.

View file

@ -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<HTMLDivElement | null>(null)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState<string | null>(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 = `
<div style="font-size:13px; line-height:1.4;">
<strong>${f.displayName}</strong><br/>
Hull ${f.holeNumber}${f.lap > 1 ? ` · runde ${f.lap}` : ""}
${
f.onGreen && f.distanceToPinCm !== null
? `<br/>${Math.floor(f.distanceToPinCm / 100)} m ${f.distanceToPinCm % 100} cm fra hullet`
: ""
}
</div>
`
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 (
<div className="flex h-full items-center justify-center p-6 text-center">
<p className="text-base text-muted-foreground">
{visibleToAll ? "Ingen flagg plantet ennå" : "Du har ikke plantet flagget ennå"}
</p>
</div>
)
}
return (
<div className="relative h-full w-full">
<div
ref={containerRef}
className="absolute inset-0 h-full w-full"
aria-label="Kart over plantede flagg"
role="application"
/>
{!loaded && !error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Laster kart </p>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<p role="alert" className="max-w-sm text-center text-base text-destructive">
{error}
</p>
</div>
) : null}
{!visibleToAll ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
Kun ditt eget flagg er synlig
</p>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="flex items-center gap-3 rounded-full bg-card/95 px-4 py-2 text-xs font-semibold text-foreground shadow-md">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-full" style={{ backgroundColor: "#ff5a1f" }} aria-hidden="true" />
Runde 1
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-full" style={{ backgroundColor: "#7c3aed" }} aria-hidden="true" />
Runde 2+
</span>
</div>
</div>
</div>
)
}
export default FlagMapOverview

View file

@ -12,15 +12,20 @@
// nye, ukalibrerte farger. // nye, ukalibrerte farger.
import type React from "react" import type React from "react"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import dynamic from "next/dynamic"
import type { FlagMapEntry } from "@/components/flag-map-overview"
import { import {
ArrowLeft, ArrowLeft,
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft,
Copy, Copy,
Crosshair,
Flag, Flag,
KeyRound, KeyRound,
MapPin,
Medal, Medal,
Plus, Plus,
Trash2, Trash2,
@ -61,6 +66,8 @@ type ApiTournamentInfo = {
scoring_method: string | null scoring_method: string | null
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
bbb_sweep_bonus_enabled: boolean bbb_sweep_bonus_enabled: boolean
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: boolean
} }
type ApiParticipant = { type ApiParticipant = {
@ -151,6 +158,55 @@ type ApiFlagResult = {
strokes_remaining: number 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 = { type ApiBBBHole = {
hole_number: number hole_number: number
bingo_participant_id: string | null 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) { async function addParticipant(playerId: string) {
setError(null) setError(null)
const res = await fetch(`${base}/participants`, { const res = await fetch(`${base}/participants`, {
@ -550,6 +624,7 @@ export function IndividualTournamentDetail({
classes={classes} classes={classes}
onUpdateScoringMethod={updateScoringMethod} onUpdateScoringMethod={updateScoringMethod}
onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateBbbSweepBonus={updateBbbSweepBonus}
onUpdateFlagMapVisible={updateFlagMapVisible}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant} onAddNewPlayer={addNewPlayerAndParticipant}
onRemoveParticipant={removeParticipant} onRemoveParticipant={removeParticipant}
@ -620,6 +695,7 @@ function SetupTab({
classes, classes,
onUpdateScoringMethod, onUpdateScoringMethod,
onUpdateBbbSweepBonus, onUpdateBbbSweepBonus,
onUpdateFlagMapVisible,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
onRemoveParticipant, onRemoveParticipant,
@ -641,6 +717,7 @@ function SetupTab({
classes: ApiTournamentClass[] classes: ApiTournamentClass[]
onUpdateScoringMethod: (method: string) => void onUpdateScoringMethod: (method: string) => void
onUpdateBbbSweepBonus: (enabled: boolean) => void onUpdateBbbSweepBonus: (enabled: boolean) => void
onUpdateFlagMapVisible: (visible: boolean) => void
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void> onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onRemoveParticipant: (id: string) => Promise<void> onRemoveParticipant: (id: string) => Promise<void>
@ -706,6 +783,22 @@ function SetupTab({
</span> </span>
</label> </label>
)} )}
{tournament.scoring_method === "flag" && (
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
<input
type="checkbox"
checked={tournament.flag_map_visible}
onChange={(e) => onUpdateFlagMapVisible(e.target.checked)}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
<span className="text-xs text-muted-foreground">
Når dette er , kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
</span>
</span>
</label>
)}
</section> </section>
<RoundsCard <RoundsCard
@ -1838,6 +1931,27 @@ function ScoreTab({
{round && scoringMethod === "flag" && ( {round && scoringMethod === "flag" && (
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} /> <FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
)} )}
{/* 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) && (
<FlagPlantPanel
base={base}
round={round}
participant={roundParticipants.find((rp) => 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" && (
<FlagMapSectionOrg base={base} round={round} refreshTick={refreshTick} />
)}
</> </>
)} )}
</div> </div>
@ -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<boolean | null>(null)
const [meters, setMeters] = useState("")
const [centimeters, setCentimeters] = useState("")
const [submitting, setSubmitting] = useState(false)
const [serverError, setServerError] = useState<string | null>(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 (
<div
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
role="dialog"
aria-modal="true"
aria-label={`Plant flagget, hull ${expectedHoleNumber}`}
>
{/* Header */}
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-start gap-2">
{showBack ? (
<button
type="button"
onClick={goBack}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
Tilbake
</button>
) : null}
<div className="flex items-start gap-2 pt-1.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<Flag className="size-5" aria-hidden="true" />
</span>
<div>
<h2 className="text-lg font-bold leading-tight text-balance">Plant flagget</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{`Hull ${expectedHoleNumber} · runde ${expectedLap}`}
</p>
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-5 shrink-0" aria-hidden="true" />
Lukk
</button>
</header>
{/* Main */}
<main className="flex-1 overflow-y-auto p-4">
{/* Step 1 — GPS */}
{step === "gps" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
{geoStatus === "loading" ? (
<>
<div className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-lg font-semibold">Finner posisjonen din </p>
<p className="text-base text-muted-foreground text-pretty">
Stå der du gikk tom for slag mens vi henter GPS-posisjonen.
</p>
</>
) : (
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left">
<p role="alert" className="text-base text-destructive text-pretty">
Fant ikke posisjonen din. Sjekk at posisjonstjenester er for
TeeCup, og prøv igjen.
</p>
<button
type="button"
onClick={retryGeo}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
Prøv igjen
</button>
</div>
)}
</div>
) : null}
{/* Step 2 — Holed out on this hole? */}
{step === "holedOut" ? (
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
<h3 className="text-2xl font-bold text-balance">
{`Hullet du ut på hull ${expectedHoleNumber}?`}
</h3>
<p className="text-base text-muted-foreground text-pretty">
Er hullet fullført, scoren føres inn før du kan plante flagget.
</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setHoledOutYes(true)}
aria-pressed={holedOutYes}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
holedOutYes
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Ja
</button>
<button
type="button"
onClick={() => {
setHoledOutYes(false)
setStep("green")
}}
className="inline-flex min-h-14 items-center justify-center rounded-xl border-2 border-border bg-card px-6 text-lg font-bold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Nei
</button>
</div>
{/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */}
{holedOutYes ? (
<div className="flex flex-col gap-4 rounded-2xl border border-brand-orange/40 bg-brand-orange/5 p-4">
<p role="alert" className="text-base text-foreground text-pretty">
{`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`}
</p>
<button
type="button"
onClick={onClose}
className="inline-flex min-h-14 items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Lukk
</button>
</div>
) : null}
</div>
) : null}
{/* Step 3 — On the green? + distance */}
{step === "green" ? (
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
<h3 className="text-2xl font-bold text-balance">Landet du green?</h3>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOnGreen(true)}
aria-pressed={onGreen === true}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
onGreen === true
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Ja
</button>
<button
type="button"
onClick={() => {
setOnGreen(false)
setMeters("")
setCentimeters("")
}}
aria-pressed={onGreen === false}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
onGreen === false
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Nei
</button>
</div>
{onGreen === true ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<p className="text-sm text-muted-foreground text-pretty">
Dette avgjør rekkefølgen mot andre spillere som gikk tom
samme hull.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="fp-org-meters" className="text-base font-semibold">
Meter
</label>
<input
id="fp-org-meters"
inputMode="numeric"
pattern="[0-9]*"
value={meters}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="fp-org-cm" className="text-base font-semibold">
Centimeter
</label>
<input
id="fp-org-cm"
inputMode="numeric"
pattern="[0-9]*"
value={centimeters}
onChange={(e) => 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"
/>
</div>
</div>
{centimeters !== "" && !cmValid ? (
<p role="alert" className="text-sm font-medium text-destructive">
Centimeter være mellom 0 og 99.
</p>
) : null}
</div>
) : null}
{serverError ? (
<p
role="alert"
className="rounded-xl border border-destructive/40 bg-destructive/10 p-3 text-base font-medium text-destructive text-pretty"
>
{serverError}
</p>
) : null}
</div>
) : null}
{/* Step 4 — Success */}
{step === "done" && confirmedSummary ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
<span className="flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary">
<Check className="size-11" strokeWidth={2.5} aria-hidden="true" />
</span>
<h3 className="text-2xl font-bold">Flagget er plantet!</h3>
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-base font-semibold">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
{`Hull ${expectedHoleNumber}`}
</span>
{confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? (
<span className="text-base text-muted-foreground tabular-nums">
{`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${
confirmedSummary.distanceToPinCm % 100
} cm fra hullet`}
</span>
) : null}
</div>
</div>
) : null}
</main>
{/* Footer — contextual primary action */}
{step === "green" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!canConfirm || submitting}
onClick={handleConfirm}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? (
<>
<span className="size-5 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" />
Sender
</>
) : (
<>
<Flag className="size-5 shrink-0" aria-hidden="true" />
Bekreft plassering
</>
)}
</button>
</footer>
) : null}
{step === "done" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Ferdig
</button>
</footer>
) : null}
</div>
)
}
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<ApiHole[] | null>(null)
const [plant, setPlant] = useState<ApiFlagPlant | null>(null)
const [overflow, setOverflow] = useState<ApiFlagOverflowHole[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [draft, setDraft] = useState<Record<number, string>>({})
const [overflowError, setOverflowError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
Promise.all([
getJson<ApiHole[]>(`${base}/rounds/${round.id}/participants/${participant.id}/holes`),
getJson<ApiFlagPlant | null>(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`),
getJson<ApiFlagOverflowHole[]>(`${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 (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-sm font-bold text-foreground">GPS-flaggplanting -- {participant.player_name}</span>
<div className="flex flex-wrap items-center gap-2">
{plant ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
<Flag aria-hidden="true" className="size-4" />
Flagg plantet · hull {plant.hole_number}
{plant.lap > 1 ? ` (runde ${plant.lap})` : ""}
{plant.on_green && plant.distance_to_pin_cm !== null
? ` · ${Math.floor(plant.distance_to_pin_cm / 100)}m ${plant.distance_to_pin_cm % 100}cm fra hullet`
: ""}
</span>
<button
type="button"
disabled={busy}
onClick={handleUnplant}
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
>
Angre
</button>
</>
) : (
<button
type="button"
onClick={() => setSheetOpen(true)}
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
<Flag aria-hidden="true" className="size-4" />
Plant flagget
</button>
)}
</div>
{currentPosition.lap >= 2 && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-sm font-bold text-foreground">Runde {currentPosition.lap} -- før inn slag per hull</span>
<div className="flex flex-wrap gap-2">
{playOrder.map((h) => {
const existing = byOverflowHole.get(h)
return (
<div key={h} className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">Hull {h}</span>
{existing?.played ? (
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary tabular-nums">
{existing.gross_strokes}
</span>
) : (
<Input
type="number"
inputMode="numeric"
min={1}
max={20}
value={draft[h] ?? ""}
onChange={(e) => setDraft((prev) => ({ ...prev, [h]: e.target.value }))}
onBlur={() => submitOverflowHole(h)}
className="h-9 w-14 text-center"
/>
)}
</div>
)
})}
</div>
{overflowError && <p role="alert" className="text-sm font-medium text-destructive">{overflowError}</p>}
</div>
)}
<FlagPlantSheetOrg
open={sheetOpen}
onClose={() => 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 }
}}
/>
</div>
)
}
// --- Flaggturnering -- per-runde-resultat (2026-07-30) ---------------------- // --- Flaggturnering -- per-runde-resultat (2026-07-30) ----------------------
function FlagRoundPanel({ 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<ApiFlagMapOrg | null>(null)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
let cancelled = false
getJson<{ visible_to_all: boolean; flags: Array<Record<string, unknown>> }>(
`${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 (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-h-11 items-center justify-between gap-2 text-left"
aria-expanded={expanded}
>
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
</button>
{expanded && (
<div className="h-80 overflow-hidden rounded-xl border border-border">
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
</div>
)}
</div>
)
}
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classify(diff: number): ScoreClass { function classify(diff: number): ScoreClass {

File diff suppressed because it is too large Load diff

View file

@ -15,10 +15,77 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import dynamic from "next/dynamic"
import { Radio, Trophy } from "lucide-react" import { Radio, Trophy } from "lucide-react"
import { WatchTabs } from "@/components/watch-tabs" import { WatchTabs } from "@/components/watch-tabs"
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players" import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
import { RoundMessages } from "@/components/round-messages" 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<ApiFlagMapPublic | null>(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<Record<string, unknown>> } | 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 (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-h-11 items-center justify-between gap-2 text-left"
aria-expanded={expanded}
>
<span className="text-base font-bold text-foreground">Kartoversikt over flagg</span>
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
</button>
{expanded && (
<div className="h-80 overflow-hidden rounded-xl border border-border">
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
</div>
)}
</div>
)
}
type ApiSide = { id: string; label: string | null } type ApiSide = { id: string; label: string | null }
@ -192,6 +259,8 @@ export function WatchRound({ roundId }: { roundId: string }) {
flightGroup={flightGroup} flightGroup={flightGroup}
/> />
{round.play_format === "flag" && <WatchFlagMap roundId={roundId} refreshKey={refreshKey} />}
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes {/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
aldri her, kun round-detail.tsx sin egen "Score"-fane. En aldri her, kun round-detail.tsx sin egen "Score"-fane. En
person runden er delt med hadde dermed ingen vei til å se eller person runden er delt med hadde dermed ingen vei til å se eller

View file

@ -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) 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 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 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, # Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse,
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle # bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle

View file

@ -43,6 +43,7 @@ from handicap_engine import (
compute_bbb, compute_bbb,
FlagResult, FlagResult,
flag_result, flag_result,
flag_lap_and_hole,
shamble_hole_score, shamble_hole_score,
money_ball_hole_score, money_ball_hole_score,
high_low_high_points_for_hole, 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) 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) # Shamble (2026-07-30)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -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, participant_id, organization_id, match_id, team_side, team_roster_id, tee_id,
) )
return participant_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

259
tests/test_flag_plant.py Normal file
View file

@ -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}

View file

@ -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}