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(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(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(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": [
|
"additionalDirectories": [
|
||||||
"/opt/teecup/frontend/app/my-rounds/[id]"
|
"/opt/teecup/frontend/app/my-rounds/[id]"
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,21 @@
|
||||||
"Bash(grep -n \"return { round, leaderboard\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
"Bash(grep -n \"return { round, leaderboard\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
||||||
"WebFetch(domain:docs.mapbox.com)",
|
"WebFetch(domain:docs.mapbox.com)",
|
||||||
"Bash(python3 -m py_compile app/routers/rounds.py app/routers/round_messages.py app/config.py)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/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
|
token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
|
||||||
delingen.
|
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`
|
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
|
||||||
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
||||||
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
|
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
|
**Rullet ut umiddelbart** (samme produksjonsbug-alvorlighet som
|
||||||
punkt 51 -- traff alle som brukte kart-valget utenfor Oslo sentrum)
|
punkt 51 -- traff alle som brukte kart-valget utenfor Oslo sentrum)
|
||||||
-- kun frontend-image, `teecup_api` uendret.
|
-- 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_method: Literal["gps", "map_tap"]
|
||||||
start_lat: float = Field(ge=-90, le=90)
|
start_lat: float = Field(ge=-90, le=90)
|
||||||
start_lng: float = Field(ge=-180, le=180)
|
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_lat: float = Field(ge=-90, le=90)
|
||||||
end_lng: float = Field(ge=-180, le=180)
|
end_lng: float = Field(ge=-180, le=180)
|
||||||
|
|
||||||
|
|
@ -5092,6 +5096,7 @@ class ShotOut(BaseModel):
|
||||||
start_method: str
|
start_method: str
|
||||||
start_lat: float
|
start_lat: float
|
||||||
start_lng: float
|
start_lng: float
|
||||||
|
end_method: str
|
||||||
end_lat: float
|
end_lat: float
|
||||||
end_lng: float
|
end_lng: float
|
||||||
shared_round_message_id: str | None
|
shared_round_message_id: str | None
|
||||||
|
|
@ -5104,7 +5109,7 @@ class ShotOut(BaseModel):
|
||||||
|
|
||||||
_SHOT_SELECT = """
|
_SHOT_SELECT = """
|
||||||
SELECT rs.id::text AS id, rs.shot_number, rs.club, rs.distance_meters, rs.start_method,
|
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.shared_round_message_id::text AS shared_round_message_id,
|
||||||
rs.captured_at, rm.image_key AS shared_image_key
|
rs.captured_at, rm.image_key AS shared_image_key
|
||||||
FROM round_shot rs
|
FROM round_shot rs
|
||||||
|
|
@ -5121,6 +5126,7 @@ def _shot_out(row: asyncpg.Record) -> ShotOut:
|
||||||
start_method=row["start_method"],
|
start_method=row["start_method"],
|
||||||
start_lat=row["start_lat"],
|
start_lat=row["start_lat"],
|
||||||
start_lng=row["start_lng"],
|
start_lng=row["start_lng"],
|
||||||
|
end_method=row["end_method"],
|
||||||
end_lat=row["end_lat"],
|
end_lat=row["end_lat"],
|
||||||
end_lng=row["end_lng"],
|
end_lng=row["end_lng"],
|
||||||
shared_round_message_id=row["shared_round_message_id"],
|
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 (
|
INSERT INTO round_shot (
|
||||||
round_hole_id, recorded_by_user_id, shot_number, club, distance_meters,
|
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 (
|
VALUES (
|
||||||
$1, $2,
|
$1, $2,
|
||||||
COALESCE((SELECT MAX(shot_number) FROM round_shot WHERE round_hole_id = $1), 0) + 1,
|
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
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
hole_id, user_id, body.club, body.distance_meters,
|
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"])
|
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", row["id"])
|
||||||
return _shot_out(full_row)
|
return _shot_out(full_row)
|
||||||
|
|
|
||||||
|
|
@ -2113,6 +2113,7 @@ function ShotMeasurementEntry({
|
||||||
startMethod: "gps" | "map_tap"
|
startMethod: "gps" | "map_tap"
|
||||||
startLat: number
|
startLat: number
|
||||||
startLng: number
|
startLng: number
|
||||||
|
endMethod: "gps" | "map_tap"
|
||||||
endLat: number
|
endLat: number
|
||||||
endLng: number
|
endLng: number
|
||||||
share: boolean
|
share: boolean
|
||||||
|
|
@ -2131,6 +2132,7 @@ function ShotMeasurementEntry({
|
||||||
start_method: result.startMethod,
|
start_method: result.startMethod,
|
||||||
start_lat: result.startLat,
|
start_lat: result.startLat,
|
||||||
start_lng: result.startLng,
|
start_lng: result.startLng,
|
||||||
|
end_method: result.endMethod,
|
||||||
end_lat: result.endLat,
|
end_lat: result.endLat,
|
||||||
end_lng: result.endLng,
|
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
|
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
|
||||||
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
|
* 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
|
* 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
|
* 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 containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
const markerRef = useRef<mapboxgl.Marker | null>(null)
|
const markerRef = useRef<mapboxgl.Marker | null>(null)
|
||||||
|
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
|
||||||
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [point, setPoint] = useState<LngLat | null>(null)
|
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
|
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende
|
||||||
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan
|
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan
|
||||||
// hentes (avslått tillatelse, tidsavbrudd, ingen støtte).
|
// 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
|
if (cancelled || !container) return
|
||||||
mapboxgl.accessToken = token as string
|
mapboxgl.accessToken = token as string
|
||||||
const map = new mapboxgl.Map({
|
const map = new mapboxgl.Map({
|
||||||
container,
|
container,
|
||||||
style: "mapbox://styles/mapbox/satellite-v9",
|
style: "mapbox://styles/mapbox/satellite-v9",
|
||||||
center,
|
center,
|
||||||
zoom: 16,
|
zoom: referencePoint ? 17 : 16,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
mapRef.current = map
|
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."))
|
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.
|
// Tap to place/move a single draggable marker — never re-inits the map.
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
|
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
|
||||||
if (!markerRef.current) {
|
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)
|
.setLngLat(e.lngLat)
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
marker.on("dragend", () => {
|
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 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) {
|
if (typeof navigator !== "undefined" && navigator.geolocation) {
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => initMap([pos.coords.longitude, pos.coords.latitude]),
|
(pos) => {
|
||||||
() => initMap(OSLO_FALLBACK),
|
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 },
|
{ enableHighAccuracy: true, timeout: 8000 },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
initMap(OSLO_FALLBACK)
|
initMap(fallbackCenter)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
referenceMarkerRef.current?.remove()
|
||||||
|
referenceMarkerRef.current = null
|
||||||
markerRef.current?.remove()
|
markerRef.current?.remove()
|
||||||
markerRef.current = null
|
markerRef.current = null
|
||||||
mapRef.current?.remove()
|
mapRef.current?.remove()
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- montert én gang per steg, se komponent-docstring
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -134,11 +183,18 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{loaded && !point && !error ? (
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
|
<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">
|
<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" />
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -151,7 +207,7 @@ export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => v
|
||||||
onClick={() => point && onConfirm(point)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const MapPointPicker = dynamic(
|
||||||
)
|
)
|
||||||
|
|
||||||
type LngLat = { lng: number; lat: number }
|
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"
|
type GeoStatus = "idle" | "loading" | "error"
|
||||||
|
|
||||||
export type ShotMeasurementSheetProps = {
|
export type ShotMeasurementSheetProps = {
|
||||||
|
|
@ -50,6 +50,7 @@ export type ShotMeasurementSheetProps = {
|
||||||
startMethod: "gps" | "map_tap"
|
startMethod: "gps" | "map_tap"
|
||||||
startLat: number
|
startLat: number
|
||||||
startLng: number
|
startLng: number
|
||||||
|
endMethod: "gps" | "map_tap"
|
||||||
endLat: number
|
endLat: number
|
||||||
endLng: number
|
endLng: number
|
||||||
share: boolean
|
share: boolean
|
||||||
|
|
@ -86,6 +87,7 @@ export function ShotMeasurementSheet({
|
||||||
const [step, setStep] = useState<Step>("start")
|
const [step, setStep] = useState<Step>("start")
|
||||||
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
|
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
|
||||||
const [startPoint, setStartPoint] = useState<LngLat | 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 [endPoint, setEndPoint] = useState<LngLat | null>(null)
|
||||||
|
|
||||||
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
|
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.
|
// Ball position via GPS. Deliberately NOT auto-fired on step mount (real
|
||||||
// Deliberately NOT auto-fired on step mount (real bug found 2026-08-08:
|
// bug found 2026-08-08: firing immediately gave the user zero time to
|
||||||
// firing immediately gave the user zero time to actually walk from the
|
// actually walk from the start point to the ball, so start/end ended up
|
||||||
// start point to the ball, so start/end ended up at ~the same spot and
|
// at ~the same spot and ~the same instant -- distance was always 0m).
|
||||||
// ~the same instant -- distance was always 0m). Requires an explicit
|
// Requires an explicit "Jeg er ved ballen nå" tap once the user has
|
||||||
// "Jeg er ved ballen nå" tap once the user has actually walked there.
|
// actually walked there.
|
||||||
function measureEndPoint() {
|
function measureEndPoint() {
|
||||||
|
setEndMethod("gps")
|
||||||
setEndStatus("loading")
|
setEndStatus("loading")
|
||||||
acquirePosition(
|
acquirePosition(
|
||||||
(p) => {
|
(p) => {
|
||||||
|
|
@ -166,9 +169,13 @@ export function ShotMeasurementSheet({
|
||||||
setStartStatus("idle")
|
setStartStatus("idle")
|
||||||
setEndStatus("idle")
|
setEndStatus("idle")
|
||||||
setStep("start")
|
setStep("start")
|
||||||
|
} else if (step === "end_map") {
|
||||||
|
setEndStatus("idle")
|
||||||
|
setStep("end")
|
||||||
} else if (step === "club") {
|
} else if (step === "club") {
|
||||||
// Re-measure the ball position rather than reusing a stale fix.
|
// Re-measure the ball position rather than reusing a stale fix.
|
||||||
setEndPoint(null)
|
setEndPoint(null)
|
||||||
|
setEndMethod(null)
|
||||||
setStep("end")
|
setStep("end")
|
||||||
} else if (step === "result") {
|
} else if (step === "result") {
|
||||||
setStep("club")
|
setStep("club")
|
||||||
|
|
@ -183,6 +190,7 @@ export function ShotMeasurementSheet({
|
||||||
startMethod: startMethod ?? "gps",
|
startMethod: startMethod ?? "gps",
|
||||||
startLat: startPoint.lat,
|
startLat: startPoint.lat,
|
||||||
startLng: startPoint.lng,
|
startLng: startPoint.lng,
|
||||||
|
endMethod: endMethod ?? "gps",
|
||||||
endLat: endPoint.lat,
|
endLat: endPoint.lat,
|
||||||
endLng: endPoint.lng,
|
endLng: endPoint.lng,
|
||||||
share,
|
share,
|
||||||
|
|
@ -190,7 +198,7 @@ export function ShotMeasurementSheet({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMapStep = step === "map"
|
const onMapStep = step === "map" || step === "end_map"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -240,7 +248,7 @@ export function ShotMeasurementSheet({
|
||||||
{step === "start" ? (
|
{step === "start" ? (
|
||||||
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
|
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
|
||||||
<p className="text-base text-muted-foreground text-pretty">
|
<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>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -306,19 +314,45 @@ export function ShotMeasurementSheet({
|
||||||
}} /> : null}
|
}} /> : null}
|
||||||
|
|
||||||
{step === "end" ? (
|
{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" ? (
|
{endStatus === "idle" ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-base text-muted-foreground text-pretty">
|
<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>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={measureEndPoint}
|
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" />
|
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
Jeg er ved ballen nå
|
<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>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -346,11 +380,33 @@ export function ShotMeasurementSheet({
|
||||||
<Crosshair className="size-5" aria-hidden="true" />
|
<Crosshair className="size-5" aria-hidden="true" />
|
||||||
Prøv igjen
|
Prøv igjen
|
||||||
</button>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{step === "end_map" ? (
|
||||||
|
<MapPointPicker
|
||||||
|
referencePoint={startPoint ?? undefined}
|
||||||
|
referenceLabel="Utslag"
|
||||||
|
onConfirm={(p) => {
|
||||||
|
setEndPoint(p)
|
||||||
|
setStep("club")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{step === "club" ? (
|
{step === "club" ? (
|
||||||
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
|
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue