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:
parent
7bcb09a15c
commit
26313c8017
10 changed files with 292 additions and 33 deletions
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
9
061_round_shot_end_method.sql
Normal file
9
061_round_shot_end_method.sql
Normal 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'));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
72
CHANGELOG.md
72
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 på ballposisjon-steget (ADR-048-tillegg 2026-08-08): et fast,
|
||||
* ikke-flyttbart referansepunkt (utslagspunktet) vises på 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 på 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
Gå til der ballen ligger, og trykk når du er fremme.
|
||||
Gå 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 nå
|
||||
<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 nå</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 på 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 på 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
Loading…
Reference in a new issue