From 93af7c5beff6c5d867248e6af9daed358e53115b Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 17 Aug 2026 12:06:47 +0200 Subject: [PATCH] Rangefinder-koordinater for offisielle (TeeOff-koblede) baner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rangefinder-funksjonalitet fantes til nå kun for GolfAPI-importerte personlige baner. Offisielle teeoff-koblede baner (org-turneringer OG frittstående runder spilt på en ekte teeoff-bane) hadde aldri noen koordinatkilde. Ny migrasjon 079: delte domener (course_poi_type/ location/side) for å unngå duplisert CHECK-vedlikehold på tvers av tabeller, ny global tabell teeoff_course_coordinate. Ny delt modul app/target_points.py + resolve_match_course_key() i hole_history.py (tredje søster til de to eksisterende bane-bro- funksjonene) -- alle tre kallesteder (frittstående runder, individuell org-turnering, lag-matchplay) bruker nå samme CourseKey-abstraksjon. rounds.py sitt eksisterende endepunkt refaktorert til samme mønster -- sideeffekt: frittstående runder på en ekte teeoff-bane får nå også rangefinder (var alltid tom liste før). Ny skrive-vei i courses.py (PUT/GET .../coordinates) -- generell, gjenbrukbar mulighet for å legge inn koordinater på enhver offisiell bane, ikke en engangsfiks. HoleTargetDistance generalisert (roundId -> baseUrl) og wiret inn i begge org-turnering-scoringsflytene. 11 nye tester (106/106 backend totalt), full scratch-verifisert ende-til-ende i nettleser i alle tre flyter inkl. lys/mørk og selvskjuling, regresjonssjekk av eksisterende GolfAPI-rangefinder bekreftet uendret oppførsel. Co-Authored-By: Claude Sonnet 5 --- 079_teeoff_course_coordinates.sql | 76 +++++ app/hole_history.py | 26 ++ app/routers/courses.py | 98 ++++++ app/routers/individual_tournaments.py | 27 ++ app/routers/rounds.py | 52 +-- app/routers/scoring.py | 35 ++ app/target_points.py | 60 ++++ frontend/components/hole-target-distance.tsx | 15 +- .../individual-tournament-detail.tsx | 9 + frontend/components/round-detail.tsx | 13 +- frontend/components/session-scorecard.tsx | 25 ++ tests/test_target_points.py | 299 ++++++++++++++++++ 12 files changed, 685 insertions(+), 50 deletions(-) create mode 100644 079_teeoff_course_coordinates.sql create mode 100644 app/target_points.py create mode 100644 tests/test_target_points.py diff --git a/079_teeoff_course_coordinates.sql b/079_teeoff_course_coordinates.sql new file mode 100644 index 0000000..213d894 --- /dev/null +++ b/079_teeoff_course_coordinates.sql @@ -0,0 +1,76 @@ +-- ===================================================================== +-- TeeCup — migrasjon 079 +-- Rangefinder-koordinater for OFFISIELLE (TeeOff-koblede) baner +-- ===================================================================== +-- Frem til nå har rangefinder-koordinater (golfapi_course_coordinate, +-- 065/067/078) KUN eksistert for GolfAPI-importerte baner. Offisielle +-- baner (course.source = 'official', koblet til den ekte teeoff- +-- databasen via course.external_course_ref = "{facility_slug}: +-- {teeoff_course_id}") har ALDRI hatt noen koordinatkilde -- verken for +-- org-turneringer eller for frittstående runder spilt på en ekte +-- teeoff-bane (round.course_source = 'teeoff'). Konkret anledning: +-- bruker ba om at Tjøme Golfklubb sin OFFISIELLE bane (brukt av +-- organisasjonen "Tjøme Gents") skal få samme feltbefarte koordinater +-- som nettopp ble lagt inn på Erol sin PERSONLIGE GolfAPI-bane for +-- Tjøme -- og at dette skal være en generell, gjenbrukbar mulighet for +-- ALLE offisielle baner fremover, ikke en engangsfiks. +-- +-- DEL 1 -- delte domener for poi_type/location/side_fairway. To runder +-- på rad (067: rock/layup, 078: landmark) måtte hver for seg utvide +-- DEN SAMME CHECK-constraint-listen. Å innføre en ny tabell med en +-- IDENTISK, dupliserende CHECK-liste ville gjort dette vedlikeholds- +-- problemet permanent (to steder å huske å endre, garantert å skli). +-- Domenene gir én kilde til sannhet -- fremtidige utvidelser endrer +-- domenet ett sted, brukt av begge tabeller. +-- +-- DEL 2 -- teeoff_course_coordinate, samme kolonneform som +-- golfapi_course_coordinate, men nøkkel er course.external_course_ref +-- (tekst, IKKE en FK -- speiler at external_course_ref selv er en myk +-- referanse, ingen global "teeoff_course"-tabell finnes å FK-e mot). +-- GLOBAL (ikke org-scopet, ingen RLS) -- samme begrunnelse som +-- golfapi_course_coordinate: flere organisasjoner kan importere samme +-- fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles, +-- ikke registreres på nytt per organisasjon. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --- Del 1: delte domener ------------------------------------------- + +CREATE DOMAIN course_poi_type AS text CHECK (VALUE IN ( + 'green', 'green_bunker', 'fairway_bunker', 'water', + 'trees', 'marker_100', 'marker_150', 'marker_200', + 'dogleg', 'road', 'tee_front', 'tee_back', + 'rock', 'layup', 'landmark' +)); + +CREATE DOMAIN course_poi_location AS text CHECK (VALUE IN ('front', 'middle', 'back')); + +CREATE DOMAIN course_poi_side AS text CHECK (VALUE IN ('left', 'center', 'right')); + +ALTER TABLE golfapi_course_coordinate + DROP CONSTRAINT golfapi_course_coordinate_poi_type_check, + DROP CONSTRAINT golfapi_course_coordinate_location_check, + DROP CONSTRAINT golfapi_course_coordinate_side_fairway_check; + +ALTER TABLE golfapi_course_coordinate + ALTER COLUMN poi_type TYPE course_poi_type USING poi_type::text::course_poi_type, + ALTER COLUMN location TYPE course_poi_location USING location::text::course_poi_location, + ALTER COLUMN side_fairway TYPE course_poi_side USING side_fairway::text::course_poi_side; + +-- --- Del 2: teeoff_course_coordinate --------------------------------- + +CREATE TABLE teeoff_course_coordinate ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + external_course_ref text NOT NULL, + hole_number smallint NOT NULL CHECK (hole_number >= 1 AND hole_number <= 18), + poi_type course_poi_type NOT NULL, + location course_poi_location, + side_fairway course_poi_side, + latitude double precision NOT NULL CHECK (latitude >= -90 AND latitude <= 90), + longitude double precision NOT NULL CHECK (longitude >= -180 AND longitude <= 180) +); + +CREATE INDEX ON teeoff_course_coordinate (external_course_ref, hole_number); + +GRANT SELECT, INSERT, UPDATE, DELETE ON teeoff_course_coordinate TO teecup_app; diff --git a/app/hole_history.py b/app/hole_history.py index 8da8415..6d73e4e 100644 --- a/app/hole_history.py +++ b/app/hole_history.py @@ -25,6 +25,13 @@ Custom personal_course (external_golfapi_course_id IS NULL) og custom course (source = 'custom') har INGEN bro her -- ekskluderes (resolve- funksjonene returnerer None, kallerne skjuler da historikk-panelet). +`CourseKey`/de tre `resolve_*_course_key()`-funksjonene gjenbrukes også av +`app/target_points.py` (migrasjon 079, rangefinder for offisielle baner) +-- samme bane-bro, annet formål (koordinater, ikke historikk). Tredje +søster `resolve_match_course_key()` (lag-matchplay: match->session-> +course) har INGEN egen hole-history-bruker ennå (kun ADR-072s to +flyter), men følger identisk mønster for symmetriens skyld. + GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE `score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i 020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel -- @@ -84,6 +91,25 @@ async def resolve_tournament_round_course_key(conn: Connection, tournament_round return ("golfapi", row["external_course_ref"]) +async def resolve_match_course_key(conn: Connection, match_id: str) -> CourseKey | None: + row = await conn.fetchrow( + """ + SELECT c.source, c.external_course_ref + FROM match m + JOIN session s ON s.id = m.session_id + JOIN course c ON c.id = s.course_id + WHERE m.id = $1 + """, + match_id, + ) + if row is None or row["source"] == "custom" or row["external_course_ref"] is None: + return None + if row["source"] == "official": + facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":") + return ("teeoff", facility_slug, teeoff_course_id) + return ("golfapi", row["external_course_ref"]) + + @dataclass class HoleInstance: source: Literal["personal", "tournament"] diff --git a/app/routers/courses.py b/app/routers/courses.py index 507aa69..6c58640 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -611,6 +611,104 @@ async def import_official_course( return Course(**dict(course_row)) +# --- Rangefinder-koordinater for offisielle baner (migrasjon 079) ----------- +# +# Frem til nå har rangefinder-koordinater (grønn/hindringer, GPS-basert) KUN +# eksistert for GolfAPI-importerte baner. Offisielle (teeoff-koblede) baner +# har ALDRI hatt noen koordinatkilde -- teeoff selv har ingen slik data, og +# det finnes ingen automatisk importvei (i motsetning til GolfAPI). Dette er +# derfor en RENT MANUELL skrive-vei: selve tolkningsarbeidet (fritekst-navn +# på feltbefarte punkter -> poi_type/location/side_fairway) krever +# dømmekraft og skjer FØR kallet hit, ikke automatisert her (bekreftet +# konkret av Tjøme-runden -- "Voll"/"Bjella" kunne ikke vært gjettet av et +# script). Full erstatning (ikke delta) -- speiler hvordan koordinatsett +# faktisk oppstår i praksis (en ny feltbefaring erstatter den forrige, ikke +# et lite tillegg til den). Lagres GLOBALT (teeoff_course_coordinate, nøkkel +# course.external_course_ref) -- IKKE per organisasjon, samme begrunnelse +# som golfapi_course_coordinate: flere organisasjoner kan importere samme +# fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles. + + +class CoursePointIn(BaseModel): + hole_number: int = Field(ge=1, le=18) + poi_type: str + location: str | None = None + side_fairway: str | None = None + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + + +class CoursePointOut(BaseModel): + hole_number: int + poi_type: str + location: str | None + side_fairway: str | None + latitude: float + longitude: float + + +_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude" + + +@router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut]) +async def list_course_coordinates( + course_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[CoursePointOut]: + async with org_connection(organization_id) as conn: + course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id) + if course_row is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") + if course_row["source"] != "official" or course_row["external_course_ref"] is None: + return [] + rows = await conn.fetch( + f""" + SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate + WHERE external_course_ref = $1 ORDER BY hole_number + """, + course_row["external_course_ref"], + ) + return [CoursePointOut(**dict(r)) for r in rows] + + +@router.put("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut]) +async def replace_course_coordinates( + course_id: str, + body: list[CoursePointIn], + organization_id: str = Depends(get_authorized_org), +) -> list[CoursePointOut]: + async with org_connection(organization_id) as conn, translate_db_errors(): + course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id) + if course_row is None: + raise app_error(404, "NOT_FOUND", "Banen finnes ikke.") + if course_row["source"] != "official": + raise app_error( + 400, + "VALIDATION_FAILED", + "Kun offisielle (TeeOff-koblede) baner kan få rangefinder-koordinater satt manuelt.", + ) + external_ref = course_row["external_course_ref"] + + await conn.execute("DELETE FROM teeoff_course_coordinate WHERE external_course_ref = $1", external_ref) + for p in body: + await conn.execute( + """ + INSERT INTO teeoff_course_coordinate + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ($1, $2, $3, $4, $5, $6, $7) + """, + external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, + ) + rows = await conn.fetch( + f""" + SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate + WHERE external_course_ref = $1 ORDER BY hole_number + """, + external_ref, + ) + return [CoursePointOut(**dict(r)) for r in rows] + + # --- Internasjonal banedata fra GolfAPI.io (ADR-064) ------------------------- # # Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index f2b7b11..511d4be 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -71,6 +71,7 @@ from ..hole_history import ( hole_history_for_user, resolve_tournament_round_course_key, ) +from ..target_points import TargetPoint, get_target_points from ..team_authz import user_is_own_tournament_participant from .scoring import played_hole_numbers @@ -1107,6 +1108,32 @@ async def get_round_participant_hole_history( return await hole_history_for_user(target_user_id, key, hole_number) +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" + "/holes/{hole_number}/target-points", + response_model=list[TargetPoint], +) +async def get_tournament_round_hole_target_points( + tournament_id: str, + round_id: str, + hole_number: int, + organization_id: str = Depends(get_authorized_org), +) -> list[TargetPoint]: + """Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp + som frittstående runders `GET /rounds/{id}/holes/{n}/target-points`, + her for en org-turnering-runde (individuell slagspill). Tom liste for + håndlagde baner, rangefinder-UI-et skjules da bare.""" + async with org_connection(organization_id) as conn: + exists = await conn.fetchval( + "SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id + ) + if exists is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + course_key = await resolve_tournament_round_course_key(conn, round_id) + points = await get_target_points(conn, course_key, hole_number) + return points + + @router.get( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}" "/participants/{round_participant_id}/holes", diff --git a/app/routers/rounds.py b/app/routers/rounds.py index a346d5b..038f860 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -59,6 +59,7 @@ from ..hole_history import ( resolve_personal_round_course_key, ) from ..realtime import broadcast_round_update, live_sockets_for_round +from ..target_points import TargetPoint, get_target_points from .auth import _hash_secret from .notifications import create_notification from handicap_engine import ( @@ -3148,54 +3149,25 @@ 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.""" + """Rangefinder-data for hullet (ADR-064, utvidet til ekte teeoff-baner + i migrasjon 079) -- grønn front/midt/bak, hindringer, tee-punkter. + Returnerer tom liste for håndlagde baner (ingen koordinatkilde), + 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] + course_key = await resolve_personal_round_course_key(conn, round_id) + points = await get_target_points(conn, course_key, hole_number) + return points class ParticipantUpdate(BaseModel): diff --git a/app/routers/scoring.py b/app/routers/scoring.py index da4bd3e..deb7bfa 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -60,6 +60,8 @@ from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import parse_allowance_config, relative_strokes_for_match from ..realtime import broadcast_live_update +from ..hole_history import resolve_match_course_key +from ..target_points import TargetPoint, get_target_points from ..team_authz import ( is_org_member, user_is_match_participant, @@ -852,6 +854,39 @@ async def get_scorecard( return await fetch_scorecard(conn, match_id) +@router.get( + "/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points", + response_model=list[TargetPoint], +) +async def get_match_hole_target_points( + match_id: str, + hole_number: int, + organization_id: str, + user: CurrentUser = Depends(get_current_user), +) -> list[TargetPoint]: + """Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp + som frittstående runders og individuell-turnerings target-points, her + for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som + `get_scorecard` over (org-medlem ELLER deltaker i turneringen).""" + async with org_connection(organization_id) as conn: + tournament_id = await conn.fetchval( + """ + SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id + WHERE m.id = $1 + """, + match_id, + ) + if tournament_id is None: + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") + if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant( + conn, organization_id, tournament_id, user.user_id + ): + raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.") + course_key = await resolve_match_course_key(conn, match_id) + points = await get_target_points(conn, course_key, hole_number) + return points + + # --------------------------------------------------------------------------- # Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre # parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull- diff --git a/app/target_points.py b/app/target_points.py new file mode 100644 index 0000000..c6f784a --- /dev/null +++ b/app/target_points.py @@ -0,0 +1,60 @@ +""" +Rangefinder-koordinater (migrasjon 065/079) -- avstand til green/hindringer, +GPS-basert. Delt mellom rounds.py (frittstående runder), individual_ +tournaments.py (org-turnering, individuell slagspill) og scoring.py +(org-turnering, lag-matchplay) -- alle tre løser sin egen kontekst om til +samme `CourseKey` (se hole_history.py, som denne modulen gjenbruker banen- +bro-abstraksjonen fra) og kaller `get_target_points()` her. + +To kildetabeller, ingen RLS på noen av dem (globale, delt på tvers av +organisasjoner/brukere som tilfeldigvis bruker samme fysiske bane -- samme +begrunnelse som golfapi_course_coordinate alltid har hatt): `golfapi_ +course_coordinate` (GolfAPI-importerte baner) og `teeoff_course_coordinate` +(ekte teeoff-baner, migrasjon 079). Fordi ingen av dem er RLS-beskyttet, +virker `get_target_points()` uendret uansett om `conn` kommer fra +`plain_connection()` eller `org_connection()`. +""" + +from __future__ import annotations + +from asyncpg import Connection +from pydantic import BaseModel + +from .hole_history import CourseKey + + +class TargetPoint(BaseModel): + poi_type: str + location: str | None + side_fairway: str | None + latitude: float + longitude: float + + +async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]: + if course_key is None: + return [] + kind = course_key[0] + if kind == "teeoff": + _, facility_slug, teeoff_course_id = course_key + rows = await conn.fetch( + """ + SELECT poi_type, location, side_fairway, latitude, longitude + FROM teeoff_course_coordinate + WHERE external_course_ref = $1 AND hole_number = $2 + """, + f"{facility_slug}:{teeoff_course_id}", + hole_number, + ) + else: + _, golfapi_course_id = course_key + 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] diff --git a/frontend/components/hole-target-distance.tsx b/frontend/components/hole-target-distance.tsx index c2fe564..6ad3a64 100644 --- a/frontend/components/hole-target-distance.tsx +++ b/frontend/components/hole-target-distance.tsx @@ -17,6 +17,13 @@ import { haversineMeters, type LatLng } from "@/lib/geo" // 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba // om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13. // +// `baseUrl` (ikke `roundId`) siden migrasjon 079 -- komponenten gjenbrukes +// nå av tre kallesteder med tre ulike URL-familier (frittstående runde +// `/rounds/{id}`, individuell org-turnering `/orgs/{org}/tournaments/{t}/ +// rounds/{r}`, lag-matchplay `/orgs/{org}/matches/{m}`) -- kalleren bygger +// selv `${baseUrl}/holes/{n}/target-points`, komponenten trenger ikke vite +// hvilken flyt den sitter i. +// // Strømsparing (2026-08-13, brukerrapport om høyt batteriforbruk): // (1) `maximumAge` satt til 4000ms (var 1000ms) -- en golfer beveger seg // ~1,5 m/s, oppdatering hvert 4. sekund er umerkelig i praksis men ber @@ -44,12 +51,12 @@ const _HAZARD_LABELS: Record = { } export function HoleTargetDistance({ - roundId, + baseUrl, holeNumber, size = "compact", className, }: { - roundId: string + baseUrl: string holeNumber: number size?: "compact" | "full" className?: string @@ -61,7 +68,7 @@ export function HoleTargetDistance({ useEffect(() => { let cancelled = false setPoints(null) - fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" }) + fetch(`${baseUrl}/holes/${holeNumber}/target-points`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiTargetPoint[]) => { if (!cancelled) setPoints(data) @@ -72,7 +79,7 @@ export function HoleTargetDistance({ return () => { cancelled = true } - }, [roundId, holeNumber]) + }, [baseUrl, holeNumber]) useEffect(() => { // Ingen vits å spore GPS når banen uansett ikke har koordinatdata for diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 52f405b..f52eaf0 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -52,6 +52,7 @@ import { WizardSection, } from "@/components/hole-stat-inputs" import { HoleHistoryDetail } from "@/components/hole-history-detail" +import { HoleTargetDistance } from "@/components/hole-target-distance" import { ClubPicker } from "@/components/teecup/club-picker" import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" @@ -3282,6 +3283,7 @@ function HoleStatsSheet({ strokesReceived, ownBagClubs, historyUrl, + targetPointsBaseUrl, onSave, onClose, }: { @@ -3291,6 +3293,7 @@ function HoleStatsSheet({ strokesReceived: number | null ownBagClubs: string[] historyUrl: string + targetPointsBaseUrl: string onSave: (patch: HolePatch) => Promise onClose: () => void }) { @@ -3436,6 +3439,11 @@ function HoleStatsSheet({
+ {/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent + som frittstående runder, persistent uansett hvilket steg + brukeren er på (samme plassering/prinsipp som round-detail.tsx). */} + + {/* ADR-079: personlig historikk, flyttet hit ut av "strokes"- steget -- se begrunnelsen for auto-hopp ved SheetStep over. Samme kompakte oppsummering + klikk-for-full-historikk- @@ -3726,6 +3734,7 @@ function HoleGrid({ strokesReceived={strokesReceived(sheetHole)} ownBagClubs={ownBagClubs} historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`} + targetPointsBaseUrl={`${base}/rounds/${roundId}`} onSave={(patch) => onUpdateFull(sheetHole, patch)} onClose={() => setSheetHole(null)} /> diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 02ba5e0..28b6414 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -6680,12 +6680,13 @@ function PlayerHoleCards({
- {/* 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). Full bredde, samme - plassering brukeren pekte ut (rett under hull-navigatoren, - over spillerkortene) -- ikke den smale kompaktvarianten. */} - + {/* Avstand til grønn (ADR-064, utvidet til ekte teeoff-baner i + migrasjon 079) -- kun for baner med koordinatdata; ingen synlig + rad i det hele tatt for andre baner (HoleTargetDistance returnerer + null da). Full bredde, samme plassering brukeren pekte ut (rett + under hull-navigatoren, over spillerkortene) -- ikke den smale + kompaktvarianten. */} + {isTwoSided && sides.length === 2 && (
diff --git a/frontend/components/session-scorecard.tsx b/frontend/components/session-scorecard.tsx index 5618669..d1e4fba 100644 --- a/frontend/components/session-scorecard.tsx +++ b/frontend/components/session-scorecard.tsx @@ -14,6 +14,7 @@ import { WifiOff, } from "lucide-react" import { Button } from "@/components/ui/button" +import { HoleTargetDistance } from "@/components/hole-target-distance" import { cn } from "@/lib/utils" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" @@ -855,6 +856,18 @@ export function SessionScorecard({

Hull {currentHole}

· Par {par}
+ + {/* Rangefinder (migrasjon 079) -- samme selvskjulende + komponent som frittstående runder/individuell + org-turnering, ingen synlig rad for baner uten + koordinatdata. */} +
+ +
{units.map((unit) => { const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" @@ -929,6 +942,18 @@ export function SessionScorecard({

Hull {currentHole}

· Par {par}
+ + {/* Rangefinder (migrasjon 079) -- samme selvskjulende + komponent som frittstående runder/individuell + org-turnering, ingen synlig rad for baner uten + koordinatdata. */} +
+ +
{( [ diff --git a/tests/test_target_points.py b/tests/test_target_points.py new file mode 100644 index 0000000..a5736b3 --- /dev/null +++ b/tests/test_target_points.py @@ -0,0 +1,299 @@ +""" +Rangefinder-koordinater for offisielle (TeeOff-koblede) baner (migrasjon +079, ADR-081). To lag med tester: + +1. `get_target_points()`/`resolve_match_course_key()` -- de delte + byggeklossene direkte, uavhengig av hvilket av de tre kallestedene + som bruker dem. +2. Integrasjon mot alle tre endepunktene (frittstående runde, individuell + org-turnering, lag-matchplay) + den nye skrive-veien for offisielle + baners koordinater i courses.py. +""" + +from fastapi import HTTPException +import pytest + +from app.auth import CurrentUser +from app.hole_history import resolve_match_course_key +from app.target_points import get_target_points +from app.routers.courses import CoursePointIn, list_course_coordinates, replace_course_coordinates +from app.routers.individual_tournaments import get_tournament_round_hole_target_points +from app.routers.rounds import get_round_hole_target_points +from app.routers.scoring import get_match_hole_target_points + +from tests.conftest import ( + add_membership, + create_course, + create_match, + create_org, + create_org_hole, + create_participant, + create_player, + create_round, + create_session, + create_team, + create_team_roster, + create_tee, + create_tournament, + create_tournament_participant, + create_tournament_round, + create_tournament_round_participant, + create_user, +) + +import app.db as app_db + + +# --------------------------------------------------------------------------- +# 1. get_target_points() / resolve_match_course_key() +# --------------------------------------------------------------------------- + +async def test_get_target_points_returns_empty_for_none_key(pool): + async with app_db.plain_connection() as conn: + points = await get_target_points(conn, None, 1) + assert points == [] + + +async def test_get_target_points_teeoff_branch(pool): + async with app_db.plain_connection() as conn: + await conn.execute( + """ + INSERT INTO teeoff_course_coordinate + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ('slug:99', 5, 'green', 'middle', 'center', 59.1, 10.4) + """ + ) + points = await get_target_points(conn, ("teeoff", "slug", "99"), 5) + other_hole = await get_target_points(conn, ("teeoff", "slug", "99"), 6) + assert len(points) == 1 + assert points[0].poi_type == "green" + assert points[0].latitude == 59.1 + assert other_hole == [] + + +async def test_get_target_points_golfapi_branch(pool): + async with app_db.plain_connection() as conn: + await conn.execute( + """ + INSERT INTO golfapi_course (course_id, club_id, club_name, course_name, num_holes, num_coordinates) + VALUES ('gapi-1', 'club-1', 'Testklubb', 'Bane', 18, 1) + """ + ) + await conn.execute( + """ + INSERT INTO golfapi_course_coordinate + (golfapi_course_id, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ('gapi-1', 3, 'water', 'front', 'left', 59.2, 10.5) + """ + ) + points = await get_target_points(conn, ("golfapi", "gapi-1"), 3) + assert len(points) == 1 + assert points[0].poi_type == "water" + + +async def test_resolve_match_course_key_custom_course_returns_none(pool): + user_id = await create_user() + org_id = await create_org() + await add_membership(org_id, user_id, role="member") + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) # source defaults to 'custom' + team_a = await create_team(org_id, tournament_id) + team_b = await create_team(org_id, tournament_id) + session_id = await create_session(org_id, tournament_id, course_id) + match_id = await create_match(org_id, session_id, team_a, team_b) + + async with app_db.org_connection(org_id) as conn: + key = await resolve_match_course_key(conn, match_id) + assert key is None + + +async def test_resolve_match_course_key_official_course(pool): + org_id = await create_org() + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'official', external_course_ref = 'slug-x:42' WHERE id = $1", + course_id, + ) + team_a = await create_team(org_id, tournament_id) + team_b = await create_team(org_id, tournament_id) + session_id = await create_session(org_id, tournament_id, course_id) + match_id = await create_match(org_id, session_id, team_a, team_b) + + async with app_db.org_connection(org_id) as conn: + key = await resolve_match_course_key(conn, match_id) + assert key == ("teeoff", "slug-x", "42") + + +async def test_resolve_match_course_key_international_course(pool): + org_id = await create_org() + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'international', external_course_ref = 'gapi-77' WHERE id = $1", + course_id, + ) + team_a = await create_team(org_id, tournament_id) + team_b = await create_team(org_id, tournament_id) + session_id = await create_session(org_id, tournament_id, course_id) + match_id = await create_match(org_id, session_id, team_a, team_b) + + async with app_db.org_connection(org_id) as conn: + key = await resolve_match_course_key(conn, match_id) + assert key == ("golfapi", "gapi-77") + + +async def test_resolve_match_course_key_nonexistent_match_returns_none(pool): + org_id = await create_org() + async with app_db.org_connection(org_id) as conn: + key = await resolve_match_course_key(conn, "00000000-0000-0000-0000-000000000000") + assert key is None + + +# --------------------------------------------------------------------------- +# 2. Integrasjon: de tre GET-endepunktene + skrive-veien i courses.py. +# --------------------------------------------------------------------------- + +async def test_round_target_points_teeoff_round_now_returns_points(pool): + """Regresjonsbevis for refaktoreringen: FØR migrasjon 079 returnerte + dette endepunktet [] for EN HVER teeoff-sourcet frittstående runde, + uansett koordinatdata. Nå slår den opp via samme bane-bro som + hole_history.py.""" + user_id = await create_user() + async with app_db.plain_connection() as conn: + round_id = await create_round(conn, user_id) # teeoff/test-facility/test-course + await create_participant(conn, round_id, user_id=user_id, is_owner=True) + await conn.execute( + """ + INSERT INTO teeoff_course_coordinate + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ('test-facility:test-course', 4, 'tee_front', 'middle', 'center', 59.0, 10.0) + """ + ) + points = await get_round_hole_target_points(round_id, 4, user=CurrentUser(user_id=user_id)) + assert len(points) == 1 + assert points[0].poi_type == "tee_front" + + empty = await get_round_hole_target_points(round_id, 5, user=CurrentUser(user_id=user_id)) + assert empty == [] + + +async def test_tournament_round_target_points_official_course(pool): + user_id = await create_user() + org_id = await create_org() + await add_membership(org_id, user_id, role="member") + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'official', external_course_ref = 'tourn-slug:7' WHERE id = $1", + course_id, + ) + await conn.execute( + """ + INSERT INTO teeoff_course_coordinate + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ('tourn-slug:7', 9, 'rock', 'front', 'center', 59.3, 10.6) + """ + ) + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n) + tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id) + + points = await get_tournament_round_hole_target_points( + tournament_id, tournament_round_id, 9, organization_id=org_id, + ) + assert len(points) == 1 + assert points[0].poi_type == "rock" + + with pytest.raises(HTTPException) as exc_info: + await get_tournament_round_hole_target_points( + "00000000-0000-0000-0000-000000000000", tournament_round_id, 9, organization_id=org_id, + ) + assert exc_info.value.status_code == 404 + + +async def test_match_target_points_official_course_and_access_control(pool): + member_id = await create_user() + outsider_id = await create_user() + org_id = await create_org() + await add_membership(org_id, member_id, role="member") + tournament_id = await create_tournament(org_id) + course_id = await create_course(org_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'official', external_course_ref = 'match-slug:3' WHERE id = $1", + course_id, + ) + await conn.execute( + """ + INSERT INTO teeoff_course_coordinate + (external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude) + VALUES ('match-slug:3', 2, 'water', 'back', 'right', 59.4, 10.7) + """ + ) + team_a = await create_team(org_id, tournament_id) + team_b = await create_team(org_id, tournament_id) + session_id = await create_session(org_id, tournament_id, course_id) + match_id = await create_match(org_id, session_id, team_a, team_b) + + points = await get_match_hole_target_points( + match_id, 2, organization_id=org_id, user=CurrentUser(user_id=member_id), + ) + assert len(points) == 1 + assert points[0].poi_type == "water" + + with pytest.raises(HTTPException) as exc_info: + await get_match_hole_target_points( + match_id, 2, organization_id=org_id, user=CurrentUser(user_id=outsider_id), + ) + assert exc_info.value.status_code == 403 + + +async def test_course_coordinates_write_path_requires_official_and_fully_replaces(pool): + org_id = await create_org() + course_id = await create_course(org_id) # 'custom' by default + + # 400 på ikke-offisiell bane. + with pytest.raises(HTTPException) as exc_info: + await replace_course_coordinates( + course_id, + [CoursePointIn(hole_number=1, poi_type="green", location="middle", side_fairway="center", + latitude=59.0, longitude=10.0)], + organization_id=org_id, + ) + assert exc_info.value.status_code == 400 + + # GET returnerer stille tom liste for ikke-offisiell bane (ikke feil). + assert await list_course_coordinates(course_id, organization_id=org_id) == [] + + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE course SET source = 'official', external_course_ref = 'write-slug:1' WHERE id = $1", + course_id, + ) + + first_set = [ + CoursePointIn(hole_number=1, poi_type="green", location="front", side_fairway="center", + latitude=59.0, longitude=10.0), + CoursePointIn(hole_number=1, poi_type="water", location="back", side_fairway="left", + latitude=59.01, longitude=10.01), + ] + result = await replace_course_coordinates(course_id, first_set, organization_id=org_id) + assert len(result) == 2 + + # Ny PUT ERSTATTER hele settet -- ikke et tillegg. + second_set = [ + CoursePointIn(hole_number=2, poi_type="rock", location="middle", side_fairway="right", + latitude=59.02, longitude=10.02), + ] + result = await replace_course_coordinates(course_id, second_set, organization_id=org_id) + assert len(result) == 1 + assert result[0].hole_number == 2 + assert result[0].poi_type == "rock" + + read_back = await list_course_coordinates(course_id, organization_id=org_id) + assert len(read_back) == 1 + assert read_back[0].poi_type == "rock"