From a9abe8611656777d7c0b80c2f706bd92d1914031 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 12 Aug 2026 21:39:13 +0200 Subject: [PATCH] =?UTF-8?q?Avstand=20til=20m=C3=A5l=20via=20GolfAPI.io=20(?= =?UTF-8?q?Tj=C3=B8me)=20+=20statistikk-graf-finpuss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 065_golfapi_courses.sql | 145 ++++++++++++ ARCHITECTURE_DECISIONS.md | 152 ++++++++++++ CHANGELOG.md | 124 ++++++++++ app/config.py | 7 + app/golfapi_cache.py | 114 +++++++++ app/golfapi_client.py | 85 +++++++ app/routers/courses.py | 150 +++++++++++- app/routers/rounds.py | 204 +++++++++++++++- docker-compose.yml | 1 + frontend/components/hole-target-distance.tsx | 131 +++++++++++ frontend/components/round-detail.tsx | 141 ++++++++++- frontend/components/rounds-stats-summary.tsx | 53 +++-- frontend/components/target-distance.tsx | 207 ++++++++++++++++ frontend/components/tournament-program.tsx | 235 ++++++++++++++++++- 14 files changed, 1729 insertions(+), 20 deletions(-) create mode 100644 065_golfapi_courses.sql create mode 100644 app/golfapi_cache.py create mode 100644 app/golfapi_client.py create mode 100644 frontend/components/hole-target-distance.tsx create mode 100644 frontend/components/target-distance.tsx diff --git a/065_golfapi_courses.sql b/065_golfapi_courses.sql new file mode 100644 index 0000000..5fba178 --- /dev/null +++ b/065_golfapi_courses.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 42463be..294a856 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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å diff --git a/CHANGELOG.md b/CHANGELOG.md index f794a87..84365c5 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/app/config.py b/app/config.py index a685471..3fcd505 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/golfapi_cache.py b/app/golfapi_cache.py new file mode 100644 index 0000000..ec70750 --- /dev/null +++ b/app/golfapi_cache.py @@ -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"], + ) diff --git a/app/golfapi_client.py b/app/golfapi_client.py new file mode 100644 index 0000000..e065fbf --- /dev/null +++ b/app/golfapi_client.py @@ -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", []) diff --git a/app/routers/courses.py b/app/routers/courses.py index 695488a..7d7a4a0 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -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)) diff --git a/app/routers/rounds.py b/app/routers/rounds.py index a20c8fc..5acfeaf 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4bf6e00..144ce7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx new file mode 100644 index 0000000..0847578 --- /dev/null +++ b/frontend/components/hole-target-distance.tsx @@ -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 = { + 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(null) + const [livePosition, setLivePosition] = useState(null) + const [isLive, setIsLive] = useState(false) + const watchIdRef = useRef(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 ( +
+ +
+ ) + } + + 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 + } + + 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 ( +
+ +
+ ) +} diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index fc316cf..bcd0e98 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -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({ )} - {source === "custom" && !selectedCustomCourse && ( + {source === "custom" && !selectedCustomCourse && !internationalOpen && (
Ingen treff. )} + +
+ )} + + {source === "custom" && !selectedCustomCourse && internationalOpen && ( +
+ + {!selectedInternationalClub ? ( + <> +
+ 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" + /> + +
+ {internationalClubs && ( +
    + {internationalClubs.map((c) => ( +
  • + +
  • + ))} + {internationalClubs.length === 0 &&
  • Ingen treff.
  • } +
+ )} + + ) : ( +
    + {selectedInternationalClub.courses.map((c) => ( +
  • + +
  • + ))} +
+ )}
)} diff --git a/frontend/components/rounds-stats-summary.tsx b/frontend/components/rounds-stats-summary.tsx index 0ba527b..925452f 100644 --- a/frontend/components/rounds-stats-summary.tsx +++ b/frontend/components/rounds-stats-summary.tsx @@ -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 (
+ {yAxisLabel}
0 const isLast = i === points.length - 1 + const isFirst = i === 0 return ( - {/* 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) && ( {formatValue(point.value)} @@ -595,7 +608,7 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) { if (points.length < 2) return null return ( - + ) } @@ -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 (
+ {yAxisLabel}
)} @@ -879,7 +902,7 @@ export function RoundsStatsSummary() { {girTrend.length >= 2 && (

Greentreff over tid

- +
)} @@ -916,13 +939,13 @@ export function RoundsStatsSummary() { {puttsTrend.length >= 2 && (

Putt / 18 hull over tid

- +
)} {onePuttTrend.length >= 2 && (

Én-putt over tid

- +
)} @@ -950,19 +973,19 @@ export function RoundsStatsSummary() { {chipTrend.length >= 2 && (

Chip / runde

- +
)} {bunkerTrend.length >= 2 && (

Bunkerslag / runde

- +
)} {penaltyTrend.length >= 2 && (

Straffeslag / runde

- +
)} @@ -980,7 +1003,7 @@ export function RoundsStatsSummary() { {anywayTrend.length >= 2 && ( - + )} @@ -1016,13 +1039,13 @@ export function RoundsStatsSummary() { {scramblingCumulative.length >= 2 && (

Scrambling over tid

- +
)} {sandSaveCumulative.length >= 2 && (

Sand save over tid

- +
)}
diff --git a/frontend/components/target-distance.tsx b/frontend/components/target-distance.tsx new file mode 100644 index 0000000..aa0614c --- /dev/null +++ b/frontend/components/target-distance.tsx @@ -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 ( + + {isLive ? ( + <> +
+ ) +} + // --- Small UI helpers ------------------------------------------------------ function SegmentButton({