Rangefinder-koordinater for offisielle (TeeOff-koblede) baner

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 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-17 12:06:47 +02:00
parent f2ae68b30d
commit 93af7c5bef
12 changed files with 685 additions and 50 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

60
app/target_points.py Normal file
View file

@ -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 noen av dem (globale, delt 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]

View file

@ -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<string, string> = {
}
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

View file

@ -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<void>
onClose: () => void
}) {
@ -3436,6 +3439,11 @@ function HoleStatsSheet({
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
som frittstående runder, persistent uansett hvilket steg
brukeren er (samme plassering/prinsipp som round-detail.tsx). */}
<HoleTargetDistance baseUrl={targetPointsBaseUrl} holeNumber={hole.hole_number} size="full" />
{/* 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)}
/>

View file

@ -6680,12 +6680,13 @@ function PlayerHoleCards({
</nav>
</div>
{/* 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. */}
<HoleTargetDistance roundId={roundId} holeNumber={currentHole} size="full" />
{/* 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. */}
<HoleTargetDistance baseUrl={`/rounds/${roundId}`} holeNumber={currentHole} size="full" />
{isTwoSided && sides.length === 2 && (
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">

View file

@ -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({
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
komponent som frittstående runder/individuell
org-turnering, ingen synlig rad for baner uten
koordinatdata. */}
<div className="mb-4">
<HoleTargetDistance
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
holeNumber={currentHole}
size="full"
/>
</div>
<div className="flex flex-col gap-3">
{units.map((unit) => {
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
@ -929,6 +942,18 @@ export function SessionScorecard({
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
komponent som frittstående runder/individuell
org-turnering, ingen synlig rad for baner uten
koordinatdata. */}
<div className="mb-4">
<HoleTargetDistance
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
holeNumber={currentHole}
size="full"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{(
[

299
tests/test_target_points.py Normal file
View file

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