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,
})
}
// ADR-083-konvensjonen ("sortert nærmest spilleren først") manglet her --
// uten denne falt rekkefølgen tilbake til rå API-/innsettingsrekkefølge,
// som ikke sier noe om avstand og heller ikke oppdaterer seg når
// spilleren beveger seg. `below` er alltid den nærmeste av forkant/
// enkeltpunkt-avstanden, så stigende sortering på den gir riktig
// "nærmest spilleren først" for både parrede og enkeltstående hindringer.
return entities.sort((a, b) => a.below - b.below)
// Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som
// kartet den står under -- holdt loddrett med greenen øverst (lengst
// unna) og utslaget nederst (nærmest), skal hindringene imellom følge
// SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på
// BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret
// hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som]
// 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({
@ -314,9 +316,13 @@ export function HoleTargetDistance({
}
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
// løkken til ett enkelt nearestHazard-objekt) -- sortert nærmest
// spilleren først. Avstandene er ALLTID live fra spilleren, uavhengig av
// om et geometrisk diagram kan tegnes under.
// løkken til ett enkelt nearestHazard-objekt). Avstandene er ALLTID live
// fra spilleren, uavhengig av 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)
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
@ -336,7 +342,7 @@ export function HoleTargetDistance({
: base
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
// 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 { HoleHistoryDetail } from "@/components/hole-history-detail"
import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types -----------------------------------------------------------------
@ -2431,25 +2432,69 @@ function ShotMeasurementEntry({
}, [urlBase])
const count = shots.length
// Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen"
// følger spillerens EGEN gangretning på hullet, uten lagrede green-
// koordinater. 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å hullet --
// MapPointPicker 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
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// utslags- OG green-koordinater kjent (samme datakilde som
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet
// for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/
// ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder
// i denne filen, kun roundId+holeNumber er felles for alle) -- ett
// ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at
// 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.
const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots])
}, [shots, courseBearing])
async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
@ -6812,6 +6857,35 @@ function PlayerHoleCards({
window.removeEventListener("resize", check)
}
}, [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`) --
// samme begrunnelse som `getHeaderHeight()` over: en CSS-klasse som
// `scroll-mt-28` kan ikke være dynamisk, og headerens høyde varierer
@ -6954,18 +7028,18 @@ function PlayerHoleCards({
</button>
)}
{/* Alltid synlig, tydelig snarvei rett til score-registreringen under
-- samme grunn som knappen over: en førstegangsbruker skal ikke
måtte oppdage ved et tilfeldig sveip at dette finnes. KUN i normal
side-flyt når banekartet er kollapset -- utvidet blir den samme
knappen flytende i stedet, se render-stedet nederst i denne
komponenten (brukerønske 2026-08-20: det utvidede diagrammet
kunne dytte knappen helt utenfor synlig skjermbilde). */}
{!mapExpanded && (
{/* Alltid i normal side-flyt, uansett banekart-tilstand (rettet
2026-08-20 -- se `inFlowButtonVisible`-kommentaren over: den
MÅTTE bli værende i flyten uansett, siden en lang hindringsliste
alene kan dytte den ut av syne selv når kartet ALDRI ble utvidet).
`ref` brukt til å måle om DENNE knappen faktisk er synlig -- den
flytende versjonen (render-stedet nederst i komponenten) dukker
kun opp når den IKKE er det. */}
<div ref={inFlowButtonRef}>
<RegisterScoreButton
onClick={scrollToPlayerList}
/>
)}
</div>
{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">
@ -7206,24 +7280,26 @@ function PlayerHoleCards({
/>
)}
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20) -- mens
banekartet er utvidet OG spillerlisten fortsatt er utenfor synlig
område (se `playerListReached` over -- uten denne betingelsen hang
knappen igjen synlig, og dekket delvis over spillerkort 1, selv
etter at brukeren allerede hadde scrollet dit). `fixed`
posisjonerer mot selve viewporten (ingen ancestor her setter
transform/filter, det er trygt), gradient-scrim + skygge
løfter den visuelt over innholdet under, `env(safe-area-inset-
bottom)` unngår at den klemmes helt inntil kanten på telefoner
med gest-navigasjon. `pointer-events-none` selve scrim-sonen
når skjult/i overgang, slik at den aldri blokkerer klikk
innhold bak. */}
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
logikk rettet samme dag) -- vises når side-flyt-knappen over
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
OG spillerlisten fortsatt er utenfor synlig område (se
`playerListReached` -- uten denne dekket den delvis over
spillerkort 1 selv etter at brukeren allerede hadde scrollet
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
her setter transform/filter, det er trygt), gradient-scrim +
skygge løfter den visuelt over innholdet under,
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
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
aria-hidden={!mapExpanded || playerListReached}
aria-hidden={inFlowButtonVisible || playerListReached}
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",
"bg-gradient-to-t from-background via-background/90 to-transparent",
mapExpanded && !playerListReached
!inFlowButtonVisible && !playerListReached
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-full opacity-0",
)}
@ -7231,7 +7307,7 @@ function PlayerHoleCards({
<div
className={cn(
"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">
@ -7243,10 +7319,8 @@ function PlayerHoleCards({
</div>
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende
knappen i stedet for å bli permanent skjult bak den -- samme
betingelse som selve knappen (2026-08-20-rettelsen), ellers ble
det stående et tomt, uforklart mellomrom selv etter at knappen
alt var skjult. */}
{mapExpanded && !playerListReached && <div aria-hidden="true" className="h-20" />}
betingelse som selve knappen. */}
{!inFlowButtonVisible && !playerListReached && <div aria-hidden="true" className="h-20" />}
</div>
)
}