diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6c3b2a7..238a961 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/023_round_start_time.sql b/023_round_start_time.sql new file mode 100644 index 0000000..0c5951b --- /dev/null +++ b/023_round_start_time.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 4e60d95..fa3ed7d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -2066,6 +2066,97 @@ og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/` 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(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å) diff --git a/CLAUDE.md b/CLAUDE.md index 7fc9f9e..d98c969 100644 --- a/CLAUDE.md +++ b/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 diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 1c6b7e3..ff493b7 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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: diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx index 423cc2b..7f03399 100644 --- a/frontend/components/new-round.tsx +++ b/frontend/components/new-round.tsx @@ -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(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 ( +
+ Nærmest deg +
    + {nearby.map((facility, i) => ( +
  • + +
  • + ))} +
+
+ ) +} + function OfficialSearchStep({ onSelectClub, resolving, @@ -427,6 +516,8 @@ function OfficialSearchStep({
+ +
(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("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({ )} - {/* Date + start hole */} -
+ {/* Date + tee time + start hole */} +
+
+ + setTeeTime(e.target.value)} + className="h-14 rounded-2xl text-lg" + /> +
@@ -439,8 +525,28 @@ export function RoundDetail({ roundId }: { roundId: string }) {

)} +
+ {showEditRound ? ( + setShowEditRound(false)} /> + ) : ( + + )} +
+ {/* Completed summary banner */} - {completed && } + {completed && ( + + )} {/* Participant tabs */} { setCurrentHole(n) - setDetailsOpen(false) + setPanelTab("score") }} /> @@ -484,7 +590,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {

- Hull {hole.holeNumber} · Par {hole.par} · Idx {hole.index} + Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}

{showGir && ( @@ -493,142 +599,125 @@ export function RoundDetail({ roundId }: { roundId: string }) { )}
- updateStat({ strokes: v, played: true })} - readOnly={readOnly} - /> + {activePlayer.statLevel === "full" && } - {activePlayer.statLevel !== "strokes_only" && ( - updateStat({ putts: v })} - readOnly={readOnly} - /> - )} + {(activePlayer.statLevel !== "full" || panelTab === "score") ? ( +
+ updateStat({ strokes: v, played: true })} + readOnly={readOnly} + /> - {activePlayer.statLevel === "full" && ( -
- - {detailsOpen && ( -
-
- - {activePlayer.isSelf && ownBagClubs.length > 0 ? ( -
- {ownBagClubs.map((club) => { - const selected = currentStat.club === club - return ( - - ) - })} -
- ) : ( - updateStat({ club: e.target.value })} - placeholder="F.eks. Driver, 3-jern" - disabled={readOnly} - className="h-12 rounded-2xl text-base" - /> - )} -
- - {hole.par !== 3 && ( - updateStat({ teeShot: v as TeeShot })} - readOnly={readOnly} - /> - )} - - updateStat({ approach: v as Approach })} - readOnly={readOnly} - /> - -
- updateStat({ chip: v })} readOnly={readOnly} /> - updateStat({ bunker: v })} readOnly={readOnly} /> - updateStat({ penalty: v })} readOnly={readOnly} /> -
- - updateStat({ firstPuttBucket: v as PuttBucket })} - readOnly={readOnly} - /> - - updateStat({ anywayStrokes: v })} - readOnly={readOnly} - /> -
)}
+ ) : ( +
+
+ + {activePlayer.isSelf && ownBagClubs.length > 0 ? ( +
+ {ownBagClubs.map((club) => { + const selected = currentStat.club === club + return ( + + ) + })} +
+ ) : ( + updateStat({ club: e.target.value })} + placeholder="F.eks. Driver, 3-jern" + disabled={readOnly} + className="h-12 rounded-2xl text-base" + /> + )} +
+ + {hole.par !== 3 && ( + updateStat({ teeShot: v as TeeShot })} + readOnly={readOnly} + /> + )} + + updateStat({ approach: v as Approach })} + readOnly={readOnly} + /> + +
+ updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> + updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> + updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> +
+ + updateStat({ firstPuttBucket: v as PuttBucket })} + readOnly={readOnly} + /> + + updateStat({ anywayStrokes: v })} + readOnly={readOnly} + /> +
)}
@@ -652,6 +741,17 @@ export function RoundDetail({ roundId }: { roundId: string }) { {completing ? "Fullfører…" : "Fullfør runde"} )} + +
) @@ -659,7 +759,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { // --- Completed banner ------------------------------------------------------ -function CompletedBanner({ players }: { players: Player[] }) { +function CompletedBanner({ players, duration }: { players: Player[]; duration: string | null }) { return (
@@ -670,6 +770,15 @@ function CompletedBanner({ players }: { players: Player[] }) { Ferdigspilt

Runde fullført

+ {duration && ( +
+ Tid brukt + + +
+ )}
    {players.map((player) => { @@ -848,6 +957,440 @@ function AddGuestForm({ ) } +// --- Rediger runde (bane/utslag/antall hull) ------------------------------- +// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI +// allerede registrerte slag/putter/etc. (backend garanterer dette, se +// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført. + +type ApiFacility = { slug: string; name: string; city: string | null; county: string | null } +type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] } +type ApiPersonalCourseSummary = { id: string; name: string } +type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] } + +// Konverterer til/fra -verdiformatet +// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte +// tidsstempler (UTC), kun visningen er lokal. +function toDatetimeLocalValue(iso: string | null): string { + if (!iso) return "" + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return "" + const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000) + return local.toISOString().slice(0, 16) +} +function fromDatetimeLocalValue(value: string): string | null { + if (!value) return null + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d.toISOString() +} + +function EditRoundPanel({ + round, + onPatch, + onClose, +}: { + round: ApiRound + onPatch: (body: Record) => Promise<{ ok: true } | { ok: false; message: string }> + onClose: () => void +}) { + const isCompleted = round.completed_at !== null + const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18) + const [startHole, setStartHole] = useState(String(round.start_hole)) + const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at)) + const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at)) + const [saving, setSaving] = useState(false) + const [showChangeCourse, setShowChangeCourse] = useState(false) + const [error, setError] = useState(null) + + const hasMetadataChanges = + (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || + toDatetimeLocalValue(round.started_at) !== startedAt || + (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) + + async function saveMetadata() { + setSaving(true) + setError(null) + const body: Record = {} + if (!isCompleted) { + if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned + if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole) + } + if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt) + if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) { + body.completed_at = fromDatetimeLocalValue(completedAt) + } + const result = await onPatch(body) + if (!result.ok) setError(result.message) + setSaving(false) + } + + return ( +
    +
    + Rediger runde + +
    + + {error &&

    {error}

    } + + {!isCompleted && ( + <> +
    + Antall hull +
    + {([9, 18] as const).map((n) => ( + + ))} +
    +
    + +
    + + +
    + + )} + +
    + + setStartedAt(e.target.value)} + className="h-11 rounded-xl text-base" + /> +
    + + {isCompleted && ( +
    + + setCompletedAt(e.target.value)} + className="h-11 rounded-xl text-base" + /> +
    + )} + + {hasMetadataChanges && ( + + )} + + {!isCompleted && ( +
    + {showChangeCourse ? ( + setShowChangeCourse(false)} /> + ) : ( + + )} +
    + )} +
    + ) +} + +function ChangeCourseForm({ + onPatch, + onCancel, +}: { + onPatch: (body: Record) => Promise<{ ok: true } | { ok: false; message: string }> + onCancel: () => void +}) { + const [source, setSource] = useState<"teeoff" | "custom" | null>(null) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + // Teeoff-søk + const [query, setQuery] = useState("") + const [facilities, setFacilities] = useState(null) + const [selectedFacility, setSelectedFacility] = useState(null) + const [officialCourses, setOfficialCourses] = useState(null) + const [selectedOfficialCourse, setSelectedOfficialCourse] = useState(null) + + // Egen bane-søk + const [customQuery, setCustomQuery] = useState("") + const [customResults, setCustomResults] = useState([]) + const [selectedCustomCourse, setSelectedCustomCourse] = useState(null) + + const [teeName, setTeeName] = useState("") + + useEffect(() => { + if (source !== "custom") return + let cancelled = false + const timer = setTimeout(async () => { + try { + const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" }) + if (res.ok && !cancelled) setCustomResults(await res.json()) + } catch { + // Stille -- listen blir bare uendret, ingen kritisk feil å vise her. + } + }, 250) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [source, customQuery]) + + async function searchFacilities() { + setError(null) + try { + const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" }) + if (!res.ok) throw new Error() + setFacilities(await res.json()) + } catch { + setError("Klarte ikke å søke i teeoff sine baner akkurat nå.") + } + } + + async function pickFacility(facility: ApiFacility) { + setSelectedFacility(facility) + setError(null) + try { + const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" }) + if (!res.ok) throw new Error() + const detail: { courses: ApiOfficialCourseOption[] } = await res.json() + setOfficialCourses(detail.courses) + } catch { + setError("Klarte ikke å hente baner for dette anlegget.") + } + } + + async function pickCustomCourse(course: ApiPersonalCourseSummary) { + setError(null) + try { + const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" }) + if (!res.ok) throw new Error() + setSelectedCustomCourse(await res.json()) + } catch { + setError("Klarte ikke å hente banedetaljer.") + } + } + + const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? [] + + async function handleSubmit() { + if (!teeName) return + setSubmitting(true) + setError(null) + const body = + source === "teeoff" && selectedFacility && selectedOfficialCourse + ? { + course_source: "teeoff", + teeoff_facility_slug: selectedFacility.slug, + teeoff_course_id: selectedOfficialCourse.teeoff_course_id, + tee_name: teeName, + } + : selectedCustomCourse + ? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName } + : null + if (!body) { + setSubmitting(false) + return + } + const result = await onPatch(body) + setSubmitting(false) + if (!result.ok) { + setError(result.message) + return + } + onCancel() + } + + return ( +
    +

    + Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke. +

    + {error &&

    {error}

    } + + {source === null && ( +
    + + +
    + )} + + {source === "teeoff" && !selectedFacility && ( +
    +
    + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + searchFacilities() + } + }} + placeholder="Søk klubbnavn…" + className="h-11 flex-1 rounded-xl text-base" + /> + +
    + {facilities && ( +
      + {facilities.map((f) => ( +
    • + +
    • + ))} + {facilities.length === 0 &&
    • Ingen treff.
    • } +
    + )} +
    + )} + + {source === "teeoff" && selectedFacility && !selectedOfficialCourse && ( +
    + + {officialCourses === null ? ( +

    Laster baner…

    + ) : ( +
      + {officialCourses.map((c) => ( +
    • + +
    • + ))} +
    + )} +
    + )} + + {source === "custom" && !selectedCustomCourse && ( +
    + setCustomQuery(e.target.value)} + placeholder="Søk egendefinert bane…" + className="h-11 rounded-xl text-base" + /> +
      + {customResults.map((c) => ( +
    • + +
    • + ))} + {customResults.length === 0 && ( +
    • Ingen treff.
    • + )} +
    +
    + )} + + {(selectedOfficialCourse || selectedCustomCourse) && ( +
    + + {selectedOfficialCourse?.name ?? selectedCustomCourse?.name} + +
    + Utslag +
    + {availableTees.map((t) => ( + + ))} +
    +
    + +
    + )} + + +
    + ) +} + // --- Hole navigation ------------------------------------------------------- function HoleNav({ @@ -937,6 +1480,8 @@ function NumberPicker({ directValues, expandValues, expandLabel, + parValue, + maxValue, onChange, readOnly, }: { @@ -945,44 +1490,66 @@ function NumberPicker({ directValues: number[] expandValues: number[] expandLabel: string + // Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst + // (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten + // å måtte huske det fra hull-overskriften. + parValue?: number + // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige + // antall slag registrert på hullet) -- ingen vits i å tilby et tall som + // uansett ville vært selvmotsigende. + maxValue?: number onChange: (value: number) => void readOnly: boolean }) { - const valueInExpand = value !== null && expandValues.includes(value) + const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue)) + const cappedDirect = cap(directValues) + const cappedExpand = cap(expandValues) + + const valueInExpand = value !== null && cappedExpand.includes(value) const [expanded, setExpanded] = useState(false) const showExpanded = expanded || valueInExpand - const visibleValues = showExpanded ? [...directValues, ...expandValues] : directValues + const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect return (
    {label} -
    + {/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater + for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */} +
    {visibleValues.map((n) => { const selected = value === n + const isPar = n === parValue return ( ) })} - {!showExpanded && !readOnly && ( + {!showExpanded && !readOnly && cappedExpand.length > 0 && ( @@ -992,6 +1559,130 @@ function NumberPicker({ ) } +// --- Panel tabs (Score / Statistikk) ---------------------------------------- +// Erstatter den tidligere "Flere detaljer"-utvidbare seksjonen. Bevisst +// trykk-baserte faner, IKKE et sveip-panel -- skjermen har allerede to +// horisontalt scrollende rader (spillerfaner, hull-navigasjon), og enda en +// sveiperetning på samme skjerm ville vært forvirrende (V0s egen vurdering, +// 2026-07-24-runden, se FEATURE_BACKLOG.md for hele prompten). + +function PanelTabs({ value, onChange }: { value: "score" | "stats"; onChange: (value: "score" | "stats") => void }) { + const tabs: Array<{ value: "score" | "stats"; label: string }> = [ + { value: "score", label: "Score" }, + { value: "stats", label: "Statistikk" }, + ] + return ( +
    + {tabs.map((tab) => { + const active = value === tab.value + return ( + + ) + })} +
    + ) +} + +// --- Retningskors (D-pad for Utslag / Innspill) ----------------------------- + +function DirButton({ + icon: Icon, + label, + selected, + onClick, + readOnly, +}: { + icon: typeof ArrowUp + label: string + selected: boolean + onClick: () => void + readOnly: boolean +}) { + return ( + + ) +} + +function DirectionCross({ + label, + value, + onChange, + readOnly, + variant, + centerLabel, +}: { + label: string + value: string | null + onChange: (value: string) => void + readOnly: boolean + // "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill) + variant: "horizontal" | "full" + centerLabel: string +}) { + const spacer =