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:
parent
25c6056e76
commit
25bdabe3ea
18 changed files with 3770 additions and 9 deletions
66
069_flag_plant.sql
Normal file
66
069_flag_plant.sql
Normal 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;
|
||||
64
070_flag_plant_individual_tournament.sql
Normal file
64
070_flag_plant_individual_tournament.sql
Normal 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 $$;
|
||||
21
071_flag_map_visibility.sql
Normal file
21
071_flag_map_visibility.sql
Normal 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;
|
||||
|
|
@ -6510,6 +6510,212 @@ gang.
|
|||
|
||||
---
|
||||
|
||||
## ADR-066: Flaggturnering -- GPS-flaggplanting + runde 2+-scoreføring ("Del A") — 2026-08-14
|
||||
|
||||
Bruker ba om en tredelt utvidelse av Flaggturnering: (A) spilleren
|
||||
"planter flagget" via GPS der slagbudsjettet tar slutt, (B)
|
||||
turneringsledelsen kan slå av/på en kartoversikt over ALLE plantede
|
||||
flagg (satellittfoto), synlig for spillere og tilskuere, (C) nytt format
|
||||
Eclectic (brutto/netto/Stableford, beste resultat per hull på tvers av
|
||||
en turnerings runder). **Kun Del A er bygget i denne runden** -- Del
|
||||
B/C er fullt spesifisert i en godkjent plan, men ingen kode skrevet
|
||||
ennå.
|
||||
|
||||
Bekreftet med bruker (AskUserQuestion, to runder):
|
||||
1. Flagg-GPS bygges for BEGGE hjem -- frittstående runder OG
|
||||
org-individuelle turneringer (Flaggturnering finnes i begge, selv om
|
||||
nærmeste presedens -- ADR-048 slag-for-slag-GPS -- kun fantes for
|
||||
frittstående runder).
|
||||
2. **Full runde 2-scoreføring**, ikke bare en visuell markering --
|
||||
spilleren som fullfører 18 hull MED slag igjen fortsetter reelt inn i
|
||||
"runde 2" på samme play_order.
|
||||
3. **Selvkorrigerende, interaktiv flyt** (brukerens eget forslag,
|
||||
foretrukket fremfor mitt opprinnelige forslag om en passiv
|
||||
påminnelse): spilleren trykker "Plant flagget" når som helst; appen
|
||||
spør "Hullet du ut på hull N?" (N = første hull uten registrert score
|
||||
i spillerekkefølgen). JA → flagget var prematurt, spilleren må føre
|
||||
inn scoren for hull N og trykke "Plant flagget" på nytt fra neste
|
||||
utslag (ingen innsending skjer). NEI → dette ER flagg-punktet, GPS
|
||||
fanges, og appen spør om spilleren landet på green (i så fall:
|
||||
avstand i meter+cm, som direkte påvirker resultatlisten -- jo nærmere
|
||||
koppen, jo lengre har spilleren kommet blant andre som gikk tom på
|
||||
samme hull). **Backend validerer UANSETT server-side** mot faktisk
|
||||
registrert scoredata ("stol aldri blindt på klienten") -- klientflyten
|
||||
er en UX-guide, ikke autoritativ.
|
||||
|
||||
**Datamodell -- to nye, HELT ISOLERTE tabellpar** (migrasjon 069
|
||||
frittstående, 070 org, se filenes egne headerkommentarer for full
|
||||
kolonneliste): `round_participant_flag_plant`/`tournament_round_
|
||||
participant_flag_plant` (posisjon der spilleren gikk tom for slag, UNIQUE
|
||||
på deltaker-id -- re-planting er slett+opprett-på-nytt, samme prinsipp
|
||||
som `round_shot`) og `round_hole_flag_overflow`/`tournament_round_hole_
|
||||
flag_overflow` (scoreføring for runde 2+). **Bevisst IKKE en `lap`-
|
||||
kolonne på selve `round_hole`/`tournament_round_hole`** -- de tabellene
|
||||
er delt av samtlige ni eksisterende formater og lest av et stort antall
|
||||
spørringer (scorekort/statistikk/HCP-differensial) som alle antar ≤18
|
||||
rader per deltaker; en delt utvidelse hadde krevd en revisjon av HVER
|
||||
eksisterende spørring for å unngå at runde-2-rader lekker inn uventet
|
||||
andre steder. En helt egen tabell holder all ny risiko innenfor selve
|
||||
flagg-funksjonen. Org-tabellene er RLS-beskyttet, samme
|
||||
`org_isolation`-mønster som migrasjon 040; frittstående-tabellene har
|
||||
ingen RLS (samme `plain_connection()`-presedens som resten av
|
||||
frittstående-rundesystemet).
|
||||
|
||||
**Motor:** ny, ren `flag_lap_and_hole(holes_completed, play_order) ->
|
||||
(lap, hole_number)`-hjelpefunksjon i `handicap_engine.py`, rett etter
|
||||
eksisterende `flag_result()`/`FlagResult`. Selve `flag_result()` viste
|
||||
seg IKKE å trenge endring -- kalleren konkatenerer bare lap 1s fulle
|
||||
gross_strokes-sekvens med lap 2s (kun hvis lap 1 var fullført) til én
|
||||
flat liste før kallet; `holes_completed` blir naturlig en telling som
|
||||
kan overstige 18. 5 nye tester i `test_handicap_engine.py` (budsjett
|
||||
brukt opp midt i lap 2, budsjett akkurat nok til begge laps uten
|
||||
plantbart punkt, lap/hull-oversettelse ved ikke-standard start_hole,
|
||||
m.fl.) -- full suite 122/122.
|
||||
|
||||
**API (speilende ruter i `rounds.py`/`individual_tournaments.py`):**
|
||||
`POST/GET/DELETE .../flag-plant`, `GET/PUT .../flag-overflow/{lap}/
|
||||
holes/{hole_number}`. Server beregner FAKTISK "hull i gang" fra
|
||||
eksisterende scoredata (`_flag_current_position`/`_flag_current_
|
||||
position_org`) og avviser (`VALIDATION_FAILED`, 400) ethvert
|
||||
plant-/overflow-forsøk som ikke stemmer -- selv om frontend allerede
|
||||
guider brukeren dit. **To ulike autorisasjonsmodeller bevisst
|
||||
beholdt, ikke slått sammen:** frittstående runder er flight-styrt
|
||||
(eier ELLER lenket medspiller kan handle for enhver deltaker, samme som
|
||||
`update_hole`); org-turneringer er selv-only (`user_is_own_tournament_
|
||||
participant`, samme som den filens `update_hole`).
|
||||
|
||||
**Frontend -- ny UI-overflate, bygget via V0 (standingregel i
|
||||
CLAUDE.md/minne).** Skrev en detaljert V0-prompt for `FlagPlantSheet`
|
||||
(GPS auto-fanget → "Hullet du ut?" Ja/Nei → evt. "Landet du på green?"
|
||||
+ meter/cm → bekreftelse), sendt til bruker. Bygde parallelt en tydelig
|
||||
MERKET midlertidig håndkodet versjon (samme props-kontrakt) for å kunne
|
||||
verifisere hele backend+integrasjonen uten å vente. **V0-eksporten (zip
|
||||
11) kom tilbake samme dag** og erstattet den midlertidige versjonen i
|
||||
BÅDE `round-detail.tsx` (`FlagPlantSheet`) og
|
||||
`individual-tournament-detail.tsx` (`FlagPlantSheetOrg`, egen kopi per
|
||||
filens konvensjon -- fikk i tillegg `expectedLap`-prop lagt til
|
||||
kallstedet, som den midlertidige org-varianten manglet). Selvstendig
|
||||
GPS-håndtering (ingen kartkomponent -- det er "Del B"), server-avvisning
|
||||
vist som vedvarende inline-feil (ikke en forsvinnende toast).
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (46/46, inkl. 15 nye flagg-plant-
|
||||
tester i to nye testfiler). Egen scratch-database + scratch
|
||||
`teecup_api`-container (port 18000) + lokal `next dev` (port 13000):
|
||||
ende-til-ende-flyt for BEGGE hjem, inkl. server-side avvisning av et
|
||||
for-tidlig plantingsforsøk og full runde 2-scoreføring, geolocation
|
||||
emulert via Chrome DevTools MCP (måtte omgå at `emulate()` kun setter
|
||||
koordinater, ikke selve Permissions API-tilstanden -- løst med
|
||||
`navigate_page({type:"reload", initScript: ...})` som overstyrer
|
||||
`navigator.geolocation` direkte). Lys+mørk bekreftet, lap 2 sin
|
||||
`(runde {lap})`-visuelle markering bekreftet live. Etter V0-swap:
|
||||
`tsc --noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet
|
||||
ned igjen etterpå -- ingen av dette har rørt ekte `teecup_db`.
|
||||
|
||||
**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte `teecup_db`
|
||||
som `teeoff_admin` (`teecup_app` mangler bevisst `CREATE`-rett på schema
|
||||
public, se ADR-002-begrunnelsen i `002_roles_and_grants.sql` -- migrasjoner
|
||||
kjøres alltid av admin-rollen, aldri av runtime-rollen), etter eksplisitt
|
||||
bekreftelse fra bruker. Alle fire tabeller verifisert direkte i skjemaet
|
||||
etterpå (kolonner/constraints/RLS-policy som forventet), deretter
|
||||
`docker compose build teecup_api teecup_frontend && up -d` -- begge
|
||||
containere startet rent, ingen feil i logg. Del B (kartoversikt +
|
||||
synlighetsbryter) og Del C (Eclectic) gjenstår, egne
|
||||
scratch-verifiserings-/bekreftelsesrunder per CLAUDE.md/plan-disiplinen.
|
||||
|
||||
---
|
||||
|
||||
## ADR-067: Flaggturnering -- kartoversikt over plantede flagg + synlighetsbryter ("Del B") — 2026-08-14
|
||||
|
||||
Andre del av den tredelte Flaggturnering-utvidelsen (se ADR-066 for Del A
|
||||
og full kontekst). Turneringsledelsen (frittstående: eier: org:
|
||||
ethvert medlem) kan slå av/på en satellittkartoversikt som viser ALLE
|
||||
deltakeres plantede flagg samtidig -- standard AV, samme "skjult til
|
||||
avslørt"-filosofi som `visibility_mode`/blind draw. En spiller ser
|
||||
ALLTID sitt eget flagg uansett bryter-status; det er kun ANDRES flagg
|
||||
bryteren styrer.
|
||||
|
||||
**Avklart med bruker underveis (AskUserQuestion):** org-individuelle
|
||||
turneringer har INGEN offentlig tilskuer-side i det hele tatt ennå
|
||||
(`/t/[id]/live` er bygget utelukkende for lagturneringer -- team_a/
|
||||
team_b/matcher, ingen `format_type`-gren for individuelt). Å bygge en
|
||||
slik side fra bunnen for å kunne vise kartet der også ville vært en HELT
|
||||
EGEN, mye større oppgave enn selve kartfunksjonen. Bruker valgte
|
||||
(anbefalt alternativ) å AVGRENSE Del B sin org-side til org-medlemmer/
|
||||
deltakere (samme tilgangsnivå som det eksisterende `flag-result`-
|
||||
endepunktet) -- INGEN ny offentlig spectator-infrastruktur bygget her.
|
||||
Frittstående runder FÅR full tilskuerstøtte, siden `/watch/[id]` og
|
||||
`/public/rounds/*`-mønsteret allerede finnes og fungerer likt innlogget
|
||||
som anonymt.
|
||||
|
||||
**Datamodell (migrasjon 071):** to enkle boolean-kolonner, `round.
|
||||
flag_map_visible` og `tournament.flag_map_visible`, begge `NOT NULL
|
||||
DEFAULT false`. Ingen nye tabeller -- gjenbruker flagg-plant-tabellene
|
||||
fra migrasjon 069/070 (ADR-066) direkte, kun ETT nytt felt per hjem for
|
||||
selve synlighets-bryteren.
|
||||
|
||||
**API:** ingen nye skrive-endepunkter for selve bryteren -- kun nye
|
||||
felt på de allerede eksisterende PATCH-modellene (`RoundUpdate.
|
||||
flag_map_visible`, eier-only via `_get_owned_round_or_404`;
|
||||
`TournamentUpdate.flag_map_visible`, ethvert org-medlem via
|
||||
`get_authorized_org` -- begge gjenbruker sine filers eksisterende PATCH-
|
||||
handlere UENDRET, siden begge allerede var generiske "kun de feltene som
|
||||
faktisk sendes" whitelist-drevne). Nytt LESE-endepunkt per hjem: `GET
|
||||
/rounds/{id}/flag-map` (delt `_build_flag_map()`-hjelper, gjenbrukt av
|
||||
BÅDE den autentiserte ruten og `GET /public/rounds/{id}/flag-map` for
|
||||
tilskuere -- samme datauttrekk, ulik tilgangssjekk via
|
||||
`_get_accessible_round_or_404` vs. `_get_viewable_round_or_404`) og `GET
|
||||
/orgs/{id}/tournaments/{id}/rounds/{id}/flag-map` (kun autentisert,
|
||||
`get_authorized_org`, se avgrensningen over). Responsen har alltid
|
||||
`visible_to_all` + `flags`-listen -- ALLE deltakeres flagg når bryteren
|
||||
er PÅ, ELLERS kun requesterens eget (0 eller 1 rad).
|
||||
|
||||
**Frontend -- nok en ny UI-overflate via V0.** Skrev en detaljert prompt
|
||||
for `flag-map-overview.tsx` (FØRSTE flerpunkts-Mapbox-kart i appen --
|
||||
alt annet Mapbox-innhold viser ett punkt om gangen), sendt til bruker.
|
||||
Bygde parallelt en tydelig MERKET midlertidig håndkodet versjon (samme
|
||||
props-kontrakt: `flags: FlagMapEntry[]`, `visibleToAll: boolean`, ren
|
||||
UI uten egne nettverkskall) for å kunne verifisere hele funksjonen uten
|
||||
å vente. Wired inn via tre tynne, selvstendige "seksjon"-komponenter
|
||||
(egen fetch + `next/dynamic(..., {ssr:false})`-lasting av selve kartet,
|
||||
samme mønster som `NassauPanel`): `FlagMapSection`
|
||||
(`round-detail.tsx`, Administrer-fanen), `FlagMapSectionOrg`
|
||||
(`individual-tournament-detail.tsx`, Score-fanen) og `WatchFlagMap`
|
||||
(`watch-round.tsx`, tilskuer-siden -- viser ingenting i det hele tatt
|
||||
når bryteren er av OG tilskueren ikke har noe eget flagg, i stedet for
|
||||
en tom/forvirrende boks). Kartet skiller lap 1 fra lap 2+ med BÅDE farge
|
||||
OG en tekst-badge ("R{lap}") på selve markøren, aldri farge alene
|
||||
(fargeblindhet-hensynet i CLAUDE.md sin tilgjengelighetsregel), pluss en
|
||||
alltid synlig tekst-legende.
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (50/50, 4 nye flag-map-tester i de
|
||||
samme to testfilene som ADR-066 -- "kun eget flagg vises når bryteren er
|
||||
av"/"alle flagg vises når den er på", begge hjem). `tsc --noEmit` rent +
|
||||
45/45 vitest etter frontend-endringene. Egen scratch-database + scratch
|
||||
`teecup_api`-container (port 18000, kodendringer live-mountet inn i
|
||||
containeren for å unngå gjenoppbygging ved hver iterasjon) + lokal `next
|
||||
dev` (port 13000): sådde to komplette scenarioer direkte via
|
||||
`tests/conftest.py` sine hjelpefunksjoner (ikke gjenimplementert) --
|
||||
frittstående runde og org-turnering, hver med to spillere og ett flagg
|
||||
plantet hver (én lap 1, én lap 2 med `on_green`+avstand). Bekreftet i
|
||||
ekte nettleser: bryteren av → kun eget flagg + "Kun ditt eget flagg er
|
||||
synlig"-banner; bryteren på → begge flagg, korrekt lap 2-merking, popup
|
||||
med spillernavn/hull/runde/avstand ved trykk; samme for org-siden;
|
||||
tilskuer-siden (`/watch/[id]`) viser begge flagg når bryteren er på,
|
||||
uten å kreve innlogging. Lys+mørk bekreftet. Mapbox sin URL-restrikterte
|
||||
offentlige token krevde samme `Referer`-header-omgåelse via Chrome
|
||||
DevTools MCP som tidligere kartverifiseringer i denne loggen (se punkt
|
||||
51 i CHANGELOG.md). Scratch-stacken (database, rolle, container, MinIO-
|
||||
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
|
||||
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.
|
||||
|
||||
**IKKE rullet ut ennå** -- migrasjon 071 venter på eksplisitt
|
||||
bekreftelse fra bruker før noe kjøres mot ekte `teecup_db`. Del C
|
||||
(Eclectic) gjenstår.
|
||||
|
||||
---
|
||||
|
||||
## Utviklingsplan (rekkefølge)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
107
CHANGELOG.md
107
CHANGELOG.md
|
|
@ -11067,3 +11067,110 @@ Neste steg:
|
|||
under hver rettelse er beholdt uendret, ikke slettet. `Sist
|
||||
oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet
|
||||
tilsvarende. Ingen kodeendring, ren dokumentasjonsopprydding.
|
||||
|
||||
82. **Flaggturnering "Del A": GPS-flaggplanting + runde 2+-scoreføring
|
||||
(ADR-066) — 2026-08-14.** Første av tre bedt-om utvidelser
|
||||
(Del A GPS-planting/runde 2, Del B kartoversikt+synlighetsbryter,
|
||||
Del C Eclectic-format) -- kun Del A bygget denne runden, se ADR-066
|
||||
for full begrunnelse/avklaringer og plan-filens spesifikasjon av
|
||||
Del B/C.
|
||||
|
||||
Motor: ny `flag_lap_and_hole()`-hjelpefunksjon i
|
||||
`handicap_engine.py` (`flag_result()` selv trengte ingen endring --
|
||||
kalleren konkatenerer lap 1+lap 2s gross_strokes til én flat liste
|
||||
før kallet). 5 nye tester, full suite 122/122.
|
||||
|
||||
Migrasjon 069 (`round_participant_flag_plant` +
|
||||
`round_hole_flag_overflow`, frittstående) og 070 (samme par,
|
||||
org-individuelle turneringer, RLS-beskyttet) -- to HELT ISOLERTE
|
||||
tabellpar, bevisst IKKE en `lap`-kolonne på delte
|
||||
`round_hole`/`tournament_round_hole` (se ADR-066 for
|
||||
blindsone-begrunnelsen).
|
||||
|
||||
API: speilende `flag-plant`/`flag-overflow`-endepunkter i
|
||||
`rounds.py` og `individual_tournaments.py`. Server beregner faktisk
|
||||
"hull i gang" fra registrert scoredata og avviser
|
||||
(`VALIDATION_FAILED`) ethvert forsøk som ikke stemmer -- klientens
|
||||
"Hullet du ut?"-flyt er kun en UX-guide. To ulike, bevisst beholdte
|
||||
autorisasjonsmodeller: flight-styrt (frittstående) vs. selv-only
|
||||
(org), speiler hver fils eksisterende `update_hole`-mønster.
|
||||
|
||||
Frontend: `FlagPlantSheet`/`FlagPlantSheetOrg` -- ny UI-overflate,
|
||||
derfor bygget via en Claude-skrevet V0-prompt (sendt til bruker),
|
||||
med en tydelig MERKET midlertidig håndkodet versjon (samme
|
||||
props-kontrakt) i mellomtiden for å kunne verifisere hele
|
||||
backend-flyten uten å vente på eksporten. **V0-eksporten (zip 11)
|
||||
kom tilbake samme dag** og erstattet den midlertidige versjonen i
|
||||
begge filer (org-varianten fikk i tillegg `expectedLap`-prop lagt
|
||||
til kallstedet, som den manglet før).
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (46/46, 15 nye tester i to nye
|
||||
testfiler). Egen scratch-database + scratch `teecup_api`-container
|
||||
(port 18000) + lokal `next dev` (port 13000): ende-til-ende for
|
||||
BEGGE hjem inkl. server-side avvisning av for-tidlig planting og
|
||||
full runde 2-scoreføring, geolocation emulert via Chrome DevTools
|
||||
MCP (måtte omgå at `emulate()` kun setter koordinater, ikke
|
||||
Permissions API-tilstanden -- løst med `navigate_page({type:
|
||||
"reload", initScript: ...})`), lys+mørk bekreftet, lap 2 sin
|
||||
`(runde {lap})`-markering bekreftet live. Etter V0-swap: `tsc
|
||||
--noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet
|
||||
ned igjen etterpå.
|
||||
|
||||
**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte
|
||||
`teecup_db` som `teeoff_admin` (`teecup_app` har bevisst ingen
|
||||
`CREATE`-rett på schema public, jf. ADR-002 -- migrasjoner kjøres av
|
||||
admin-rollen, aldri runtime-rollen), etter eksplisitt bekreftelse fra
|
||||
bruker. Alle fire nye tabeller verifisert direkte i skjemaet
|
||||
etterpå. `docker compose build teecup_api teecup_frontend && up -d`
|
||||
-- begge containere startet rent (`Application startup complete.`
|
||||
henholdsvis `✓ Ready`), ingen feil i logg.
|
||||
|
||||
83. **Flaggturnering "Del B": kartoversikt over plantede flagg +
|
||||
synlighetsbryter (ADR-067) — 2026-08-14.** Andre av tre bedt-om
|
||||
utvidelser (se punkt 82/ADR-066 for Del A). Underveis avdekket:
|
||||
org-individuelle turneringer har INGEN offentlig tilskuer-side i det
|
||||
hele tatt (`/t/[id]/live` er rendyrket for lagturneringer) --
|
||||
avklart med bruker via AskUserQuestion, som valgte å avgrense Del B
|
||||
sin org-side til org-medlemmer/deltakere fremfor å bygge en helt ny
|
||||
spectator-infrastruktur bare for kartet. Frittstående runder fikk
|
||||
full tilskuerstøtte (gjenbruker eksisterende `/watch/[id]`/
|
||||
`/public/rounds/*`-mønster).
|
||||
|
||||
Migrasjon 071: to boolean-kolonner (`round.flag_map_visible`,
|
||||
`tournament.flag_map_visible`, begge default false) -- ingen nye
|
||||
tabeller, gjenbruker flagg-plant-tabellene fra migrasjon 069/070
|
||||
direkte. Bryteren endres via de allerede eksisterende PATCH-
|
||||
endepunktene (kun nye felt på whitelist-drevne modeller, ingen ny
|
||||
handler-kode). Nytt leseendepunkt per hjem (`GET .../flag-map`),
|
||||
delt `_build_flag_map()`-hjelper mellom autentisert og offentlig
|
||||
variant -- alle flagg når bryteren er PÅ, ellers kun requesterens
|
||||
eget.
|
||||
|
||||
Frontend: enda en ny UI-overflate via V0-prompt (`flag-map-
|
||||
overview.tsx`, FØRSTE flerpunkts-Mapbox-kart i appen), midlertidig
|
||||
håndkodet versjon bygget parallelt for å unngå å vente. Tre tynne
|
||||
"seksjon"-wrappere (`FlagMapSection`/`FlagMapSectionOrg`/
|
||||
`WatchFlagMap`) i de tre relevante skjermene, samme
|
||||
"egen fetch + next/dynamic(ssr:false)"-mønster som `NassauPanel`.
|
||||
Lap 1/2+ skilles med BÅDE farge OG en "R{lap}"-tekstbadge på selve
|
||||
markøren (aldri farge alene), pluss alltid synlig tekst-legende.
|
||||
|
||||
**Verifisert:** `python3 -m py_compile` + full
|
||||
`./scripts/run_backend_tests.sh` (50/50, 4 nye tester). `tsc
|
||||
--noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||||
`teecup_api`-container (port 18000, live-mountet kode for rask
|
||||
iterasjon) + lokal `next dev` (port 13000): to fulle scenarioer
|
||||
(frittstående + org, hver med to spillere, ett lap 1- og ett lap
|
||||
2-flagg med on-green-avstand) sådd via `tests/conftest.py` sine
|
||||
hjelpefunksjoner. Bekreftet i ekte nettleser: bryter av/på for begge
|
||||
hjem, korrekt "kun eget flagg"-banner, lap 2-merking, popup-innhold
|
||||
(navn/hull/runde/avstand), tilskuer-siden viser begge flagg uten
|
||||
innlogging når bryteren er på. Lys+mørk bekreftet. Samme `Referer`-
|
||||
header-omgåelse for Mapbox sin URL-restrikterte token som tidligere
|
||||
(se punkt 51). Scratch-stacken (db/rolle/container/MinIO-bucket)
|
||||
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||
`teecup_frontend` urørt.
|
||||
|
||||
**IKKE rullet ut ennå** -- migrasjon 071 venter på eksplisitt
|
||||
bekreftelse fra bruker. Del C (Eclectic) gjenstår.
|
||||
|
|
|
|||
|
|
@ -573,10 +573,28 @@
|
|||
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
|
||||
hull-granularitet). **GPS-avhengigheten er også borte** — GPS/
|
||||
avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling,
|
||||
ADR-064 rangefinder til grønn) — men selve GPS-posisjon-ved-siste-slag-
|
||||
ideen spesifikt for Flaggturnering er fortsatt ikke designet/bygget
|
||||
(stale-opprydding 2026-08-14: kun avhengigheten er avklart, ikke
|
||||
funksjonen selv).
|
||||
ADR-064 rangefinder til grønn).
|
||||
|
||||
**✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
|
||||
"Del A" av en tredelt utvidelse** (kartoversikt "Del B" og nytt format
|
||||
Eclectic "Del C" gjenstår, se egne punkter): "Plant flagget"-knapp +
|
||||
guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt.
|
||||
"Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot
|
||||
andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG
|
||||
org-individuelle turneringer). Full runde 2+-scoreføring bygget for
|
||||
spillere med uvanlig gunstig slagbudsjett (isolert overflow-tabell,
|
||||
rører ikke delt `round_hole`/`tournament_round_hole`-infrastruktur).
|
||||
UI-sheeten er en Claude-skrevet V0-eksport (zip 11), ikke håndkodet.
|
||||
Migrasjon 069 (frittstående)/070 (org) — se ADR-066 for full detalj,
|
||||
CHANGELOG.md for byggelogg/verifisering.
|
||||
|
||||
**✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på-
|
||||
bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg
|
||||
på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. Kun
|
||||
Eclectic "Del C" gjenstår nå. Org-individuelle turneringers offentlige
|
||||
tilskuervisning er BEVISST utenfor omfang her (finnes ikke i det hele
|
||||
tatt ennå — `/t/[id]/live` er lagturnerings-only — egen, større
|
||||
oppgave hvis/når etterspurt). Migrasjon 071 — se ADR-067.
|
||||
|
||||
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
||||
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
||||
|
|
|
|||
|
|
@ -1403,3 +1403,370 @@ async def flag_round_result(
|
|||
)
|
||||
results.sort(key=lambda r: -r.holes_completed)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14).
|
||||
# Speiler rounds.py sin frittstående-variant presist -- se der for full
|
||||
# begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap,
|
||||
# aldri stolt blindt på fra klienten). Ulikt frittstående runder er
|
||||
# scoreregistrering her SELV-only (user_is_own_tournament_participant),
|
||||
# samme presedens som update_hole over -- ikke flight-styrt.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _flag_current_position_org(
|
||||
conn, tournament_round_participant_id: str, hole_config: str
|
||||
) -> tuple[int, int]:
|
||||
"""Org-ekvivalent av rounds.py sin _flag_current_position -- samme
|
||||
logikk, men leser tournament_round_hole/tournament_round_hole_flag_
|
||||
overflow, og play_order kommer fra played_hole_numbers(hole_config)
|
||||
(ingen start_hole-rotasjon på org-siden)."""
|
||||
play_order = played_hole_numbers(hole_config)
|
||||
lap1_rows = await conn.fetch(
|
||||
"SELECT hole_number FROM tournament_round_hole "
|
||||
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL",
|
||||
tournament_round_participant_id,
|
||||
)
|
||||
lap1_played = {r["hole_number"] for r in lap1_rows}
|
||||
for h in play_order:
|
||||
if h not in lap1_played:
|
||||
return 1, h
|
||||
lap = 2
|
||||
while True:
|
||||
overflow_rows = await conn.fetch(
|
||||
"SELECT hole_number FROM tournament_round_hole_flag_overflow "
|
||||
"WHERE tournament_round_participant_id = $1 AND lap = $2 AND played",
|
||||
tournament_round_participant_id, lap,
|
||||
)
|
||||
overflow_played = {r["hole_number"] for r in overflow_rows}
|
||||
for h in play_order:
|
||||
if h not in overflow_played:
|
||||
return lap, h
|
||||
lap += 1
|
||||
|
||||
|
||||
class FlagPlantIn(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
hole_number: int = Field(ge=1, le=18)
|
||||
lap: int = Field(ge=1)
|
||||
on_green: bool = False
|
||||
distance_to_pin_cm: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class FlagPlantOut(BaseModel):
|
||||
id: str
|
||||
lap: int
|
||||
hole_number: int
|
||||
lat: float
|
||||
lng: float
|
||||
on_green: bool
|
||||
distance_to_pin_cm: int | None
|
||||
planted_by_user_id: str
|
||||
planted_at: str
|
||||
|
||||
|
||||
def _flag_plant_out(row) -> FlagPlantOut:
|
||||
return FlagPlantOut(
|
||||
id=row["id"], lap=row["lap"], hole_number=row["hole_number"],
|
||||
lat=row["lat"], lng=row["lng"], on_green=row["on_green"],
|
||||
distance_to_pin_cm=row["distance_to_pin_cm"],
|
||||
planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _get_round_participant_or_404(conn, tournament_id: str, round_id: str, round_participant_id: str):
|
||||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT tr.hole_config::text AS hole_config, t.scoring_method
|
||||
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
||||
WHERE tr.id = $1 AND tr.tournament_id = $2
|
||||
""",
|
||||
round_id, tournament_id,
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
if round_row["scoring_method"] != "flag":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
|
||||
rp = await conn.fetchrow(
|
||||
"SELECT id, tournament_participant_id::text AS tournament_participant_id "
|
||||
"FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
||||
round_participant_id, round_id,
|
||||
)
|
||||
if rp is None:
|
||||
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||
return round_row, rp
|
||||
|
||||
|
||||
async def _require_own_tournament_participant(conn, organization_id: str, tournament_participant_id: str, user_id: str) -> None:
|
||||
if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user_id):
|
||||
raise app_error(
|
||||
403, "NOT_TOURNAMENT_PARTICIPANT",
|
||||
"Du kan kun plante/registrere flagg for deg selv (eller være organisasjonsadministrator).",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/flag-plant",
|
||||
response_model=FlagPlantOut,
|
||||
)
|
||||
async def plant_flag(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
body: FlagPlantIn,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagPlantOut:
|
||||
if body.on_green and body.distance_to_pin_cm is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.")
|
||||
if not body.on_green and body.distance_to_pin_cm is not None:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green."
|
||||
)
|
||||
async with org_connection(organization_id) as conn:
|
||||
round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
||||
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
||||
|
||||
actual_lap, actual_hole = await _flag_current_position_org(
|
||||
conn, round_participant_id, round_row["hole_config"]
|
||||
)
|
||||
if (body.lap, body.hole_number) != (actual_lap, actual_hole):
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), "
|
||||
f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.",
|
||||
)
|
||||
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
|
||||
round_participant_id,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tournament_round_participant_flag_plant
|
||||
(organization_id, tournament_round_participant_id, lap, hole_number, lat, lng,
|
||||
on_green, distance_to_pin_cm, planted_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
|
||||
planted_by_user_id::text AS planted_by_user_id, planted_at
|
||||
""",
|
||||
organization_id, round_participant_id, body.lap, body.hole_number, body.lat, body.lng,
|
||||
body.on_green, body.distance_to_pin_cm, user.user_id,
|
||||
)
|
||||
return _flag_plant_out(row)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/flag-plant",
|
||||
response_model=FlagPlantOut | None,
|
||||
)
|
||||
async def get_flag_plant(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> FlagPlantOut | None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
|
||||
planted_by_user_id::text AS planted_by_user_id, planted_at
|
||||
FROM tournament_round_participant_flag_plant
|
||||
WHERE tournament_round_participant_id = $1
|
||||
""",
|
||||
round_participant_id,
|
||||
)
|
||||
return _flag_plant_out(row) if row is not None else None
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/flag-plant",
|
||||
status_code=204,
|
||||
)
|
||||
async def unplant_flag(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
_, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
||||
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
|
||||
round_participant_id,
|
||||
)
|
||||
|
||||
|
||||
class FlagOverflowHoleIn(BaseModel):
|
||||
gross_strokes: int = Field(gt=0, le=20)
|
||||
|
||||
|
||||
class FlagOverflowHoleOut(BaseModel):
|
||||
lap: int
|
||||
hole_number: int
|
||||
gross_strokes: int | None
|
||||
played: bool
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/flag-overflow",
|
||||
response_model=list[FlagOverflowHoleOut],
|
||||
)
|
||||
async def list_flag_overflow_holes(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[FlagOverflowHoleOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
||||
rows = await conn.fetch(
|
||||
"SELECT lap, hole_number, gross_strokes, played FROM tournament_round_hole_flag_overflow "
|
||||
"WHERE tournament_round_participant_id = $1 ORDER BY lap, hole_number",
|
||||
round_participant_id,
|
||||
)
|
||||
return [FlagOverflowHoleOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
|
||||
"/participants/{round_participant_id}/flag-overflow/{lap}/holes/{hole_number}",
|
||||
response_model=FlagOverflowHoleOut,
|
||||
)
|
||||
async def update_flag_overflow_hole(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
round_participant_id: str,
|
||||
lap: int,
|
||||
hole_number: int,
|
||||
body: FlagOverflowHoleIn,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagOverflowHoleOut:
|
||||
if lap < 2:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.")
|
||||
if not (1 <= hole_number <= 18):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
|
||||
async with org_connection(organization_id) as conn:
|
||||
round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
|
||||
await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
|
||||
|
||||
actual_lap, _ = await _flag_current_position_org(conn, round_participant_id, round_row["hole_config"])
|
||||
if lap > actual_lap:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.",
|
||||
)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO tournament_round_hole_flag_overflow
|
||||
(organization_id, tournament_round_participant_id, lap, hole_number, gross_strokes, played)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
ON CONFLICT (tournament_round_participant_id, lap, hole_number)
|
||||
DO UPDATE SET gross_strokes = $5, played = true
|
||||
RETURNING lap, hole_number, gross_strokes, played
|
||||
""",
|
||||
organization_id, round_participant_id, lap, hole_number, body.gross_strokes,
|
||||
)
|
||||
return FlagOverflowHoleOut(**dict(row))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- speiler
|
||||
# rounds.py sin frittstående-variant presist. Styrt av tournament.flag_map_
|
||||
# visible (ethvert org-medlem kan slå av/på via update_tournament,
|
||||
# tournaments.py -- se TournamentUpdate.flag_map_visible). Bevisst KUN
|
||||
# org-medlem-/deltaker-tilgang (samme presedens som flag_round_result over)
|
||||
# -- ingen offentlig tilskuer-gren, individuelle turneringer har ingen
|
||||
# offentlig spectator-side i det hele tatt ennå (ulikt frittstående
|
||||
# rundevisning), se ADR-067 for avklaringen.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FlagMapEntryOut(BaseModel):
|
||||
participant_id: str
|
||||
display_name: str
|
||||
lap: int
|
||||
hole_number: int
|
||||
lat: float
|
||||
lng: float
|
||||
on_green: bool
|
||||
distance_to_pin_cm: int | None
|
||||
planted_at: str
|
||||
|
||||
|
||||
class FlagMapOut(BaseModel):
|
||||
visible_to_all: bool
|
||||
flags: list[FlagMapEntryOut]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-map",
|
||||
response_model=FlagMapOut,
|
||||
)
|
||||
async def get_flag_map(
|
||||
tournament_id: str,
|
||||
round_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagMapOut:
|
||||
async with org_connection(organization_id) as conn:
|
||||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT t.flag_map_visible
|
||||
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
|
||||
WHERE tr.id = $1 AND tr.tournament_id = $2
|
||||
""",
|
||||
round_id,
|
||||
tournament_id,
|
||||
)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
visible_to_all = round_row["flag_map_visible"]
|
||||
|
||||
base_query = """
|
||||
SELECT trpfp.tournament_round_participant_id::text AS participant_id,
|
||||
p.display_name AS display_name,
|
||||
trpfp.lap, trpfp.hole_number, trpfp.lat, trpfp.lng,
|
||||
trpfp.on_green, trpfp.distance_to_pin_cm, trpfp.planted_at
|
||||
FROM tournament_round_participant_flag_plant trpfp
|
||||
JOIN tournament_round_participant trp ON trp.id = trpfp.tournament_round_participant_id
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
WHERE trp.tournament_round_id = $1
|
||||
"""
|
||||
if visible_to_all:
|
||||
rows = await conn.fetch(base_query, round_id)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
base_query + " AND p.user_id = $2",
|
||||
round_id,
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
return FlagMapOut(
|
||||
visible_to_all=visible_to_all,
|
||||
flags=[
|
||||
FlagMapEntryOut(
|
||||
participant_id=r["participant_id"],
|
||||
display_name=r["display_name"],
|
||||
lap=r["lap"],
|
||||
hole_number=r["hole_number"],
|
||||
lat=r["lat"],
|
||||
lng=r["lng"],
|
||||
on_green=r["on_green"],
|
||||
distance_to_pin_cm=r["distance_to_pin_cm"],
|
||||
planted_at=r["planted_at"].isoformat(),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1468,6 +1468,8 @@ class RoundOut(BaseModel):
|
|||
flight_group_id: str | None
|
||||
# HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard.
|
||||
allowance_override: dict | None
|
||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
flag_map_visible: bool
|
||||
|
||||
|
||||
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||
|
|
@ -1477,7 +1479,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
||||
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
||||
allowance_override::text AS allowance_override
|
||||
allowance_override::text AS allowance_override, flag_map_visible
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -1602,6 +1604,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
visible_categories=[r["category"] for r in visible_category_rows],
|
||||
flight_group_id=round_row["flight_group_id"],
|
||||
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
||||
flag_map_visible=round_row["flag_map_visible"],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2636,6 +2639,11 @@ class RoundUpdate(BaseModel):
|
|||
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
|
||||
# samme "None = ikke rør"-konvensjon som resten av denne modellen.
|
||||
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
|
||||
# -- eier-only (samme tilgang som resten av denne PATCH-en). Standard AV
|
||||
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
|
||||
# None = ikke rør, samme konvensjon som resten av modellen her.
|
||||
flag_map_visible: bool | None = None
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||
|
|
@ -2755,6 +2763,10 @@ async def update_round(
|
|||
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
||||
if body.visibility_mode is not None:
|
||||
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
|
||||
if body.flag_map_visible is not None:
|
||||
await conn.execute(
|
||||
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
|
||||
)
|
||||
if body.flight_group_id is not None:
|
||||
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
||||
if body.visible_categories is not None:
|
||||
|
|
@ -5508,6 +5520,353 @@ async def update_hole(
|
|||
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14).
|
||||
# Spilleren markerer nøyaktig posisjon der slagbudsjettet ble brukt opp.
|
||||
# Hvilket hull/lap flagget hører til beregnes ALLTID server-side fra
|
||||
# faktisk registrert scoredata (aldri stolt blindt på fra klienten, som kun
|
||||
# guider brukeren dit via en "Hullet du ut?"-flyt) -- se
|
||||
# _flag_current_position under. round_hole_flag_overflow er en BEVISST
|
||||
# egen, isolert tabell for runde 2+-scoreføring, ikke en lap-kolonne på
|
||||
# selve round_hole (delt av alle ni formater, se 069_flag_plant.sql).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _flag_current_position(
|
||||
conn, participant_id: str, start_hole: int, holes_planned: int
|
||||
) -> tuple[int, int]:
|
||||
"""Første hull UTEN registrert score, i spillerekkefølge, på tvers av
|
||||
laps -- dette ER "der spilleren fysisk er" akkurat nå, uavhengig av om
|
||||
slagbudsjettet TEORETISK er brukt opp (appen hindrer aldri videre
|
||||
scoreføring forbi budsjettet, se flag_result() sin egen "v1-
|
||||
granularitet"-kommentar i handicap_engine.py). Lap 1 leses fra
|
||||
round_hole, lap 2+ fra round_hole_flag_overflow -- avsluttes så snart
|
||||
en lap uten NOEN registrerte hull nås (garantert terminering, ingen
|
||||
uendelig løkke, siden en lap kun kan nås ved at forrige er fullt
|
||||
scoreført)."""
|
||||
play_order = _round_hole_order(start_hole, holes_planned)
|
||||
lap1_rows = await conn.fetch(
|
||||
"SELECT hole_number FROM round_hole WHERE round_participant_id = $1 AND played",
|
||||
participant_id,
|
||||
)
|
||||
lap1_played = {r["hole_number"] for r in lap1_rows}
|
||||
for h in play_order:
|
||||
if h not in lap1_played:
|
||||
return 1, h
|
||||
lap = 2
|
||||
while True:
|
||||
overflow_rows = await conn.fetch(
|
||||
"SELECT hole_number FROM round_hole_flag_overflow WHERE round_participant_id = $1 AND lap = $2 AND played",
|
||||
participant_id, lap,
|
||||
)
|
||||
overflow_played = {r["hole_number"] for r in overflow_rows}
|
||||
for h in play_order:
|
||||
if h not in overflow_played:
|
||||
return lap, h
|
||||
lap += 1
|
||||
|
||||
|
||||
class FlagPlantIn(BaseModel):
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
lng: float = Field(ge=-180, le=180)
|
||||
hole_number: int = Field(ge=1, le=18)
|
||||
lap: int = Field(ge=1)
|
||||
on_green: bool = False
|
||||
# Meter+cm slås sammen til én cm-verdi her (splittes kun i UI-et) --
|
||||
# brukes til å rangere spillere som gikk tom på SAMME hull mot
|
||||
# hverandre (jo nærmere koppen, jo lengre har spilleren kommet).
|
||||
distance_to_pin_cm: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class FlagPlantOut(BaseModel):
|
||||
id: str
|
||||
lap: int
|
||||
hole_number: int
|
||||
lat: float
|
||||
lng: float
|
||||
on_green: bool
|
||||
distance_to_pin_cm: int | None
|
||||
planted_by_user_id: str
|
||||
planted_at: str
|
||||
|
||||
|
||||
def _flag_plant_out(row: asyncpg.Record) -> FlagPlantOut:
|
||||
return FlagPlantOut(
|
||||
id=row["id"], lap=row["lap"], hole_number=row["hole_number"],
|
||||
lat=row["lat"], lng=row["lng"], on_green=row["on_green"],
|
||||
distance_to_pin_cm=row["distance_to_pin_cm"],
|
||||
planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rounds/{round_id}/participants/{participant_id}/flag-plant",
|
||||
response_model=FlagPlantOut,
|
||||
)
|
||||
async def plant_flag(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
body: FlagPlantIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagPlantOut:
|
||||
if body.on_green and body.distance_to_pin_cm is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.")
|
||||
if not body.on_green and body.distance_to_pin_cm is not None:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green."
|
||||
)
|
||||
async with plain_connection() as conn:
|
||||
# Samme flight-styrte tilgang som update_hole -- en lenket
|
||||
# medspiller kan plante flagget for HELE flighten, ikke bare seg selv.
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT start_hole, holes_planned, play_format FROM round WHERE id = $1", round_id
|
||||
)
|
||||
if round_row["play_format"] != "flag":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Flagg kan kun plantes i en flaggturnering.")
|
||||
participant_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id
|
||||
)
|
||||
if not participant_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
|
||||
actual_lap, actual_hole = await _flag_current_position(
|
||||
conn, participant_id, round_row["start_hole"], round_row["holes_planned"]
|
||||
)
|
||||
if (body.lap, body.hole_number) != (actual_lap, actual_hole):
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), "
|
||||
f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.",
|
||||
)
|
||||
|
||||
async with conn.transaction():
|
||||
# Re-planting: slett og opprett på nytt, samme prinsipp som
|
||||
# round_shot -- aldri en delvis UPDATE av et allerede plantet flagg.
|
||||
await conn.execute(
|
||||
"DELETE FROM round_participant_flag_plant WHERE round_participant_id = $1", participant_id
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO round_participant_flag_plant
|
||||
(round_participant_id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm, planted_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
|
||||
planted_by_user_id::text AS planted_by_user_id, planted_at
|
||||
""",
|
||||
participant_id, body.lap, body.hole_number, body.lat, body.lng,
|
||||
body.on_green, body.distance_to_pin_cm, user.user_id,
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
return _flag_plant_out(row)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/flag-plant",
|
||||
response_model=FlagPlantOut | None,
|
||||
)
|
||||
async def get_flag_plant(
|
||||
round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> FlagPlantOut | None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT fp.id::text AS id, fp.lap, fp.hole_number, fp.lat, fp.lng, fp.on_green,
|
||||
fp.distance_to_pin_cm, fp.planted_by_user_id::text AS planted_by_user_id, fp.planted_at
|
||||
FROM round_participant_flag_plant fp
|
||||
JOIN round_participant rp ON rp.id = fp.round_participant_id
|
||||
WHERE fp.round_participant_id = $1 AND rp.round_id = $2
|
||||
""",
|
||||
participant_id, round_id,
|
||||
)
|
||||
return _flag_plant_out(row) if row is not None else None
|
||||
|
||||
|
||||
@router.delete("/rounds/{round_id}/participants/{participant_id}/flag-plant", status_code=204)
|
||||
async def unplant_flag(
|
||||
round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
await conn.execute(
|
||||
"""
|
||||
DELETE FROM round_participant_flag_plant
|
||||
WHERE round_participant_id = $1
|
||||
AND EXISTS (SELECT 1 FROM round_participant rp WHERE rp.id = $1 AND rp.round_id = $2)
|
||||
""",
|
||||
participant_id, round_id,
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
|
||||
|
||||
class FlagOverflowHoleIn(BaseModel):
|
||||
score: int = Field(gt=0, le=20)
|
||||
|
||||
|
||||
class FlagOverflowHoleOut(BaseModel):
|
||||
lap: int
|
||||
hole_number: int
|
||||
score: int | None
|
||||
played: bool
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/flag-overflow",
|
||||
response_model=list[FlagOverflowHoleOut],
|
||||
)
|
||||
async def list_flag_overflow_holes(
|
||||
round_id: str, participant_id: str, user: CurrentUser = Depends(get_current_user)
|
||||
) -> list[FlagOverflowHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT o.lap, o.hole_number, o.score, o.played
|
||||
FROM round_hole_flag_overflow o
|
||||
JOIN round_participant rp ON rp.id = o.round_participant_id
|
||||
WHERE o.round_participant_id = $1 AND rp.round_id = $2
|
||||
ORDER BY o.lap, o.hole_number
|
||||
""",
|
||||
participant_id, round_id,
|
||||
)
|
||||
return [FlagOverflowHoleOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/rounds/{round_id}/participants/{participant_id}/flag-overflow/{lap}/holes/{hole_number}",
|
||||
response_model=FlagOverflowHoleOut,
|
||||
)
|
||||
async def update_flag_overflow_hole(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
lap: int,
|
||||
hole_number: int,
|
||||
body: FlagOverflowHoleIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagOverflowHoleOut:
|
||||
if lap < 2:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.")
|
||||
if not (1 <= hole_number <= 18):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT start_hole, holes_planned, play_format FROM round WHERE id = $1", round_id
|
||||
)
|
||||
if round_row["play_format"] != "flag":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Runde 2 finnes kun i en flaggturnering.")
|
||||
participant_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id
|
||||
)
|
||||
if not participant_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
|
||||
# Kan ikke score-føre en lap FØR forrige lap faktisk er fullført --
|
||||
# gjenbruker samme "hvor er spilleren egentlig"-beregning som
|
||||
# selve flaggplantingen, for å unngå å duplisere fullførings-logikken.
|
||||
actual_lap, _ = await _flag_current_position(
|
||||
conn, participant_id, round_row["start_hole"], round_row["holes_planned"]
|
||||
)
|
||||
if lap > actual_lap:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.",
|
||||
)
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO round_hole_flag_overflow (round_participant_id, lap, hole_number, score, played)
|
||||
VALUES ($1, $2, $3, $4, true)
|
||||
ON CONFLICT (round_participant_id, lap, hole_number)
|
||||
DO UPDATE SET score = $4, played = true
|
||||
RETURNING lap, hole_number, score, played
|
||||
""",
|
||||
participant_id, lap, hole_number, body.score,
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
return FlagOverflowHoleOut(**dict(row))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- alle
|
||||
# plantede flagg for runden, på satellittkart. Styrt av round.flag_map_
|
||||
# visible (eier-only bryter, se RoundUpdate.flag_map_visible over): PÅ
|
||||
# viser samtlige deltakeres flagg, AV viser kun spørrerens eget (samme
|
||||
# "skjult til avslørt"-prinsipp som visibility_mode/blind draw). Delt
|
||||
# mellom den autentiserte ruten (eier/medspiller) og den offentlige
|
||||
# tilskuer-ruten (/public/rounds/{id}/flag-map, se lenger ned) -- samme
|
||||
# datauttrekk, ulik tilgangssjekk.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FlagMapEntryOut(BaseModel):
|
||||
participant_id: str
|
||||
display_name: str
|
||||
lap: int
|
||||
hole_number: int
|
||||
lat: float
|
||||
lng: float
|
||||
on_green: bool
|
||||
distance_to_pin_cm: int | None
|
||||
planted_at: str
|
||||
|
||||
|
||||
class FlagMapOut(BaseModel):
|
||||
visible_to_all: bool
|
||||
flags: list[FlagMapEntryOut]
|
||||
|
||||
|
||||
async def _build_flag_map(conn, round_id: str, viewer_user_id: str | None) -> FlagMapOut:
|
||||
round_row = await conn.fetchrow("SELECT flag_map_visible FROM round WHERE id = $1", round_id)
|
||||
if round_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
visible_to_all = round_row["flag_map_visible"]
|
||||
|
||||
base_query = """
|
||||
SELECT rpfp.round_participant_id::text AS participant_id,
|
||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||
rpfp.lap, rpfp.hole_number, rpfp.lat, rpfp.lng,
|
||||
rpfp.on_green, rpfp.distance_to_pin_cm, rpfp.planted_at
|
||||
FROM round_participant_flag_plant rpfp
|
||||
JOIN round_participant rp ON rp.id = rpfp.round_participant_id
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.round_id = $1
|
||||
"""
|
||||
if visible_to_all:
|
||||
rows = await conn.fetch(base_query, round_id)
|
||||
elif viewer_user_id is not None:
|
||||
rows = await conn.fetch(base_query + " AND rp.user_id = $2", round_id, viewer_user_id)
|
||||
else:
|
||||
rows = []
|
||||
|
||||
return FlagMapOut(
|
||||
visible_to_all=visible_to_all,
|
||||
flags=[
|
||||
FlagMapEntryOut(
|
||||
participant_id=r["participant_id"],
|
||||
display_name=r["display_name"],
|
||||
lap=r["lap"],
|
||||
hole_number=r["hole_number"],
|
||||
lat=r["lat"],
|
||||
lng=r["lng"],
|
||||
on_green=r["on_green"],
|
||||
distance_to_pin_cm=r["distance_to_pin_cm"],
|
||||
planted_at=r["planted_at"].isoformat(),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/flag-map", response_model=FlagMapOut)
|
||||
async def get_flag_map(
|
||||
round_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> FlagMapOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
return await _build_flag_map(conn, round_id, user.user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra
|
||||
# round_hole sine faste hull-aggregater over. round_shot kjenner kun
|
||||
|
|
@ -6375,6 +6734,16 @@ async def get_public_format_result(
|
|||
return await _build_format_result(conn, round_id)
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}/flag-map", response_model=FlagMapOut)
|
||||
async def get_public_flag_map(
|
||||
round_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> FlagMapOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_flag_map(conn, round_id, user.user_id if user else None)
|
||||
|
||||
|
||||
async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
|
||||
"""Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
|
||||
"avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ _TOURNAMENT_COLUMNS = """
|
|||
id::text, name, status::text, start_date, end_date,
|
||||
visibility, description, registration_deadline, registration_capacity,
|
||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled
|
||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible
|
||||
"""
|
||||
|
||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||
|
|
@ -88,6 +88,8 @@ class Tournament(BaseModel):
|
|||
format_type: str
|
||||
scoring_method: str | None
|
||||
bbb_sweep_bonus_enabled: bool
|
||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
flag_map_visible: bool
|
||||
|
||||
|
||||
def _tournament_from_row(row) -> Tournament:
|
||||
|
|
@ -119,7 +121,8 @@ async def list_tournaments(
|
|||
t.visibility, t.description, t.registration_deadline,
|
||||
t.registration_capacity, t.registration_overflow_policy,
|
||||
t.registration_requires_approval, t.hero_image_key,
|
||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
|
||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
||||
t.flag_map_visible
|
||||
FROM tournament t
|
||||
LEFT JOIN (
|
||||
SELECT tournament_id,
|
||||
|
|
@ -189,6 +192,11 @@ class TournamentUpdate(BaseModel):
|
|||
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
||||
registration_requires_approval: bool | None = None
|
||||
bbb_sweep_bonus_enabled: bool | None = None
|
||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
|
||||
# -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se
|
||||
# get_authorized_org under). Standard AV, samme "skjult til avslørt"-
|
||||
# filosofi som visibility.
|
||||
flag_map_visible: bool | None = None
|
||||
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||
|
|
|
|||
157
frontend/components/flag-map-overview.tsx
Normal file
157
frontend/components/flag-map-overview.tsx
Normal 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
|
||||
|
|
@ -12,15 +12,20 @@
|
|||
// nye, ukalibrerte farger.
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import dynamic from "next/dynamic"
|
||||
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
Crosshair,
|
||||
Flag,
|
||||
KeyRound,
|
||||
MapPin,
|
||||
Medal,
|
||||
Plus,
|
||||
Trash2,
|
||||
|
|
@ -61,6 +66,8 @@ type ApiTournamentInfo = {
|
|||
scoring_method: string | null
|
||||
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
||||
bbb_sweep_bonus_enabled: boolean
|
||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||
flag_map_visible: boolean
|
||||
}
|
||||
|
||||
type ApiParticipant = {
|
||||
|
|
@ -151,6 +158,55 @@ type ApiFlagResult = {
|
|||
strokes_remaining: number
|
||||
}
|
||||
|
||||
// GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14) -- speiler
|
||||
// rounds.py sin frittstående-variant (round-detail.tsx), se der for full
|
||||
// begrunnelse. Duplisert her (ikke importert på tvers av de to store
|
||||
// sidefilene) -- samme selvstendighets-konvensjon som round-scorecard.tsx
|
||||
// allerede følger for sine egne typer/hjelpefunksjoner.
|
||||
type ApiFlagPlant = {
|
||||
id: string
|
||||
lap: number
|
||||
hole_number: number
|
||||
lat: number
|
||||
lng: number
|
||||
on_green: boolean
|
||||
distance_to_pin_cm: number | null
|
||||
planted_by_user_id: string
|
||||
planted_at: string
|
||||
}
|
||||
|
||||
type ApiFlagOverflowHole = {
|
||||
lap: number
|
||||
hole_number: number
|
||||
gross_strokes: number | null
|
||||
played: boolean
|
||||
}
|
||||
|
||||
function playedHoleNumbersOrg(holeConfig: "full_18" | "front_9" | "back_9"): number[] {
|
||||
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
||||
return Array.from({ length: 18 }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
function computeFlagCurrentPositionOrg(
|
||||
playOrder: number[],
|
||||
holes: ApiHole[] | null,
|
||||
overflowHoles: ApiFlagOverflowHole[],
|
||||
): { lap: number; holeNumber: number } {
|
||||
const lap1Played = new Set((holes ?? []).filter((h) => h.gross_strokes !== null).map((h) => h.hole_number))
|
||||
for (const h of playOrder) {
|
||||
if (!lap1Played.has(h)) return { lap: 1, holeNumber: h }
|
||||
}
|
||||
let lap = 2
|
||||
while (true) {
|
||||
const lapPlayed = new Set(overflowHoles.filter((h) => h.lap === lap && h.played).map((h) => h.hole_number))
|
||||
for (const h of playOrder) {
|
||||
if (!lapPlayed.has(h)) return { lap, holeNumber: h }
|
||||
}
|
||||
lap += 1
|
||||
}
|
||||
}
|
||||
|
||||
type ApiBBBHole = {
|
||||
hole_number: number
|
||||
bingo_participant_id: string | null
|
||||
|
|
@ -308,6 +364,24 @@ export function IndividualTournamentDetail({
|
|||
}
|
||||
}
|
||||
|
||||
async function updateFlagMapVisible(visible: boolean) {
|
||||
if (!tournament) return
|
||||
const previous = tournament
|
||||
setTournament({ ...tournament, flag_map_visible: visible })
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ flag_map_visible: visible }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
} catch {
|
||||
setTournament(previous)
|
||||
setError("Klarte ikke å endre kartoversikt-innstillingen. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
async function addParticipant(playerId: string) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/participants`, {
|
||||
|
|
@ -550,6 +624,7 @@ export function IndividualTournamentDetail({
|
|||
classes={classes}
|
||||
onUpdateScoringMethod={updateScoringMethod}
|
||||
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
||||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||
onAddParticipant={addParticipant}
|
||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||
onRemoveParticipant={removeParticipant}
|
||||
|
|
@ -620,6 +695,7 @@ function SetupTab({
|
|||
classes,
|
||||
onUpdateScoringMethod,
|
||||
onUpdateBbbSweepBonus,
|
||||
onUpdateFlagMapVisible,
|
||||
onAddParticipant,
|
||||
onAddNewPlayer,
|
||||
onRemoveParticipant,
|
||||
|
|
@ -641,6 +717,7 @@ function SetupTab({
|
|||
classes: ApiTournamentClass[]
|
||||
onUpdateScoringMethod: (method: string) => void
|
||||
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
||||
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||||
onRemoveParticipant: (id: string) => Promise<void>
|
||||
|
|
@ -706,6 +783,22 @@ function SetupTab({
|
|||
</span>
|
||||
</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 PÅ, kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<RoundsCard
|
||||
|
|
@ -1838,6 +1931,27 @@ function ScoreTab({
|
|||
{round && scoringMethod === "flag" && (
|
||||
<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>
|
||||
|
|
@ -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 på 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, må 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 på 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 på
|
||||
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 må 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) ----------------------
|
||||
|
||||
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"
|
||||
|
||||
function classify(diff: number): ScoreClass {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,77 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import dynamic from "next/dynamic"
|
||||
import { Radio, Trophy } from "lucide-react"
|
||||
import { WatchTabs } from "@/components/watch-tabs"
|
||||
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
|
||||
import { RoundMessages } from "@/components/round-messages"
|
||||
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||
|
||||
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
|
||||
|
||||
// Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --
|
||||
// tilskuer-varianten, mot /public/rounds/{id}/flag-map. Tilgang er
|
||||
// allerede gatet av _get_viewable_round_or_404 (samme regel som resten
|
||||
// av denne siden, styrt av round.visibility_mode) -- selve INNHOLDET
|
||||
// (alle flagg vs. ingenting) styres i tillegg av round.flag_map_visible
|
||||
// server-side (`visible_to_all` i responsen), en tilskuer har jo ikke
|
||||
// noe "eget flagg" å falle tilbake til når bryteren er av.
|
||||
type ApiFlagMapPublic = { visible_to_all: boolean; flags: FlagMapEntry[] }
|
||||
|
||||
function WatchFlagMap({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
|
||||
const [data, setData] = useState<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 }
|
||||
|
||||
|
|
@ -192,6 +259,8 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
|||
flightGroup={flightGroup}
|
||||
/>
|
||||
|
||||
{round.play_format === "flag" && <WatchFlagMap roundId={roundId} refreshKey={refreshKey} />}
|
||||
|
||||
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
|
||||
aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
|
||||
person runden er delt med hadde dermed ingen vei til å se eller
|
||||
|
|
|
|||
|
|
@ -906,6 +906,28 @@ def flag_result(gross_strokes: Sequence[int], total_strokes_budget: int) -> Flag
|
|||
return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining)
|
||||
|
||||
|
||||
def flag_lap_and_hole(holes_completed: int, play_order: Sequence[int]) -> tuple[int, int]:
|
||||
"""Runde 2+-utvidelse (2026-08-14, GPS-flaggplanting) -- "fortsettelse på
|
||||
hull 19+" er nå støttet: går budsjettet IKKE tomt innen 18 hull, spilles
|
||||
play_order på nytt fra begynnelsen med gjenværende slag (lap 2, lap 3,
|
||||
...). `flag_result()` selv trenger INGEN endring for dette -- den er
|
||||
allerede lap-agnostisk, kalleren limer ganske enkelt sammen lap 1 sin
|
||||
FULLE 18-lange prefiks med lap 2 sin (kun hvis lap 1 faktisk ble
|
||||
fullført) osv. før den kalles, og `holes_completed` blir da naturlig
|
||||
et tall som kan overstige `len(play_order)`.
|
||||
|
||||
Denne funksjonen oversetter et slikt flatt `holes_completed`-tall
|
||||
(fra `flag_result()`) til (lap, hullnummer) -- lap 1 = `play_order`
|
||||
som den er, lap 2 = samme rekkefølge på nytt, osv. Brukes til å
|
||||
beregne hvilket hull en spiller reelt er "i gang med" (der flagget
|
||||
skal plantes), og til server-side å validere at et innsendt
|
||||
flagg-plant-forsøk faktisk stemmer med registrert scoredata."""
|
||||
n = len(play_order)
|
||||
lap = holes_completed // n + 1
|
||||
hole_index_in_lap = holes_completed % n
|
||||
return lap, play_order[hole_index_in_lap]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse,
|
||||
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from handicap_engine import (
|
|||
compute_bbb,
|
||||
FlagResult,
|
||||
flag_result,
|
||||
flag_lap_and_hole,
|
||||
shamble_hole_score,
|
||||
money_ball_hole_score,
|
||||
high_low_high_points_for_hole,
|
||||
|
|
@ -901,6 +902,53 @@ def test_flag_result_no_holes_played_yet():
|
|||
assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90)
|
||||
|
||||
|
||||
# --- Runde 2+ (lap-fortsettelse, 2026-08-14) --------------------------------
|
||||
|
||||
def test_flag_result_runs_out_mid_lap_two():
|
||||
# Lap 1 fullført (18 hull, par 4 hver = 72), budsjett 100 -> 28 igjen inn
|
||||
# i lap 2. Lap 2: hull 1 (5) -> 23, hull 2 (4) -> 19, hull 3 krever 30
|
||||
# (umulig i praksis, men beviser bare at flag_result() selv ikke bryr
|
||||
# seg om laps -- kalleren limer bare sammen sekvensen).
|
||||
lap1 = [4] * 18
|
||||
lap2_prefix = [5, 4, 30]
|
||||
result = flag_result(lap1 + lap2_prefix, total_strokes_budget=100)
|
||||
assert result == FlagResult(holes_completed=20, ran_out=True, strokes_remaining=19)
|
||||
|
||||
|
||||
def test_flag_result_completes_two_full_laps_with_budget_left():
|
||||
lap1 = [4] * 18
|
||||
lap2 = [4] * 18
|
||||
result = flag_result(lap1 + lap2, total_strokes_budget=200)
|
||||
assert result == FlagResult(holes_completed=36, ran_out=False, strokes_remaining=56)
|
||||
|
||||
|
||||
def test_flag_lap_and_hole_within_lap_one_start_hole_one():
|
||||
play_order = list(range(1, 19))
|
||||
assert flag_lap_and_hole(0, play_order) == (1, 1)
|
||||
assert flag_lap_and_hole(2, play_order) == (1, 3)
|
||||
assert flag_lap_and_hole(17, play_order) == (1, 18)
|
||||
|
||||
|
||||
def test_flag_lap_and_hole_wraps_into_lap_two():
|
||||
play_order = list(range(1, 19))
|
||||
# 18 fullførte hull = akkurat ferdig med lap 1 -- neste er lap 2, hull 1.
|
||||
assert flag_lap_and_hole(18, play_order) == (2, 1)
|
||||
assert flag_lap_and_hole(20, play_order) == (2, 3)
|
||||
assert flag_lap_and_hole(35, play_order) == (2, 18)
|
||||
assert flag_lap_and_hole(36, play_order) == (3, 1)
|
||||
|
||||
|
||||
def test_flag_lap_and_hole_respects_non_default_start_hole():
|
||||
# start_hole=10 -> play_order er 10,11,...,18,1,2,...,9 (samme sirkulære
|
||||
# rekkefølge round-detail.tsx/rounds.py allerede bruker andre steder).
|
||||
play_order = [10, 11, 12, 13, 14, 15, 16, 17, 18, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
assert flag_lap_and_hole(0, play_order) == (1, 10)
|
||||
assert flag_lap_and_hole(8, play_order) == (1, 18)
|
||||
assert flag_lap_and_hole(9, play_order) == (1, 1)
|
||||
# Lap 2 gjentar SAMME spillerekkefølge, ikke fysisk hull 1.
|
||||
assert flag_lap_and_hole(18, play_order) == (2, 10)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shamble (2026-07-30)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -250,3 +250,64 @@ async def create_match_participant(organization_id: str, match_id: str, team_sid
|
|||
participant_id, organization_id, match_id, team_side, team_roster_id, tee_id,
|
||||
)
|
||||
return participant_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individuelle/flerrunde-turneringer (ADR-037, migrasjon 040) -- brukt av
|
||||
# test_flag_plant_individual_tournament.py. Samme org_connection()-mønster
|
||||
# som resten av org-turnering-kjeden over.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def create_org_hole(organization_id: str, course_id: str, hole_number: int, par: int = 4,
|
||||
stroke_index: int | None = None, **overrides) -> str:
|
||||
hole_id = overrides.get("id", new_id())
|
||||
stroke_index = stroke_index if stroke_index is not None else hole_number
|
||||
async with app_db.org_connection(organization_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO hole (id, organization_id, course_id, hole_number, par, stroke_index) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
hole_id, organization_id, course_id, hole_number, par, stroke_index,
|
||||
)
|
||||
return hole_id
|
||||
|
||||
|
||||
async def create_tournament_round(organization_id: str, tournament_id: str, course_id: str,
|
||||
**overrides) -> str:
|
||||
round_id = overrides.get("id", new_id())
|
||||
sequence = overrides.get("sequence", 1)
|
||||
hole_config = overrides.get("hole_config", "full_18")
|
||||
async with app_db.org_connection(organization_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO tournament_round (id, organization_id, tournament_id, sequence, course_id, hole_config) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
round_id, organization_id, tournament_id, sequence, course_id, hole_config,
|
||||
)
|
||||
return round_id
|
||||
|
||||
|
||||
async def create_tournament_participant(organization_id: str, tournament_id: str, player_id: str,
|
||||
**overrides) -> str:
|
||||
participant_id = overrides.get("id", new_id())
|
||||
async with app_db.org_connection(organization_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO tournament_participant (id, organization_id, tournament_id, player_id) "
|
||||
"VALUES ($1, $2, $3, $4)",
|
||||
participant_id, organization_id, tournament_id, player_id,
|
||||
)
|
||||
return participant_id
|
||||
|
||||
|
||||
async def create_tournament_round_participant(organization_id: str, tournament_round_id: str,
|
||||
tournament_participant_id: str, tee_id: str,
|
||||
**overrides) -> str:
|
||||
round_participant_id = overrides.get("id", new_id())
|
||||
course_handicap = overrides.get("course_handicap", 10)
|
||||
async with app_db.org_connection(organization_id) as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO tournament_round_participant "
|
||||
"(id, organization_id, tournament_round_id, tournament_participant_id, tee_id, course_handicap) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
round_participant_id, organization_id, tournament_round_id, tournament_participant_id,
|
||||
tee_id, course_handicap,
|
||||
)
|
||||
return round_participant_id
|
||||
|
|
|
|||
259
tests/test_flag_plant.py
Normal file
259
tests/test_flag_plant.py
Normal 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}
|
||||
226
tests/test_flag_plant_individual_tournament.py
Normal file
226
tests/test_flag_plant_individual_tournament.py
Normal 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}
|
||||
Loading…
Reference in a new issue