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:
Erol Haagenrud 2026-08-12 21:39:13 +02:00
parent 12504343ec
commit a9abe86116
14 changed files with 1729 additions and 20 deletions

145
065_golfapi_courses.sql Normal file
View 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;

View file

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

View file

@ -10632,3 +10632,127 @@ Neste steg:
migrasjon -- `docker compose build teecup_api teecup_frontend &&
up -d`. Ren omstart, ingen feil i containerloggene, ingen konsollfeil
`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.

View file

@ -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
View file

@ -0,0 +1,114 @@
"""
Cache-chokepoint mot GolfAPI.io (ADR-064). ALL import-kode (org-scopet
`course` OG global `personal_course`) 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
View 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 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, 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", [])

View file

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

View file

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

View file

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

View 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>
)
}

View file

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

View file

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

View 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>
)
}

View file

@ -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({