Avstand til mål via GolfAPI.io (Tjøme) + statistikk-graf-finpuss
Ny tredje banekilde (ADR-064) for baner utenfor TeeOffs dekning: delt GolfAPI-cache (migrasjon 065), import til både org-turneringer og frittstående runder, target-points-endepunkt, og en rangefinder- visning i scorekortet (V0-generert target-distance.tsx + hånd-koblet GPS/Haversine-wrapper). Fant og fikset at TEECUP_GOLFAPI_TOKEN aldri ble videreført til teecup_api-containeren i docker-compose.yml. I samme runde: y-akse-benevnelser og verdi i begge ender på statistikk-trendgrafene (rounds-stats-summary.tsx). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
12504343ec
commit
a9abe86116
14 changed files with 1729 additions and 20 deletions
145
065_golfapi_courses.sql
Normal file
145
065_golfapi_courses.sql
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — migrasjon 065
|
||||||
|
-- GolfAPI.io som tredje banekilde (ADR-064) -- baner utenfor TeeOffs
|
||||||
|
-- dekning (f.eks. Tjøme Golfklubb, bekreftet IKKE i TeeOff 2026-08-12).
|
||||||
|
-- =====================================================================
|
||||||
|
-- GolfAPI-kall koster ekte, begrenset budsjett (kjøpt 20 kall, ~2/bane)
|
||||||
|
-- -- ULIKT TeeOffs frie, offentlige API. Derfor: ETT delt, globalt
|
||||||
|
-- cache-lag (golfapi_course + underliggende hull/tee/koordinat-tabeller)
|
||||||
|
-- som ALDRI hentes på nytt for samme fysiske bane (GolfAPIs avtale
|
||||||
|
-- tillater eksplisitt permanent caching). Både den org-scopede
|
||||||
|
-- turneringsmodellen (course/hole/tee/tee_rating) OG den globale
|
||||||
|
-- personal_course-katalogen (migrasjon 020) kopierer INN fra denne
|
||||||
|
-- cachen ved eksplisitt import -- selve cachen eies ikke av noen av de
|
||||||
|
-- to, den er en tredje, uavhengig katalog.
|
||||||
|
--
|
||||||
|
-- Koordinatene (grønn front/midt/bak, hindringer, tee-punkter) kopieres
|
||||||
|
-- ALDRI inn per import -- de er ikke en del av HCP-beregningen (ingen
|
||||||
|
-- reproduserbarhets-hensyn som par/rating har via ADR-007/ADR-019), så
|
||||||
|
-- rangefinder-oppslag leser alltid direkte fra golfapi_course_coordinate
|
||||||
|
-- via banens lagrede GolfAPI-courseID. Én fysisk bane = ett
|
||||||
|
-- koordinatsett, uansett hvor mange ganger den importeres.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Delt GolfAPI-cache (global, ingen organization_id/owner -- én rad per
|
||||||
|
-- fysisk bane som NOEN i TeeCup noensinne har importert)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- GolfAPIs courseID brukes direkte som PK (globalt unik hos dem, ingen
|
||||||
|
-- grunn til en egen surrogate-nøkkel).
|
||||||
|
CREATE TABLE golfapi_course (
|
||||||
|
course_id text PRIMARY KEY,
|
||||||
|
club_id text NOT NULL,
|
||||||
|
club_name text NOT NULL,
|
||||||
|
course_name text NOT NULL,
|
||||||
|
city text,
|
||||||
|
country text,
|
||||||
|
num_holes smallint NOT NULL CHECK (num_holes BETWEEN 1 AND 18),
|
||||||
|
-- Alltid hentet med measureUnit=m (golfapi_client.py) -- ingen yard-data
|
||||||
|
-- lagres, unngår enhets-fellen research allerede advarte om.
|
||||||
|
has_gps boolean NOT NULL DEFAULT false,
|
||||||
|
num_coordinates integer NOT NULL DEFAULT 0,
|
||||||
|
golfapi_updated_at timestamptz, -- deres timestampUpdated, til evt. fremtidig re-sync-vurdering
|
||||||
|
fetched_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Par/SI per hull. Kun menns verdier er kanonisk brukt av kopier-inn-
|
||||||
|
-- stegene under (TeeCups eksisterende hole/personal_course_hole har
|
||||||
|
-- ingen kjønnsdelt par -- samme forenkling som TeeOff-importen, ADR-019).
|
||||||
|
-- Kvinners verdier caches likevel, i tilfelle en fremtidig utvidelse.
|
||||||
|
CREATE TABLE golfapi_course_hole (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
|
||||||
|
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
|
||||||
|
par_men smallint NOT NULL CHECK (par_men BETWEEN 3 AND 6),
|
||||||
|
index_men smallint NOT NULL CHECK (index_men BETWEEN 1 AND 18),
|
||||||
|
par_women smallint CHECK (par_women BETWEEN 3 AND 6),
|
||||||
|
index_women smallint CHECK (index_women BETWEEN 1 AND 18),
|
||||||
|
UNIQUE (golfapi_course_id, hole_number),
|
||||||
|
UNIQUE (golfapi_course_id, index_men)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE golfapi_course_tee (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
|
||||||
|
golfapi_tee_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
color_hex text,
|
||||||
|
-- Meter per hull, indeks 0 = hull 1 ... indeks 17 = hull 18.
|
||||||
|
lengths_meters smallint[] NOT NULL,
|
||||||
|
course_rating_men numeric(4,1) NOT NULL,
|
||||||
|
slope_men smallint NOT NULL CHECK (slope_men BETWEEN 55 AND 155),
|
||||||
|
course_rating_women numeric(4,1),
|
||||||
|
slope_women smallint CHECK (slope_women BETWEEN 55 AND 155),
|
||||||
|
course_rating_men_front9 numeric(4,1),
|
||||||
|
course_rating_men_back9 numeric(4,1),
|
||||||
|
course_rating_women_front9 numeric(4,1),
|
||||||
|
course_rating_women_back9 numeric(4,1),
|
||||||
|
UNIQUE (golfapi_course_id, golfapi_tee_id),
|
||||||
|
CHECK (array_length(lengths_meters, 1) BETWEEN 1 AND 18)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rangefinder-datalaget: grønn front/midt/bak, hindringer, tee-punkter.
|
||||||
|
-- poi_type/green_location/side_fairway holdes som tekst+CHECK (ikke
|
||||||
|
-- egne enum-typer) -- samme "kan utvides uten ALTER TYPE"-begrunnelse
|
||||||
|
-- som 001 allerede bruker for `format`.
|
||||||
|
CREATE TABLE golfapi_course_coordinate (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
|
||||||
|
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
|
||||||
|
poi_type text NOT NULL CHECK (poi_type IN (
|
||||||
|
'green', 'green_bunker', 'fairway_bunker', 'water',
|
||||||
|
'trees', 'marker_100', 'marker_150', 'marker_200',
|
||||||
|
'dogleg', 'road', 'tee_front', 'tee_back'
|
||||||
|
)),
|
||||||
|
-- GolfAPIs `location`-felt er generisk (front/midt/bak for POI-et),
|
||||||
|
-- ikke kun for 'green' -- f.eks. hadde Tjømes bunkere også front/bak.
|
||||||
|
-- Ikke navngitt green_location -- ville feilaktig antydet at kun
|
||||||
|
-- grønn-punkter har denne verdien.
|
||||||
|
location text CHECK (location IN ('front', 'middle', 'back')),
|
||||||
|
side_fairway text CHECK (side_fairway IN ('left', 'center', 'right')),
|
||||||
|
latitude double precision NOT NULL CHECK (latitude BETWEEN -90 AND 90),
|
||||||
|
longitude double precision NOT NULL CHECK (longitude BETWEEN -180 AND 180)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON golfapi_course_hole (golfapi_course_id);
|
||||||
|
CREATE INDEX ON golfapi_course_tee (golfapi_course_id);
|
||||||
|
CREATE INDEX ON golfapi_course_coordinate (golfapi_course_id, hole_number);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course TO teecup_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_hole TO teecup_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_tee TO teecup_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_coordinate TO teecup_app;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Kopier-inn-mål 1: org-scopet turneringsbane (course), ny kilde
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Samme mønster som ADR-019s 'official' (teeoff) -- men 'international'
|
||||||
|
-- betyr GolfAPI, IKKE live-oppslag (Beslutning i ADR-064: GolfAPI-kall
|
||||||
|
-- koster budsjett, teeoffs gjør ikke -- derfor kopier-inn her også).
|
||||||
|
ALTER TYPE course_source ADD VALUE 'international';
|
||||||
|
|
||||||
|
-- CHECK-constraint kan ikke endres direkte -- droppes og gjenopprettes
|
||||||
|
-- med samme betingelse utvidet symmetrisk. Navnet (course_check) er
|
||||||
|
-- Postgres sitt auto-genererte navn for det opprinnelige, unavngitte
|
||||||
|
-- table-level CHECK-et i 001 -- bekreftet mot ekte teecup_db, ikke gjettet.
|
||||||
|
ALTER TABLE course DROP CONSTRAINT course_check;
|
||||||
|
ALTER TABLE course ADD CONSTRAINT course_check
|
||||||
|
CHECK (source NOT IN ('official', 'international') OR external_course_ref IS NOT NULL);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Kopier-inn-mål 2: global personal_course-katalog, ny provenance-kolonne
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- round.course_source trenger INGEN endring -- en GolfAPI-importert bane
|
||||||
|
-- blir en helt vanlig personal_course-rad (course_source='custom' i
|
||||||
|
-- round, uendret), kun med denne kolonnen satt for å (a) hindre
|
||||||
|
-- dupliserte importer, (b) la rangefinder-oppslag finne koordinatene.
|
||||||
|
ALTER TABLE personal_course
|
||||||
|
ADD COLUMN external_golfapi_course_id text REFERENCES golfapi_course(course_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX personal_course_golfapi_ref_unique
|
||||||
|
ON personal_course (external_golfapi_course_id)
|
||||||
|
WHERE external_golfapi_course_id IS NOT NULL;
|
||||||
|
|
@ -6041,6 +6041,158 @@ Org-turneringenes tilsvarende "Banter Board" (`message`/
|
||||||
ikke nevnt i den opprinnelige forespørselen, fortsatt bevisst utenfor
|
ikke nevnt i den opprinnelige forespørselen, fortsatt bevisst utenfor
|
||||||
omfang.
|
omfang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-064: GolfAPI.io som tredje banekilde — avstand til mål (rangefinder) — 2026-08-12
|
||||||
|
|
||||||
|
Brukeren fikk et GolfAPI.io-token (20 API-kall) og ba om avstandsmåling
|
||||||
|
til grønn (front/midt/bak) og hindringer — notert som en kjent, ikke-
|
||||||
|
designet ambisjon siden 2026-07-22 (se FEATURE_BACKLOG.md), atskilt fra
|
||||||
|
den allerede byggede slag-for-slag GPS-avstandsmålingen (ADR-048, som
|
||||||
|
måler AD HOC-punkter brukeren selv velger, ikke faste banepunkter).
|
||||||
|
Startbane: **Tjøme Golfklubb** — bekreftet av bruker at den IKKE finnes
|
||||||
|
i TeeOff, som gjør dette til det generelle "bane utenfor TeeOffs
|
||||||
|
dekning"-tilfellet FEATURE_BACKLOG.md (tillegg 2026-08-03) allerede
|
||||||
|
pekte ut som naturlig neste steg, ikke et spesialtilfelle.
|
||||||
|
|
||||||
|
**Live validert FØR noe ble bygget** (ekte GolfAPI-kall, ikke antatt fra
|
||||||
|
research alene): søk (`GET /clubs?name=Tjøme`), fullt scorekort
|
||||||
|
(`GET /courses/{id}` — par/SI per hull, 4 tees med per-hull-lengde i
|
||||||
|
meter, course rating/slope inkl. front9/back9), og koordinater
|
||||||
|
(`GET /coordinates/{id}` — 167 punkter for Tjøme: grønn front/midt/bak
|
||||||
|
per hull, bunkere, vann, avstandsmarkører, tee-punkter). Kostnad
|
||||||
|
bekreftet: søk 0,1 kall, full henting (course + coordinates) 2 kall —
|
||||||
|
matcher brukerens egen "20 kall ≈ 10 baner"-anslag.
|
||||||
|
|
||||||
|
**Beslutning A — ett delt, globalt cache-lag (`golfapi_course`/
|
||||||
|
`_hole`/`_tee`/`_coordinate`, migrasjon 065), ALDRI live oppslag.**
|
||||||
|
Motsatt av teeoffs policy (fritt, offentlig API, live oppslag er
|
||||||
|
kostnadsfritt) — GolfAPI-kall koster ekte, sterkt begrenset budsjett.
|
||||||
|
GolfAPIs avtale tillater eksplisitt permanent caching ("no need to call
|
||||||
|
the API to fetch the same course multiple times"), så cachen hentes KUN
|
||||||
|
én gang NOENSINNE per fysisk bane, uansett hvor mange organisasjoner/
|
||||||
|
brukere som senere kobler til den samme banen — håndhevet av én sentral
|
||||||
|
chokepoint-funksjon, `golfapi_cache.get_or_fetch_golfapi_course()`, som
|
||||||
|
ALL import-kode må gå via (aldri et direkte `golfapi_client`-kall fra et
|
||||||
|
endepunkt). Speiler ADR-019s "import ved eksplisitt valg, ikke live
|
||||||
|
oppslag"-filosofi, men med en enda strengere begrunnelse (penger, ikke
|
||||||
|
bare transaksjonssikkerhet/oppetid).
|
||||||
|
|
||||||
|
**Beslutning B — kopier inn i BEGGE eksisterende banemodeller, ikke en
|
||||||
|
tredje.** Bekreftet med bruker (AskUserQuestion): GolfAPI-importerte
|
||||||
|
baner skal kunne brukes i BÅDE org-turneringer OG frittstående runder.
|
||||||
|
- `course` (org-scopet): `course_source`-enum utvidet med
|
||||||
|
`'international'`; `external_course_ref` lagrer GolfAPIs `courseID`
|
||||||
|
direkte (samme kolonne teeoff allerede bruker til
|
||||||
|
`facility_slug:course_id`). Nye endepunkt `international-search`/
|
||||||
|
`international-import` i `courses.py`, speiler `official-search`/
|
||||||
|
`official-import` (ADR-019) strukturelt og validerings-messig
|
||||||
|
("fail loudly" på ufullstendige data, aldri en delvis import).
|
||||||
|
- `personal_course` (global, ADR-042): ny nullable
|
||||||
|
`external_golfapi_course_id`-kolonne + delvis unik indeks (samme
|
||||||
|
mønster som `course`s migrasjon 010). Speilende endepunkter i
|
||||||
|
`rounds.py`. `round.course_source` trenger INGEN endring — en
|
||||||
|
GolfAPI-importert bane blir en helt vanlig `personal_course`-rad
|
||||||
|
(`course_source='custom'`), kun med proveniens-kolonnen satt.
|
||||||
|
- Begge kopier-inn-stegene leser FRA det samme delte cache-laget —
|
||||||
|
importeres Tjøme via en turnering OG senere via en frittstående runde,
|
||||||
|
koster det GolfAPI 0 ekstra kall andre gang, uansett rekkefølge.
|
||||||
|
|
||||||
|
**Beslutning C — koordinatene kopieres ALDRI inn per import.** Ulikt
|
||||||
|
par/rating (som MÅ fryses per import for reproduserbare HCP-resultater,
|
||||||
|
ADR-007/ADR-019s prinsipp), er koordinatene ikke en del av HCP-
|
||||||
|
beregningen i det hele tatt. Rangefinder-oppslag (nytt endepunkt
|
||||||
|
`GET /rounds/{id}/holes/{n}/target-points`) leser derfor alltid DIREKTE
|
||||||
|
fra den delte `golfapi_course_coordinate`-cachen via banens lagrede
|
||||||
|
GolfAPI-courseID — én fysisk bane har ett koordinatsett, uansett hvor
|
||||||
|
mange ganger banen er importert til ulike organisasjoner/brukere.
|
||||||
|
Endepunktet returnerer RÅ punkter (lat/lng), ALDRI en ferdigregnet
|
||||||
|
avstand — en forhåndsberegnet avstand ville vært utdatert i det
|
||||||
|
øyeblikket spilleren beveger seg. Klienten Haversine-regner selv
|
||||||
|
(`frontend/lib/geo.ts`, samme rene funksjon som ADR-048 allerede bygget)
|
||||||
|
mot spillerens EGEN, ferske GPS-posisjon.
|
||||||
|
|
||||||
|
**Beslutning D — v1-visning: ren tall-/tekstvisning, intet kart.**
|
||||||
|
Bekreftet med bruker (AskUserQuestion) — "142 m front / 151 m midt /
|
||||||
|
163 m bak" pluss evt. nærmeste hindring, ingen Mapbox-kartlasting i det
|
||||||
|
hele tatt. Null ekstra driftskostnad utover selve GolfAPI-importen,
|
||||||
|
matcher Claude sin egen tidligere loggførte anbefaling
|
||||||
|
(FEATURE_BACKLOG.md, 2026-08-06) og ADR-048s etablerte kostnadskontroll-
|
||||||
|
filosofi (kart lastes kun når det er strengt nødvendig). Brukeren
|
||||||
|
foreslo selv en fremtidig **freemium-idé** (gratis = tall, betalt =
|
||||||
|
interaktivt satellittkart/"Plays Like"-vind-/høydejustering/flyover,
|
||||||
|
etter mønster fra etablerte konkurrenter som Hole19/18Birdies) —
|
||||||
|
BEVISST NOTERT, IKKE BYGGET: appen har ingen betalingsinfrastruktur i
|
||||||
|
dag, og v1-beslutningen (tall, ikke kart) er uansett riktig uavhengig av
|
||||||
|
om et fremtidig betalt lag legges til senere.
|
||||||
|
|
||||||
|
**Beslutning E — datamodell for koordinatene speiler GolfAPIs egen POI-
|
||||||
|
struktur, men normalisert til lesbar tekst.** `poi_type` (grønn/bunker/
|
||||||
|
vann/trær/avstandsmarkør/dogleg/vei/tee-punkt), `location`
|
||||||
|
(front/midt/bak — IKKE navngitt "green_location": Tjømes bunkere hadde
|
||||||
|
også front/bak-punkter, feltet er generisk for ethvert POI, ikke kun
|
||||||
|
grønn) og `side_fairway` (venstre/senter/høyre) er tekst+CHECK, ikke
|
||||||
|
egne enum-typer — samme "kan utvides uten `ALTER TYPE`"-begrunnelse som
|
||||||
|
`session.format` allerede bruker (001).
|
||||||
|
|
||||||
|
**Budsjettdisiplin:** import er ALLTID en eksplisitt, synlig
|
||||||
|
brukerhandling (et "Hent bane fra GolfAPI"-søk + valg), ALDRI
|
||||||
|
automatisk/implisitt — med kun ~15-16 kall igjen etter denne rundens
|
||||||
|
utvikling+verifisering (2,3 til research/validering + 2 til det siste
|
||||||
|
bevisste, ekte E2E-kallet), er dette en reell begrensning appen må
|
||||||
|
respektere fremover, ikke en teoretisk bekymring.
|
||||||
|
|
||||||
|
**Bevisst utenfor denne runden:** rangefinder-VISNINGEN i selve
|
||||||
|
scorekortet (`round-detail.tsx` sin hull-header har allerede reservert
|
||||||
|
plass ved GIR-merket siden 2026-07-25) er ikke koblet inn ennå — venter
|
||||||
|
på en Claude-skrevet V0-prompt (samme arbeidsdeling som ADR-048s
|
||||||
|
`ShotMeasurementSheet`), ikke bygget håndkodet. Org-siden fikk full
|
||||||
|
import-støtte (`international-search`/`-import`), men INGEN tilsvarende
|
||||||
|
rangefinder-visning i turneringens scoreførings-UI ennå — ingen
|
||||||
|
reservert plass finnes der i dag, egen, senere vurdering når/hvis
|
||||||
|
etterspurt. GolfAPI som generell fallback for ALLE norske søk (ikke
|
||||||
|
bare når TeeOff mangler banen) er en bevisst IKKE-endring — Tjøme er
|
||||||
|
eksempelet som utløste dette, ikke en politikkendring for Norge
|
||||||
|
generelt.
|
||||||
|
|
||||||
|
**Verifisert:** migrasjon 065 kjørt mot en automatisert scratch-database
|
||||||
|
(`scripts/run_backend_tests.sh`-mønsteret) — alle 31 eksisterende
|
||||||
|
backend-tester fortsatt grønne (ingen regresjon). Egen scratch-runde
|
||||||
|
(fixture-basert, monkeypatchet `golfapi_client` med de FAKTISKE Tjøme-
|
||||||
|
svarene fra research-fasen, for å unngå å bruke flere av de knappe
|
||||||
|
API-kallene under iterasjon) bekreftet: cache-chokepoktet henter GolfAPI
|
||||||
|
nøyaktig én gang og ALDRI på nytt ved gjentatte kall (idempotens), 18
|
||||||
|
hull + 4 tees + 167 koordinater cachet korrekt, org-import oppretter en
|
||||||
|
`course`-rad med `source='international'` + riktig `hole`/`tee_rating`-
|
||||||
|
antall (18 hull, 8 tee_rating-rader — 4 tees × 2 kjønn), duplikat-import
|
||||||
|
avvist av unik indeks i BEGGE modeller (`course_org_external_ref_unique`
|
||||||
|
fra migrasjon 010, ny `personal_course_golfapi_ref_unique`), og
|
||||||
|
target-points-oppslaget returnerer korrekte front/midt/bak-punkter for
|
||||||
|
et gitt hull. **Ett bevisst, siste ekte kall** mot GolfAPI (course +
|
||||||
|
coordinates, 2 kall) bekreftet at hele kjeden — `golfapi_client.py`s
|
||||||
|
faktiske HTTP-kall inkludert, ikke bare cache-logikken — fungerer mot
|
||||||
|
den virkelige tjenesten, ikke bare mot fixtures. `teecup_db` bekreftet
|
||||||
|
uendret gjennom hele verifiseringen (`\l`-sjekk før/etter, scratch-
|
||||||
|
database+rolle droppet). Frontend: `tsc --noEmit` rent, 45/45 vitest.
|
||||||
|
**Tillegg samme dag — rangefinder-visningen koblet inn og rullet ut.**
|
||||||
|
Brukeren kjørte V0-prompten (zip 10), resultatet
|
||||||
|
(`components/target-distance.tsx`) matchet spesifikasjonen nøyaktig.
|
||||||
|
Claude koblet den inn via en ny `components/hole-target-distance.tsx`
|
||||||
|
(GPS/fetch/Haversine-logikken, samme `watchPosition`-mønster som
|
||||||
|
ADR-048), montert i `ScoringWizard` sin hull-header
|
||||||
|
(`round-detail.tsx`). Fant og fikset en reell driftsfeil under
|
||||||
|
utrullingen: `TEECUP_GOLFAPI_TOKEN` var satt i `.env`, men
|
||||||
|
`docker-compose.yml` sin `teecup_api`-tjeneste videreførte den aldri
|
||||||
|
til containeren. Full-stack scratch-verifisert (ekte innlogging, ekte
|
||||||
|
import via det virkelige endepunktet -- cache-treff, 0 nye API-kall --
|
||||||
|
ekte runde, nettleserverifisert lys+mørk med geolocation-emulering:
|
||||||
|
front/midt/bak-avstander og nærmeste hindring rendret korrekt, ingen
|
||||||
|
konsollfeil). **Rullet ut 2026-08-12**, bruker bekreftet. Migrasjon 065
|
||||||
|
kjørt mot ekte `teecup_db`, `docker compose build && up -d` kjørt (to
|
||||||
|
ganger denne dagen -- brukeren stanset bevisst mellom første og andre
|
||||||
|
gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full
|
||||||
|
verifiseringsdetalj.
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
||||||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
||||||
|
|
|
||||||
124
CHANGELOG.md
124
CHANGELOG.md
|
|
@ -10632,3 +10632,127 @@ Neste steg:
|
||||||
migrasjon -- `docker compose build teecup_api teecup_frontend &&
|
migrasjon -- `docker compose build teecup_api teecup_frontend &&
|
||||||
up -d`. Ren omstart, ingen feil i containerloggene, ingen konsollfeil
|
up -d`. Ren omstart, ingen feil i containerloggene, ingen konsollfeil
|
||||||
på `https://teecup.golf/logg-inn` etter omstart.
|
på `https://teecup.golf/logg-inn` etter omstart.
|
||||||
|
|
||||||
|
76. **Statistikk: y-akse-benevnelser + verdi i begge ender på
|
||||||
|
trendgrafene — 2026-08-12.** Oppfølging til punkt 75, samme dag.
|
||||||
|
Bruker: "Y-axen må ha verdi i begge ender" + ønske om benevnelse på
|
||||||
|
y-aksen.
|
||||||
|
|
||||||
|
`BarTrendChart`/`CumulativeTrendLine` (`rounds-stats-summary.tsx`)
|
||||||
|
fikk en ny `yAxisLabel`-prop -- rendres som EKTE HTML-tekst over hver
|
||||||
|
graf (ikke roterte SVG-glyffer), slik at den skalerer med brukerens
|
||||||
|
skriftstørrelse i stedet for å bli uleselig ved zoom
|
||||||
|
(tilgjengelighetsregelen i CLAUDE.md). Benevnelser: "Slag til par"
|
||||||
|
(Utvikling), "%" (alle prosentgrafer: Fairwaytreff, Greentreff,
|
||||||
|
Én-putt, Scrambling/Sand save), "Putt" (Putt/18), "Antall" (Chip/
|
||||||
|
Bunkerslag/Straffeslag/Anywayslag).
|
||||||
|
|
||||||
|
`BarTrendChart` viste tidligere kun verdi-etikett på SISTE (nyeste)
|
||||||
|
stolpe. Lagt til samme etikett på FØRSTE stolpe også (dempet stil,
|
||||||
|
samme mønster som `CumulativeTrendLine` allerede brukte for sine
|
||||||
|
start-/sluttpunkt-etiketter) -- grafen kan nå leses uten en egen
|
||||||
|
tallskala i begge ender, ikke bare ved siste punkt.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest.
|
||||||
|
|
||||||
|
77. **Avstand til mål (rangefinder) via GolfAPI.io — Tjøme Golfklubb,
|
||||||
|
2026-08-12.** Ny, tredje banekilde (`app/golfapi_client.py`,
|
||||||
|
`app/golfapi_cache.py`) for baner utenfor TeeOffs dekning. Full
|
||||||
|
detalj og alle beslutninger i **ADR-064**, kort oppsummert her:
|
||||||
|
|
||||||
|
Brukeren fikk et GolfAPI.io-token (20 kall) og ba om avstands-
|
||||||
|
visning til grønn på baner TeeOff ikke dekker -- konkret testet mot
|
||||||
|
Tjøme Golfklubb, som brukeren bekreftet IKKE finnes i TeeOff. Live
|
||||||
|
validert mot ekte GolfAPI FØR noe ble bygget (søk, fullt scorekort,
|
||||||
|
167 koordinatpunkter for Tjøme -- grønn front/midt/bak, hindringer,
|
||||||
|
tee-punkter), 2,3 av 20 kall brukt på research.
|
||||||
|
|
||||||
|
Migrasjon 065: delt, globalt cache-lag (`golfapi_course`/`_hole`/
|
||||||
|
`_tee`/`_coordinate`) -- hentes KUN én gang noensinne per fysisk
|
||||||
|
bane (GolfAPIs avtale tillater eksplisitt permanent caching), via
|
||||||
|
én sentral chokepoint-funksjon som all import-kode må gå via.
|
||||||
|
`course_source` utvidet med `'international'` (org-turneringer);
|
||||||
|
`personal_course` fikk en ny `external_golfapi_course_id`-kolonne
|
||||||
|
(frittstående runder) -- bekreftet med bruker at BEGGE skal
|
||||||
|
støttes. Nye endepunkter: `international-search`/`-import` i både
|
||||||
|
`courses.py` (org) og `rounds.py` (personal), samt
|
||||||
|
`GET /rounds/{id}/holes/{n}/target-points` (returnerer RÅ
|
||||||
|
koordinater, aldri en ferdigregnet avstand -- klienten Haversine-
|
||||||
|
regner selv mot spillerens ferske GPS-posisjon, samme
|
||||||
|
`frontend/lib/geo.ts`-funksjon som ADR-048).
|
||||||
|
|
||||||
|
v1-visning: ren tall-/tekstvisning ("142 m front/151 m midt/163 m
|
||||||
|
bak"), intet kart -- bekreftet med bruker (AskUserQuestion), null
|
||||||
|
ekstra Mapbox-kostnad. En fremtidig freemium-idé (gratis=tall,
|
||||||
|
betalt=interaktivt kart) ble foreslått av bruker og notert i
|
||||||
|
ADR-064, ikke bygget (ingen betalingsinfrastruktur finnes).
|
||||||
|
|
||||||
|
Frontend: søk-og-koble-UI lagt til i `tournament-program.tsx`
|
||||||
|
(org-siden, to kallsteder) og `round-detail.tsx` (frittstående
|
||||||
|
runder, "Fant ikke banen? Søk internasjonalt"-fallback under egen
|
||||||
|
bane-søk).
|
||||||
|
|
||||||
|
**Rangefinder-visningen, samme dag:** V0-prompten
|
||||||
|
(`v0-prompt-rangefinder.md`) ble kjørt av bruker (zip 10,
|
||||||
|
`Temp-uploads/tee-cup (10).zip`) -- `components/target-distance.tsx`
|
||||||
|
kom tilbake nøyaktig som spesifisert (props-drevet, ingen egen GPS-/
|
||||||
|
fetch-logikk, clubhouse-paletten korrekt brukt). Koblet inn av
|
||||||
|
Claude via en ny wrapper, `components/hole-target-distance.tsx`
|
||||||
|
(henter `target-points`, kjører `watchPosition` -- samme
|
||||||
|
`enableHighAccuracy`/`maximumAge: 1000`/grasiøs-degraderings-mønster
|
||||||
|
som `map-point-picker.tsx` allerede bruker for slagmåling, ADR-048
|
||||||
|
tillegg 2026-08-08 del 2 -- og Haversine-regner selv), montert i
|
||||||
|
`ScoringWizard` sin hull-header i `round-detail.tsx`. Viser INGEN
|
||||||
|
rad i det hele tatt for baner uten GolfAPI-koordinatdata (det store
|
||||||
|
flertallet av runder) -- ikke en "ingen data"-lapp på hver eneste
|
||||||
|
scorekort-åpning.
|
||||||
|
|
||||||
|
**Reell driftsfeil fanget under denne utrullingen:**
|
||||||
|
`TEECUP_GOLFAPI_TOKEN` var satt i `.env`, men `docker-compose.yml`
|
||||||
|
sin `teecup_api`-tjeneste videreførte den aldri til containeren --
|
||||||
|
endepunktene ville svart "ikke konfigurert" i produksjon uansett
|
||||||
|
hva `.env` sa. Lagt til i miljø-blokken, samme mønster som de andre
|
||||||
|
`TEECUP_*`-hemmelighetene.
|
||||||
|
|
||||||
|
**Verifisert:** migrasjon 065 kjørt mot automatisert scratch-
|
||||||
|
database (`scripts/run_backend_tests.sh`), 31/31 eksisterende
|
||||||
|
backend-tester uendret grønne. Egen scratch-runde med de FAKTISKE
|
||||||
|
Tjøme-svarene som fixtures (monkeypatchet `golfapi_client`, for å
|
||||||
|
ikke bruke flere av de knappe API-kallene under iterasjon):
|
||||||
|
cache-chokepunktet bekreftet idempotent (henter GolfAPI nøyaktig én
|
||||||
|
gang, aldri på nytt ved gjentatte kall), 18 hull + 4 tees + 167
|
||||||
|
koordinater cachet korrekt, org-import ga riktig antall
|
||||||
|
hull/tee_rating-rader, duplikat-import avvist i BEGGE modeller,
|
||||||
|
target-points-oppslaget ga korrekte front/midt/bak-punkter. **Ett
|
||||||
|
bevisst, siste ekte kall** mot GolfAPI (2 kall) bekreftet at hele
|
||||||
|
kjeden -- inkl. `golfapi_client.py` sitt faktiske HTTP-kall, ikke
|
||||||
|
bare cache-logikken -- fungerer mot den virkelige tjenesten.
|
||||||
|
`teecup_db` bekreftet uendret gjennom hele verifiseringen
|
||||||
|
(scratch-database+rolle droppet etterpå). Frontend: `tsc --noEmit`
|
||||||
|
rent, 45/45 vitest.
|
||||||
|
|
||||||
|
**Full-stack scratch-verifisering av rangefinder-koblingen**
|
||||||
|
(separat scratch-database/API/frontend/MinIO-sett, GolfAPI-cachen
|
||||||
|
forhåndsseedet fra de samme lagrede Tjøme-fixturene -- null nye
|
||||||
|
API-kall): ekte innlogging, import av Tjøme via det virkelige
|
||||||
|
`POST /personal-courses/international-import`-endepunktet (cache-
|
||||||
|
treff, 0 kall), opprettet en runde, åpnet scoreførings-veiviseren
|
||||||
|
for hull 1 i nettleseren (Chrome DevTools MCP, geolocation-
|
||||||
|
emulering). Bekreftet visuelt i BÅDE lys og mørk modus: "Front
|
||||||
|
118 m / Midt 132 m / Bak 144 m" (Midt uthevet), "● Live"-indikator,
|
||||||
|
nærmeste hindring korrekt identifisert og vist ("Bunker (grønn)
|
||||||
|
(front): 104 m"), ingen konsollfeil. `tsc --noEmit` rent, 45/45
|
||||||
|
vitest. Scratch-database/rolle/containere ryddet opp etterpå,
|
||||||
|
`teecup_db` bekreftet uendret (`\l`-sjekk).
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-12**, bruker bekreftet ("kjør" / "NÅ kan du
|
||||||
|
kjøre på" etter å ha lastet opp V0-eksporten). Migrasjon 065 kjørt
|
||||||
|
mot ekte `teecup_db` (additiv, ingen eksisterende data rørt) --
|
||||||
|
`docker exec -i teeoff_db psql ... -f 065_golfapi_courses.sql`,
|
||||||
|
deretter `docker compose build teecup_api teecup_frontend && up -d`
|
||||||
|
(kjørt TO ganger denne runden -- første gang før rangefinder-
|
||||||
|
komponenten/docker-compose-fiksen var klare, brukeren stanset
|
||||||
|
bevisst midtveis for å laste opp V0-eksporten først, andre gang
|
||||||
|
etter). Rene containerlogger begge ganger, ingen konsollfeil på
|
||||||
|
`https://teecup.golf/logg-inn` etter siste omstart. ~15,5 av 20
|
||||||
|
GolfAPI-kall gjenstår.
|
||||||
|
|
|
||||||
|
|
@ -114,5 +114,12 @@ class Settings:
|
||||||
# bilde faller tilbake til ren tekst hvis ikke satt.
|
# bilde faller tilbake til ren tekst hvis ikke satt.
|
||||||
MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
|
MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
|
||||||
|
|
||||||
|
# GolfAPI.io (ADR-064) -- tredje banekilde for baner utenfor TeeOffs
|
||||||
|
# dekning (f.eks. Tjøme Golfklubb). ULIKT teeoff: koster ekte, begrenset
|
||||||
|
# budsjett per kall -- se golfapi_client.py sitt cache-lag. Valgfritt,
|
||||||
|
# samme grasiøs-degraderings-mønster: import-/søkeendepunktene svarer
|
||||||
|
# tydelig "ikke konfigurert" uten å krasje resten av appen.
|
||||||
|
GOLFAPI_TOKEN: str | None = os.environ.get("TEECUP_GOLFAPI_TOKEN", "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
114
app/golfapi_cache.py
Normal file
114
app/golfapi_cache.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
Cache-chokepoint mot GolfAPI.io (ADR-064). ALL import-kode (org-scopet
|
||||||
|
`course` OG global `personal_course`) MÅ gå via get_or_fetch_golfapi_course()
|
||||||
|
her -- ALDRI kalle golfapi_client direkte fra en import-endepunkt. Sjekker
|
||||||
|
`golfapi_course`-cachen FØRST, kaller GolfAPI (course + coordinates, 2 kall)
|
||||||
|
KUN hvis banen mangler -- kritisk for det begrensede kall-budsjettet
|
||||||
|
(GolfAPIs avtale tillater eksplisitt permanent caching av hentet banedata,
|
||||||
|
"no need to call the API to fetch the same course multiple times").
|
||||||
|
|
||||||
|
Ren DB-glue rundt den transportlag-rene golfapi_client.py -- samme
|
||||||
|
ansvarsdeling som courses.py allerede har rundt teeoff_client.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import golfapi_client
|
||||||
|
|
||||||
|
# GolfAPIs rå tallkoder -> TeeCups lesbare tekst-enum (golfapi_course_coordinate.poi_type).
|
||||||
|
_POI_TYPE_BY_CODE = {
|
||||||
|
1: "green", 2: "green_bunker", 3: "fairway_bunker", 4: "water",
|
||||||
|
5: "trees", 6: "marker_100", 7: "marker_150", 8: "marker_200",
|
||||||
|
9: "dogleg", 10: "road", 11: "tee_front", 12: "tee_back",
|
||||||
|
}
|
||||||
|
_LOCATION_BY_CODE = {1: "front", 2: "middle", 3: "back"}
|
||||||
|
_SIDE_FW_BY_CODE = {1: "left", 2: "center", 3: "right"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None:
|
||||||
|
"""Idempotent -- no-op (0 API-kall) hvis banen allerede er cachet fra
|
||||||
|
en TIDLIGERE import, uansett hvilken organisasjon/bruker som gjorde den."""
|
||||||
|
already_cached = await conn.fetchval("SELECT 1 FROM golfapi_course WHERE course_id = $1", golfapi_course_id)
|
||||||
|
if already_cached:
|
||||||
|
return
|
||||||
|
|
||||||
|
course = await golfapi_client.get_course(golfapi_course_id)
|
||||||
|
coordinates = await golfapi_client.get_coordinates(golfapi_course_id)
|
||||||
|
num_holes = int(course["numHoles"])
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course
|
||||||
|
(course_id, club_id, club_name, course_name, city, country,
|
||||||
|
num_holes, has_gps, num_coordinates, golfapi_updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, to_timestamp($10))
|
||||||
|
""",
|
||||||
|
golfapi_course_id,
|
||||||
|
course["clubID"],
|
||||||
|
course["clubName"],
|
||||||
|
course.get("courseName") or "Bane",
|
||||||
|
course.get("city"),
|
||||||
|
course.get("country"),
|
||||||
|
num_holes,
|
||||||
|
bool(int(course.get("hasGPS") or 0)),
|
||||||
|
len(coordinates),
|
||||||
|
int(course["timestampUpdated"]) if course.get("timestampUpdated") else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
pars_men = course["parsMen"]
|
||||||
|
indexes_men = course["indexesMen"]
|
||||||
|
pars_women = course.get("parsWomen") or [None] * num_holes
|
||||||
|
indexes_women = course.get("indexesWomen") or [None] * num_holes
|
||||||
|
for i in range(num_holes):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course_hole
|
||||||
|
(golfapi_course_id, hole_number, par_men, index_men, par_women, index_women)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
golfapi_course_id, i + 1, pars_men[i], indexes_men[i], pars_women[i], indexes_women[i],
|
||||||
|
)
|
||||||
|
|
||||||
|
for t in course.get("tees", []):
|
||||||
|
lengths = [t.get(f"length{n}") for n in range(1, num_holes + 1)]
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course_tee
|
||||||
|
(golfapi_course_id, golfapi_tee_id, name, color_hex, lengths_meters,
|
||||||
|
course_rating_men, slope_men, course_rating_women, slope_women,
|
||||||
|
course_rating_men_front9, course_rating_men_back9,
|
||||||
|
course_rating_women_front9, course_rating_women_back9)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
""",
|
||||||
|
golfapi_course_id,
|
||||||
|
t["teeID"],
|
||||||
|
t.get("teeName") or "Tee",
|
||||||
|
t.get("teeColor"),
|
||||||
|
lengths,
|
||||||
|
t["courseRatingMen"],
|
||||||
|
int(t["slopeMen"]),
|
||||||
|
t.get("courseRatingWomen"),
|
||||||
|
int(t["slopeWomen"]) if t.get("slopeWomen") is not None else None,
|
||||||
|
t.get("courseRatingMenFront9"),
|
||||||
|
t.get("courseRatingMenBack9"),
|
||||||
|
t.get("courseRatingWomenFront9"),
|
||||||
|
t.get("courseRatingWomenBack9"),
|
||||||
|
)
|
||||||
|
|
||||||
|
for c in coordinates:
|
||||||
|
poi_type = _POI_TYPE_BY_CODE.get(int(c["poi"]))
|
||||||
|
if poi_type is None:
|
||||||
|
continue # ukjent/fremtidig GolfAPI-POI-kode -- hopp over, ikke feil hele importen
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO golfapi_course_coordinate
|
||||||
|
(golfapi_course_id, hole_number, poi_type, location, side_fairway, latitude, longitude)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
""",
|
||||||
|
golfapi_course_id,
|
||||||
|
int(c["hole"]),
|
||||||
|
poi_type,
|
||||||
|
_LOCATION_BY_CODE.get(int(c["location"])) if c.get("location") is not None else None,
|
||||||
|
_SIDE_FW_BY_CODE.get(int(c["sideFW"])) if c.get("sideFW") is not None else None,
|
||||||
|
c["latitude"],
|
||||||
|
c["longitude"],
|
||||||
|
)
|
||||||
85
app/golfapi_client.py
Normal file
85
app/golfapi_client.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""
|
||||||
|
Utgående, lesende HTTP-klient mot GolfAPI.io (ADR-064) -- tredje banekilde
|
||||||
|
for baner utenfor TeeOffs dekning (f.eks. Tjøme Golfklubb, bekreftet IKKE
|
||||||
|
i TeeOff 2026-08-12). Ren HTTP-modul -- ingen db/RLS-avhengighet, samme
|
||||||
|
isolasjonsprinsipp som teeoff_client.py/handicap_engine.py.
|
||||||
|
|
||||||
|
ULIKT teeoff: GolfAPI-kall koster ekte, begrenset budsjett (kjøpt 20 kall,
|
||||||
|
~2/bane -- 1 for /courses/{id}, 1 for /coordinates/{id}, søk koster 0.1).
|
||||||
|
Denne modulen gjør RÅ HTTP-kall og returnerer JSON uendret -- selve
|
||||||
|
cache-sjekken ("har vi denne banen fra før?") ligger i
|
||||||
|
_get_or_fetch_golfapi_course() i courses.py, IKKE her, slik at denne
|
||||||
|
modulen forblir en ren, testbar transportlag uten db-avhengighet.
|
||||||
|
|
||||||
|
Alltid measureUnit=m i kallene -- unngår yard/meter-fellen research
|
||||||
|
advarte om (internasjonale kilder returnerer ofte yards som standard).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
_BASE_URL = "https://golfapi.io/api/v2.3"
|
||||||
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
class GolfApiNotConfiguredError(Exception):
|
||||||
|
"""TEECUP_GOLFAPI_TOKEN er ikke satt i .env."""
|
||||||
|
|
||||||
|
|
||||||
|
class GolfApiUnavailableError(Exception):
|
||||||
|
"""GolfAPI svarte ikke (nede, timeout, uventet statuskode)."""
|
||||||
|
|
||||||
|
|
||||||
|
class GolfApiNotFoundError(Exception):
|
||||||
|
"""Banen/klubben finnes ikke hos GolfAPI (404)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
if not settings.GOLFAPI_TOKEN:
|
||||||
|
raise GolfApiNotConfiguredError()
|
||||||
|
return {"Authorization": f"Bearer {settings.GOLFAPI_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def search_clubs(name: str | None = None, city: str | None = None, country: str | None = None) -> list[dict]:
|
||||||
|
"""Koster 0.1 API-kall. Returnerer rå `clubs`-array (hver med `courses[]`)."""
|
||||||
|
params = {k: v for k, v in {"name": name, "city": city, "country": country}.items() if v}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
res = await client.get(f"{_BASE_URL}/clubs", params=params, headers=_headers())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise GolfApiUnavailableError(str(e)) from e
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
||||||
|
return res.json().get("clubs", [])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_course(course_id: str) -> dict:
|
||||||
|
"""Koster 1 API-kall. Full scorekortdata: par/SI per hull, alle tees
|
||||||
|
med per-hull-lengde (meter) og rating/slope."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
res = await client.get(f"{_BASE_URL}/courses/{course_id}", params={"measureUnit": "m"}, headers=_headers())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise GolfApiUnavailableError(str(e)) from e
|
||||||
|
if res.status_code == 404:
|
||||||
|
raise GolfApiNotFoundError(course_id)
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_coordinates(course_id: str) -> list[dict]:
|
||||||
|
"""Koster 1 API-kall. Rå `coordinates`-array (poi/location/sideFW/hole/
|
||||||
|
latitude/longitude, rå GolfAPI-tallkoder -- oversettes til TeeCups
|
||||||
|
tekst-enum i courses.py, ikke her)."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
res = await client.get(f"{_BASE_URL}/coordinates/{course_id}", headers=_headers())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise GolfApiUnavailableError(str(e)) from e
|
||||||
|
if res.status_code == 404:
|
||||||
|
raise GolfApiNotFoundError(course_id)
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
|
||||||
|
return res.json().get("coordinates", [])
|
||||||
|
|
@ -13,8 +13,9 @@ bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from .. import teeoff_client
|
from .. import golfapi_cache, golfapi_client, teeoff_client
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
|
from ..config import settings
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..team_authz import is_org_member, user_is_org_player
|
from ..team_authz import is_org_member, user_is_org_player
|
||||||
|
|
@ -608,3 +609,150 @@ async def import_official_course(
|
||||||
)
|
)
|
||||||
|
|
||||||
return Course(**dict(course_row))
|
return Course(**dict(course_row))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
|
||||||
|
#
|
||||||
|
# Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var
|
||||||
|
# den konkrete anledningen (bekreftet IKKE i TeeOff 2026-08-12). ULIKT
|
||||||
|
# TeeOff-importen over: GolfAPI-kall koster ekte, begrenset budsjett, derfor
|
||||||
|
# går ALL henting via golfapi_cache.get_or_fetch_golfapi_course() -- aldri
|
||||||
|
# et direkte golfapi_client-kall herfra. Samme "kopier inn, aldri live
|
||||||
|
# oppslag"-filosofi og samme "fail loudly on ufullstendige data"-prinsipp
|
||||||
|
# som ADR-019, men fra en annen kilde.
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalCourseOption(BaseModel):
|
||||||
|
course_id: str
|
||||||
|
course_name: str
|
||||||
|
num_holes: int
|
||||||
|
has_gps: bool
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalClub(BaseModel):
|
||||||
|
club_id: str
|
||||||
|
club_name: str
|
||||||
|
city: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
courses: list[InternationalCourseOption]
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalCourseImport(BaseModel):
|
||||||
|
golfapi_course_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/courses/international-search", response_model=list[InternationalClub])
|
||||||
|
async def search_international_courses(
|
||||||
|
q: str = Query(default=""),
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[InternationalClub]:
|
||||||
|
if not q.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
clubs = await golfapi_client.search_clubs(name=q)
|
||||||
|
except golfapi_client.GolfApiNotConfiguredError:
|
||||||
|
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
|
||||||
|
except golfapi_client.GolfApiUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
|
||||||
|
return [
|
||||||
|
InternationalClub(
|
||||||
|
club_id=c["clubID"],
|
||||||
|
club_name=c["clubName"],
|
||||||
|
city=c.get("city"),
|
||||||
|
country=c.get("country"),
|
||||||
|
courses=[
|
||||||
|
InternationalCourseOption(
|
||||||
|
course_id=course["courseID"],
|
||||||
|
course_name=course.get("courseName") or "Bane",
|
||||||
|
num_holes=int(course["numHoles"]),
|
||||||
|
has_gps=bool(int(course.get("hasGPS") or 0)),
|
||||||
|
)
|
||||||
|
for course in c.get("courses", [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for c in clubs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orgs/{organization_id}/courses/international-import", response_model=Course, status_code=201)
|
||||||
|
async def import_international_course(
|
||||||
|
body: InternationalCourseImport,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Course:
|
||||||
|
if not settings.GOLFAPI_TOKEN:
|
||||||
|
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
|
||||||
|
|
||||||
|
# Idempotent, samme begrunnelse som import_official_course over: samme
|
||||||
|
# organisasjon importerer ofte samme bane til flere økter.
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
f"SELECT {_COURSE_COLUMNS} FROM course WHERE external_course_ref = $1 AND source = 'international'",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return Course(**dict(existing))
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
try:
|
||||||
|
await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
|
||||||
|
except golfapi_client.GolfApiNotFoundError:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
|
||||||
|
except golfapi_client.GolfApiUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
|
||||||
|
|
||||||
|
gc = await conn.fetchrow("SELECT club_name, course_name, city FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id)
|
||||||
|
holes = await conn.fetch(
|
||||||
|
"SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
tees = await conn.fetch(
|
||||||
|
"SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ADR-064 (speiler ADR-019 Beslutning C): ufullstendige data feiler
|
||||||
|
# importen tydelig FØR noe skrives til course/hole/tee.
|
||||||
|
if len(holes) != 18:
|
||||||
|
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
|
||||||
|
if not tees:
|
||||||
|
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
|
||||||
|
|
||||||
|
par_total = sum(h["par_men"] for h in holes)
|
||||||
|
course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"]
|
||||||
|
|
||||||
|
course_row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO course (organization_id, name, source, external_course_ref)
|
||||||
|
VALUES ($1, $2, 'international', $3)
|
||||||
|
RETURNING {_COURSE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
course_name,
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
course_id = course_row["id"]
|
||||||
|
|
||||||
|
for h in holes:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
organization_id, course_id, h["hole_number"], h["par_men"], h["index_men"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for t in tees:
|
||||||
|
tee_row = await conn.fetchrow(
|
||||||
|
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
organization_id, course_id, t["name"],
|
||||||
|
)
|
||||||
|
ratings: list[tuple[str, float, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]))]
|
||||||
|
if t["course_rating_women"] is not None and t["slope_women"] is not None:
|
||||||
|
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"])))
|
||||||
|
for gender, course_rating, slope_rating in ratings:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tee_rating (organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
|
||||||
|
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
organization_id, tee_row["id"], gender, course_rating, slope_rating, par_total,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Course(**dict(course_row))
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,10 @@ from typing import Literal
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from .. import storage, teeoff_client
|
from .. import golfapi_cache, golfapi_client, storage, teeoff_client
|
||||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..db import plain_connection
|
from ..db import plain_connection
|
||||||
|
|
@ -353,6 +353,156 @@ async def create_personal_course(
|
||||||
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
|
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
|
||||||
|
#
|
||||||
|
# Speiler international-search/-import i courses.py (org-scopet variant),
|
||||||
|
# men skriver inn i den globale personal_course-katalogen i stedet for
|
||||||
|
# course/hole/tee. Samme cache-chokepoint (golfapi_cache), samme "fail
|
||||||
|
# loudly"-prinsipp. round.course_source trenger INGEN endring -- en
|
||||||
|
# GolfAPI-importert bane blir en helt vanlig personal_course-rad
|
||||||
|
# (course_source='custom'), kun med external_golfapi_course_id satt for
|
||||||
|
# å (a) hindre dupliserte importer, (b) la rangefinder-oppslaget finne
|
||||||
|
# koordinatene senere.
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalCourseOption(BaseModel):
|
||||||
|
course_id: str
|
||||||
|
course_name: str
|
||||||
|
num_holes: int
|
||||||
|
has_gps: bool
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalClub(BaseModel):
|
||||||
|
club_id: str
|
||||||
|
club_name: str
|
||||||
|
city: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
courses: list[InternationalCourseOption]
|
||||||
|
|
||||||
|
|
||||||
|
class InternationalCourseImport(BaseModel):
|
||||||
|
golfapi_course_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/personal-courses/international-search", response_model=list[InternationalClub])
|
||||||
|
async def search_international_personal_courses(
|
||||||
|
q: str = Query(default=""),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[InternationalClub]:
|
||||||
|
if not q.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
clubs = await golfapi_client.search_clubs(name=q)
|
||||||
|
except golfapi_client.GolfApiNotConfiguredError:
|
||||||
|
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
|
||||||
|
except golfapi_client.GolfApiUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
|
||||||
|
return [
|
||||||
|
InternationalClub(
|
||||||
|
club_id=c["clubID"],
|
||||||
|
club_name=c["clubName"],
|
||||||
|
city=c.get("city"),
|
||||||
|
country=c.get("country"),
|
||||||
|
courses=[
|
||||||
|
InternationalCourseOption(
|
||||||
|
course_id=course["courseID"],
|
||||||
|
course_name=course.get("courseName") or "Bane",
|
||||||
|
num_holes=int(course["numHoles"]),
|
||||||
|
has_gps=bool(int(course.get("hasGPS") or 0)),
|
||||||
|
)
|
||||||
|
for course in c.get("courses", [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for c in clubs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/personal-courses/international-import", response_model=PersonalCourseOut, status_code=201)
|
||||||
|
async def import_international_personal_course(
|
||||||
|
body: InternationalCourseImport,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> PersonalCourseOut:
|
||||||
|
if not settings.GOLFAPI_TOKEN:
|
||||||
|
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
|
||||||
|
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
# Idempotent, samme begrunnelse som TeeOff-importen: gi tilbake den
|
||||||
|
# EKSISTERENDE (globale -- kan være importert av en ANNEN bruker)
|
||||||
|
# personal_course-raden fremfor å avvise med 409.
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT pc.id::text AS id, pc.name, (pc.created_by_user_id::text = $2) AS is_mine, au.display_name AS created_by_display_name
|
||||||
|
FROM personal_course pc JOIN app_user au ON au.id = pc.created_by_user_id
|
||||||
|
WHERE pc.external_golfapi_course_id = $1
|
||||||
|
""",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
user.user_id,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return PersonalCourseOut(**dict(existing))
|
||||||
|
|
||||||
|
async with plain_connection() as conn, translate_db_errors():
|
||||||
|
try:
|
||||||
|
await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
|
||||||
|
except golfapi_client.GolfApiNotFoundError:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
|
||||||
|
except golfapi_client.GolfApiUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
|
||||||
|
|
||||||
|
gc = await conn.fetchrow(
|
||||||
|
"SELECT club_name, course_name FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id
|
||||||
|
)
|
||||||
|
holes = await conn.fetch(
|
||||||
|
"SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
tees = await conn.fetch(
|
||||||
|
"SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
if len(holes) != 18:
|
||||||
|
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
|
||||||
|
if not tees:
|
||||||
|
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
|
||||||
|
|
||||||
|
par_total = sum(h["par_men"] for h in holes)
|
||||||
|
course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"]
|
||||||
|
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
course_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO personal_course (name, created_by_user_id, external_golfapi_course_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id::text AS id, name
|
||||||
|
""",
|
||||||
|
course_name,
|
||||||
|
user.user_id,
|
||||||
|
body.golfapi_course_id,
|
||||||
|
)
|
||||||
|
for h in holes:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
||||||
|
course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
|
||||||
|
)
|
||||||
|
for t in tees:
|
||||||
|
tee_row = await conn.fetchrow(
|
||||||
|
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
|
||||||
|
course_row["id"], t["name"],
|
||||||
|
)
|
||||||
|
ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
|
||||||
|
if t["course_rating_women"] is not None and t["slope_women"] is not None:
|
||||||
|
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
|
||||||
|
for gender, course_rating, slope_rating, par in ratings:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO personal_course_tee_rating (personal_course_tee_id, gender, course_rating, slope_rating, par)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
""",
|
||||||
|
tee_row["id"], gender, course_rating, slope_rating, par,
|
||||||
|
)
|
||||||
|
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
|
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
|
||||||
async def update_personal_course(
|
async def update_personal_course(
|
||||||
personal_course_id: str,
|
personal_course_id: str,
|
||||||
|
|
@ -2953,6 +3103,56 @@ async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_c
|
||||||
return resolved.tee_options()
|
return resolved.tee_options()
|
||||||
|
|
||||||
|
|
||||||
|
class TargetPoint(BaseModel):
|
||||||
|
poi_type: str
|
||||||
|
location: str | None
|
||||||
|
side_fairway: str | None
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rounds/{round_id}/holes/{hole_number}/target-points", response_model=list[TargetPoint])
|
||||||
|
async def get_round_hole_target_points(
|
||||||
|
round_id: str,
|
||||||
|
hole_number: int = Path(ge=1, le=18),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[TargetPoint]:
|
||||||
|
"""Rangefinder-data for hullet (ADR-064) -- grønn front/midt/bak,
|
||||||
|
hindringer, tee-punkter fra GolfAPI. KUN for baner importert derfra
|
||||||
|
(`personal_course.external_golfapi_course_id` satt) -- returnerer tom
|
||||||
|
liste ellers (TeeOff-baner/manuelt opprettede baner har ingen
|
||||||
|
koordinatdata), rangefinder-UI-et skjules da bare, resten av
|
||||||
|
scorekortet upåvirket. Returnerer RÅ punkter, ALDRI en ferdigregnet
|
||||||
|
avstand -- den ville vært utdatert idet spilleren beveger seg. Klienten
|
||||||
|
Haversine-regner selv (frontend/lib/geo.ts, samme funksjon som
|
||||||
|
ADR-048) mot spillerens EGEN, ferske GPS-posisjon."""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||||
|
round_row = await conn.fetchrow(
|
||||||
|
"SELECT course_source, personal_course_id FROM round WHERE id = $1", round_id
|
||||||
|
)
|
||||||
|
if round_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||||
|
if round_row["course_source"] != "custom":
|
||||||
|
return []
|
||||||
|
golfapi_course_id = await conn.fetchval(
|
||||||
|
"SELECT external_golfapi_course_id FROM personal_course WHERE id = $1",
|
||||||
|
round_row["personal_course_id"],
|
||||||
|
)
|
||||||
|
if golfapi_course_id is None:
|
||||||
|
return []
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT poi_type, location, side_fairway, latitude, longitude
|
||||||
|
FROM golfapi_course_coordinate
|
||||||
|
WHERE golfapi_course_id = $1 AND hole_number = $2
|
||||||
|
""",
|
||||||
|
golfapi_course_id,
|
||||||
|
hole_number,
|
||||||
|
)
|
||||||
|
return [TargetPoint(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
class ParticipantUpdate(BaseModel):
|
class ParticipantUpdate(BaseModel):
|
||||||
# Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
|
# Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
|
||||||
# utelatt felt endres ikke. `handicap_index` kan sendes eksplisitt som
|
# utelatt felt endres ikke. `handicap_index` kan sendes eksplisitt som
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ services:
|
||||||
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
||||||
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
||||||
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
||||||
|
TEECUP_GOLFAPI_TOKEN: ${TEECUP_GOLFAPI_TOKEN}
|
||||||
networks:
|
networks:
|
||||||
- teeoff_default
|
- teeoff_default
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
131
frontend/components/hole-target-distance.tsx
Normal file
131
frontend/components/hole-target-distance.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { TargetDistance } from "@/components/target-distance"
|
||||||
|
import { haversineMeters, type LatLng } from "@/lib/geo"
|
||||||
|
|
||||||
|
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (grønn
|
||||||
|
// front/midt/bak, hindringer) og kombinerer dem klient-side med spillerens
|
||||||
|
// LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx
|
||||||
|
// allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) --
|
||||||
|
// `enableHighAccuracy`, `maximumAge: 1000`, grasiøs degradering ved avslått
|
||||||
|
// tillatelse, `clearWatch` ved avmontering. `TargetDistance` selv forblir
|
||||||
|
// "dum" (V0-eksportert, ren visning) -- all logikk bor her.
|
||||||
|
|
||||||
|
type ApiTargetPoint = {
|
||||||
|
poi_type: string
|
||||||
|
location: "front" | "middle" | "back" | null
|
||||||
|
side_fairway: "left" | "center" | "right" | null
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const _HAZARD_LABELS: Record<string, string> = {
|
||||||
|
green_bunker: "Bunker (grønn)",
|
||||||
|
fairway_bunker: "Bunker (fairway)",
|
||||||
|
water: "Vannhinder",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HoleTargetDistance({
|
||||||
|
roundId,
|
||||||
|
holeNumber,
|
||||||
|
size = "compact",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
holeNumber: number
|
||||||
|
size?: "compact" | "full"
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
|
||||||
|
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
|
||||||
|
const [isLive, setIsLive] = useState(false)
|
||||||
|
const watchIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setPoints(null)
|
||||||
|
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiTargetPoint[]) => {
|
||||||
|
if (!cancelled) setPoints(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setPoints([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, holeNumber])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
|
||||||
|
// dette hullet -- unngår unødvendig batteribruk/tillatelses-prompt.
|
||||||
|
if (!points || points.length === 0) return
|
||||||
|
if (typeof navigator === "undefined" || !navigator.geolocation) return
|
||||||
|
|
||||||
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||||||
|
setIsLive(true)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
|
||||||
|
setIsLive(false)
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 1000 },
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
|
||||||
|
navigator.geolocation.clearWatch(watchIdRef.current)
|
||||||
|
}
|
||||||
|
watchIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [points])
|
||||||
|
|
||||||
|
// Fortsatt laster, ELLER banen har rett og slett ingen koordinatdata
|
||||||
|
// (det store flertallet av runder, TeeOff-baner har ingen GolfAPI-kobling)
|
||||||
|
// -- ingen synlig rad i det hele tatt, ikke en "ingen data"-lapp på HVER
|
||||||
|
// eneste runde. Lappen (TargetDistance sin egen null-tilstand) er forbeholdt
|
||||||
|
// "banen HAR data, men venter på GPS-posisjon ennå" -- da vet vi det er
|
||||||
|
// verdt å vise noe.
|
||||||
|
if (points === null || points.length === 0) return null
|
||||||
|
if (!livePosition) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 justify-center border-b border-border px-4 py-2">
|
||||||
|
<TargetDistance size={size} distances={null} isLive={false} className={className} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
|
||||||
|
const front = greenPoints.find((p) => p.location === "front")
|
||||||
|
const middle = greenPoints.find((p) => p.location === "middle")
|
||||||
|
const back = greenPoints.find((p) => p.location === "back")
|
||||||
|
if (!front || !middle || !back) {
|
||||||
|
return <TargetDistance size={size} distances={null} isLive={false} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const distances = {
|
||||||
|
front: Math.round(haversineMeters(livePosition, { lat: front.latitude, lng: front.longitude })),
|
||||||
|
middle: Math.round(haversineMeters(livePosition, { lat: middle.latitude, lng: middle.longitude })),
|
||||||
|
back: Math.round(haversineMeters(livePosition, { lat: back.latitude, lng: back.longitude })),
|
||||||
|
}
|
||||||
|
|
||||||
|
const hazards = points.filter((p) => p.poi_type in _HAZARD_LABELS)
|
||||||
|
let nearestHazard: { label: string; distanceMeters: number } | null = null
|
||||||
|
for (const h of hazards) {
|
||||||
|
const d = haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })
|
||||||
|
if (nearestHazard === null || d < nearestHazard.distanceMeters) {
|
||||||
|
const base = _HAZARD_LABELS[h.poi_type]
|
||||||
|
const label = h.location === "front" || h.location === "back" ? `${base} (${h.location === "front" ? "front" : "bak"})` : base
|
||||||
|
nearestHazard = { label, distanceMeters: Math.round(d) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 justify-center border-b border-border px-4 py-2">
|
||||||
|
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} isLive={isLive} className={className} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { ClubPicker } from "@/components/teecup/club-picker"
|
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||||
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
||||||
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { bearingDegrees } from "@/lib/geo"
|
import { bearingDegrees } from "@/lib/geo"
|
||||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
|
|
@ -1903,6 +1904,11 @@ function ScoringWizard({
|
||||||
<div className="size-11 shrink-0" aria-hidden="true" />
|
<div className="size-11 shrink-0" aria-hidden="true" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Avstand til grønn (ADR-064, GolfAPI) -- kun for baner med
|
||||||
|
koordinatdata; ingen synlig rad i det hele tatt for andre baner
|
||||||
|
(HoleTargetDistance returnerer null da, eier egen rad-wrapper). */}
|
||||||
|
<HoleTargetDistance roundId={roundId} holeNumber={hole.holeNumber} size="compact" />
|
||||||
|
|
||||||
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
|
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
|
||||||
konkurrentappen: man mister aldri oversikten over hvem som allerede
|
konkurrentappen: man mister aldri oversikten over hvem som allerede
|
||||||
er ferdig på hullet. Ren informasjon, ikke en snarvei -- bytte
|
er ferdig på hullet. Ren informasjon, ikke en snarvei -- bytte
|
||||||
|
|
@ -4440,6 +4446,10 @@ type ApiFacility = { slug: string; name: string; city: string | null; county: st
|
||||||
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
|
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
|
||||||
type ApiPersonalCourseSummary = { id: string; name: string }
|
type ApiPersonalCourseSummary = { id: string; name: string }
|
||||||
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
|
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
|
||||||
|
// GolfAPI.io (ADR-064) -- for baner utenfor teeoffs dekning (Tjøme Golfklubb var
|
||||||
|
// den konkrete anledningen, bekreftet IKKE i teeoff 2026-08-12).
|
||||||
|
type ApiInternationalCourseOption = { course_id: string; course_name: string; num_holes: number; has_gps: boolean }
|
||||||
|
type ApiInternationalClub = { club_id: string; club_name: string; city: string | null; country: string | null; courses: ApiInternationalCourseOption[] }
|
||||||
|
|
||||||
// Konverterer til/fra <input type="datetime-local">-verdiformatet
|
// Konverterer til/fra <input type="datetime-local">-verdiformatet
|
||||||
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
|
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
|
||||||
|
|
@ -5074,8 +5084,52 @@ function ChangeCourseForm({
|
||||||
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
|
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
|
||||||
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
|
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
|
||||||
|
|
||||||
|
// GolfAPI-søk (ADR-064) -- for baner utenfor teeoffs dekning. Importerte
|
||||||
|
// baner havner i den samme personal_course-katalogen som "egen bane"
|
||||||
|
// over, så selve valg-/utslag-/submit-flyten under trenger INGEN endring
|
||||||
|
// -- kun et alternativt inngangspunkt til `selectedCustomCourse`.
|
||||||
|
const [internationalOpen, setInternationalOpen] = useState(false)
|
||||||
|
const [internationalQuery, setInternationalQuery] = useState("")
|
||||||
|
const [internationalClubs, setInternationalClubs] = useState<ApiInternationalClub[] | null>(null)
|
||||||
|
const [selectedInternationalClub, setSelectedInternationalClub] = useState<ApiInternationalClub | null>(null)
|
||||||
|
const [internationalImporting, setInternationalImporting] = useState(false)
|
||||||
|
|
||||||
const [teeName, setTeeName] = useState("")
|
const [teeName, setTeeName] = useState("")
|
||||||
|
|
||||||
|
async function searchInternational() {
|
||||||
|
if (!internationalQuery.trim()) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/personal-courses/international-search?q=${encodeURIComponent(internationalQuery.trim())}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setInternationalClubs(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å søke hos GolfAPI akkurat nå.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importInternational(course: ApiInternationalCourseOption) {
|
||||||
|
setInternationalImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const importRes = await fetch(`/personal-courses/international-import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ golfapi_course_id: course.course_id }),
|
||||||
|
})
|
||||||
|
if (!importRes.ok) throw new Error()
|
||||||
|
const imported: { id: string } = await importRes.json()
|
||||||
|
const detailRes = await fetch(`/personal-courses/${imported.id}`, { credentials: "include" })
|
||||||
|
if (!detailRes.ok) throw new Error()
|
||||||
|
setSelectedCustomCourse(await detailRes.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å importere banen fra GolfAPI. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setInternationalImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (source !== "custom") return
|
if (source !== "custom") return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -5247,7 +5301,7 @@ function ChangeCourseForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{source === "custom" && !selectedCustomCourse && (
|
{source === "custom" && !selectedCustomCourse && !internationalOpen && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -5272,6 +5326,91 @@ function ChangeCourseForm({
|
||||||
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInternationalOpen(true)}
|
||||||
|
className="flex min-h-11 items-center self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Fant ikke banen? Søk internasjonalt (GolfAPI)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source === "custom" && !selectedCustomCourse && internationalOpen && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setInternationalOpen(false)
|
||||||
|
setSelectedInternationalClub(null)
|
||||||
|
setInternationalClubs(null)
|
||||||
|
}}
|
||||||
|
className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground"
|
||||||
|
>
|
||||||
|
← Egen bane-søk
|
||||||
|
</button>
|
||||||
|
{!selectedInternationalClub ? (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={internationalQuery}
|
||||||
|
onChange={(e) => setInternationalQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
searchInternational()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Søk klubbnavn…"
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={searchInternational} className="h-11 shrink-0 rounded-xl text-sm font-bold">
|
||||||
|
Søk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{internationalClubs && (
|
||||||
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{internationalClubs.map((c) => (
|
||||||
|
<li key={c.club_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedInternationalClub(c)}
|
||||||
|
className="flex min-h-11 w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{c.club_name}</span>
|
||||||
|
{(c.city || c.country) && (
|
||||||
|
<span className="text-xs text-muted-foreground">{[c.city, c.country].filter(Boolean).join(", ")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{internationalClubs.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||||||
|
{selectedInternationalClub.courses.map((c) => (
|
||||||
|
<li key={c.course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={internationalImporting}
|
||||||
|
onClick={() => importInternational(c)}
|
||||||
|
className="flex min-h-11 w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
{c.course_name}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{internationalImporting && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,7 @@ function BarTrendChart({
|
||||||
formatSlope = formatValue,
|
formatSlope = formatValue,
|
||||||
goodDirection,
|
goodDirection,
|
||||||
slopeSuffix,
|
slopeSuffix,
|
||||||
|
yAxisLabel,
|
||||||
}: {
|
}: {
|
||||||
points: TrendPoint[]
|
points: TrendPoint[]
|
||||||
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
|
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
|
||||||
|
|
@ -500,6 +501,10 @@ function BarTrendChart({
|
||||||
goodDirection: "up" | "down"
|
goodDirection: "up" | "down"
|
||||||
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
|
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
|
||||||
slopeSuffix: string
|
slopeSuffix: string
|
||||||
|
// Enhet for y-aksen (f.eks. "%", "Slag til par", "Antall") -- ekte HTML-
|
||||||
|
// tekst over grafen, ikke roterte SVG-glyffer, så den skalerer med
|
||||||
|
// brukerens skriftstørrelse (tilgjengelighetsregelen i CLAUDE.md).
|
||||||
|
yAxisLabel: string
|
||||||
}) {
|
}) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
|
|
||||||
|
|
@ -527,6 +532,7 @@ function BarTrendChart({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{yAxisLabel}</span>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
|
|
@ -542,6 +548,7 @@ function BarTrendChart({
|
||||||
const cx = barCenterX(i)
|
const cx = barCenterX(i)
|
||||||
const overPar = mode === "diverging" && point.value > 0
|
const overPar = mode === "diverging" && point.value > 0
|
||||||
const isLast = i === points.length - 1
|
const isLast = i === points.length - 1
|
||||||
|
const isFirst = i === 0
|
||||||
return (
|
return (
|
||||||
<g key={point.played_at + i}>
|
<g key={point.played_at + i}>
|
||||||
<path
|
<path
|
||||||
|
|
@ -552,14 +559,20 @@ function BarTrendChart({
|
||||||
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
||||||
</title>
|
</title>
|
||||||
</path>
|
</path>
|
||||||
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
|
{/* Direkte etikett på FØRSTE og SISTE stolpe -- verdi i begge
|
||||||
"merk endepunktet, ikke hvert punkt". */}
|
ender så grafen kan leses uten en egen y-akse-skala (samme
|
||||||
{isLast && (
|
prinsipp som CumulativeTrendLine allerede bruker). Første er
|
||||||
|
dempet (utgangspunkt), siste er uthevet (nåværende nivå). */}
|
||||||
|
{(isFirst || isLast) && !(isFirst && isLast) && (
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-foreground text-[11px] font-bold tabular-nums"
|
className={
|
||||||
|
isLast
|
||||||
|
? "fill-foreground text-[11px] font-bold tabular-nums"
|
||||||
|
: "fill-muted-foreground text-[10px] font-semibold tabular-nums"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{formatValue(point.value)}
|
{formatValue(point.value)}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -595,7 +608,7 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
return (
|
return (
|
||||||
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
|
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
|
||||||
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Slag til par" />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -613,7 +626,15 @@ function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoi
|
||||||
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
|
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
|
||||||
// til at et per-runde-tall er meningsfullt, så her vises kun raten
|
// til at et per-runde-tall er meningsfullt, så her vises kun raten
|
||||||
// "til og med denne runden" som en enkel linje, ingen stolper.
|
// "til og med denne runden" som en enkel linje, ingen stolper.
|
||||||
function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: string }) {
|
function CumulativeTrendLine({
|
||||||
|
points,
|
||||||
|
label,
|
||||||
|
yAxisLabel,
|
||||||
|
}: {
|
||||||
|
points: TrendPoint[]
|
||||||
|
label: string
|
||||||
|
yAxisLabel: string
|
||||||
|
}) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
const values = points.map((p) => p.value)
|
const values = points.map((p) => p.value)
|
||||||
const maxVal = Math.max(...values, 1)
|
const maxVal = Math.max(...values, 1)
|
||||||
|
|
@ -626,6 +647,7 @@ function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: s
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{yAxisLabel}</span>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
|
|
@ -865,6 +887,7 @@ export function RoundsStatsSummary() {
|
||||||
formatSlope={roundedNoUnit}
|
formatSlope={roundedNoUnit}
|
||||||
goodDirection="up"
|
goodDirection="up"
|
||||||
slopeSuffix="pp per runde"
|
slopeSuffix="pp per runde"
|
||||||
|
yAxisLabel="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -879,7 +902,7 @@ export function RoundsStatsSummary() {
|
||||||
{girTrend.length >= 2 && (
|
{girTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
|
||||||
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
|
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -916,13 +939,13 @@ export function RoundsStatsSummary() {
|
||||||
{puttsTrend.length >= 2 && (
|
{puttsTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
|
||||||
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" />
|
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" yAxisLabel="Putt" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onePuttTrend.length >= 2 && (
|
{onePuttTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
|
||||||
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
|
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -950,19 +973,19 @@ export function RoundsStatsSummary() {
|
||||||
{chipTrend.length >= 2 && (
|
{chipTrend.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
|
||||||
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bunkerTrend.length >= 2 && (
|
{bunkerTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
|
||||||
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{penaltyTrend.length >= 2 && (
|
{penaltyTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
|
||||||
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -980,7 +1003,7 @@ export function RoundsStatsSummary() {
|
||||||
|
|
||||||
{anywayTrend.length >= 2 && (
|
{anywayTrend.length >= 2 && (
|
||||||
<Card title="Anywayslag over tid">
|
<Card title="Anywayslag over tid">
|
||||||
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1016,13 +1039,13 @@ export function RoundsStatsSummary() {
|
||||||
{scramblingCumulative.length >= 2 && (
|
{scramblingCumulative.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
|
||||||
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" />
|
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sandSaveCumulative.length >= 2 && (
|
{sandSaveCumulative.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
|
||||||
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" />
|
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
207
frontend/components/target-distance.tsx
Normal file
207
frontend/components/target-distance.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { TriangleAlert, Satellite } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TargetDistance — ren visningskomponent for gjenstående avstand til grønn.
|
||||||
|
*
|
||||||
|
* "Dum" med vilje: den mottar ferdigregnede tall som props. Ingen GPS,
|
||||||
|
* ingen fetch, ingen kart. Følger TeeCups clubhouse-palett (grønn/oransje
|
||||||
|
* aksenter, samme korthjørner/skygge-stil som resten av scorekortet).
|
||||||
|
*/
|
||||||
|
export type TargetDistanceProps = {
|
||||||
|
distances: {
|
||||||
|
front: number // meter
|
||||||
|
middle: number
|
||||||
|
back: number
|
||||||
|
} | null // null = ingen koordinatdata for dette hullet (normal, forventet tilstand)
|
||||||
|
nearestHazard?: {
|
||||||
|
label: string // f.eks. "Bunker (front)"
|
||||||
|
distanceMeters: number
|
||||||
|
} | null
|
||||||
|
isLive: boolean // true = tallene oppdateres kontinuerlig fra GPS akkurat nå
|
||||||
|
/** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */
|
||||||
|
size?: "compact" | "full"
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liten "oppdateres live"-indikator: pulserende prikk + tekst (aldri kun farge). */
|
||||||
|
function LiveStatus({ isLive, compact }: { isLive: boolean; compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 font-medium text-clubhouse-muted",
|
||||||
|
compact ? "text-[11px]" : "text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLive ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true" className="relative flex size-2">
|
||||||
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-tee-strong opacity-70" />
|
||||||
|
<span className="relative inline-flex size-2 rounded-full bg-tee-strong" />
|
||||||
|
</span>
|
||||||
|
{compact ? "Live" : "Oppdateres live"}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Satellite aria-hidden="true" className="size-3.5" />
|
||||||
|
{compact ? "Sist målt" : "Ikke live — sist målte posisjon"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hindring-linje: kun synlig når det finnes en relevant hindring. Oransje varsel, ikke rødt. */
|
||||||
|
function HazardLine({
|
||||||
|
hazard,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
hazard: { label: string; distanceMeters: number }
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 font-medium text-cup-strong",
|
||||||
|
compact ? "text-[11px]" : "text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TriangleAlert aria-hidden="true" className={compact ? "size-3.5 shrink-0" : "size-4 shrink-0"} />
|
||||||
|
<span className="text-pretty">
|
||||||
|
{hazard.label}: <span className="tabular-nums font-bold">{hazard.distanceMeters}</span> m
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En avstandskolonne (Front/Midt/Bak). Alltid tekst-label + tall + enhet. */
|
||||||
|
function DistanceColumn({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
emphasized,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
emphasized?: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-end gap-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold uppercase tracking-wide text-clubhouse-muted",
|
||||||
|
compact ? "text-[10px]" : "text-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-baseline gap-0.5 leading-none tabular-nums",
|
||||||
|
emphasized ? "text-tee-strong" : "text-clubhouse-ink",
|
||||||
|
// Stort nok til å leses på avstand, i sollys — som en klokke-visning.
|
||||||
|
compact
|
||||||
|
? emphasized
|
||||||
|
? "text-3xl font-black"
|
||||||
|
: "text-xl font-bold"
|
||||||
|
: emphasized
|
||||||
|
? "text-6xl font-black"
|
||||||
|
: "text-4xl font-bold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-semibold text-clubhouse-muted",
|
||||||
|
compact ? "text-[10px]" : "text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
m
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nøytral, lavmælt placeholder når banen ikke har koordinatdata. Aldri en feilmelding. */
|
||||||
|
function NoData({ compact, className }: { compact?: boolean; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="note"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-field text-clubhouse-muted",
|
||||||
|
compact ? "px-2.5 py-1.5 text-[11px]" : "px-4 py-3 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Satellite aria-hidden="true" className={compact ? "size-3.5" : "size-4"} />
|
||||||
|
<span>Avstand ikke tilgjengelig</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TargetDistance({
|
||||||
|
distances,
|
||||||
|
nearestHazard,
|
||||||
|
isLive,
|
||||||
|
size = "full",
|
||||||
|
className,
|
||||||
|
}: TargetDistanceProps) {
|
||||||
|
const compact = size === "compact"
|
||||||
|
|
||||||
|
// Punkt 4: ingen data er en normal, forventet tilstand — vis en nøytral placeholder.
|
||||||
|
if (!distances) {
|
||||||
|
return <NoData compact={compact} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-col gap-1 rounded-xl border border-clubhouse-border bg-clubhouse-card px-3 py-2 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<DistanceColumn label="Front" value={distances.front} compact />
|
||||||
|
<DistanceColumn label="Midt" value={distances.middle} emphasized compact />
|
||||||
|
<DistanceColumn label="Bak" value={distances.back} compact />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<LiveStatus isLive={isLive} compact />
|
||||||
|
{nearestHazard ? <HazardLine hazard={nearestHazard} compact /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Avstand til grønn"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-clubhouse-muted">Til grønn</h3>
|
||||||
|
<LiveStatus isLive={isLive} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between gap-2">
|
||||||
|
<DistanceColumn label="Front" value={distances.front} />
|
||||||
|
<DistanceColumn label="Midt" value={distances.middle} emphasized />
|
||||||
|
<DistanceColumn label="Bak" value={distances.back} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nearestHazard ? (
|
||||||
|
<div className="border-t border-clubhouse-border pt-3">
|
||||||
|
<HazardLine hazard={nearestHazard} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -999,6 +999,7 @@ function EditCourseField({
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
||||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -1073,7 +1074,7 @@ function EditCourseField({
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{!officialSearchOpen && !templatePickerOpen ? (
|
{!officialSearchOpen && !internationalSearchOpen && !templatePickerOpen ? (
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1082,6 +1083,13 @@ function EditCourseField({
|
||||||
>
|
>
|
||||||
Hent bane fra teeoff i stedet
|
Hent bane fra teeoff i stedet
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInternationalSearchOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Hent bane fra GolfAPI i stedet
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTemplatePickerOpen(true)}
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
|
@ -1100,6 +1108,16 @@ function EditCourseField({
|
||||||
setOfficialSearchOpen(false)
|
setOfficialSearchOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : internationalSearchOpen ? (
|
||||||
|
<InternationalCourseSearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onClose={() => setInternationalSearchOpen(false)}
|
||||||
|
onImported={(newCourse) => {
|
||||||
|
onCourseImported(newCourse)
|
||||||
|
select(newCourse)
|
||||||
|
setInternationalSearchOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CourseTemplatePicker
|
<CourseTemplatePicker
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
|
|
@ -1214,6 +1232,7 @@ function CreateSessionCard({
|
||||||
const [courseQuery, setCourseQuery] = useState("")
|
const [courseQuery, setCourseQuery] = useState("")
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
||||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||||
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
|
|
@ -1439,6 +1458,13 @@ function CreateSessionCard({
|
||||||
>
|
>
|
||||||
Hent bane fra teeoff i stedet
|
Hent bane fra teeoff i stedet
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setInternationalSearchOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Hent bane fra GolfAPI i stedet
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTemplatePickerOpen(true)}
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
|
@ -1458,6 +1484,17 @@ function CreateSessionCard({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{internationalSearchOpen && (
|
||||||
|
<InternationalCourseSearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onClose={() => setInternationalSearchOpen(false)}
|
||||||
|
onImported={(course) => {
|
||||||
|
onCourseImported(course)
|
||||||
|
setSelectedCourseId(course.id)
|
||||||
|
setInternationalSearchOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{templatePickerOpen && (
|
{templatePickerOpen && (
|
||||||
<CourseTemplatePicker
|
<CourseTemplatePicker
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
|
|
@ -1837,6 +1874,202 @@ function OfficialCourseSearch({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Internasjonal bane fra GolfAPI.io (ADR-064) ----------------------------
|
||||||
|
//
|
||||||
|
// For baner utenfor teeoffs dekning (f.eks. Tjøme Golfklubb, bekreftet
|
||||||
|
// IKKE i teeoff 2026-08-12). Speiler OfficialCourseSearch over i STRUKTUR,
|
||||||
|
// men GolfAPIs /clubs-søk returnerer klubbens baner INLINE i samme svar
|
||||||
|
// (ingen eget facility-detalj-kall trengs, ulikt teeoff).
|
||||||
|
|
||||||
|
type ApiInternationalCourseOption = {
|
||||||
|
course_id: string
|
||||||
|
course_name: string
|
||||||
|
num_holes: number
|
||||||
|
has_gps: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiInternationalClub = {
|
||||||
|
club_id: string
|
||||||
|
club_name: string
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
courses: ApiInternationalCourseOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function InternationalCourseSearch({
|
||||||
|
organizationId,
|
||||||
|
onClose,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onClose: () => void
|
||||||
|
onImported: (course: ApiCourse) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [clubs, setClubs] = useState<ApiInternationalClub[] | null>(null)
|
||||||
|
const [selectedClub, setSelectedClub] = useState<ApiInternationalClub | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
if (!query.trim()) return
|
||||||
|
setSearching(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/courses/international-search?q=${encodeURIComponent(query.trim())}`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
||||||
|
setClubs(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å søke hos GolfAPI akkurat nå.")
|
||||||
|
setClubs([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importCourse(course: ApiInternationalCourseOption) {
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/international-import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ golfapi_course_id: course.course_id }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
const code = body?.detail?.code
|
||||||
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
||||||
|
setError("Denne banen mangler nok data hos GolfAPI til å importeres ennå.")
|
||||||
|
} else if (code === "NOT_CONFIGURED") {
|
||||||
|
setError("GolfAPI er ikke konfigurert ennå.")
|
||||||
|
} else {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onImported(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Hent bane fra GolfAPI</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="size-8 rounded-lg text-muted-foreground"
|
||||||
|
aria-label="Lukk"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{!selectedClub ? (
|
||||||
|
<>
|
||||||
|
{/* Bevisst IKKE et eget <form> her -- samme nesting-fellgruve som
|
||||||
|
OfficialCourseSearch over (nestede <form>-er er ugyldig HTML). */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk klubbnavn…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
runSearch()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runSearch()}
|
||||||
|
disabled={searching}
|
||||||
|
className="h-11 shrink-0 rounded-xl font-bold"
|
||||||
|
>
|
||||||
|
{searching ? "Søker…" : "Søk"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{clubs && (
|
||||||
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{clubs.map((c) => (
|
||||||
|
<li key={c.club_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedClub(c)}
|
||||||
|
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{c.club_name}</span>
|
||||||
|
{(c.city || c.country) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[c.city, c.country].filter(Boolean).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{clubs.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedClub(null)}
|
||||||
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← {selectedClub.club_name}
|
||||||
|
</button>
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||||||
|
{selectedClub.courses.map((c) => (
|
||||||
|
<li key={c.course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={() => importCourse(c)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
{c.course_name}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{selectedClub.courses.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen baner registrert hos denne klubben hos GolfAPI ennå.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Small UI helpers ------------------------------------------------------
|
// --- Small UI helpers ------------------------------------------------------
|
||||||
|
|
||||||
function SegmentButton({
|
function SegmentButton({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue