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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
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
|
under hver rettelse er beholdt uendret, ikke slettet. `Sist
|
||||||
oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet
|
oppdatert`-datoen øverst i filen (stod på 2026-07-17) rettet
|
||||||
tilsvarende. Ingen kodeendring, ren dokumentasjonsopprydding.
|
tilsvarende. Ingen kodeendring, ren dokumentasjonsopprydding.
|
||||||
|
|
||||||
|
82. **Flaggturnering "Del A": GPS-flaggplanting + runde 2+-scoreføring
|
||||||
|
(ADR-066) — 2026-08-14.** Første av tre bedt-om utvidelser
|
||||||
|
(Del A GPS-planting/runde 2, Del B kartoversikt+synlighetsbryter,
|
||||||
|
Del C Eclectic-format) -- kun Del A bygget denne runden, se ADR-066
|
||||||
|
for full begrunnelse/avklaringer og plan-filens spesifikasjon av
|
||||||
|
Del B/C.
|
||||||
|
|
||||||
|
Motor: ny `flag_lap_and_hole()`-hjelpefunksjon i
|
||||||
|
`handicap_engine.py` (`flag_result()` selv trengte ingen endring --
|
||||||
|
kalleren konkatenerer lap 1+lap 2s gross_strokes til én flat liste
|
||||||
|
før kallet). 5 nye tester, full suite 122/122.
|
||||||
|
|
||||||
|
Migrasjon 069 (`round_participant_flag_plant` +
|
||||||
|
`round_hole_flag_overflow`, frittstående) og 070 (samme par,
|
||||||
|
org-individuelle turneringer, RLS-beskyttet) -- to HELT ISOLERTE
|
||||||
|
tabellpar, bevisst IKKE en `lap`-kolonne på delte
|
||||||
|
`round_hole`/`tournament_round_hole` (se ADR-066 for
|
||||||
|
blindsone-begrunnelsen).
|
||||||
|
|
||||||
|
API: speilende `flag-plant`/`flag-overflow`-endepunkter i
|
||||||
|
`rounds.py` og `individual_tournaments.py`. Server beregner faktisk
|
||||||
|
"hull i gang" fra registrert scoredata og avviser
|
||||||
|
(`VALIDATION_FAILED`) ethvert forsøk som ikke stemmer -- klientens
|
||||||
|
"Hullet du ut?"-flyt er kun en UX-guide. To ulike, bevisst beholdte
|
||||||
|
autorisasjonsmodeller: flight-styrt (frittstående) vs. selv-only
|
||||||
|
(org), speiler hver fils eksisterende `update_hole`-mønster.
|
||||||
|
|
||||||
|
Frontend: `FlagPlantSheet`/`FlagPlantSheetOrg` -- ny UI-overflate,
|
||||||
|
derfor bygget via en Claude-skrevet V0-prompt (sendt til bruker),
|
||||||
|
med en tydelig MERKET midlertidig håndkodet versjon (samme
|
||||||
|
props-kontrakt) i mellomtiden for å kunne verifisere hele
|
||||||
|
backend-flyten uten å vente på eksporten. **V0-eksporten (zip 11)
|
||||||
|
kom tilbake samme dag** og erstattet den midlertidige versjonen i
|
||||||
|
begge filer (org-varianten fikk i tillegg `expectedLap`-prop lagt
|
||||||
|
til kallstedet, som den manglet før).
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (46/46, 15 nye tester i to nye
|
||||||
|
testfiler). Egen scratch-database + scratch `teecup_api`-container
|
||||||
|
(port 18000) + lokal `next dev` (port 13000): ende-til-ende for
|
||||||
|
BEGGE hjem inkl. server-side avvisning av for-tidlig planting og
|
||||||
|
full runde 2-scoreføring, geolocation emulert via Chrome DevTools
|
||||||
|
MCP (måtte omgå at `emulate()` kun setter koordinater, ikke
|
||||||
|
Permissions API-tilstanden -- løst med `navigate_page({type:
|
||||||
|
"reload", initScript: ...})`), lys+mørk bekreftet, lap 2 sin
|
||||||
|
`(runde {lap})`-markering bekreftet live. Etter V0-swap: `tsc
|
||||||
|
--noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet
|
||||||
|
ned igjen etterpå.
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte
|
||||||
|
`teecup_db` som `teeoff_admin` (`teecup_app` har bevisst ingen
|
||||||
|
`CREATE`-rett på schema public, jf. ADR-002 -- migrasjoner kjøres av
|
||||||
|
admin-rollen, aldri runtime-rollen), etter eksplisitt bekreftelse fra
|
||||||
|
bruker. Alle fire nye tabeller verifisert direkte i skjemaet
|
||||||
|
etterpå. `docker compose build teecup_api teecup_frontend && up -d`
|
||||||
|
-- begge containere startet rent (`Application startup complete.`
|
||||||
|
henholdsvis `✓ Ready`), ingen feil i logg.
|
||||||
|
|
||||||
|
83. **Flaggturnering "Del B": kartoversikt over plantede flagg +
|
||||||
|
synlighetsbryter (ADR-067) — 2026-08-14.** Andre av tre bedt-om
|
||||||
|
utvidelser (se punkt 82/ADR-066 for Del A). Underveis avdekket:
|
||||||
|
org-individuelle turneringer har INGEN offentlig tilskuer-side i det
|
||||||
|
hele tatt (`/t/[id]/live` er rendyrket for lagturneringer) --
|
||||||
|
avklart med bruker via AskUserQuestion, som valgte å avgrense Del B
|
||||||
|
sin org-side til org-medlemmer/deltakere fremfor å bygge en helt ny
|
||||||
|
spectator-infrastruktur bare for kartet. Frittstående runder fikk
|
||||||
|
full tilskuerstøtte (gjenbruker eksisterende `/watch/[id]`/
|
||||||
|
`/public/rounds/*`-mønster).
|
||||||
|
|
||||||
|
Migrasjon 071: to boolean-kolonner (`round.flag_map_visible`,
|
||||||
|
`tournament.flag_map_visible`, begge default false) -- ingen nye
|
||||||
|
tabeller, gjenbruker flagg-plant-tabellene fra migrasjon 069/070
|
||||||
|
direkte. Bryteren endres via de allerede eksisterende PATCH-
|
||||||
|
endepunktene (kun nye felt på whitelist-drevne modeller, ingen ny
|
||||||
|
handler-kode). Nytt leseendepunkt per hjem (`GET .../flag-map`),
|
||||||
|
delt `_build_flag_map()`-hjelper mellom autentisert og offentlig
|
||||||
|
variant -- alle flagg når bryteren er PÅ, ellers kun requesterens
|
||||||
|
eget.
|
||||||
|
|
||||||
|
Frontend: enda en ny UI-overflate via V0-prompt (`flag-map-
|
||||||
|
overview.tsx`, FØRSTE flerpunkts-Mapbox-kart i appen), midlertidig
|
||||||
|
håndkodet versjon bygget parallelt for å unngå å vente. Tre tynne
|
||||||
|
"seksjon"-wrappere (`FlagMapSection`/`FlagMapSectionOrg`/
|
||||||
|
`WatchFlagMap`) i de tre relevante skjermene, samme
|
||||||
|
"egen fetch + next/dynamic(ssr:false)"-mønster som `NassauPanel`.
|
||||||
|
Lap 1/2+ skilles med BÅDE farge OG en "R{lap}"-tekstbadge på selve
|
||||||
|
markøren (aldri farge alene), pluss alltid synlig tekst-legende.
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (50/50, 4 nye tester). `tsc
|
||||||
|
--noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||||||
|
`teecup_api`-container (port 18000, live-mountet kode for rask
|
||||||
|
iterasjon) + lokal `next dev` (port 13000): to fulle scenarioer
|
||||||
|
(frittstående + org, hver med to spillere, ett lap 1- og ett lap
|
||||||
|
2-flagg med on-green-avstand) sådd via `tests/conftest.py` sine
|
||||||
|
hjelpefunksjoner. Bekreftet i ekte nettleser: bryter av/på for begge
|
||||||
|
hjem, korrekt "kun eget flagg"-banner, lap 2-merking, popup-innhold
|
||||||
|
(navn/hull/runde/avstand), tilskuer-siden viser begge flagg uten
|
||||||
|
innlogging når bryteren er på. Lys+mørk bekreftet. Samme `Referer`-
|
||||||
|
header-omgåelse for Mapbox sin URL-restrikterte token som tidligere
|
||||||
|
(se punkt 51). Scratch-stacken (db/rolle/container/MinIO-bucket)
|
||||||
|
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||||
|
`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**IKKE rullet ut ennå** -- migrasjon 071 venter på eksplisitt
|
||||||
|
bekreftelse fra bruker. Del C (Eclectic) gjenstår.
|
||||||
|
|
|
||||||
|
|
@ -573,10 +573,28 @@
|
||||||
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
|
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
|
||||||
hull-granularitet). **GPS-avhengigheten er også borte** — GPS/
|
hull-granularitet). **GPS-avhengigheten er også borte** — GPS/
|
||||||
avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling,
|
avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling,
|
||||||
ADR-064 rangefinder til grønn) — men selve GPS-posisjon-ved-siste-slag-
|
ADR-064 rangefinder til grønn).
|
||||||
ideen spesifikt for Flaggturnering er fortsatt ikke designet/bygget
|
|
||||||
(stale-opprydding 2026-08-14: kun avhengigheten er avklart, ikke
|
**✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
|
||||||
funksjonen selv).
|
"Del A" av en tredelt utvidelse** (kartoversikt "Del B" og nytt format
|
||||||
|
Eclectic "Del C" gjenstår, se egne punkter): "Plant flagget"-knapp +
|
||||||
|
guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt.
|
||||||
|
"Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot
|
||||||
|
andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG
|
||||||
|
org-individuelle turneringer). Full runde 2+-scoreføring bygget for
|
||||||
|
spillere med uvanlig gunstig slagbudsjett (isolert overflow-tabell,
|
||||||
|
rører ikke delt `round_hole`/`tournament_round_hole`-infrastruktur).
|
||||||
|
UI-sheeten er en Claude-skrevet V0-eksport (zip 11), ikke håndkodet.
|
||||||
|
Migrasjon 069 (frittstående)/070 (org) — se ADR-066 for full detalj,
|
||||||
|
CHANGELOG.md for byggelogg/verifisering.
|
||||||
|
|
||||||
|
**✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på-
|
||||||
|
bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg
|
||||||
|
på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. Kun
|
||||||
|
Eclectic "Del C" gjenstår nå. Org-individuelle turneringers offentlige
|
||||||
|
tilskuervisning er BEVISST utenfor omfang her (finnes ikke i det hele
|
||||||
|
tatt ennå — `/t/[id]/live` er lagturnerings-only — egen, større
|
||||||
|
oppgave hvis/når etterspurt). Migrasjon 071 — se ADR-067.
|
||||||
|
|
||||||
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
||||||
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
||||||
|
|
|
||||||
|
|
@ -1403,3 +1403,370 @@ async def flag_round_result(
|
||||||
)
|
)
|
||||||
results.sort(key=lambda r: -r.holes_completed)
|
results.sort(key=lambda r: -r.holes_completed)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14).
|
||||||
|
# Speiler rounds.py sin frittstående-variant presist -- se der for full
|
||||||
|
# begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap,
|
||||||
|
# aldri stolt blindt på fra klienten). Ulikt frittstående runder er
|
||||||
|
# scoreregistrering her SELV-only (user_is_own_tournament_participant),
|
||||||
|
# samme presedens som update_hole over -- ikke flight-styrt.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _flag_current_position_org(
|
||||||
|
conn, tournament_round_participant_id: str, hole_config: str
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Org-ekvivalent av rounds.py sin _flag_current_position -- samme
|
||||||
|
logikk, men leser tournament_round_hole/tournament_round_hole_flag_
|
||||||
|
overflow, og play_order kommer fra played_hole_numbers(hole_config)
|
||||||
|
(ingen start_hole-rotasjon 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
|
flight_group_id: str | None
|
||||||
# HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard.
|
# HCP-prosent + Match-HCP (migrasjon 051) -- null = formatets standard.
|
||||||
allowance_override: dict | None
|
allowance_override: dict | None
|
||||||
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
|
flag_map_visible: bool
|
||||||
|
|
||||||
|
|
||||||
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
|
|
@ -1477,7 +1479,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||||
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
||||||
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
||||||
allowance_override::text AS allowance_override
|
allowance_override::text AS allowance_override, flag_map_visible
|
||||||
FROM round WHERE id = $1
|
FROM round WHERE id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -1602,6 +1604,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
visible_categories=[r["category"] for r in visible_category_rows],
|
visible_categories=[r["category"] for r in visible_category_rows],
|
||||||
flight_group_id=round_row["flight_group_id"],
|
flight_group_id=round_row["flight_group_id"],
|
||||||
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
||||||
|
flag_map_visible=round_row["flag_map_visible"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2636,6 +2639,11 @@ class RoundUpdate(BaseModel):
|
||||||
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
|
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
|
||||||
# samme "None = ikke rør"-konvensjon som resten av denne modellen.
|
# samme "None = ikke rør"-konvensjon som resten av denne modellen.
|
||||||
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100)
|
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100)
|
||||||
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
|
||||||
|
# -- eier-only (samme tilgang som resten av denne PATCH-en). Standard AV
|
||||||
|
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
|
||||||
|
# None = ikke rør, samme konvensjon som resten av modellen her.
|
||||||
|
flag_map_visible: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||||
|
|
@ -2755,6 +2763,10 @@ async def update_round(
|
||||||
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
||||||
if body.visibility_mode is not None:
|
if body.visibility_mode is not None:
|
||||||
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
|
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
|
||||||
|
if body.flag_map_visible is not None:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
|
||||||
|
)
|
||||||
if body.flight_group_id is not None:
|
if body.flight_group_id is not None:
|
||||||
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
||||||
if body.visible_categories is not None:
|
if body.visible_categories is not None:
|
||||||
|
|
@ -5508,6 +5520,353 @@ async def update_hole(
|
||||||
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14).
|
||||||
|
# Spilleren markerer nøyaktig posisjon der slagbudsjettet ble brukt opp.
|
||||||
|
# Hvilket hull/lap flagget hører til beregnes ALLTID server-side fra
|
||||||
|
# faktisk registrert scoredata (aldri stolt blindt på fra klienten, som kun
|
||||||
|
# guider brukeren dit via en "Hullet du ut?"-flyt) -- se
|
||||||
|
# _flag_current_position under. round_hole_flag_overflow er en BEVISST
|
||||||
|
# egen, isolert tabell for runde 2+-scoreføring, ikke en lap-kolonne på
|
||||||
|
# selve round_hole (delt av alle ni formater, se 069_flag_plant.sql).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _flag_current_position(
|
||||||
|
conn, participant_id: str, start_hole: int, holes_planned: int
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Første hull UTEN registrert score, i spillerekkefølge, på tvers av
|
||||||
|
laps -- dette ER "der spilleren fysisk er" akkurat 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
|
# Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra
|
||||||
# round_hole sine faste hull-aggregater over. round_shot kjenner kun
|
# round_hole sine faste hull-aggregater over. round_shot kjenner kun
|
||||||
|
|
@ -6375,6 +6734,16 @@ async def get_public_format_result(
|
||||||
return await _build_format_result(conn, round_id)
|
return await _build_format_result(conn, round_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/public/rounds/{round_id}/flag-map", response_model=FlagMapOut)
|
||||||
|
async def get_public_flag_map(
|
||||||
|
round_id: str,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> FlagMapOut:
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||||
|
return await _build_flag_map(conn, round_id, user.user_id if user else None)
|
||||||
|
|
||||||
|
|
||||||
async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
|
async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
|
||||||
"""Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
|
"""Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
|
||||||
"avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),
|
"avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ _TOURNAMENT_COLUMNS = """
|
||||||
id::text, name, status::text, start_date, end_date,
|
id::text, name, status::text, start_date, end_date,
|
||||||
visibility, description, registration_deadline, registration_capacity,
|
visibility, description, registration_deadline, registration_capacity,
|
||||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled
|
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||||
|
|
@ -88,6 +88,8 @@ class Tournament(BaseModel):
|
||||||
format_type: str
|
format_type: str
|
||||||
scoring_method: str | None
|
scoring_method: str | None
|
||||||
bbb_sweep_bonus_enabled: bool
|
bbb_sweep_bonus_enabled: bool
|
||||||
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
|
flag_map_visible: bool
|
||||||
|
|
||||||
|
|
||||||
def _tournament_from_row(row) -> Tournament:
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
|
@ -119,7 +121,8 @@ async def list_tournaments(
|
||||||
t.visibility, t.description, t.registration_deadline,
|
t.visibility, t.description, t.registration_deadline,
|
||||||
t.registration_capacity, t.registration_overflow_policy,
|
t.registration_capacity, t.registration_overflow_policy,
|
||||||
t.registration_requires_approval, t.hero_image_key,
|
t.registration_requires_approval, t.hero_image_key,
|
||||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
|
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
||||||
|
t.flag_map_visible
|
||||||
FROM tournament t
|
FROM tournament t
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT tournament_id,
|
SELECT tournament_id,
|
||||||
|
|
@ -189,6 +192,11 @@ class TournamentUpdate(BaseModel):
|
||||||
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
||||||
registration_requires_approval: bool | None = None
|
registration_requires_approval: bool | None = None
|
||||||
bbb_sweep_bonus_enabled: bool | None = None
|
bbb_sweep_bonus_enabled: bool | None = None
|
||||||
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
|
||||||
|
# -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se
|
||||||
|
# get_authorized_org under). Standard AV, samme "skjult til avslørt"-
|
||||||
|
# filosofi som visibility.
|
||||||
|
flag_map_visible: bool | None = None
|
||||||
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||||
|
|
|
||||||
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.
|
// nye, ukalibrerte farger.
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
|
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
Copy,
|
Copy,
|
||||||
|
Crosshair,
|
||||||
Flag,
|
Flag,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
MapPin,
|
||||||
Medal,
|
Medal,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -61,6 +66,8 @@ type ApiTournamentInfo = {
|
||||||
scoring_method: string | null
|
scoring_method: string | null
|
||||||
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
||||||
bbb_sweep_bonus_enabled: boolean
|
bbb_sweep_bonus_enabled: boolean
|
||||||
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
|
flag_map_visible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
|
|
@ -151,6 +158,55 @@ type ApiFlagResult = {
|
||||||
strokes_remaining: number
|
strokes_remaining: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14) -- speiler
|
||||||
|
// rounds.py sin frittstående-variant (round-detail.tsx), se der for full
|
||||||
|
// begrunnelse. Duplisert her (ikke importert på tvers av de to store
|
||||||
|
// sidefilene) -- samme selvstendighets-konvensjon som round-scorecard.tsx
|
||||||
|
// allerede følger for sine egne typer/hjelpefunksjoner.
|
||||||
|
type ApiFlagPlant = {
|
||||||
|
id: string
|
||||||
|
lap: number
|
||||||
|
hole_number: number
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
on_green: boolean
|
||||||
|
distance_to_pin_cm: number | null
|
||||||
|
planted_by_user_id: string
|
||||||
|
planted_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiFlagOverflowHole = {
|
||||||
|
lap: number
|
||||||
|
hole_number: number
|
||||||
|
gross_strokes: number | null
|
||||||
|
played: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function playedHoleNumbersOrg(holeConfig: "full_18" | "front_9" | "back_9"): number[] {
|
||||||
|
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||||
|
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
||||||
|
return Array.from({ length: 18 }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFlagCurrentPositionOrg(
|
||||||
|
playOrder: number[],
|
||||||
|
holes: ApiHole[] | null,
|
||||||
|
overflowHoles: ApiFlagOverflowHole[],
|
||||||
|
): { lap: number; holeNumber: number } {
|
||||||
|
const lap1Played = new Set((holes ?? []).filter((h) => h.gross_strokes !== null).map((h) => h.hole_number))
|
||||||
|
for (const h of playOrder) {
|
||||||
|
if (!lap1Played.has(h)) return { lap: 1, holeNumber: h }
|
||||||
|
}
|
||||||
|
let lap = 2
|
||||||
|
while (true) {
|
||||||
|
const lapPlayed = new Set(overflowHoles.filter((h) => h.lap === lap && h.played).map((h) => h.hole_number))
|
||||||
|
for (const h of playOrder) {
|
||||||
|
if (!lapPlayed.has(h)) return { lap, holeNumber: h }
|
||||||
|
}
|
||||||
|
lap += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ApiBBBHole = {
|
type ApiBBBHole = {
|
||||||
hole_number: number
|
hole_number: number
|
||||||
bingo_participant_id: string | null
|
bingo_participant_id: string | null
|
||||||
|
|
@ -308,6 +364,24 @@ export function IndividualTournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateFlagMapVisible(visible: boolean) {
|
||||||
|
if (!tournament) return
|
||||||
|
const previous = tournament
|
||||||
|
setTournament({ ...tournament, flag_map_visible: visible })
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ flag_map_visible: visible }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
} catch {
|
||||||
|
setTournament(previous)
|
||||||
|
setError("Klarte ikke å endre kartoversikt-innstillingen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addParticipant(playerId: string) {
|
async function addParticipant(playerId: string) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`${base}/participants`, {
|
const res = await fetch(`${base}/participants`, {
|
||||||
|
|
@ -550,6 +624,7 @@ export function IndividualTournamentDetail({
|
||||||
classes={classes}
|
classes={classes}
|
||||||
onUpdateScoringMethod={updateScoringMethod}
|
onUpdateScoringMethod={updateScoringMethod}
|
||||||
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
||||||
|
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||||
onRemoveParticipant={removeParticipant}
|
onRemoveParticipant={removeParticipant}
|
||||||
|
|
@ -620,6 +695,7 @@ function SetupTab({
|
||||||
classes,
|
classes,
|
||||||
onUpdateScoringMethod,
|
onUpdateScoringMethod,
|
||||||
onUpdateBbbSweepBonus,
|
onUpdateBbbSweepBonus,
|
||||||
|
onUpdateFlagMapVisible,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
onAddNewPlayer,
|
onAddNewPlayer,
|
||||||
onRemoveParticipant,
|
onRemoveParticipant,
|
||||||
|
|
@ -641,6 +717,7 @@ function SetupTab({
|
||||||
classes: ApiTournamentClass[]
|
classes: ApiTournamentClass[]
|
||||||
onUpdateScoringMethod: (method: string) => void
|
onUpdateScoringMethod: (method: string) => void
|
||||||
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
||||||
|
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||||
onAddParticipant: (playerId: string) => Promise<void>
|
onAddParticipant: (playerId: string) => Promise<void>
|
||||||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||||||
onRemoveParticipant: (id: string) => Promise<void>
|
onRemoveParticipant: (id: string) => Promise<void>
|
||||||
|
|
@ -706,6 +783,22 @@ function SetupTab({
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{tournament.scoring_method === "flag" && (
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tournament.flag_map_visible}
|
||||||
|
onChange={(e) => onUpdateFlagMapVisible(e.target.checked)}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Når dette er PÅ, kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RoundsCard
|
<RoundsCard
|
||||||
|
|
@ -1838,6 +1931,27 @@ function ScoreTab({
|
||||||
{round && scoringMethod === "flag" && (
|
{round && scoringMethod === "flag" && (
|
||||||
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
|
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* GPS-flaggplanting + runde 2+ (2026-08-14) -- for DEN VALGTE
|
||||||
|
deltakeren (samme selv-only autorisasjon som selve
|
||||||
|
scoreføringen over, ingen vits å vise for andre enn den man
|
||||||
|
faktisk fører for). */}
|
||||||
|
{round && scoringMethod === "flag" && roundParticipants.find((rp) => rp.id === participantId) && (
|
||||||
|
<FlagPlantPanel
|
||||||
|
base={base}
|
||||||
|
round={round}
|
||||||
|
participant={roundParticipants.find((rp) => rp.id === participantId)!}
|
||||||
|
refreshTick={refreshTick}
|
||||||
|
onChanged={() => setRefreshTick((n) => n + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kartoversikt over flagg (migrasjon 071, "Del B", ADR-067) --
|
||||||
|
kun for org-medlem/deltaker (ingen offentlig tilskuer-gren
|
||||||
|
for individuelle turneringer, se ADR-067). */}
|
||||||
|
{round && scoringMethod === "flag" && (
|
||||||
|
<FlagMapSectionOrg base={base} round={round} refreshTick={refreshTick} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1962,6 +2076,621 @@ function BBBRoundPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- GPS-flaggplanting + runde 2+ (2026-08-14) -------------------------------
|
||||||
|
// V0-eksport (zip 11), samme komponent som round-detail.tsx sin
|
||||||
|
// FlagPlantSheet -- egen kopi her (fil-lokalt konvensjon, se
|
||||||
|
// FlagPlantSheet i round-detail.tsx for samme innmat/kommentar).
|
||||||
|
|
||||||
|
function FlagPlantSheetOrg({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
expectedHoleNumber,
|
||||||
|
expectedLap,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
expectedHoleNumber: number
|
||||||
|
expectedLap: number
|
||||||
|
onConfirm: (result: {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
onGreen: boolean
|
||||||
|
distanceToPinCm: number | null
|
||||||
|
}) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps")
|
||||||
|
const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading")
|
||||||
|
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null)
|
||||||
|
|
||||||
|
// Step 2 "Ja" reveals a dead-end guidance card (no submission).
|
||||||
|
const [holedOutYes, setHoledOutYes] = useState(false)
|
||||||
|
|
||||||
|
// Step 3 answer + distance inputs (kept as strings for clean input UX).
|
||||||
|
const [onGreen, setOnGreen] = useState<boolean | null>(null)
|
||||||
|
const [meters, setMeters] = useState("")
|
||||||
|
const [centimeters, setCentimeters] = useState("")
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null)
|
||||||
|
const [confirmedSummary, setConfirmedSummary] = useState<{
|
||||||
|
onGreen: boolean
|
||||||
|
distanceToPinCm: number | null
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// Reset everything each time the sheet is (re)opened.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setStep("gps")
|
||||||
|
setGeoStatus("loading")
|
||||||
|
setPosition(null)
|
||||||
|
setHoledOutYes(false)
|
||||||
|
setOnGreen(null)
|
||||||
|
setMeters("")
|
||||||
|
setCentimeters("")
|
||||||
|
setSubmitting(false)
|
||||||
|
setServerError(null)
|
||||||
|
setConfirmedSummary(null)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Step 1 — automatic GPS acquisition. Runs on entering the gps step.
|
||||||
|
const geoRequested = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || step !== "gps") {
|
||||||
|
geoRequested.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (geoRequested.current) return
|
||||||
|
geoRequested.current = true
|
||||||
|
|
||||||
|
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||||
|
setGeoStatus("error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGeoStatus("loading")
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||||||
|
setStep("holedOut")
|
||||||
|
},
|
||||||
|
() => setGeoStatus("error"),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
)
|
||||||
|
}, [open, step])
|
||||||
|
|
||||||
|
function retryGeo() {
|
||||||
|
geoRequested.current = false
|
||||||
|
setGeoStatus("loading")
|
||||||
|
if (typeof navigator === "undefined" || !navigator.geolocation) {
|
||||||
|
setGeoStatus("error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
geoRequested.current = true
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||||||
|
setStep("holedOut")
|
||||||
|
},
|
||||||
|
() => setGeoStatus("error"),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
// ---- validation for the on-green distance --------------------------------
|
||||||
|
const metersNum = meters === "" ? null : Number(meters)
|
||||||
|
const cmNum = centimeters === "" ? null : Number(centimeters)
|
||||||
|
const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0
|
||||||
|
const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99
|
||||||
|
const greenInputValid = metersValid && cmValid
|
||||||
|
|
||||||
|
const canConfirm = onGreen === false || (onGreen === true && greenInputValid)
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!position || onGreen === null || submitting) return
|
||||||
|
const distanceToPinCm =
|
||||||
|
onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
setServerError(null)
|
||||||
|
try {
|
||||||
|
const res = await onConfirm({
|
||||||
|
lat: position.lat,
|
||||||
|
lng: position.lng,
|
||||||
|
onGreen,
|
||||||
|
distanceToPinCm,
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setConfirmedSummary({ onGreen, distanceToPinCm })
|
||||||
|
setStep("done")
|
||||||
|
} else {
|
||||||
|
setServerError(res.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
setServerError(null)
|
||||||
|
if (step === "green") {
|
||||||
|
setOnGreen(null)
|
||||||
|
setMeters("")
|
||||||
|
setCentimeters("")
|
||||||
|
setStep("holedOut")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showBack = step === "green"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Plant flagget, hull ${expectedHoleNumber}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{showBack ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goBack}
|
||||||
|
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
|
||||||
|
Tilbake
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-start gap-2 pt-1.5">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
|
||||||
|
<Flag className="size-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold leading-tight text-balance">Plant flagget</h2>
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
{`Hull ${expectedHoleNumber} · runde ${expectedLap}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="size-5 shrink-0" aria-hidden="true" />
|
||||||
|
Lukk
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4">
|
||||||
|
{/* Step 1 — GPS */}
|
||||||
|
{step === "gps" ? (
|
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
|
||||||
|
{geoStatus === "loading" ? (
|
||||||
|
<>
|
||||||
|
<div className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
<p className="text-lg font-semibold">Finner posisjonen din …</p>
|
||||||
|
<p className="text-base text-muted-foreground text-pretty">
|
||||||
|
Stå der du gikk tom for slag mens vi henter GPS-posisjonen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left">
|
||||||
|
<p role="alert" className="text-base text-destructive text-pretty">
|
||||||
|
Fant ikke posisjonen din. Sjekk at posisjonstjenester er 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) ----------------------
|
// --- Flaggturnering -- per-runde-resultat (2026-07-30) ----------------------
|
||||||
|
|
||||||
function FlagRoundPanel({
|
function FlagRoundPanel({
|
||||||
|
|
@ -2014,6 +2743,74 @@ function FlagRoundPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --------
|
||||||
|
// Samme "egen fetch + refreshTick"-mønster som FlagRoundPanel over. Ingen
|
||||||
|
// offentlig tilskuer-gren her (org-medlem/deltaker-only, se ADR-067 --
|
||||||
|
// individuelle turneringer har ingen offentlig spectator-side ennå).
|
||||||
|
|
||||||
|
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
|
||||||
|
|
||||||
|
type ApiFlagMapOrg = { visible_to_all: boolean; flags: FlagMapEntry[] }
|
||||||
|
|
||||||
|
function FlagMapSectionOrg({
|
||||||
|
base,
|
||||||
|
round,
|
||||||
|
refreshTick,
|
||||||
|
}: {
|
||||||
|
base: string
|
||||||
|
round: ApiRound
|
||||||
|
refreshTick: number
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<ApiFlagMapOrg | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
getJson<{ visible_to_all: boolean; flags: Array<Record<string, unknown>> }>(
|
||||||
|
`${base}/rounds/${round.id}/flag-map`,
|
||||||
|
).then((json) => {
|
||||||
|
if (cancelled || !json) return
|
||||||
|
setData({
|
||||||
|
visible_to_all: json.visible_to_all,
|
||||||
|
flags: json.flags.map((f) => ({
|
||||||
|
participantId: f.participant_id as string,
|
||||||
|
displayName: f.display_name as string,
|
||||||
|
lap: f.lap as number,
|
||||||
|
holeNumber: f.hole_number as number,
|
||||||
|
lat: f.lat as number,
|
||||||
|
lng: f.lng as number,
|
||||||
|
onGreen: f.on_green as boolean,
|
||||||
|
distanceToPinCm: f.distance_to_pin_cm as number | null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [base, round.id, refreshTick])
|
||||||
|
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex min-h-11 items-center justify-between gap-2 text-left"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
|
||||||
|
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="h-80 overflow-hidden rounded-xl border border-border">
|
||||||
|
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
||||||
|
|
||||||
function classify(diff: number): ScoreClass {
|
function classify(diff: number): ScoreClass {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,77 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import dynamic from "next/dynamic"
|
||||||
import { Radio, Trophy } from "lucide-react"
|
import { Radio, Trophy } from "lucide-react"
|
||||||
import { WatchTabs } from "@/components/watch-tabs"
|
import { WatchTabs } from "@/components/watch-tabs"
|
||||||
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
|
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
|
||||||
import { RoundMessages } from "@/components/round-messages"
|
import { RoundMessages } from "@/components/round-messages"
|
||||||
|
import type { FlagMapEntry } from "@/components/flag-map-overview"
|
||||||
|
|
||||||
|
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
|
||||||
|
|
||||||
|
// Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --
|
||||||
|
// tilskuer-varianten, mot /public/rounds/{id}/flag-map. Tilgang er
|
||||||
|
// allerede gatet av _get_viewable_round_or_404 (samme regel som resten
|
||||||
|
// av denne siden, styrt av round.visibility_mode) -- selve INNHOLDET
|
||||||
|
// (alle flagg vs. ingenting) styres i tillegg av round.flag_map_visible
|
||||||
|
// server-side (`visible_to_all` i responsen), en tilskuer har jo ikke
|
||||||
|
// noe "eget flagg" å falle tilbake til når bryteren er av.
|
||||||
|
type ApiFlagMapPublic = { visible_to_all: boolean; flags: FlagMapEntry[] }
|
||||||
|
|
||||||
|
function WatchFlagMap({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
|
||||||
|
const [data, setData] = useState<ApiFlagMapPublic | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/public/rounds/${roundId}/flag-map`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((json: { visible_to_all: boolean; flags: Array<Record<string, unknown>> } | null) => {
|
||||||
|
if (cancelled || !json) return
|
||||||
|
setData({
|
||||||
|
visible_to_all: json.visible_to_all,
|
||||||
|
flags: json.flags.map((f) => ({
|
||||||
|
participantId: f.participant_id as string,
|
||||||
|
displayName: f.display_name as string,
|
||||||
|
lap: f.lap as number,
|
||||||
|
holeNumber: f.hole_number as number,
|
||||||
|
lat: f.lat as number,
|
||||||
|
lng: f.lng as number,
|
||||||
|
onGreen: f.on_green as boolean,
|
||||||
|
distanceToPinCm: f.distance_to_pin_cm as number | null,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, refreshKey])
|
||||||
|
|
||||||
|
// Ingen synlig seksjon i det hele tatt når bryteren er av OG tilskueren
|
||||||
|
// (naturligvis) ikke har noe eget flagg -- ikke en tom/forvirrende boks.
|
||||||
|
if (!data || (!data.visible_to_all && data.flags.length === 0)) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex min-h-11 items-center justify-between gap-2 text-left"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<span className="text-base font-bold text-foreground">Kartoversikt over flagg</span>
|
||||||
|
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="h-80 overflow-hidden rounded-xl border border-border">
|
||||||
|
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ApiSide = { id: string; label: string | null }
|
type ApiSide = { id: string; label: string | null }
|
||||||
|
|
||||||
|
|
@ -192,6 +259,8 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
||||||
flightGroup={flightGroup}
|
flightGroup={flightGroup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{round.play_format === "flag" && <WatchFlagMap roundId={roundId} refreshKey={refreshKey} />}
|
||||||
|
|
||||||
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
|
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
|
||||||
aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
|
aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
|
||||||
person runden er delt med hadde dermed ingen vei til å se eller
|
person runden er delt med hadde dermed ingen vei til å se eller
|
||||||
|
|
|
||||||
|
|
@ -906,6 +906,28 @@ def flag_result(gross_strokes: Sequence[int], total_strokes_budget: int) -> Flag
|
||||||
return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining)
|
return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining)
|
||||||
|
|
||||||
|
|
||||||
|
def flag_lap_and_hole(holes_completed: int, play_order: Sequence[int]) -> tuple[int, int]:
|
||||||
|
"""Runde 2+-utvidelse (2026-08-14, GPS-flaggplanting) -- "fortsettelse på
|
||||||
|
hull 19+" er nå støttet: går budsjettet IKKE tomt innen 18 hull, spilles
|
||||||
|
play_order 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,
|
# Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse,
|
||||||
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle
|
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ from handicap_engine import (
|
||||||
compute_bbb,
|
compute_bbb,
|
||||||
FlagResult,
|
FlagResult,
|
||||||
flag_result,
|
flag_result,
|
||||||
|
flag_lap_and_hole,
|
||||||
shamble_hole_score,
|
shamble_hole_score,
|
||||||
money_ball_hole_score,
|
money_ball_hole_score,
|
||||||
high_low_high_points_for_hole,
|
high_low_high_points_for_hole,
|
||||||
|
|
@ -901,6 +902,53 @@ def test_flag_result_no_holes_played_yet():
|
||||||
assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90)
|
assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Runde 2+ (lap-fortsettelse, 2026-08-14) --------------------------------
|
||||||
|
|
||||||
|
def test_flag_result_runs_out_mid_lap_two():
|
||||||
|
# Lap 1 fullført (18 hull, par 4 hver = 72), budsjett 100 -> 28 igjen inn
|
||||||
|
# i lap 2. Lap 2: hull 1 (5) -> 23, hull 2 (4) -> 19, hull 3 krever 30
|
||||||
|
# (umulig i praksis, men beviser bare at flag_result() selv ikke bryr
|
||||||
|
# seg om laps -- kalleren limer bare sammen sekvensen).
|
||||||
|
lap1 = [4] * 18
|
||||||
|
lap2_prefix = [5, 4, 30]
|
||||||
|
result = flag_result(lap1 + lap2_prefix, total_strokes_budget=100)
|
||||||
|
assert result == FlagResult(holes_completed=20, ran_out=True, strokes_remaining=19)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_result_completes_two_full_laps_with_budget_left():
|
||||||
|
lap1 = [4] * 18
|
||||||
|
lap2 = [4] * 18
|
||||||
|
result = flag_result(lap1 + lap2, total_strokes_budget=200)
|
||||||
|
assert result == FlagResult(holes_completed=36, ran_out=False, strokes_remaining=56)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_lap_and_hole_within_lap_one_start_hole_one():
|
||||||
|
play_order = list(range(1, 19))
|
||||||
|
assert flag_lap_and_hole(0, play_order) == (1, 1)
|
||||||
|
assert flag_lap_and_hole(2, play_order) == (1, 3)
|
||||||
|
assert flag_lap_and_hole(17, play_order) == (1, 18)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_lap_and_hole_wraps_into_lap_two():
|
||||||
|
play_order = list(range(1, 19))
|
||||||
|
# 18 fullførte hull = akkurat ferdig med lap 1 -- neste er lap 2, hull 1.
|
||||||
|
assert flag_lap_and_hole(18, play_order) == (2, 1)
|
||||||
|
assert flag_lap_and_hole(20, play_order) == (2, 3)
|
||||||
|
assert flag_lap_and_hole(35, play_order) == (2, 18)
|
||||||
|
assert flag_lap_and_hole(36, play_order) == (3, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_lap_and_hole_respects_non_default_start_hole():
|
||||||
|
# start_hole=10 -> play_order er 10,11,...,18,1,2,...,9 (samme sirkulære
|
||||||
|
# rekkefølge round-detail.tsx/rounds.py allerede bruker andre steder).
|
||||||
|
play_order = [10, 11, 12, 13, 14, 15, 16, 17, 18, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||||
|
assert flag_lap_and_hole(0, play_order) == (1, 10)
|
||||||
|
assert flag_lap_and_hole(8, play_order) == (1, 18)
|
||||||
|
assert flag_lap_and_hole(9, play_order) == (1, 1)
|
||||||
|
# Lap 2 gjentar SAMME spillerekkefølge, ikke fysisk hull 1.
|
||||||
|
assert flag_lap_and_hole(18, play_order) == (2, 10)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shamble (2026-07-30)
|
# Shamble (2026-07-30)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -250,3 +250,64 @@ async def create_match_participant(organization_id: str, match_id: str, team_sid
|
||||||
participant_id, organization_id, match_id, team_side, team_roster_id, tee_id,
|
participant_id, organization_id, match_id, team_side, team_roster_id, tee_id,
|
||||||
)
|
)
|
||||||
return participant_id
|
return participant_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Individuelle/flerrunde-turneringer (ADR-037, migrasjon 040) -- brukt av
|
||||||
|
# test_flag_plant_individual_tournament.py. Samme org_connection()-mønster
|
||||||
|
# som resten av org-turnering-kjeden over.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_org_hole(organization_id: str, course_id: str, hole_number: int, par: int = 4,
|
||||||
|
stroke_index: int | None = None, **overrides) -> str:
|
||||||
|
hole_id = overrides.get("id", new_id())
|
||||||
|
stroke_index = stroke_index if stroke_index is not None else hole_number
|
||||||
|
async with app_db.org_connection(organization_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO hole (id, organization_id, course_id, hole_number, par, stroke_index) "
|
||||||
|
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
hole_id, organization_id, course_id, hole_number, par, stroke_index,
|
||||||
|
)
|
||||||
|
return hole_id
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tournament_round(organization_id: str, tournament_id: str, course_id: str,
|
||||||
|
**overrides) -> str:
|
||||||
|
round_id = overrides.get("id", new_id())
|
||||||
|
sequence = overrides.get("sequence", 1)
|
||||||
|
hole_config = overrides.get("hole_config", "full_18")
|
||||||
|
async with app_db.org_connection(organization_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO tournament_round (id, organization_id, tournament_id, sequence, course_id, hole_config) "
|
||||||
|
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
round_id, organization_id, tournament_id, sequence, course_id, hole_config,
|
||||||
|
)
|
||||||
|
return round_id
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tournament_participant(organization_id: str, tournament_id: str, player_id: str,
|
||||||
|
**overrides) -> str:
|
||||||
|
participant_id = overrides.get("id", new_id())
|
||||||
|
async with app_db.org_connection(organization_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO tournament_participant (id, organization_id, tournament_id, player_id) "
|
||||||
|
"VALUES ($1, $2, $3, $4)",
|
||||||
|
participant_id, organization_id, tournament_id, player_id,
|
||||||
|
)
|
||||||
|
return participant_id
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tournament_round_participant(organization_id: str, tournament_round_id: str,
|
||||||
|
tournament_participant_id: str, tee_id: str,
|
||||||
|
**overrides) -> str:
|
||||||
|
round_participant_id = overrides.get("id", new_id())
|
||||||
|
course_handicap = overrides.get("course_handicap", 10)
|
||||||
|
async with app_db.org_connection(organization_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO tournament_round_participant "
|
||||||
|
"(id, organization_id, tournament_round_id, tournament_participant_id, tee_id, course_handicap) "
|
||||||
|
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
round_participant_id, organization_id, tournament_round_id, tournament_participant_id,
|
||||||
|
tee_id, course_handicap,
|
||||||
|
)
|
||||||
|
return round_participant_id
|
||||||
|
|
|
||||||
259
tests/test_flag_plant.py
Normal file
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