teecup/frontend/components/hole-target-distance.tsx
Erol Haagenrud 9b7ff8e2ef Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser
Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som
startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format
eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per
hull på tvers av en turnerings egne runder, krever samme bane (avvist
tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye
tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren
i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget
endepunkt -- se ADR-068 for begrunnelsen.

Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme
runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige
golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet.
"Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne
aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv).

Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for
full begrunnelse og verifiseringslogg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:12:25 +02:00

162 lines
6.3 KiB
TypeScript

"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 (green
// front/senter/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`, grasiøs degradering ved avslått tillatelse,
// `clearWatch` ved avmontering. `TargetDistance` selv forblir "dum"
// (V0-eksportert, ren visning) -- all logikk bor her. Ingen egen
// rad-wrapper (border/padding) her -- kalleren styrer plassering/bredde.
// Montert i PlayerHoleCards sin hull-navigator (round-detail.tsx),
// size="full" -- IKKE i ScoringWizard-headeren (der den satt først,
// 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba
// om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13.
//
// 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
// GPS-brikken om et ferskt fix mye sjeldnere. (2) `watchPosition` pauses
// helt når fanen/appen ikke er synlig (`document.visibilitychange`) --
// ingen vits å holde GPS-en aktiv for en skjerm ingen ser på (skjerm låst,
// annen app i forgrunnen), og gjenopptas automatisk når brukeren kommer
// tilbake. `enableHighAccuracy` beholdt UENDRET -- lav nøyaktighet ville
// gitt titalls meters feilmargin, for upresist til at front/midt/bak-
// tallene gir mening.
type ApiTargetPoint = {
poi_type: string
location: "front" | "middle" | "back" | null
side_fairway: "left" | "center" | "right" | null
latitude: number
longitude: number
}
const _HAZARD_LABELS: Record<string, string> = {
green_bunker: "Bunker (green)",
fairway_bunker: "Bunker (fairway)",
water: "Vannhinder",
rock: "Fjellknaus",
}
export function HoleTargetDistance({
roundId,
holeNumber,
size = "compact",
className,
}: {
roundId: string
holeNumber: number
size?: "compact" | "full"
className?: string
}) {
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
const watchIdRef = useRef<number | null>(null)
useEffect(() => {
let cancelled = false
setPoints(null)
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTargetPoint[]) => {
if (!cancelled) setPoints(data)
})
.catch(() => {
if (!cancelled) setPoints([])
})
return () => {
cancelled = true
}
}, [roundId, holeNumber])
useEffect(() => {
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
// dette hullet -- unngår unødvendig batteribruk/tillatelses-prompt.
if (!points || points.length === 0) return
if (typeof navigator === "undefined" || !navigator.geolocation) return
if (typeof document === "undefined") return
function startWatch() {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
},
() => {
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
},
{ enableHighAccuracy: true, maximumAge: 4000 },
)
}
function stopWatch() {
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
watchIdRef.current = null
}
// Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm
// låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren
// kommer tilbake.
function handleVisibilityChange() {
if (document.hidden) {
stopWatch()
} else {
startWatch()
}
}
if (!document.hidden) startWatch()
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
stopWatch()
}
}, [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 <TargetDistance size={size} distances={null} className={className} />
}
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
const front = greenPoints.find((p) => p.location === "front")
const middle = greenPoints.find((p) => p.location === "middle")
const back = greenPoints.find((p) => p.location === "back")
if (!front || !middle || !back) {
return <TargetDistance size={size} distances={null} className={className} />
}
const distances = {
front: Math.round(haversineMeters(livePosition, { lat: front.latitude, lng: front.longitude })),
middle: Math.round(haversineMeters(livePosition, { lat: middle.latitude, lng: middle.longitude })),
back: Math.round(haversineMeters(livePosition, { lat: back.latitude, lng: back.longitude })),
}
const hazards = points.filter((p) => p.poi_type in _HAZARD_LABELS)
let nearestHazard: { label: string; distanceMeters: number } | null = null
for (const h of hazards) {
const d = haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })
if (nearestHazard === null || d < nearestHazard.distanceMeters) {
const base = _HAZARD_LABELS[h.poi_type]
const label = h.location === "front" || h.location === "back" ? `${base} (${h.location === "front" ? "front" : "bak"})` : base
nearestHazard = { label, distanceMeters: Math.round(d) }
}
}
return (
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} className={className} />
)
}