Kort på hull-kortet: "end"-steget (ballens posisjon) tilbyr nå samme valg som utslags-steget: GPS ("Jeg er ved ballen nå") eller kart ("Vis kart mens jeg går").

Velger du kart, vises utslagspunktet som en fast oransje referansemarkør, og kartet zoomer/panorerer automatisk slik at både din posisjon og utslagspunktet er synlige med det samme. Du trykker der ballen ligger — markøren blir grønn for å skille den fra referansen.
This commit is contained in:
Erol Haagenrud 2026-08-08 21:38:10 +02:00
parent 7bcb09a15c
commit 26313c8017
10 changed files with 292 additions and 33 deletions

View file

@ -81,7 +81,8 @@
"Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")",
"Bash(grep -n \"activePlayerIndex\\\\|isLastPlayer\\\\|nextPlayer\\\\|canAdvance\\\\|advanceToNextPlayerOrHole\\\\|isEntryComplete\\\\|activePlayerIdRef\\\\|const loadHoles\\\\|useEffect\\(\\(\\) => {$\" /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_v2.mjs)",
"Bash(tee /tmp/wizard_setup2.json)"
"Bash(tee /tmp/wizard_setup2.json)",
"Bash(curl -s -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies_friend.txt -X POST http://172.18.0.11:8000/auth/verify-link -H 'Content-Type: application/json' -d '{\"token\":\"jb2Gsc6-W4G5oP47cZGRkjU01GF58bj2aEGDfCoa3Xc\"}')"
],
"additionalDirectories": [
"/opt/teecup/frontend/app/my-rounds/[id]"

View file

@ -437,7 +437,21 @@
"Bash(grep -n \"return { round, leaderboard\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
"WebFetch(domain:docs.mapbox.com)",
"Bash(python3 -m py_compile app/routers/rounds.py app/routers/round_messages.py app/config.py)",
"Bash(npm view *)"
"Bash(npm view *)",
"Bash(echo \"BUILD EXIT:$?\")",
"Bash(sudo du -h --max-depth=1 /var/lib/docker)",
"Bash(npx --prefix frontend tsc --noEmit -p frontend)",
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zip4)",
"Bash(unzip -o \"/opt/teecup/Temp-uploads/tee-cup \\(4\\).zip\")",
"Bash(python3 -m zipfile -e \"/opt/teecup/Temp-uploads/tee-cup \\(4\\).zip\" .)",
"Bash(awk 'NR==1700,0' /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(awk '/^function ScoringWizard/{f=1} f{print NR+1699\": \"$0} f && /^}$/{exit}')",
"Bash(echo \"API EXIT:$?\")",
"Bash(git push *)",
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')",
"Bash(awk -F: '$1 >= 2047 && $1 <= 2260')",
"Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)",
"Bash(sort -t/ -k4 -n)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -0,0 +1,9 @@
-- ADR-048 tillegg (2026-08-08): sluttpunktet (ballens posisjon) kunne
-- tidligere KUN måles med GPS. Brukeren ba om å også kunne velge ballens
-- posisjon på kartet (med utslagspunktet vist som referanse), på samme måte
-- som startpunktet allerede kunne. end_method speiler start_method (samme
-- CHECK-mønster), og backfilles til 'gps' for eksisterende rader, siden ALLE
-- slag fram til nå faktisk ble målt med GPS for sluttpunktet.
ALTER TABLE round_shot
ADD COLUMN end_method text NOT NULL DEFAULT 'gps' CHECK (end_method IN ('gps', 'map_tap'));

View file

@ -4654,6 +4654,48 @@ degraderer grasiøst til ren tekst uten satellittbilde ved feil/manglende
token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
delingen.
**Tillegg 2026-08-08 — sluttpunktet (ballen) kan nå OGSÅ velges på kart,
ikke bare GPS.** Brukeren, etter å ha brukt funksjonen i praksis: "Jeg må
også kunne velge på kartet hvor ballen ligger. Dessuten: Jeg vil gjerne se
kartet mens jeg går frem til ballen. Slagpunktet må være en del av det jeg
ser." Dette opphever den opprinnelige "sluttpunkt: alltid GPS, aldri
kart"-delen av flyt-beskrivelsen over (resten av ADR-048 står uendret) —
historikken beholdes over, ikke slettet, per CLAUDE.md-regelen om at en
endret beslutning får et tillegg, ikke en retusjert original.
- **end-steget** tilbyr nå samme valg som start-steget: "Jeg er ved ballen
nå" (GPS, uendret oppførsel) vs. "Vis kart mens jeg går" (nytt,
`MapPointPicker` med et `referencePoint`). Migrasjon `061` la til
`round_shot.end_method` (samme `gps`/`map_tap`-CHECK-mønster som
`start_method`, `DEFAULT 'gps'` siden ALLE slag før dette tillegget
faktisk ble målt med GPS for sluttpunktet).
- **`MapPointPicker` generalisert** til å ta et valgfritt `referencePoint`
(kun brukt på ball-steget): når satt, vises utslagspunktet som en fast,
ikke-flyttbar oransje markør ("Utslag"-merkelapp), og selve
punkt-markøren som plasseres ved tap er grønn i stedet for oransje —
samme fargekonvensjon som allerede fantes i delings-bildet
(`pin-s-a+ff5a1f` / `pin-s-b+2f7a3f`), nå ført konsekvent gjennom
UI-et også. Løser "slagpunktet må være en del av det jeg ser" konkret:
kartet henter brukerens live GPS-posisjon OG kjenner utslagspunktet, og
kjører `map.fitBounds([nåværende posisjon, utslagspunkt])` slik at begge
garantert er i bildet med det samme (ikke bare sentrert på det ene) —
faller tilbake til å sentrere på utslagspunktet alene (zoom 17) hvis GPS
feiler. Dekker samtidig "jeg vil se kartet mens jeg går fram til
ballen": brukeren kan la kartet stå åpent (satellittbildet, med
utslagsmarkøren som fast referansepunkt) mens de fysisk beveger seg, og
trykke der ballen faktisk ligger når de er fremme — i stedet for å måtte
vente på et GPS-fix. Fortsatt kun ETT kart-instans (Beslutning B
uendret): samme "mountes når steget åpnes, aldri på nytt per tap/pan"-
prinsipp gjelder nå BEGGE punkt-steg, ikke bare start.
- Scratch-verifisert (2026-08-08): migrasjon + CHECK-constraint
(ugyldig `end_method` avvist), API-nivå (`end_method` lagres/returneres
korrekt for begge verdier, default `gps` når feltet utelates), og full
nettleser-gjennomgang via Chrome DevTools MCP (GPS-fabrikkert
utslagspunkt → "Vis kart mens jeg går" → referansemarkør + fitBounds
bekreftet visuelt → tap plasserte grønn ballmarkør → lagret slag hadde
`start_method=gps, end_method=map_tap` i databasen, verifisert med
direkte SQL). Se CHANGELOG.md for full detalj.
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme

View file

@ -9502,3 +9502,75 @@ Neste steg:
**Rullet ut umiddelbart** (samme produksjonsbug-alvorlighet som
punkt 51 -- traff alle som brukte kart-valget utenfor Oslo sentrum)
-- kun frontend-image, `teecup_api` uendret.
53. **Ballens posisjon (sluttpunktet) kan nå OGSÅ velges på kart, ikke
bare GPS — pluss et vist referansepunkt for utslaget — 2026-08-08,
brukerønske umiddelbart etter punkt 52.** Bruker: "Jeg må også kunne
velge på kartet hvor ballen ligger. Dessuten: Jeg vil gjerne se
kartet mens jeg går frem til ballen. Slagpunktet må være en del av
det jeg ser." Dette opphever ADR-048s opprinnelige "sluttpunkt:
alltid GPS, aldri kart"-del av flyten — se "Tillegg 2026-08-08" i
ARCHITECTURE_DECISIONS.md for full begrunnelse, historikken der er
beholdt, ikke overskrevet.
**Endringer:**
- Migrasjon `061_round_shot_end_method.sql`: ny `round_shot.end_method
text NOT NULL DEFAULT 'gps' CHECK (IN ('gps','map_tap'))` — speiler
`start_method` nøyaktig. `DEFAULT 'gps'` er historisk korrekt: ALLE
slag før dette tillegget ble faktisk målt med GPS for sluttpunktet.
- Backend (`app/routers/rounds.py`): `ShotIn.end_method` (default
`"gps"` for bakoverkompatibilitet), `ShotOut.end_method`,
`_SHOT_SELECT`/`_shot_out`/`_insert_shot` oppdatert til å lese/skrive
kolonnen.
- `frontend/components/shot/map-point-picker.tsx`: ny valgfri
`referencePoint`/`referenceLabel`-prop. Når satt (kun på
ball-steget): en FAST, ikke-flyttbar oransje markør ("Utslag") vises
på kartet, og punktet som faktisk plasseres ved tap er grønt i
stedet for oransje (samme `ff5a1f`/`2f7a3f`-fargekonvensjon som
delings-bildet allerede brukte, nå ført konsekvent i selve
UI-et også). Kartet henter brukerens live GPS-posisjon OG kjenner
referansepunktet, og bruker `map.fitBounds([nåværende posisjon,
referansepunkt])` slik at BEGGE garantert er synlige med det samme
(ikke bare sentrert på ett av dem) — faller tilbake til å sentrere
på referansepunktet alene (zoom 17) hvis GPS feiler.
- `frontend/components/shot/shot-measurement-sheet.tsx`: nytt
`end_map`-steg. "end"-steget tilbyr nå samme valg som "start":
"Jeg er ved ballen nå" (GPS, uendret) vs. "Vis kart mens jeg går"
(nytt — åpner `MapPointPicker` med `referencePoint={startPoint}`).
`goBack()` og `onMapStep`-sjekken oppdatert for det nye steget;
`endMethod`-state lagt til og sendt med i `onSubmit`-payloaden.
- `frontend/components/round-detail.tsx`: `submitShot()` sender nå
`end_method` i POST-kroppen.
`tsc --noEmit` og `python3 -c "import ast; ast.parse(...)"` begge
rene.
**Scratch-verifisert** (tjuetredje scratch-miljø denne økten, samme
migrasjon-002-forsiktighet, `teecup_db`s ACL bekreftet uendret
før/etter): (1) alle 61 migrasjoner (inkl. ny 061) kjørte rent mot
scratch-DB, `test_isolation.sql` fortsatt 12/12 grønt; (2)
`\d round_shot` bekreftet ny kolonne + CHECK-constraint, et direkte
forsøk på å sette `end_method='not_valid'` ble korrekt avvist; (3)
API-nivå via ekte innlogging (magic-link, `TEECUP_DEV_LOG_MAGIC_
LINKS=true`) og direkte HTTP-kall: `POST .../shots` med
`end_method: "map_tap"` lagret og returnerte korrekt, samme endepunkt
UTEN `end_method` i kroppen falt korrekt tilbake til `"gps"`
(bakoverkompatibilitet bekreftet); (4) full nettleser-gjennomgang via
Chrome DevTools MCP (samme Referer-header-omgåelse for det
URL-restrikterte Mapbox-tokenet, og samme
`navigator.geolocation.getCurrentPosition`-monkey-patch for CDP sin
faste geolocation-`denied`-begrensning, begge kjente fra punkt 51/52):
valgte "Min posisjon nå" for startpunktet, deretter "Vis kart mens jeg
går" for ballen — bekreftet visuelt at kartet åpnet med BÅDE
"Utslag"-referansemarkøren (oransje) og var zoomet/panorert slik at
den var synlig med det samme (fitBounds), trykket et punkt på kartet
og fikk en distinkt GRØNN markør for ballen, fullførte flyten til
lagring, og verifiserte til slutt med direkte SQL mot scratch-DB-en at
det lagrede slaget faktisk hadde `start_method=gps, end_method=
map_tap` — den nøyaktige blandede kombinasjonen brukerens ønske
beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og
frontend-container/-images) ryddet opp fullstendig etterpå.
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse
før migrasjon/deploy mot ekte `teecup_db`/`teecup_api`/
`teecup_frontend`, samme rutine som punkt 48.

View file

@ -5080,6 +5080,10 @@ class ShotIn(BaseModel):
start_method: Literal["gps", "map_tap"]
start_lat: float = Field(ge=-90, le=90)
start_lng: float = Field(ge=-180, le=180)
# end_method lagt til 2026-08-08 (ADR-048-tillegg): sluttpunktet var
# opprinnelig ALLTID GPS -- default holder eldre klienter fungerende
# uten å kreve feltet, men appen sender det alltid eksplisitt nå.
end_method: Literal["gps", "map_tap"] = "gps"
end_lat: float = Field(ge=-90, le=90)
end_lng: float = Field(ge=-180, le=180)
@ -5092,6 +5096,7 @@ class ShotOut(BaseModel):
start_method: str
start_lat: float
start_lng: float
end_method: str
end_lat: float
end_lng: float
shared_round_message_id: str | None
@ -5104,7 +5109,7 @@ class ShotOut(BaseModel):
_SHOT_SELECT = """
SELECT rs.id::text AS id, rs.shot_number, rs.club, rs.distance_meters, rs.start_method,
rs.start_lat, rs.start_lng, rs.end_lat, rs.end_lng,
rs.start_lat, rs.start_lng, rs.end_method, rs.end_lat, rs.end_lng,
rs.shared_round_message_id::text AS shared_round_message_id,
rs.captured_at, rm.image_key AS shared_image_key
FROM round_shot rs
@ -5121,6 +5126,7 @@ def _shot_out(row: asyncpg.Record) -> ShotOut:
start_method=row["start_method"],
start_lat=row["start_lat"],
start_lng=row["start_lng"],
end_method=row["end_method"],
end_lat=row["end_lat"],
end_lng=row["end_lng"],
shared_round_message_id=row["shared_round_message_id"],
@ -5187,17 +5193,18 @@ async def _insert_shot(conn, hole_id: str, user_id: str, body: ShotIn) -> ShotOu
"""
INSERT INTO round_shot (
round_hole_id, recorded_by_user_id, shot_number, club, distance_meters,
start_method, start_lat, start_lng, end_lat, end_lng
start_method, start_lat, start_lng, end_method, end_lat, end_lng
)
VALUES (
$1, $2,
COALESCE((SELECT MAX(shot_number) FROM round_shot WHERE round_hole_id = $1), 0) + 1,
$3, $4, $5, $6, $7, $8, $9
$3, $4, $5, $6, $7, $8, $9, $10
)
RETURNING id::text AS id
""",
hole_id, user_id, body.club, body.distance_meters,
body.start_method, body.start_lat, body.start_lng, body.end_lat, body.end_lng,
body.start_method, body.start_lat, body.start_lng,
body.end_method, body.end_lat, body.end_lng,
)
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", row["id"])
return _shot_out(full_row)

View file

@ -2113,6 +2113,7 @@ function ShotMeasurementEntry({
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
@ -2131,6 +2132,7 @@ function ShotMeasurementEntry({
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_method: result.endMethod,
end_lat: result.endLat,
end_lng: result.endLng,
}),

View file

@ -12,15 +12,31 @@ type LngLat = { lng: number; lat: number }
*
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
* sheet-open and stays mounted until the sheet closes, so the Mapbox map is
* sheet-open (once per step, since start and end use separate mounted
* instances) and stays mounted until that step is left, so the Mapbox map is
* created exactly once (empty-deps useEffect) taps, pans and marker drags
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a
* single load per sheet-open.
* single load per step-open.
*/
export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => void }) {
export function MapPointPicker({
onConfirm,
referencePoint,
referenceLabel = "Utslag",
}: {
onConfirm: (lngLat: LngLat) => void
/**
* Satt KUN ballposisjon-steget (ADR-048-tillegg 2026-08-08): et fast,
* ikke-flyttbart referansepunkt (utslagspunktet) vises kartet, og
* kartet forsøker å vise BÅDE dette og brukerens nåværende posisjon i
* samme bilde -- brukerønske: "slagpunktet må være en del av det jeg ser".
*/
referencePoint?: LngLat
referenceLabel?: string
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markerRef = useRef<mapboxgl.Marker | null>(null)
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
const [loaded, setLoaded] = useState(false)
const [point, setPoint] = useState<LngLat | null>(null)
@ -45,26 +61,46 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan
// hentes (avslått tillatelse, tidsavbrudd, ingen støtte).
function initMap(center: [number, number]) {
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
// for bare å sentrere på ett av dem, slik at referansepunktet garantert
// er synlig med det samme.
function initMap(center: [number, number], fitTo?: [[number, number], [number, number]]) {
if (cancelled || !container) return
mapboxgl.accessToken = token as string
const map = new mapboxgl.Map({
container,
style: "mapbox://styles/mapbox/satellite-v9",
center,
zoom: 16,
zoom: referencePoint ? 17 : 16,
attributionControl: false,
})
mapRef.current = map
map.on("load", () => setLoaded(true))
map.on("load", () => {
setLoaded(true)
if (fitTo) {
const bounds = new mapboxgl.LngLatBounds(fitTo[0], fitTo[0])
bounds.extend(fitTo[1])
map.fitBounds(bounds, { padding: 80, maxZoom: 18, duration: 0 })
}
})
map.on("error", () => setError("Kunne ikke laste kartet."))
// Fast referansemarkør (utslagspunktet) -- kan ikke flyttes, kun til
// orientering. Egen farge (samme "pin-a"-oransje som brukes i
// delings-bildet) for å skille tydelig fra punktet som plasseres.
if (referencePoint) {
referenceMarkerRef.current = new mapboxgl.Marker({ color: "#ff5a1f" })
.setLngLat([referencePoint.lng, referencePoint.lat])
.addTo(map)
}
// Tap to place/move a single draggable marker — never re-inits the map.
map.on("click", (e) => {
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
if (!markerRef.current) {
const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" })
const marker = new mapboxgl.Marker({ draggable: true, color: referencePoint ? "#2f7a3f" : "#d2551a" })
.setLngLat(e.lngLat)
.addTo(map)
marker.on("dragend", () => {
@ -80,23 +116,36 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
}
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
const fallbackCenter: [number, number] = referencePoint
? [referencePoint.lng, referencePoint.lat]
: OSLO_FALLBACK
if (typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => initMap([pos.coords.longitude, pos.coords.latitude]),
() => initMap(OSLO_FALLBACK),
(pos) => {
const here: [number, number] = [pos.coords.longitude, pos.coords.latitude]
if (referencePoint) {
initMap(here, [here, [referencePoint.lng, referencePoint.lat]])
} else {
initMap(here)
}
},
() => initMap(fallbackCenter),
{ enableHighAccuracy: true, timeout: 8000 },
)
} else {
initMap(OSLO_FALLBACK)
initMap(fallbackCenter)
}
return () => {
cancelled = true
referenceMarkerRef.current?.remove()
referenceMarkerRef.current = null
markerRef.current?.remove()
markerRef.current = null
mapRef.current?.remove()
mapRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- montert én gang per steg, se komponent-docstring
}, [])
return (
@ -134,11 +183,18 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
</div>
) : null}
{referencePoint ? (
<div className="pointer-events-none absolute left-4 top-4 flex items-center gap-2 rounded-full bg-card/95 px-3 py-1.5 text-xs font-semibold text-foreground shadow-md">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: "#ff5a1f" }} aria-hidden="true" />
{referenceLabel}
</div>
) : null}
{loaded && !point && !error ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
Trykk kartet for å plassere punktet
{referencePoint ? "Trykk der ballen ligger" : "Trykk på kartet for å plassere punktet"}
</p>
</div>
) : null}
@ -151,7 +207,7 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
onClick={() => point && onConfirm(point)}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
Bekreft punkt
{referencePoint ? "Bekreft ballens posisjon" : "Bekreft punkt"}
</button>
</div>
</div>

View file

@ -26,7 +26,7 @@ const MapPointPicker = dynamic(
)
type LngLat = { lng: number; lat: number }
type Step = "start" | "map" | "end" | "club" | "result"
type Step = "start" | "map" | "end" | "end_map" | "club" | "result"
type GeoStatus = "idle" | "loading" | "error"
export type ShotMeasurementSheetProps = {
@ -50,6 +50,7 @@ export type ShotMeasurementSheetProps = {
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
@ -86,6 +87,7 @@ export function ShotMeasurementSheet({
const [step, setStep] = useState<Step>("start")
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
const [startPoint, setStartPoint] = useState<LngLat | null>(null)
const [endMethod, setEndMethod] = useState<"gps" | "map_tap" | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
@ -122,13 +124,14 @@ export function ShotMeasurementSheet({
)
}
// Ball position is ALWAYS GPS, regardless of how the start was chosen.
// Deliberately NOT auto-fired on step mount (real bug found 2026-08-08:
// firing immediately gave the user zero time to actually walk from the
// start point to the ball, so start/end ended up at ~the same spot and
// ~the same instant -- distance was always 0m). Requires an explicit
// "Jeg er ved ballen nå" tap once the user has actually walked there.
// Ball position via GPS. Deliberately NOT auto-fired on step mount (real
// bug found 2026-08-08: firing immediately gave the user zero time to
// actually walk from the start point to the ball, so start/end ended up
// at ~the same spot and ~the same instant -- distance was always 0m).
// Requires an explicit "Jeg er ved ballen nå" tap once the user has
// actually walked there.
function measureEndPoint() {
setEndMethod("gps")
setEndStatus("loading")
acquirePosition(
(p) => {
@ -166,9 +169,13 @@ export function ShotMeasurementSheet({
setStartStatus("idle")
setEndStatus("idle")
setStep("start")
} else if (step === "end_map") {
setEndStatus("idle")
setStep("end")
} else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null)
setEndMethod(null)
setStep("end")
} else if (step === "result") {
setStep("club")
@ -183,6 +190,7 @@ export function ShotMeasurementSheet({
startMethod: startMethod ?? "gps",
startLat: startPoint.lat,
startLng: startPoint.lng,
endMethod: endMethod ?? "gps",
endLat: endPoint.lat,
endLng: endPoint.lng,
share,
@ -190,7 +198,7 @@ export function ShotMeasurementSheet({
})
}
const onMapStep = step === "map"
const onMapStep = step === "map" || step === "end_map"
return (
<div
@ -240,7 +248,7 @@ export function ShotMeasurementSheet({
{step === "start" ? (
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
<p className="text-base text-muted-foreground text-pretty">
Velg hvor slaget startet. Ballens sluttposisjon måles alltid med GPS.
Velg hvor slaget startet.
</p>
<button
@ -306,19 +314,45 @@ export function ShotMeasurementSheet({
}} /> : null}
{step === "end" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-10 text-center">
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-6 text-center">
{endStatus === "idle" ? (
<>
<p className="text-base text-muted-foreground text-pretty">
til der ballen ligger, og trykk når du er fremme.
til der ballen ligger. Mål med GPS når du er fremme, eller
se kartet mens du går og pek selv ut hvor ballen ligger.
</p>
<button
type="button"
onClick={measureEndPoint}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
Jeg er ved ballen
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Crosshair className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Jeg er ved ballen </span>
<span className="text-sm text-muted-foreground">Mål med GPS</span>
</span>
</button>
<button
type="button"
onClick={() => {
setEndMethod("map_tap")
setStep("end_map")
}}
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<MapPin className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Vis kart mens jeg går</span>
<span className="text-sm text-muted-foreground">
Se utslagspunktet kartet, og pek ut ballen
</span>
</span>
</button>
</>
) : null}
@ -346,11 +380,33 @@ export function ShotMeasurementSheet({
<Crosshair className="size-5" aria-hidden="true" />
Prøv igjen
</button>
<button
type="button"
onClick={() => {
setEndStatus("idle")
setEndMethod("map_tap")
setStep("end_map")
}}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Velg punkt kart i stedet
</button>
</div>
) : null}
</div>
) : null}
{step === "end_map" ? (
<MapPointPicker
referencePoint={startPoint ?? undefined}
referenceLabel="Utslag"
onConfirm={(p) => {
setEndPoint(p)
setStep("club")
}}
/>
) : null}
{step === "club" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col gap-1">

File diff suppressed because one or more lines are too long