Score-fanen: flytende knapp ved mange hindringer, hindringsrekkefølge, kartretning

- Flytende "Registrer score" trigges nå av faktisk synlighet til
  side-flyt-knappen, ikke av om banekartet er utvidet -- en lang
  hindringsliste alene (uten utvidet kart) kunne dytte knappen ut av
  syne uten at den flytende versjonen noensinne dukket opp.
- Hindringslisten sortert fra fjernest (øverst) til nærmest (nederst),
  matcher loddrett telefon-holdning med green øverst/utslag nederst.
  Bakkant brukt som sorteringsnøkkel der en finnes.
- Kartretning ved slagmåling følger nå ekte utslag->green-geometri
  (green opp/utslag ned) når begge er kjent, vinner over den eldre
  forrige-slag-gjetningen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-21 08:05:14 +02:00
parent a0163eaa16
commit e940e932c1
2 changed files with 130 additions and 50 deletions

View file

@ -191,13 +191,15 @@ function groupDiagramHazards(
location: p.location, location: p.location,
}) })
} }
// ADR-083-konvensjonen ("sortert nærmest spilleren først") manglet her -- // Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som
// uten denne falt rekkefølgen tilbake til rå API-/innsettingsrekkefølge, // kartet den står under -- holdt loddrett med greenen øverst (lengst
// som ikke sier noe om avstand og heller ikke oppdaterer seg når // unna) og utslaget nederst (nærmest), skal hindringene imellom følge
// spilleren beveger seg. `below` er alltid den nærmeste av forkant/ // SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på
// enkeltpunkt-avstanden, så stigende sortering på den gir riktig // BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret
// "nærmest spilleren først" for både parrede og enkeltstående hindringer. // hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som]
return entities.sort((a, b) => a.below - b.below) // vi hensyntar når vi skal sortere"), ellers enkeltpunktets egen
// avstand (`below`, kun eksisterende verdi for et upar ret punkt).
return entities.sort((a, b) => (b.above ?? b.below) - (a.above ?? a.below))
} }
export function HoleTargetDistance({ export function HoleTargetDistance({
@ -314,9 +316,13 @@ export function HoleTargetDistance({
} }
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne // ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
// løkken til ett enkelt nearestHazard-objekt) -- sortert nærmest // løkken til ett enkelt nearestHazard-objekt). Avstandene er ALLTID live
// spilleren først. Avstandene er ALLTID live fra spilleren, uavhengig av // fra spilleren, uavhengig av om et geometrisk diagram kan tegnes under.
// om et geometrisk diagram kan tegnes under. // Sortert lengst unna FØRST (2026-08-21, samme retting/begrunnelse som
// groupDiagramHazards under -- listen leses loddrett med greenen
// øverst) -- denne grenen (ingen tee-koordinater) har ingen forkant/
// bakkant-parring i det hele tatt, kun ett enkelt avstandstall per
// punkt, så ingen "bakkant"-nyanse å ta hensyn til her.
const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS) const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS)
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises. // ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs, // Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
@ -336,7 +342,7 @@ export function HoleTargetDistance({
: base : base
return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude } return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude }
}) })
.sort((a, b) => a.distanceMeters - b.distanceMeters) .sort((a, b) => b.distanceMeters - a.distanceMeters)
// Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve // Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve
// hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back // hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back

View file

@ -60,6 +60,7 @@ import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { HoleTargetDistance } from "@/components/hole-target-distance" import { HoleTargetDistance } from "@/components/hole-target-distance"
import { HoleHistoryDetail } from "@/components/hole-history-detail" import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { bearingDegrees } from "@/lib/geo" import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types ----------------------------------------------------------------- // --- Types -----------------------------------------------------------------
@ -2431,25 +2432,69 @@ function ShotMeasurementEntry({
}, [urlBase]) }, [urlBase])
const count = shots.length const count = shots.length
// Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen" // Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// følger spillerens EGEN gangretning på hullet, uten lagrede green- // utslags- OG green-koordinater kjent (samme datakilde som
// koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette // HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// hullet (start→slutt-retningen) -- `shots` er allerede sortert // følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i // kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet
// rounds.py), så siste element ER forrige slag. `undefined` (ingen // for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/
// rotasjon regnet ut ennå) for det aller første slaget på hullet -- // ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder
// MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord, // i denne filen, kun roundId+holeNumber er felles for alle) -- ett
// se den komponentens dokumentasjon. Bevisst en STATISK retning, satt // ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at
// idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav // ALLE tre inngangene til kartet får riktig retning, ikke bare én.
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
useEffect(() => {
let cancelled = false
setCourseBearing(undefined)
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
if (cancelled) return
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
setCourseBearing(
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
)
})
.catch(() => {
if (!cancelled) setCourseBearing(undefined)
})
return () => {
cancelled = true
}
}, [roundId, holeNumber])
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
// (utslag->green, courseBearing over) når den er kjent -- ellers
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen)
// -- `shots` er allerede sortert stigende på shot_number av GET-
// endepunktet (se _list_shots_for_hole i rounds.py), så siste element
// ER forrige slag. `undefined` (ingen rotasjon regnet ut ennå) for det
// aller første slaget på et hull uten kjent banegeometri -- MapPoint-
// Picker faller da selv tilbake til enhetens GPS-heading/nord, se den
// komponentens dokumentasjon. Bevisst en STATISK retning, satt idet
// arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
// gangfart), se map-point-picker.tsx. // gangfart), se map-point-picker.tsx.
const initialBearing = useMemo(() => { const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1] const previous = shots[shots.length - 1]
if (!previous) return undefined if (!previous) return undefined
return bearingDegrees( return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng }, { lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng }, { lat: previous.end_lat, lng: previous.end_lng },
) )
}, [shots]) }, [shots, courseBearing])
async function deleteShot(shotId: string) { async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" }) const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
@ -6812,6 +6857,35 @@ function PlayerHoleCards({
window.removeEventListener("resize", check) window.removeEventListener("resize", check)
} }
}, [mapExpanded]) }, [mapExpanded])
// Den flytende knappen ble opprinnelig styrt av `mapExpanded` alene --
// FEIL, funnet av bruker 2026-08-20: den IKKE-utvidede ("compact")
// visningen viser OGSÅ hele hindringslisten (ADR-083, alle hindringer,
// ikke bare nærmeste), som på hull med mange hindringer alene kan bli
// høy nok til å dytte den vanlige, side-flyt-plasserte knappen utenfor
// synlig område -- UTEN at banekartet noensinne ble utvidet. Riktig
// signal er derfor ikke "er kartet utvidet", men "er selve
// side-flyt-knappen faktisk synlig akkurat nå" -- målt direkte, samme
// mønster som `playerListReached` over (ikke IntersectionObserver, av
// samme grunn som der: unngår enhver antakelse om når komponenten
// regnes som "utvidet" eller ikke -- fungerer likt uansett ÅRSAK til at
// knappen er utenfor synlig område).
const inFlowButtonRef = useRef<HTMLDivElement>(null)
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
useEffect(() => {
function check() {
const el = inFlowButtonRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setInFlowButtonVisible(rect.bottom > getHeaderHeight() && rect.top < window.innerHeight)
}
check()
window.addEventListener("scroll", check, { passive: true })
window.addEventListener("resize", check)
return () => {
window.removeEventListener("scroll", check)
window.removeEventListener("resize", check)
}
}, [mapExpanded, distanceAvailable])
// Manuell scroll (ikke `scrollIntoView` + CSS `scroll-margin-top`) -- // Manuell scroll (ikke `scrollIntoView` + CSS `scroll-margin-top`) --
// samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som // samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som
// `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer // `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer
@ -6954,18 +7028,18 @@ function PlayerHoleCards({
</button> </button>
)} )}
{/* Alltid synlig, tydelig snarvei rett til score-registreringen under {/* Alltid i normal side-flyt, uansett banekart-tilstand (rettet
-- samme grunn som knappen over: en førstegangsbruker skal ikke 2026-08-20 -- se `inFlowButtonVisible`-kommentaren over: den
måtte oppdage ved et tilfeldig sveip at dette finnes. KUN i normal MÅTTE bli værende i flyten uansett, siden en lang hindringsliste
side-flyt når banekartet er kollapset -- utvidet blir den samme alene kan dytte den ut av syne selv når kartet ALDRI ble utvidet).
knappen flytende i stedet, se render-stedet nederst i denne `ref` brukt til å måle om DENNE knappen faktisk er synlig -- den
komponenten (brukerønske 2026-08-20: det utvidede diagrammet flytende versjonen (render-stedet nederst i komponenten) dukker
kunne dytte knappen helt utenfor synlig skjermbilde). */} kun opp når den IKKE er det. */}
{!mapExpanded && ( <div ref={inFlowButtonRef}>
<RegisterScoreButton <RegisterScoreButton
onClick={scrollToPlayerList} onClick={scrollToPlayerList}
/> />
)} </div>
{isTwoSided && sides.length === 2 && ( {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"> <div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
@ -7206,24 +7280,26 @@ function PlayerHoleCards({
/> />
)} )}
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20) -- mens {/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
banekartet er utvidet OG spillerlisten fortsatt er utenfor synlig logikk rettet samme dag) -- vises når side-flyt-knappen over
område (se `playerListReached` over -- uten denne betingelsen hang IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
knappen igjen synlig, og dekket delvis over spillerkort 1, selv hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
etter at brukeren allerede hadde scrollet dit). `fixed` OG spillerlisten fortsatt er utenfor synlig område (se
posisjonerer mot selve viewporten (ingen ancestor her setter `playerListReached` -- uten denne dekket den delvis over
transform/filter, det er trygt), gradient-scrim + skygge spillerkort 1 selv etter at brukeren allerede hadde scrollet
løfter den visuelt over innholdet under, `env(safe-area-inset- dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
bottom)` unngår at den klemmes helt inntil kanten på telefoner her setter transform/filter, det er trygt), gradient-scrim +
med gest-navigasjon. `pointer-events-none` selve scrim-sonen skygge løfter den visuelt over innholdet under,
når skjult/i overgang, slik at den aldri blokkerer klikk `env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
innhold bak. */} kanten telefoner med gest-navigasjon. `pointer-events-none`
selve scrim-sonen når skjult/i overgang, slik at den aldri
blokkerer klikk innhold bak. */}
<div <div
aria-hidden={!mapExpanded || playerListReached} aria-hidden={inFlowButtonVisible || playerListReached}
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out", "fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out",
"bg-gradient-to-t from-background via-background/90 to-transparent", "bg-gradient-to-t from-background via-background/90 to-transparent",
mapExpanded && !playerListReached !inFlowButtonVisible && !playerListReached
? "translate-y-0 opacity-100" ? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-full opacity-0", : "pointer-events-none translate-y-full opacity-0",
)} )}
@ -7231,7 +7307,7 @@ function PlayerHoleCards({
<div <div
className={cn( className={cn(
"mx-auto w-full max-w-3xl", "mx-auto w-full max-w-3xl",
mapExpanded && !playerListReached ? "pointer-events-auto" : "pointer-events-none", !inFlowButtonVisible && !playerListReached ? "pointer-events-auto" : "pointer-events-none",
)} )}
> >
<div className="rounded-2xl shadow-xl shadow-black/25"> <div className="rounded-2xl shadow-xl shadow-black/25">
@ -7243,10 +7319,8 @@ function PlayerHoleCards({
</div> </div>
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende {/* Gir siste innhold på siden rom til å scrolle klar av den flytende
knappen i stedet for å bli permanent skjult bak den -- samme knappen i stedet for å bli permanent skjult bak den -- samme
betingelse som selve knappen (2026-08-20-rettelsen), ellers ble betingelse som selve knappen. */}
det stående et tomt, uforklart mellomrom selv etter at knappen {!inFlowButtonVisible && !playerListReached && <div aria-hidden="true" className="h-20" />}
alt var skjult. */}
{mapExpanded && !playerListReached && <div aria-hidden="true" className="h-20" />}
</div> </div>
) )
} }