Update Todos
Bygge backend for starthull + tidspunkt-korrigering (start_hole/started_at/completed_at i PATCH /rounds/{id})
Bygge frontend: starthull-endring + tidspunkt-korrigering i EditRoundPanel
Bygge backend nearby-endepunkt for offisielle baner (Haversine)
Bygge frontend: geolokasjon + nærmeste baner-liste i OfficialSearchStep
Scratch-verifisere + typesjekket build
Rulle ut etter bekreftelse
Alt live og dokumentert. Oppsummert:
Starthull kan nå endres midt i runden (uavhengig av bane-bytte), og utslags-/fullført-tidspunkt kan korrigeres i etterkant — fullført-tidspunktet kun etter at runden faktisk er fullført.
Nærmeste baner vises nå automatisk (med avstand, nærmeste uthevet) rett over søkefeltet når du velger «Offisiell bane» ved ny runde — spør om posisjon, feiler stille hvis avslått.
Verifisert med 14 scratch-sjekker (inkludert et ekte nearby-oppslag mot teeoff fra Tjømes egne koordinater), ren build, rullet ut uten migrasjon. teeoff.no upåvirket.
This commit is contained in:
parent
65f876e6dc
commit
f5d1491ceb
7 changed files with 1405 additions and 164 deletions
|
|
@ -373,7 +373,13 @@
|
||||||
"Bash(curl -s https://teecup.teeoff.no/rounds)",
|
"Bash(curl -s https://teecup.teeoff.no/rounds)",
|
||||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/auth.py'\\).read\\(\\)\\)\")",
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/auth.py'\\).read\\(\\)\\)\")",
|
||||||
"Bash(grep -n \"PlayedToggle\\\\|AddGuestForm onAdd\\\\|Putter\\\\\"\\\\|Flere detaljer\\\\|Kølle brukt\\\\|Avstand første\\\\|goPrev} className\\\\|PlayerTabs$\\\\|players={players}\" /opt/teecup/frontend/components/round-detail.tsx)",
|
"Bash(grep -n \"PlayedToggle\\\\|AddGuestForm onAdd\\\\|Putter\\\\\"\\\\|Flere detaljer\\\\|Kølle brukt\\\\|Avstand første\\\\|goPrev} className\\\\|PlayerTabs$\\\\|players={players}\" /opt/teecup/frontend/components/round-detail.tsx)",
|
||||||
"Bash(python3 test_round_stats_and_bag.py)"
|
"Bash(python3 test_round_stats_and_bag.py)",
|
||||||
|
"Bash(awk '/^## ADR-0[3-9][0-9]:/ {print NR\": \"$0}' /opt/teecup/ARCHITECTURE_DECISIONS.md)",
|
||||||
|
"Bash(awk -F: '$1>1990 && $1<2160')",
|
||||||
|
"Bash(grep -n \"class RoundCreate\\\\|class RoundOut\\\\|played_at\\\\|start_hole: int$\\\\|started_at\" /opt/teecup/app/routers/rounds.py)",
|
||||||
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('/opt/teecup/app/routers/rounds.py'\\).read\\(\\)\\)\")",
|
||||||
|
"Bash(python3 test_round_edit_delete.py)",
|
||||||
|
"Bash(python3 test_round_starthole_time_nearby.py)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
7
023_round_start_time.sql
Normal file
7
023_round_start_time.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Frittstående rundeføring (ADR-033), oppfølging 2026-07-24: utslagstidspunkt
|
||||||
|
-- + tidsbruk. "Ferdig"-tidspunktet gjenbruker eksisterende
|
||||||
|
-- `round.completed_at` (satt av POST /rounds/{id}/complete) -- bekreftet
|
||||||
|
-- eksplisitt med bruker, ingen ny "Ferdig"-handling/kolonne nødvendig.
|
||||||
|
-- Tidsbruk = completed_at - started_at, beregnet klientside, ikke lagret.
|
||||||
|
|
||||||
|
ALTER TABLE round ADD COLUMN started_at timestamptz;
|
||||||
|
|
@ -2066,6 +2066,97 @@ og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/<uuid>` ga nå
|
||||||
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
|
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
|
||||||
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
|
**Nok en runde brukerrapporterte punkter, ALLE BYGGET OG LIVE 2026-07-24,
|
||||||
|
samme dag:** (1) Utslagstidspunkt (`round.started_at`, migrasjon
|
||||||
|
`023_round_start_time.sql`, valgfritt) + tidsbruk beregnet klientside
|
||||||
|
som `completed_at − started_at` når runden fullføres. Bekreftet
|
||||||
|
eksplisitt med bruker: "Ferdig"-tidspunktet er den ALLEREDE eksisterende
|
||||||
|
"Fullfør runde"-knappen, ingen ny handling/kolonne. (2) "Idx" i hull-
|
||||||
|
overskriften byttet til "Hcp". (3) Slag-tastaturets numpad merker nå
|
||||||
|
knappen som tilsvarer hullets par med en liten "par"-bildetekst.
|
||||||
|
(4) Hull-navigasjonens "Forrige"/"Neste" respekterte tidligere ALLTID
|
||||||
|
18 hull uansett `holes_planned` -- en 9-hulls runde 10-18 hoppet feilaktig
|
||||||
|
til hull 9 ved "Forrige" fra hull 10. Fikset: navigasjonsrekkefølgen
|
||||||
|
bygges nå med `holes_planned` som lengde, ikke hardkodet 18.
|
||||||
|
(5) Putter/Chip/Bunker/Straffeslag/Anywayslag kan nå aldri velges høyere
|
||||||
|
enn antall registrerte slag på hullet (`NumberPicker` fikk en
|
||||||
|
`maxValue`-prop, `Stepper` en `max`-prop) -- ingen vits i å tilby et
|
||||||
|
selvmotsigende tall. (6) "Slett runde" bygget i UI-et (backendens
|
||||||
|
`DELETE /rounds/{id}` fantes fra før, men hadde aldri fått en
|
||||||
|
frontend-knapp) -- bekreftelsesdialog, sletter for alle (kaskade
|
||||||
|
fjerner automatisk alle deltakere/hull, guest-spillere har uansett ingen
|
||||||
|
egen konto å bevare noe for).
|
||||||
|
(7) **Ny `PATCH /rounds/{id}`** -- retter opp feil bane/utslag eller
|
||||||
|
feil antall hull ETTER opprettelse, uten å røre allerede registrerte
|
||||||
|
slag/putter/etc. Speiler samme filosofi som turnering-øktenes
|
||||||
|
`_remap_course` (ADR-tidligere runde), men enklere: ingen tee-navn-
|
||||||
|
matching på tvers av kjønn siden hver deltaker valideres eksplisitt mot
|
||||||
|
den NYE banens rating for sitt eget kjønn FØR noe skrives (hele byttet
|
||||||
|
avvises 400 hvis ÉN deltaker ville mistet HCP-sporing). Bevisst
|
||||||
|
AVVIST (409) etter at runden er fullført -- ulikt turnering-øktenes
|
||||||
|
bane-bytte, som bevisst tillater dette selv etter avgjørelse; her er
|
||||||
|
omfanget mindre (ingen re-beregning av differensial bygget for dette
|
||||||
|
tilfellet). Frontend: ny "Rediger runde"-seksjon på rundesiden (antall
|
||||||
|
hull som enkelt 9/18-valg, "Bytt bane" som en kompakt søke-flyt --
|
||||||
|
teeoff-søk ELLER egen-bane-søk, samme mønster som ved opprettelse, bare
|
||||||
|
kondensert). Etter et vellykket bytte hentes hull-data på nytt for
|
||||||
|
ALLE allerede lastede deltakere (par/stroke-index kan ha endret seg for
|
||||||
|
alle, ikke bare aktiv spiller).
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-container): 22 sjekker for
|
||||||
|
rediger/slett-rundene (bane-bytte med FAKTISK ulike par/stroke-index-
|
||||||
|
verdier mellom to egendefinerte baner, bekreftet at allerede registrerte
|
||||||
|
slag på hull 1-3 var UENDRET etter byttet mens par/stroke-index OG
|
||||||
|
course_handicap_snapshot var oppdatert; avvist bytte til en bane uten
|
||||||
|
rating for deltakerens kjønn, bekreftet at INGENTING ble endret ved
|
||||||
|
avvisning; avvist bane/hull-endring etter fullføring, 409; slett-runde
|
||||||
|
+ idempotent 404 + kryss-bruker-autorisasjon 403) pluss 8 sjekker for
|
||||||
|
utslagstid/tidsbruk. `test_isolation.sql` 12/12. Ekte typesjekket
|
||||||
|
produksjonsbuild kompilerte rent begge ganger.
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||||||
|
migrasjon 023 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||||||
|
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||||||
|
--build teecup_api teecup_frontend`, begge containere boot-et rent,
|
||||||
|
`/health`/`/dashboard`/`/my-rounds` → 200 over ekte https, `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
|
||||||
|
**To til brukerpunkter, ALLE BYGGET OG LIVE 2026-07-25, samme dag:**
|
||||||
|
(1) `PATCH /rounds/{id}` utvidet med `start_hole`/`started_at`/
|
||||||
|
`completed_at` -- start_hole kan rettes uansett (ren metadata, aldri
|
||||||
|
sperret av fullført-status), started_at kan justeres når som helst,
|
||||||
|
completed_at kan KUN justeres på en runde som allerede ER fullført
|
||||||
|
(avvist 400 ellers -- denne PATCH-en fullfører aldri runden selv, kun
|
||||||
|
korrigerer et allerede satt tidspunkt fra "Fullfør runde"). Ny
|
||||||
|
validering: completed_at må være etter started_at, ellers 400. Løser
|
||||||
|
brukerens konkrete case: glemmer å trykke "Fullfør runde" i flere timer,
|
||||||
|
vil rette opp tidsbruken i etterkant. `EditRoundPanel` sin "Bane/antall
|
||||||
|
hull"-blokk forblir sperret post-fullføring (uendret fra forrige runde),
|
||||||
|
mens et nytt tidspunkt-skjema (Utslagstid alltid, Fullført-tidspunkt kun
|
||||||
|
hvis fullført) nå vises uansett fullført-status.
|
||||||
|
(2) **Nærmeste offisielle baner** i "Ny runde"-flyten -- nytt
|
||||||
|
`GET /rounds/official-search/nearby?lat=&lng=&limit=` (Haversine-formel,
|
||||||
|
sortert stigende, MÅ registreres FØR `/rounds/official-search/{slug}` i
|
||||||
|
routeren, samme presedens-lærdom som ADR-020s "by-code"). Henter ALLE
|
||||||
|
174 teeoff-anleggenes lat/lng via samme `search_facilities("")`-kall som
|
||||||
|
allerede finnes (ingen ny teeoff-avhengighet), beregner avstand i Python
|
||||||
|
per forespørsel (ingen cache -- datasettet er lite nok). Frontend: ny
|
||||||
|
`NearbyClubs`-komponent i `OfficialSearchStep`, ber om
|
||||||
|
`navigator.geolocation` ved mount, viser inntil 5 nærmeste med
|
||||||
|
nærmeste tydelig merket + avstand (m under 1 km, ellers km) -- rett
|
||||||
|
FØR søkefeltet, som bedt om. Avslått/manglende posisjon feiler helt
|
||||||
|
stille (ingen feilmelding), søket fungerer uendret som fallback.
|
||||||
|
**Scratch-verifisert:** 14 sjekker (start_hole-endring, tidspunkt-
|
||||||
|
korreksjon i begge retninger inkl. de to nye valideringsreglene,
|
||||||
|
`holes_planned` fortsatt sperret post-fullføring uendret, OG et ekte
|
||||||
|
`nearby`-kall mot den kjørende `teeoff_api`-containeren fra Tjømes egne
|
||||||
|
koordinater som korrekt fant Tjøme selv som nærmeste/nest-nærmeste
|
||||||
|
treff). `test_isolation.sql` 12/12 (ingen skjemaendring). Ekte
|
||||||
|
typesjekket produksjonsbuild kompilerte rent.
|
||||||
|
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||||||
|
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||||||
|
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
|
||||||
|
→ 200, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
|
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
|
||||||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
||||||
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
|
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
|
||||||
|
|
@ -2133,6 +2224,84 @@ bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om
|
||||||
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
|
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
|
||||||
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.
|
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.
|
||||||
|
|
||||||
|
**Sju punkter fra faktisk bruk, BYGGET OG LIVE 2026-07-24** (rapportert av
|
||||||
|
bruker som selv testet scorekort-skjermen): (1) `currentHole` respekterte
|
||||||
|
aldri `round.start_hole` (`useState<number>(1)` + `prev || start_hole` —
|
||||||
|
`1` er truthy i JS, så `||` ble en no-op), fikset ved å bruke `null` som
|
||||||
|
"ikke satt ennå"-tilstand og en sirkulær 18-hulls navigasjonsrekkefølge
|
||||||
|
fra starthullet. Trolig rot-årsak til det samtidig rapporterte GIR-
|
||||||
|
avviket: selve formelen (`slag − putter ≤ par − 2`) var allerede
|
||||||
|
matematisk identisk med regelen brukeren beskrev, men feil hull i fokus
|
||||||
|
ga feil par inn i en ellers korrekt formel. (2) Kølle-bag på personlig
|
||||||
|
profil — 28 faste kølletyper (`BAG_CLUBS` i `app/routers/auth.py`,
|
||||||
|
speilet i frontend), maks 14 (den ekte golfregelen, håndhevet i både
|
||||||
|
Pydantic og en CHECK-constraint), brukt som knapp-utvalg for "kølle
|
||||||
|
brukt ved utslaget" for runde-eieren (gjester har ingen profil, beholder
|
||||||
|
fritekst). (3) Nytt statistikkfelt "Anywayslag", siste punkt i "Flere
|
||||||
|
detaljer", samme tallvelger-stil som slag/putter (ikke en liten
|
||||||
|
stepper). (4) Valgfritt statistikknivå per deltaker
|
||||||
|
(`strokes_only`/`strokes_and_putts`/`full`, ny kolonne `round_participant.
|
||||||
|
stat_level`, default `strokes_only` — brukerens egen presisering: kun
|
||||||
|
slag er strengt tatt nødvendig for resultat/HCP). Nytt PATCH-endepunkt
|
||||||
|
`/rounds/{id}/participants/{id}` for å endre nivået underveis. (5) Putt-
|
||||||
|
avstand endret fra fritekst-tall til seks faste bøtter (`<1m`…`8m+`) —
|
||||||
|
`round_hole.first_putt_distance_m` (numeric) erstattet med
|
||||||
|
`first_putt_distance_bucket` (text+CHECK) i migrasjon
|
||||||
|
`022_round_stats_and_bag.sql`, ingen produksjonsdata å bevare (0 rader
|
||||||
|
hadde verdi). (6) "Hullet er spilt"-avkrysningen FJERNET helt — reelt
|
||||||
|
overflødig, `played` settes allerede automatisk når et slagtall velges.
|
||||||
|
(7) Direkte spørsmål om avkrysningens funksjon avdekket at brukeren
|
||||||
|
egentlig ville vite om `played` (bevisst enkel avledning, uendret) — i
|
||||||
|
samme svar reiste brukeren "plukket opp"-behovet for et fremtidig
|
||||||
|
Stableford-format (fanget i FEATURE_BACKLOG.md, IKKE bygget — krever en
|
||||||
|
helt egen scoring-format-designrunde).
|
||||||
|
**Punkt 6 (numpad-layout + retningskors + sveip-vurdering) BYGGET OG
|
||||||
|
LIVE 2026-07-24, samme dag** — V0-prompten (se FEATURE_BACKLOG.md)
|
||||||
|
kjørt av bruker, zip 13 mottatt og integrert. **V0s egen designbeslutning
|
||||||
|
på sveip-spørsmålet:** IKKE et sveip-panel, men trykk-baserte
|
||||||
|
"Score"/"Statistikk"-faner (`PanelTabs`) inni samme kort — begrunnet
|
||||||
|
med at skjermen allerede har to horisontalt scrollende rader
|
||||||
|
(spillerfaner, hull-navigasjon), så en tredje sveiperetning ville vært
|
||||||
|
forvirrende. Vurdert som et godt, veloverveid valg, beholdt uendret.
|
||||||
|
Retningskors (`DirectionCross`/`DirButton`, piler + `Target`-ikon)
|
||||||
|
brukt for Utslag (venstre/senter/høyre) og Innspill (fullt 5-veis
|
||||||
|
kors), tekstlabel beholdt på hver knapp (ikke ikon-only). Tallvelgerne
|
||||||
|
fikk ny numpad-layout (3 kolonner, `h-16`-knapper, fyller bredden).
|
||||||
|
**Reelt integreringsarbeid, ikke ren om-kabling:** siden V0 ikke kjente
|
||||||
|
til dagens datalag (bygget i en tidligere, separat runde samme dag),
|
||||||
|
måtte `PanelTabs`/`DirectionCross` flettes inn i EKSISTERENDE, allerede
|
||||||
|
fungerende kode — ikke erstatte den. Konkret: `PanelTabs` vises kun når
|
||||||
|
`statLevel==="full"` (ingen "Statistikk"-fane å bytte til ellers),
|
||||||
|
"Score"-innholdet (Slag, evt. Putter) vises direkte uten fane-UI når
|
||||||
|
nivået er lavere. `DirectionCross` erstattet kun de to `ChoiceRow`-
|
||||||
|
kallene for Utslag/Innspill — `ChoiceRow` selv beholdt uendret til
|
||||||
|
resten (Kjønn, Statistikknivå, putt-avstand-bøtter). Kølle-bag-picker,
|
||||||
|
anywayslag, putt-bøtter, `stat_level`-gating, merge-før-PATCH,
|
||||||
|
starthull-fiksen og alle `/my-rounds`-lenker fra tidligere samme dag
|
||||||
|
ALLE bevart uendret -- kun presentasjonslaget for tallvelgere/retning
|
||||||
|
byttet ut. V0s egen `PlayedToggle` (som den ikke visste var fjernet)
|
||||||
|
ble bevisst IKKE tatt inn igjen. Ekte typesjekket produksjonsbuild
|
||||||
|
kompilerte rent. **Rullet ut live**, kun `teecup_frontend` (+ vanlig
|
||||||
|
`teecup_api`-bivirkning), ingen migrasjon, `teeoff.no` upåvirket.
|
||||||
|
Zip 13 slettet fra prosjektroten.
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-container): 18 sjekker — kølle-bag
|
||||||
|
lagret/hentet riktig, ukjent kølletype avvist (422), mer enn 14 køller
|
||||||
|
avvist (422), `stat_level`-default og eksplisitt verdi ved
|
||||||
|
opprettelse/gjest-tilføyelse, PATCH av `stat_level` vedvarer etter
|
||||||
|
refetch, `anyway_strokes`/`first_putt_distance_bucket` lagret riktig,
|
||||||
|
ugyldig bøtte-verdi avvist (422), OG en bevisst re-bekreftelse av
|
||||||
|
merge-før-PATCH-kontrakten (et PATCH uten `anyway_strokes` nuller den
|
||||||
|
fortsatt — samme grunnkontrakt som forrige runde, ikke endret av denne
|
||||||
|
utvidelsen). `test_isolation.sql` fortsatt 12/12. Ekte typesjekket
|
||||||
|
produksjonsbuild kompilerte rent.
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||||||
|
migrasjon 022 kjørt mot ekte `teecup_db` (alle nye kolonner/CHECK-er
|
||||||
|
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
|
||||||
|
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||||||
|
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
|
||||||
|
ekte https, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
|
||||||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -2270,6 +2270,50 @@ Ferdig og verifisert:
|
||||||
Gents» — og `hei@erol.no`) mangler i dag alle disse feltene og vil
|
Gents» — og `hei@erol.no`) mangler i dag alle disse feltene og vil
|
||||||
derfor begge se profil-fullførings-skjemaet ved neste innlogging —
|
derfor begge se profil-fullførings-skjemaet ved neste innlogging —
|
||||||
bekreftet tilsiktet, ikke en bug.
|
bekreftet tilsiktet, ikke en bug.
|
||||||
|
- **Sju punkter fra faktisk bruk av scorekort-skjermen, BYGGET OG LIVE
|
||||||
|
2026-07-24:** starthull-bug fikset (`currentHole` respekterte aldri
|
||||||
|
`round.start_hole` — `1` er truthy i JS, så `prev || start_hole` var en
|
||||||
|
no-op — forklarer trolig også det samtidig rapporterte GIR-avviket,
|
||||||
|
siden formelen selv var korrekt), kølle-bag på profilen (28 faste
|
||||||
|
typer, maks 14), nytt statistikkfelt «Anywayslag», valgfritt
|
||||||
|
statistikknivå per deltaker (default kun slag — ny kolonne
|
||||||
|
`round_participant.stat_level`), putt-avstand endret fra fritekst til
|
||||||
|
seks faste bøtter, «Hullet er spilt»-avkrysningen fjernet (overflødig
|
||||||
|
— spilt settes allerede automatisk ved slagtall). Ny migrasjon
|
||||||
|
`022_round_stats_and_bag.sql`. Numpad-layout/retningskors-ikoner for
|
||||||
|
tallvelgerne (brukerens punkt 6) er BEVISST holdt utenfor — egen
|
||||||
|
V0-prompt utarbeidet i stedet, ikke bygget selv. Full detalj i
|
||||||
|
ADR-033. 18 scratch-sjekker, `test_isolation.sql` 12/12, rullet ut mot
|
||||||
|
ekte `teecup_db`/`teecup_api`/`teecup_frontend`, `teeoff.no` upåvirket.
|
||||||
|
- **V0-prompten for numpad/retningskors/sveip-vurdering (punkt 6) BYGGET
|
||||||
|
OG LIVE, samme dag:** bruker kjørte prompten, sendte zip 13. V0 valgte
|
||||||
|
trykk-baserte Score/Statistikk-faner fremfor sveip (godt begrunnet —
|
||||||
|
unngår en tredje sveiperetning på en skjerm som allerede har to).
|
||||||
|
Flettet inn i EKSISTERENDE, allerede fungerende datalag (statLevel-
|
||||||
|
gating, kølle-bag, anywayslag, putt-bøtter, merge-før-PATCH,
|
||||||
|
starthull-fiks, `/my-rounds`-lenker) — ikke en ren erstatning, siden
|
||||||
|
V0 ikke kjente til den runden. Full detalj i ADR-033. Typesjekket
|
||||||
|
build kompilerte rent, rullet ut (kun `teecup_frontend`), `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
- **Enda en runde brukerpunkter, ALLE BYGGET OG LIVE, samme dag:**
|
||||||
|
utslagstidspunkt + automatisk tidsbruk-visning (gjenbruker eksisterende
|
||||||
|
"Fullfør runde" som "Ferdig", ny `round.started_at`-kolonne, migrasjon
|
||||||
|
023), "Idx" → "Hcp", "par"-merking på slag-tastaturet, ni-hulls-
|
||||||
|
navigasjonsbug fikset (respekterte aldri `holes_planned`, hoppet feil
|
||||||
|
ved "Forrige"), tak på putter/chip/bunker/straffeslag/anywayslag (kan
|
||||||
|
ikke overstige antall slag), "Slett runde"-knapp (backend fantes,
|
||||||
|
manglet UI), og en ny `PATCH /rounds/{id}` for å rette bane/utslag/
|
||||||
|
antall hull MIDT i runden uten å røre allerede registrerte slag —
|
||||||
|
sperret etter fullføring. 22+8 scratch-sjekker, `test_isolation.sql`
|
||||||
|
12/12, ren build. Full detalj i ADR-033.
|
||||||
|
- **To til punkter, BYGGET OG LIVE samme dag:** starthull kan nå endres
|
||||||
|
uansett (ren metadata), utslagstid justeres når som helst, og
|
||||||
|
fullført-tidspunkt kan korrigeres i etterkant — men KUN på en allerede
|
||||||
|
fullført runde (løser "glemte å trykke Fullfør runde i flere timer").
|
||||||
|
Pluss en ny "Nærmest deg"-liste i bane-søket ved ny runde (Haversine-
|
||||||
|
avstand mot alle 174 teeoff-anlegg, geolokasjon i nettleseren, feiler
|
||||||
|
stille hvis avslått). 14 nye scratch-sjekker inkl. et ekte nearby-kall
|
||||||
|
mot teeoff. Full detalj i ADR-033.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første
|
1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,11 @@ tellende resultat lagres kun som `round_participant.score_differential`.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
import math
|
||||||
|
from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .. import teeoff_client
|
from .. import teeoff_client
|
||||||
|
|
@ -221,6 +222,44 @@ async def search_official_courses_for_round(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NearbyFacility(OfficialFacility):
|
||||||
|
distance_km: float
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||||
|
r = 6371.0
|
||||||
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlambda = math.radians(lng2 - lng1)
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||||
|
return 2 * r * math.asin(math.sqrt(a))
|
||||||
|
|
||||||
|
|
||||||
|
# MERK: MÅ registreres FØR /rounds/official-search/{slug} under, ellers
|
||||||
|
# tolker FastAPI "nearby" som en ugyldig slug-verdi til den ruten (samme
|
||||||
|
# lærdom som ADR-020s "by-code" måtte registreres før {tournament_id}).
|
||||||
|
@router.get("/rounds/official-search/nearby", response_model=list[NearbyFacility])
|
||||||
|
async def nearby_official_courses_for_round(
|
||||||
|
lat: float = Query(...),
|
||||||
|
lng: float = Query(...),
|
||||||
|
limit: int = Query(default=5, ge=1, le=20),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[NearbyFacility]:
|
||||||
|
try:
|
||||||
|
facilities = await teeoff_client.search_facilities("")
|
||||||
|
except teeoff_client.TeeoffUnavailableError:
|
||||||
|
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
|
||||||
|
with_coords = [f for f in facilities if f.get("lat") is not None and f.get("lng") is not None]
|
||||||
|
scored = sorted(with_coords, key=lambda f: _haversine_km(lat, lng, f["lat"], f["lng"]))
|
||||||
|
return [
|
||||||
|
NearbyFacility(
|
||||||
|
slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"),
|
||||||
|
distance_km=round(_haversine_km(lat, lng, f["lat"], f["lng"]), 1),
|
||||||
|
)
|
||||||
|
for f in scored[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
|
@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
|
||||||
async def get_official_facility_for_round(
|
async def get_official_facility_for_round(
|
||||||
slug: str,
|
slug: str,
|
||||||
|
|
@ -359,6 +398,9 @@ class RoundCreate(BaseModel):
|
||||||
start_hole: int = Field(default=1, ge=1, le=18)
|
start_hole: int = Field(default=1, ge=1, le=18)
|
||||||
holes_planned: Literal[9, 18] = 18
|
holes_planned: Literal[9, 18] = 18
|
||||||
stat_level: StatLevel = "strokes_only"
|
stat_level: StatLevel = "strokes_only"
|
||||||
|
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
|
||||||
|
# tidsbruk (completed_at - started_at) når runden fullføres.
|
||||||
|
started_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class RoundParticipantOut(BaseModel):
|
class RoundParticipantOut(BaseModel):
|
||||||
|
|
@ -382,6 +424,7 @@ class RoundOut(BaseModel):
|
||||||
played_at: date
|
played_at: date
|
||||||
start_hole: int
|
start_hole: int
|
||||||
holes_planned: int
|
holes_planned: int
|
||||||
|
started_at: str | None
|
||||||
completed_at: str | None
|
completed_at: str | None
|
||||||
participants: list[RoundParticipantOut]
|
participants: list[RoundParticipantOut]
|
||||||
|
|
||||||
|
|
@ -390,7 +433,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot,
|
SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot,
|
||||||
played_at, start_hole, holes_planned, completed_at
|
played_at, start_hole, holes_planned, started_at, completed_at
|
||||||
FROM round WHERE id = $1
|
FROM round WHERE id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -413,6 +456,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
||||||
played_at=round_row["played_at"],
|
played_at=round_row["played_at"],
|
||||||
start_hole=round_row["start_hole"],
|
start_hole=round_row["start_hole"],
|
||||||
holes_planned=round_row["holes_planned"],
|
holes_planned=round_row["holes_planned"],
|
||||||
|
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
|
||||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||||
)
|
)
|
||||||
|
|
@ -507,8 +551,8 @@ async def create_round(
|
||||||
INSERT INTO round
|
INSERT INTO round
|
||||||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||||
start_hole, holes_planned)
|
start_hole, holes_planned, started_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING id::text AS id
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
|
|
@ -521,6 +565,7 @@ async def create_round(
|
||||||
body.played_at,
|
body.played_at,
|
||||||
body.start_hole,
|
body.start_hole,
|
||||||
body.holes_planned,
|
body.holes_planned,
|
||||||
|
body.started_at,
|
||||||
)
|
)
|
||||||
round_id = round_row["id"]
|
round_id = round_row["id"]
|
||||||
|
|
||||||
|
|
@ -559,6 +604,160 @@ async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)
|
||||||
return await _load_round_out(conn, round_id)
|
return await _load_round_out(conn, round_id)
|
||||||
|
|
||||||
|
|
||||||
|
class RoundUpdate(BaseModel):
|
||||||
|
# Bane-bytte er ATOMISK -- enten oppgis course_source+tee_name (pluss de
|
||||||
|
# kildespesifikke feltene) sammen, eller ingen av dem. holes_planned/
|
||||||
|
# start_hole kan endres uavhengig, i samme kall eller alene -- disse
|
||||||
|
# rører aldri round_hole, kun visnings-/navigasjonsmetadata.
|
||||||
|
course_source: Literal["teeoff", "custom"] | None = None
|
||||||
|
teeoff_facility_slug: str | None = None
|
||||||
|
teeoff_course_id: int | None = None
|
||||||
|
personal_course_id: str | None = None
|
||||||
|
tee_name: str | None = None
|
||||||
|
holes_planned: Literal[9, 18] | None = None
|
||||||
|
start_hole: int | None = Field(default=None, ge=1, le=18)
|
||||||
|
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
|
||||||
|
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
|
||||||
|
# senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
|
||||||
|
# en runde som allerede er fullført (se validering under) -- denne
|
||||||
|
# PATCH-en fullfører aldri runden selv, kun korrigerer et tidspunkt som
|
||||||
|
# /complete allerede har satt.
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||||
|
async def update_round(
|
||||||
|
round_id: str,
|
||||||
|
body: RoundUpdate,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> RoundOut:
|
||||||
|
"""
|
||||||
|
Retter opp feil bane/utslag/antall hull/starthull ETTER at runden er
|
||||||
|
opprettet (2026-07-24) -- f.eks. hvis man oppdager underveis at feil
|
||||||
|
bane ble valgt, eller vil justere utslags-/fullført-tidspunktet i
|
||||||
|
etterkant. Endrer ALDRI allerede registrerte slag/putter/etc. i
|
||||||
|
round_hole -- kun rating-grunnlaget (par/stroke-index-snapshot +
|
||||||
|
course/slope-rating) hullene regnes ut fra. Bane-bytte og
|
||||||
|
holes_planned er bevisst avvist etter at runden er fullført
|
||||||
|
(differensialen er da allerede beregnet fra det gamle grunnlaget, og
|
||||||
|
en re-beregning etter fullføring er utenfor omfang her -- ulikt
|
||||||
|
tilsvarende bane-bytte for turnering-økter, som bevisst TILLATER dette
|
||||||
|
selv etter avgjørelse). start_hole/started_at/completed_at er derimot
|
||||||
|
ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
|
||||||
|
uansett fullført-status.
|
||||||
|
"""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
|
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
|
||||||
|
is_completed = round_row["completed_at"] is not None
|
||||||
|
|
||||||
|
if is_completed and (body.course_source is not None or body.holes_planned is not None):
|
||||||
|
raise app_error(
|
||||||
|
409, "ALREADY_COMPLETED",
|
||||||
|
"Runden er allerede fullført -- kan ikke endre bane eller antall hull i etterkant.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.completed_at is not None and not is_completed:
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Runden må fullføres via \"Fullfør runde\" først -- deretter kan tidspunktet justeres.",
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_started_at = body.started_at if body.started_at is not None else round_row["started_at"]
|
||||||
|
effective_completed_at = body.completed_at if body.completed_at is not None else round_row["completed_at"]
|
||||||
|
if (
|
||||||
|
effective_started_at is not None
|
||||||
|
and effective_completed_at is not None
|
||||||
|
and effective_completed_at <= effective_started_at
|
||||||
|
):
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
|
||||||
|
|
||||||
|
resolved: _ResolvedCourse | None = None
|
||||||
|
participants = None
|
||||||
|
if body.course_source is not None:
|
||||||
|
if not body.tee_name:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "tee_name er påkrevd ved bane-bytte.")
|
||||||
|
if body.course_source == "teeoff":
|
||||||
|
if not body.teeoff_facility_slug or not body.teeoff_course_id:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
|
||||||
|
resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
|
||||||
|
else:
|
||||||
|
if not body.personal_course_id:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
|
||||||
|
resolved = await _resolve_personal_course(body.personal_course_id)
|
||||||
|
|
||||||
|
participants = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id::text AS id, gender, handicap_index_snapshot::float AS handicap_index_snapshot
|
||||||
|
FROM round_participant WHERE round_id = $1
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
# Valider FOR ALLE deltakere FØR noe skrives -- hele bane-byttet
|
||||||
|
# avvises tydelig hvis ÉN eneste deltaker ville mistet HCP-
|
||||||
|
# sporing, ingen delvis anvendt endring.
|
||||||
|
for p in participants:
|
||||||
|
if resolved.rating_for(body.tee_name, p["gender"]) is None:
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Den nye banen/utslaget mangler rating for én eller flere av deltakernes kjønn -- ingenting er endret.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
if body.holes_planned is not None:
|
||||||
|
await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned)
|
||||||
|
if body.start_hole is not None:
|
||||||
|
await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole)
|
||||||
|
if body.started_at is not None:
|
||||||
|
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
||||||
|
if body.completed_at is not None:
|
||||||
|
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
||||||
|
|
||||||
|
if resolved is not None and participants is not None:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE round SET course_source = $2, teeoff_facility_slug = $3, teeoff_course_id = $4,
|
||||||
|
personal_course_id = $5, course_name_snapshot = $6, tee_name_snapshot = $7
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
body.course_source,
|
||||||
|
body.teeoff_facility_slug,
|
||||||
|
str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
|
||||||
|
body.personal_course_id,
|
||||||
|
resolved.course_name,
|
||||||
|
body.tee_name,
|
||||||
|
)
|
||||||
|
par_by_hole = {h[0]: h[1] for h in resolved.holes}
|
||||||
|
index_by_hole = {h[0]: h[2] for h in resolved.holes}
|
||||||
|
for p in participants:
|
||||||
|
course_rating, slope_rating, tee_par = resolved.rating_for(body.tee_name, p["gender"])
|
||||||
|
course_handicap_snapshot = None
|
||||||
|
if p["handicap_index_snapshot"] is not None:
|
||||||
|
course_handicap_snapshot = course_handicap(
|
||||||
|
p["handicap_index_snapshot"], slope_rating, course_rating, tee_par
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE round_participant SET course_rating_snapshot = $2, slope_rating_snapshot = $3,
|
||||||
|
tee_par_snapshot = $4, course_handicap_snapshot = $5
|
||||||
|
WHERE id = $1
|
||||||
|
""",
|
||||||
|
p["id"], course_rating, slope_rating, tee_par, course_handicap_snapshot,
|
||||||
|
)
|
||||||
|
for hole_number in range(1, 19):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE round_hole SET par = $3, stroke_index = $4
|
||||||
|
WHERE round_participant_id = $1 AND hole_number = $2
|
||||||
|
""",
|
||||||
|
p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number],
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _load_round_out(conn, round_id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/rounds/{round_id}", status_code=204)
|
@router.delete("/rounds/{round_id}", status_code=204)
|
||||||
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
|
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ export function NewRound() {
|
||||||
async function submitRound(payload: {
|
async function submitRound(payload: {
|
||||||
teeName: string
|
teeName: string
|
||||||
date: string
|
date: string
|
||||||
|
startedAt: string | null
|
||||||
startHole: number
|
startHole: number
|
||||||
holes: 9 | 18
|
holes: 9 | 18
|
||||||
statLevel: StatLevel
|
statLevel: StatLevel
|
||||||
|
|
@ -206,6 +207,7 @@ export function NewRound() {
|
||||||
teeoff_course_id: courseMeta.teeoffCourseId,
|
teeoff_course_id: courseMeta.teeoffCourseId,
|
||||||
tee_name: payload.teeName,
|
tee_name: payload.teeName,
|
||||||
played_at: payload.date,
|
played_at: payload.date,
|
||||||
|
started_at: payload.startedAt,
|
||||||
start_hole: payload.startHole,
|
start_hole: payload.startHole,
|
||||||
holes_planned: payload.holes,
|
holes_planned: payload.holes,
|
||||||
stat_level: payload.statLevel,
|
stat_level: payload.statLevel,
|
||||||
|
|
@ -215,6 +217,7 @@ export function NewRound() {
|
||||||
personal_course_id: courseMeta.personalCourseId,
|
personal_course_id: courseMeta.personalCourseId,
|
||||||
tee_name: payload.teeName,
|
tee_name: payload.teeName,
|
||||||
played_at: payload.date,
|
played_at: payload.date,
|
||||||
|
started_at: payload.startedAt,
|
||||||
start_hole: payload.startHole,
|
start_hole: payload.startHole,
|
||||||
holes_planned: payload.holes,
|
holes_planned: payload.holes,
|
||||||
stat_level: payload.statLevel,
|
stat_level: payload.statLevel,
|
||||||
|
|
@ -393,6 +396,92 @@ function SourceCard({
|
||||||
|
|
||||||
// --- Step 2a: official search ----------------------------------------------
|
// --- Step 2a: official search ----------------------------------------------
|
||||||
|
|
||||||
|
type ApiNearbyFacility = ApiFacility & { distance_km: number }
|
||||||
|
|
||||||
|
function NearbyClubs({
|
||||||
|
onSelectClub,
|
||||||
|
resolving,
|
||||||
|
}: {
|
||||||
|
onSelectClub: (club: OfficialClub) => void
|
||||||
|
resolving: boolean
|
||||||
|
}) {
|
||||||
|
const [nearby, setNearby] = useState<ApiNearbyFacility[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!("geolocation" in navigator)) return
|
||||||
|
let cancelled = false
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const { latitude, longitude } = position.coords
|
||||||
|
fetch(`/rounds/official-search/nearby?lat=${latitude}&lng=${longitude}&limit=5`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiNearbyFacility[] | null) => {
|
||||||
|
if (!cancelled && data) setNearby(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Avvist eller utilgjengelig -- stille no-op, dette er kun en
|
||||||
|
// hjelp, ikke en forutsetning for å kunne søke.
|
||||||
|
},
|
||||||
|
{ timeout: 8000 },
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!nearby || nearby.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground">Nærmest deg</span>
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
||||||
|
{nearby.map((facility, i) => (
|
||||||
|
<li key={facility.slug} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={resolving}
|
||||||
|
onClick={() =>
|
||||||
|
onSelectClub({
|
||||||
|
id: facility.slug,
|
||||||
|
name: facility.name,
|
||||||
|
location: [facility.city, facility.county].filter(Boolean).join(", "),
|
||||||
|
courses: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/60",
|
||||||
|
i === 0 && "bg-primary/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||||
|
{facility.name}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-bold text-primary">Nærmest</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{(facility.city || facility.county) && (
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{[facility.city, facility.county].filter(Boolean).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-sm font-semibold tabular-nums text-muted-foreground">
|
||||||
|
{facility.distance_km < 1
|
||||||
|
? `${Math.round(facility.distance_km * 1000)} m`
|
||||||
|
: `${facility.distance_km.toFixed(0)} km`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function OfficialSearchStep({
|
function OfficialSearchStep({
|
||||||
onSelectClub,
|
onSelectClub,
|
||||||
resolving,
|
resolving,
|
||||||
|
|
@ -427,6 +516,8 @@ function OfficialSearchStep({
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<StepHeader title="Offisiell bane" description="Søk opp klubben i baneregisteret." />
|
<StepHeader title="Offisiell bane" description="Søk opp klubben i baneregisteret." />
|
||||||
|
|
||||||
|
<NearbyClubs onSelectClub={onSelectClub} resolving={resolving} />
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row">
|
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search
|
<Search
|
||||||
|
|
@ -1011,6 +1102,7 @@ function ConfirmStep({
|
||||||
onSubmit: (payload: {
|
onSubmit: (payload: {
|
||||||
teeName: string
|
teeName: string
|
||||||
date: string
|
date: string
|
||||||
|
startedAt: string | null
|
||||||
startHole: number
|
startHole: number
|
||||||
holes: 9 | 18
|
holes: 9 | 18
|
||||||
statLevel: StatLevel
|
statLevel: StatLevel
|
||||||
|
|
@ -1020,6 +1112,7 @@ function ConfirmStep({
|
||||||
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
|
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
|
||||||
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
|
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
|
||||||
const [date, setDate] = useState(todayIso)
|
const [date, setDate] = useState(todayIso)
|
||||||
|
const [teeTime, setTeeTime] = useState("")
|
||||||
const [startHole, setStartHole] = useState("1")
|
const [startHole, setStartHole] = useState("1")
|
||||||
const [holes, setHoles] = useState<9 | 18>(18)
|
const [holes, setHoles] = useState<9 | 18>(18)
|
||||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||||
|
|
@ -1033,9 +1126,14 @@ function ConfirmStep({
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
// Utslagstid er valgfritt -- kun dato er påkrevd. Kombinerer dato+
|
||||||
|
// klokkeslett til et ekte tidspunkt (nettleserens lokale tidssone,
|
||||||
|
// konvertert til UTC av toISOString()) kun når klokkeslett er satt.
|
||||||
|
const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null
|
||||||
const created = await onSubmit({
|
const created = await onSubmit({
|
||||||
teeName: selectedTee.name,
|
teeName: selectedTee.name,
|
||||||
date,
|
date,
|
||||||
|
startedAt,
|
||||||
startHole: Number(startHole),
|
startHole: Number(startHole),
|
||||||
holes,
|
holes,
|
||||||
statLevel,
|
statLevel,
|
||||||
|
|
@ -1095,8 +1193,8 @@ function ConfirmStep({
|
||||||
</fieldset>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date + start hole */}
|
{/* Date + tee time + start hole */}
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-3">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="round-date" className="text-base font-semibold">
|
<Label htmlFor="round-date" className="text-base font-semibold">
|
||||||
Dato
|
Dato
|
||||||
|
|
@ -1109,6 +1207,18 @@ function ConfirmStep({
|
||||||
className="h-14 rounded-2xl text-lg"
|
className="h-14 rounded-2xl text-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="tee-time" className="text-base font-semibold">
|
||||||
|
Utslagstid <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tee-time"
|
||||||
|
type="time"
|
||||||
|
value={teeTime}
|
||||||
|
onChange={(e) => setTeeTime(e.target.value)}
|
||||||
|
className="h-14 rounded-2xl text-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="start-hole" className="text-base font-semibold">
|
<Label htmlFor="start-hole" className="text-base font-semibold">
|
||||||
Starthull
|
Starthull
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue