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
|
||||
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:
|
||||
|
||||
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 &&
|
||||
up -d`. Ren omstart, ingen feil i containerloggene, ingen konsollfeil
|
||||
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.
|
||||
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()
|
||||
|
|
|
|||
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 .. import teeoff_client
|
||||
from .. import golfapi_cache, golfapi_client, teeoff_client
|
||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||
from ..config import settings
|
||||
from ..db import org_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
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))
|
||||
|
||||
|
||||
# --- 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 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 .. 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 ..config import settings
|
||||
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)
|
||||
|
||||
|
||||
# --- 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)
|
||||
async def update_personal_course(
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
# Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
|
||||
# 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_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
||||
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
||||
TEECUP_GOLFAPI_TOKEN: ${TEECUP_GOLFAPI_TOKEN}
|
||||
networks:
|
||||
- teeoff_default
|
||||
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 { ClubPicker } from "@/components/teecup/club-picker"
|
||||
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
||||
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||
import { bearingDegrees } from "@/lib/geo"
|
||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||
|
||||
|
|
@ -1903,6 +1904,11 @@ function ScoringWizard({
|
|||
<div className="size-11 shrink-0" aria-hidden="true" />
|
||||
</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
|
||||
konkurrentappen: man mister aldri oversikten over hvem som allerede
|
||||
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 ApiPersonalCourseSummary = { id: string; 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
|
||||
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
|
||||
|
|
@ -5074,8 +5084,52 @@ function ChangeCourseForm({
|
|||
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
|
||||
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("")
|
||||
|
||||
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(() => {
|
||||
if (source !== "custom") return
|
||||
let cancelled = false
|
||||
|
|
@ -5247,7 +5301,7 @@ function ChangeCourseForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{source === "custom" && !selectedCustomCourse && (
|
||||
{source === "custom" && !selectedCustomCourse && !internationalOpen && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
|
|
@ -5272,6 +5326,91 @@ function ChangeCourseForm({
|
|||
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -483,6 +483,7 @@ function BarTrendChart({
|
|||
formatSlope = formatValue,
|
||||
goodDirection,
|
||||
slopeSuffix,
|
||||
yAxisLabel,
|
||||
}: {
|
||||
points: TrendPoint[]
|
||||
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
|
||||
|
|
@ -500,6 +501,10 @@ function BarTrendChart({
|
|||
goodDirection: "up" | "down"
|
||||
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
|
||||
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
|
||||
|
||||
|
|
@ -527,6 +532,7 @@ function BarTrendChart({
|
|||
|
||||
return (
|
||||
<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">
|
||||
<svg
|
||||
role="img"
|
||||
|
|
@ -542,6 +548,7 @@ function BarTrendChart({
|
|||
const cx = barCenterX(i)
|
||||
const overPar = mode === "diverging" && point.value > 0
|
||||
const isLast = i === points.length - 1
|
||||
const isFirst = i === 0
|
||||
return (
|
||||
<g key={point.played_at + i}>
|
||||
<path
|
||||
|
|
@ -552,14 +559,20 @@ function BarTrendChart({
|
|||
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
||||
</title>
|
||||
</path>
|
||||
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
|
||||
"merk endepunktet, ikke hvert punkt". */}
|
||||
{isLast && (
|
||||
{/* Direkte etikett på FØRSTE og SISTE stolpe -- verdi i begge
|
||||
ender så grafen kan leses uten en egen y-akse-skala (samme
|
||||
prinsipp som CumulativeTrendLine allerede bruker). Første er
|
||||
dempet (utgangspunkt), siste er uthevet (nåværende nivå). */}
|
||||
{(isFirst || isLast) && !(isFirst && isLast) && (
|
||||
<text
|
||||
x={cx}
|
||||
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
||||
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)}
|
||||
</text>
|
||||
|
|
@ -595,7 +608,7 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
|
|||
if (points.length < 2) return null
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -613,7 +626,15 @@ function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoi
|
|||
// 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 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
|
||||
const values = points.map((p) => p.value)
|
||||
const maxVal = Math.max(...values, 1)
|
||||
|
|
@ -626,6 +647,7 @@ function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: s
|
|||
|
||||
return (
|
||||
<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">
|
||||
<svg
|
||||
role="img"
|
||||
|
|
@ -865,6 +887,7 @@ export function RoundsStatsSummary() {
|
|||
formatSlope={roundedNoUnit}
|
||||
goodDirection="up"
|
||||
slopeSuffix="pp per runde"
|
||||
yAxisLabel="%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -879,7 +902,7 @@ export function RoundsStatsSummary() {
|
|||
{girTrend.length >= 2 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -916,13 +939,13 @@ export function RoundsStatsSummary() {
|
|||
{puttsTrend.length >= 2 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<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>
|
||||
)}
|
||||
{onePuttTrend.length >= 2 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -950,19 +973,19 @@ export function RoundsStatsSummary() {
|
|||
{chipTrend.length >= 2 && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
{bunkerTrend.length >= 2 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<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>
|
||||
)}
|
||||
{penaltyTrend.length >= 2 && (
|
||||
<div className="border-t border-border pt-3">
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
|
@ -980,7 +1003,7 @@ export function RoundsStatsSummary() {
|
|||
|
||||
{anywayTrend.length >= 2 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -1016,13 +1039,13 @@ export function RoundsStatsSummary() {
|
|||
{scramblingCumulative.length >= 2 && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
{sandSaveCumulative.length >= 2 && (
|
||||
<div>
|
||||
<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>
|
||||
|
|
|
|||
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 [query, setQuery] = useState("")
|
||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||
|
||||
if (!open) {
|
||||
|
|
@ -1073,7 +1074,7 @@ function EditCourseField({
|
|||
)}
|
||||
</ul>
|
||||
|
||||
{!officialSearchOpen && !templatePickerOpen ? (
|
||||
{!officialSearchOpen && !internationalSearchOpen && !templatePickerOpen ? (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1082,6 +1083,13 @@ function EditCourseField({
|
|||
>
|
||||
Hent bane fra teeoff i stedet
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setTemplatePickerOpen(true)}
|
||||
|
|
@ -1100,6 +1108,16 @@ function EditCourseField({
|
|||
setOfficialSearchOpen(false)
|
||||
}}
|
||||
/>
|
||||
) : internationalSearchOpen ? (
|
||||
<InternationalCourseSearch
|
||||
organizationId={organizationId}
|
||||
onClose={() => setInternationalSearchOpen(false)}
|
||||
onImported={(newCourse) => {
|
||||
onCourseImported(newCourse)
|
||||
select(newCourse)
|
||||
setInternationalSearchOpen(false)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CourseTemplatePicker
|
||||
organizationId={organizationId}
|
||||
|
|
@ -1214,6 +1232,7 @@ function CreateSessionCard({
|
|||
const [courseQuery, setCourseQuery] = useState("")
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
|
@ -1439,6 +1458,13 @@ function CreateSessionCard({
|
|||
>
|
||||
Hent bane fra teeoff i stedet
|
||||
</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
|
||||
type="button"
|
||||
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 && (
|
||||
<CourseTemplatePicker
|
||||
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 ------------------------------------------------------
|
||||
|
||||
function SegmentButton({
|
||||
|
|
|
|||
Loading…
Reference in a new issue