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(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(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": [
|
||||
"/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).
|
||||
`/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,**
|
||||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
||||
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),
|
||||
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å)
|
||||
|
|
|
|||
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
|
||||
derfor begge se profil-fullførings-skjemaet ved neste innlogging —
|
||||
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:
|
||||
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 datetime import date
|
||||
import math
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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)
|
||||
async def get_official_facility_for_round(
|
||||
slug: str,
|
||||
|
|
@ -359,6 +398,9 @@ class RoundCreate(BaseModel):
|
|||
start_hole: int = Field(default=1, ge=1, le=18)
|
||||
holes_planned: Literal[9, 18] = 18
|
||||
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):
|
||||
|
|
@ -382,6 +424,7 @@ class RoundOut(BaseModel):
|
|||
played_at: date
|
||||
start_hole: int
|
||||
holes_planned: int
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participants: list[RoundParticipantOut]
|
||||
|
||||
|
|
@ -390,7 +433,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
|||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -413,6 +456,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
|
|||
played_at=round_row["played_at"],
|
||||
start_hole=round_row["start_hole"],
|
||||
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,
|
||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
)
|
||||
|
|
@ -507,8 +551,8 @@ async def create_round(
|
|||
INSERT INTO round
|
||||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
start_hole, holes_planned, started_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -521,6 +565,7 @@ async def create_round(
|
|||
body.played_at,
|
||||
body.start_hole,
|
||||
body.holes_planned,
|
||||
body.started_at,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
|
||||
async with plain_connection() as conn:
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ export function NewRound() {
|
|||
async function submitRound(payload: {
|
||||
teeName: string
|
||||
date: string
|
||||
startedAt: string | null
|
||||
startHole: number
|
||||
holes: 9 | 18
|
||||
statLevel: StatLevel
|
||||
|
|
@ -206,6 +207,7 @@ export function NewRound() {
|
|||
teeoff_course_id: courseMeta.teeoffCourseId,
|
||||
tee_name: payload.teeName,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
|
|
@ -215,6 +217,7 @@ export function NewRound() {
|
|||
personal_course_id: courseMeta.personalCourseId,
|
||||
tee_name: payload.teeName,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
|
|
@ -393,6 +396,92 @@ function SourceCard({
|
|||
|
||||
// --- 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({
|
||||
onSelectClub,
|
||||
resolving,
|
||||
|
|
@ -427,6 +516,8 @@ function OfficialSearchStep({
|
|||
<div className="flex flex-col gap-8">
|
||||
<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">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
|
|
@ -1011,6 +1102,7 @@ function ConfirmStep({
|
|||
onSubmit: (payload: {
|
||||
teeName: string
|
||||
date: string
|
||||
startedAt: string | null
|
||||
startHole: number
|
||||
holes: 9 | 18
|
||||
statLevel: StatLevel
|
||||
|
|
@ -1020,6 +1112,7 @@ function ConfirmStep({
|
|||
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
|
||||
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
|
||||
const [date, setDate] = useState(todayIso)
|
||||
const [teeTime, setTeeTime] = useState("")
|
||||
const [startHole, setStartHole] = useState("1")
|
||||
const [holes, setHoles] = useState<9 | 18>(18)
|
||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||
|
|
@ -1033,9 +1126,14 @@ function ConfirmStep({
|
|||
setSubmitting(true)
|
||||
setError(null)
|
||||
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({
|
||||
teeName: selectedTee.name,
|
||||
date,
|
||||
startedAt,
|
||||
startHole: Number(startHole),
|
||||
holes,
|
||||
statLevel,
|
||||
|
|
@ -1095,8 +1193,8 @@ function ConfirmStep({
|
|||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* Date + start hole */}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{/* Date + tee time + start hole */}
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="round-date" className="text-base font-semibold">
|
||||
Dato
|
||||
|
|
@ -1109,6 +1207,18 @@ function ConfirmStep({
|
|||
className="h-14 rounded-2xl text-lg"
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="start-hole" className="text-base font-semibold">
|
||||
Starthull
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue