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:
Erol Haagenrud 2026-07-24 11:21:39 +02:00
parent 65f876e6dc
commit f5d1491ceb
7 changed files with 1405 additions and 164 deletions

View file

@ -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
View 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;

View file

@ -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å)

View file

@ -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

View file

@ -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:

View file

@ -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