Alt kompilerer rent. Oppsummering av denne runden:

1. WHS Rule 3.1b — ferdig, testet (117/117), rullet ut... nei, venter fortsatt (se under).
2. round.tee_name_snapshot-synk — ferdig, scratch-verifisert.
3. Optimistisk versjonssjekk for samtidig hull-redigering — ferdig bygget, og jeg fant og rettet en reell UX-bug underveis (en fullskjerm-feilvisning i stedet for en liten banner) som ellers ville gjort funksjonen verre enn ingenting.

Alt er dokumentert i ADR-056/057 og CHANGELOG punkt 65–66.

Deploy — rekkefølgen har betydning (migrasjonen må kjøre først, siden ny kode leser version-kolonnen):

# 1. Migrasjon mot ekte teecup_db
docker exec teeoff_db psql -U teeoff_admin -d teecup_db -f /opt/teecup/062_round_hole_version.sql

# 2. Backend
docker compose build teecup_api
docker compose up -d teecup_api

# 3. Frontend
docker compose build teecup_frontend
docker compose up -d teecup_frontend
Og fortsatt uavklart fra tidligere: datakorreksjonen for din spesifikke runde

UPDATE round SET tee_name_snapshot = '55'
WHERE id = '520f9681-6e7f-49bf-8504-9ab223874285';
This commit is contained in:
Erol Haagenrud 2026-08-10 18:34:31 +02:00
parent 21c9f17d13
commit 17fac05ddc
22 changed files with 921 additions and 440 deletions

View file

@ -465,7 +465,8 @@
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.golf/)", "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.golf/)",
"Bash(echo \"EXIT CODE: $?\")", "Bash(echo \"EXIT CODE: $?\")",
"Bash(python3 -m json.tool /tmp/adapted.json)", "Bash(python3 -m json.tool /tmp/adapted.json)",
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)" "Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)",
"Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -0,0 +1,16 @@
-- ADR-057: enkel optimistisk versjonssjekk for samtidig hull-redigering.
-- Bruker ba eksplisitt om dette etter en ekstern kodegjennomgang som
-- påpekte at update_hole/update_side_hole tidligere var rent last-write-
-- wins (ADR-028, bevisst valgt den gangen, men bruker ønsket det endret):
-- to enheter som redigerer samme hull samtidig fikk INGEN varsel om at
-- den andres endring ble overskrevet.
--
-- Én delt `round_hole`-tabell dekker begge eierskapstyper (deltaker- og
-- side-eid, se round_hole_owner_xor) -- én kolonne holder for begge
-- endepunktene (update_hole og update_side_hole).
--
-- DEFAULT 1 (ikke 0): matcher at en fersk rad regnes som "versjon 1" fra
-- opprettelse -- en klient som nettopp har hentet et upåbegynt hull skal
-- kunne sende expected_version=1 og treffe riktig, ikke måtte vite at
-- "ubrukt" betyr 0.
ALTER TABLE round_hole ADD COLUMN version integer NOT NULL DEFAULT 1;

View file

@ -5219,6 +5219,320 @@ ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
**Bevisst utenfor omfang:** samme som ADR-052 -- ren fargeovertagelse, **Bevisst utenfor omfang:** samme som ADR-052 -- ren fargeovertagelse,
ingen layout-/strukturendring i veiviserens 11 filer. ingen layout-/strukturendring i veiviserens 11 filer.
---
## ADR-054: Kartretning ved slagmåling — bearing fra eget forrige slag, ikke green-koordinater — 2026-08-10
Brukeren spurte: ved lengdemåling av slag, kan kartet vises slik at "man
går oppover" (opp på skjermen = fremover på hullet) UTEN å registrere
green-koordinater (som verken TeeCup eller teeoff har noe sted, bekreftet
tidligere denne sesjonen)? Svaret var ja, og brukeren ba om at det bygges.
**Beslutning A — bearing regnet fra spillerens EGET forrige slag på
hullet, ikke fra en lagret hull-linje.** `round_shot` (ADR-048) lagrer
allerede start-/sluttkoordinater per slag, sortert på `shot_number`.
Ny `bearingDegrees(a, b)` i `frontend/lib/geo.ts` (standard forward-
azimuth-formel, ren funksjon, ingen Mapbox-avhengighet -- samme mønster
som `haversineMeters`) regner ut retningsvinkelen til FORRIGE slag på
hullet (siste element i den allerede hentede `shots`-listen i
`round-detail.tsx` sin `ShotMeasurementEntry`, som GET-endepunktet
allerede returnerer `ORDER BY shot_number`). Denne vinkelen sendes ned
som `initialBearing`-prop gjennom `ShotMeasurementSheet` til BEGGE
`MapPointPicker`-instansene (start-steget OG ball-steget), og settes som
`bearing` i selve Mapbox-konstruktøren.
**Beslutning B — statisk, ikke sanntids-rotasjon.** Bearingen settes ÉN
gang ved kart-initialisering, aldri oppdatert løpende mens brukeren går.
En kontinuerlig rotasjon etter live GPS-heading (`coords.heading` fra
`watchPosition`, som allerede kjører på ball-steget for sanntids-avstand)
ble vurdert og avvist -- GPS-heading er notorisk ustøyende ved lav
gangfart, og en urolig/hakkete kartrotasjon mens brukeren går ville vært
verre enn ingen rotasjon. Samme begrunnelse som ADR-048s eksisterende
"ett kart-instans, aldri re-initialisert"-prinsipp.
**Beslutning C -- fallback-rekkefølge for det FØRSTE slaget på et hull**
(ingen forrige slag å regne bearing fra ennå): (1) ett engangs-forsøk på
`coords.heading` fra det samme posisjonsoppslaget kartet uansett gjør for
sentrering (kun populert når enheten beveger seg, ofte `null` for et
enkeltstående oppslag -- akseptert, ikke en feiltilstand), (2) nord (0)
som endelig fallback. Ingen ny geolocation-spørring lagt til utover det
som allerede fantes.
Ingen migrasjon, ingen backend-endring -- rent klientside, tre filer
(`lib/geo.ts`, `components/shot/map-point-picker.tsx`,
`components/shot/shot-measurement-sheet.tsx`) pluss beregningen i
`components/round-detail.tsx` sin `ShotMeasurementEntry` (som allerede
har `shots`-listen fra sin eksisterende `useEffect`-henting).
**Verifisert i to lag:** (1) `bearingDegrees()` unit-verifisert
frittstående (Node, fire kjente himmelretninger fra ett origo-punkt --
nord/øst/sør/vest ga 0.0/90.0/180.0/270.0 eksakt). (2) Ende-til-ende i
ekte nettleser (eget scratch-miljø, samme fulle DB/rolle/MinIO/API/
frontend-oppsett som tidligere runder denne økten): seedet ett slag rett
ØST (bearing 90°) på hull 1 via de ekte API-endepunktene, midlertidig
konsollogg i `initMap()` bekreftet kartet fikk `bearing≈89.996`
(avrundingsdifferanse fra at 0.01°-lengdegrad-delta ikke er eksakt øst
på en kule -- korrekt) da måle-arket ble åpnet på hull 1. Hull 2 (ingen
tidligere slag) ga `bearing=0` (nord-fallback), som forventet. Debug-
loggen fjernet igjen etter verifisering, `tsc --noEmit` rent. Scratch-
ressurser ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
**Bevisst utenfor omfang:** sanntids-rotasjon under gange (se Beslutning
B), og en egen "nullstill til nord"-knapp i kart-UI-et (ikke bedt om,
lav verdi når bearingen uansett er statisk og sjelden feil).
---
## ADR-055: Nytt app-ikon (ball/pokal/tee), full-bleed, korrigert etter reell tegnefeil — 2026-08-10
Brukeren: dagens app-ikon var "fremdeles et gammelt utkast" til tross for
at et tidligere punkt (CHANGELOG, 2026-08-06) hevdet ikonene var "beskåret
fra den ekte TeeCup-logoen". Visuell inspeksjon av `icons/icon-512.png`,
`icon-maskable-512.png` og `apple-icon.png` (åpnet direkte i nettleser)
bekreftet brukerens vurdering: riktig motiv (golfball/pokal/tee), men en
grov beskjæring med enorm hvit padding rundt en liten sentrert grafikk --
nesten ulesbart ved favikon-størrelse (32×32), og for lite innhold i
sentrum til å tåle Androids sirkel-/dråpe-maskering.
**Beslutning A -- ny komposisjon via V0, samme motiv.** Claude skrev en
V0-prompt som IKKE ba om et nytt motiv, kun en ny KOMPOSISJON av det
eksisterende (bruker la ved den ekte kilde-SVG-en, `TeeCup-logo.svg`,
i samme V0-melding): full-bleed bakgrunn kant til kant (ingen
gjennomsiktig/hvit "luft"), motivet skalert til å fylle et sentrert
"safe zone"-område (~70-75%) som tåler både iOS- og Android-maskering,
ingen egen avrunding tegnet inn (plattformen masker selv), lesbart ned
til 32×32. V0 leverte ett master-SVG (1024×1024) med en mørkegrønn
full-bleed bakgrunn (`#14351c`) og motivet nestet i et sentrert
undervindu.
**Reelt funn under verifisering, IKKE en V0-feiltolkning.** Første
V0-svar hadde to synlige "hakk" skåret inn i pokal-kroppen ved
skulder-/hank-overgangen. Claude sammenlignet først mot feil kildefil
(en annen, rasterbasert `TeeCup-logo.svg` funnet i `Temp-uploads/` fra
tidligere i sesjonen) og konkluderte feilaktig at V0 måtte ha "tegnet på
nytt etter øyemål". Bruker delte deretter den FAKTISKE kilde-SVG-en
(`TeeCup-logo-kun.svg`, rene Inkscape-vektorbaner) direkte i chatten.
Rendret stort på hvit bakgrunn viste denne at hakkene faktisk stammer fra
en ekte, pre-eksisterende unøyaktighet i selve kildekunsten: to
overlappende oransje former (`path20`, fyll `#fd5524`, og `path21`, fyll
`#fe5d2c`) dekker ikke hverandre helt i to punkter. Usynlig på hvit
bakgrunn (de to oransjetonene ligner hverandre), men synlig som et hakk
på en sterkt kontrasterende mørk bakgrunn, siden bakgrunnsfargen lyser
gjennom gapet. V0s opprinnelige "tatt verbatim, ikke tegnet på nytt"
-påstand var altså korrekt -- Claudes første mistanke var feil.
**Beslutning B -- fikset ved å utvide, ikke omtegne.** Claude ga V0 en
presis, kildehenvisende rettemelding (identifiserte de to konkrete
path-fyllfargene og hvorfor gapet oppstår). V0s fiks: la til en synlig
`stroke` i samme farge som fyllet (`stroke="#fd5524" stroke-width="0.6"`)
`path20`, som tetter gapet ved å utvide den underliggende formens
synlige kant, i stedet for å redigere selve bezier-kurvene. To små
håndtak-innvendige "glans"-former (opprinnelig nær-hvite, ville vist som
lyse flekker i håndtak-hullene mot mørk bakgrunn) fikk samtidig fyll
byttet til bakgrunnsfargen (`#14351c`) -- korrekt, siden de fungerer som
utsparinger, ikke skyggelegging, i denne fargekonteksten.
**Claude verifiserte fiksen visuelt** (stor rendring, samme
sammenligningsteknikk som avdekket problemet) før integrering --
hakkene bekreftet borte, ingen nye artefakter.
**Integrering:** ett 1024×1024 master-SVG er eneste kilde. Claude
rasterte selv alle nødvendige størrelser via ekte nettleser-rendring
(Chrome DevTools MCP, `devicePixelRatio=1` for eksakte piksler, bekreftet
med `file`-kommandoen etterpå) -- IKKE V0-genererte PNG-er, for å unngå
enhver eksport-unøyaktighet: `icons/icon-192.png`, `icons/icon-512.png`,
`icons/icon-maskable-512.png` (samme kilde som icon-512 -- V0s
komposisjon var allerede safe-zone-riktig, ingen egen beskjæring
trengtes), `apple-icon.png` (180×180), `icon-light-32x32.png` og
`icon-dark-32x32.png` (samme bilde for begge -- det nye ikonet har sin
egen faste mørkegrønne bakgrunn og trenger ikke lenger to
tema-varianter, ulikt en eventuell fremtidig gjennomsiktig/hvit
favikon-variant). `public/icon.svg` (SVG-favikon-fallback) erstattet med
master-SVG-et selv.
`app/manifest.ts` sin `theme_color`/`background_color` sto fortsatt på
de gamle Forest Green-fargene (`#8BC24A`/`#ffffff`) -- oppdatert til
clubhouse (ADR-052): `theme_color: "#2f6b1e"` (primær merkevarefarge,
Android UI-tinting), `background_color: "#f3f6ec"` (lys bakgrunn,
PWA-splashskjerm).
**Scratch-verifisert:** `tsc --noEmit` rent. Egen frontend-only
scratch-container (ingen DB/API-endring i denne runden) -- ekte
produksjonsbuild, `GET /manifest.webmanifest` bekreftet nye farger,
`icon.svg`/`icons/icon-192.png`/`icon-light-32x32.png` bekreftet 200 OG
visuelt korrekte i ekte nettleser. Scratch-ressurser ryddet opp
fullstendig.
**Bevisst utenfor omfang:** `teecup-wordmark.svg` (det fulle
ikon+tekst-ordmerket, brukt andre steder) er IKKE endret -- kun de rene
app-ikon-filene.
---
## ADR-056: Reell WHS-svakhet + reell rundevisnings-bug — funnet via ekstern kodegjennomgang, 2026-08-10
Brukeren delte en ekstern vurdering av appen (parafrasert): to "harde
kjerner" -- WHS-beregningen og live-scoring/samtidighet -- er der en
vibe-kodet app typisk svikter, og fortjener ekte enhetstester mot kjente
fasitcaser, ikke selvbekreftende tester. To parallelle Explore-agenter
gransket hver sin kjerne. WHS-agentens funn ga direkte opphav til
Beslutning A under. Samtidighets-agentens funn (last-write-wins er et
BEVISST, allerede dokumentert valg i ADR-028 -- ikke en overraskelse)
ga IKKE grunnlag for umiddelbar koding, kun en åpen designbeslutning
brukeren må ta først (se "Åpent, ikke besluttet" nederst).
**Beslutning A -- WHS Rule 3.1b sitt >54/4+-slag-unntak implementert og
fasit-testet.** Agentens revisjon fant at `handicap_engine.py` sin NDB-
cap (`max_hole_score_for_handicap`) manglet et reelt, publisert WHS-
unntak: ved banehandicap over 54 OG 4 eller flere mottatte slag på ett
hull, er maks hullscore par+5 (IKKE den vanlige par+2+mottatte slag).
Claude verifiserte dette FØR koding direkte mot kildePDF-en (`WHS_Rules_
of_Handicapping_2024.pdf`, side 37, `pdftotext`-søk, ikke agentens
gjenfortelling): "Where a Course Handicap is calculated at more than 54
and a player receives 4 or more strokes on a hole, the maximum hole
score is par + 5 for handicap purposes."
Lagt til som et nytt, valgfritt `course_handicap`-parameter (standard
`None`) på `max_hole_score_for_handicap` og `adjusted_gross_score` --
bakoverkompatibelt (uendret oppførsel for ethvert kall som ikke sender
det). To produksjonskallsteder i `rounds.py` (`update_hole` sin
"plukket opp"-gren, og rundefullførings-differensial-beregningen)
oppdatert til å sende med den allerede tilgjengelige
`course_handicap_snapshot`.
Fire nye, presist begrunnede tester lagt til i `test_handicap_engine.py`
(grensetilfellene eksplisitt: eksakt 54 -- IKKE "mer enn 54", eksakt 3
slag -- IKKE "4 eller flere", samt end-til-ende via
`adjusted_gross_score`). 117/117 tester grønne (opp fra 115). Scratch-
verifisert også via ekte API-kall (deltaker med banehandicap 63, hull
med 4 mottatte slag, "plukket opp"-endepunktet returnerte 9 som
forventet, ikke det gamle 10).
**Beslutning B -- `round.tee_name_snapshot` synkes nå med eierens eget
utslagsbytte.** Egen, urelatert bug rapportert av bruker samme økt (se
skjermdump): rundens toppheader (delt på tvers av Score/Scorekort/
Leaderboard) viste et gammelt utslag ("50") selv etter at brukeren
hadde endret utslaget til "55" -- bekreftet i `round_participant` (begge
rader riktig "55") vs. `round.tee_name_snapshot` (fortsatt "50"). Root
cause: `PATCH /rounds/{round_id}/participants/{participant_id}`
(`update_participant`, brukt ved enkelt-deltaker-utslagsbytte) skrev
KUN til `round_participant`, aldri til `round` -- ulikt den separate
hele-bane-bytte-grenen i `update_round`, som alltid har oppdatert begge
sammen. De to skrive-stiene var ikke holdt i synk.
Fiks: når denne PATCH-en endrer `tee_name` for EIERENS EGEN
deltaker-rad (`current["user_id"] == current["round_owner_user_id"]`,
ikke en medspillers/gjests -- ulike deltakere kan bevisst spille ulike
utslag, se `ParticipantCreate.tee_name`), oppdateres
`round.tee_name_snapshot` til samme verdi i samme kall -- speiler
nøyaktig hvordan `create_round`/`_create_participant` allerede holder
disse to i synk ved selve opprettelsen.
Scratch-verifisert: eierens eget utslagsbytte oppdaterte
`round.tee_name_snapshot` korrekt; et påfølgende utslagsbytte for en
GJEST lot `round.tee_name_snapshot` stå urørt (bekrefter at kun
eierens egen rad trigger synken, som tiltenkt).
**Ikke rettet i denne runden (venter på egen brukerbekreftelse, se
CHANGELOG):** den konkrete, allerede-live runden brukeren viste
skjermdump av (`round.tee_name_snapshot` fortsatt "50" i ekte
`teecup_db` for akkurat den runden) -- kodefiksen over hindrer nye
tilfeller, men retter ikke historisk feil data. Krever en engangs
`UPDATE`-setning mot ekte `teecup_db`, vist og bekreftet separat per
CLAUDE.md.
**Besluttet samme økt:** samtidighets-/konflikt-halvparten av den
eksterne vurderingen krevde en designbeslutning før noe kunne bygges --
bruker fikk valget mellom "enkel versjonssjekk + tydelig feilmelding",
"feltvis i stedet for radvis overskriving", og "ikke nå" via
`AskUserQuestion`, valgte det anbefalte første alternativet. Bygget
samme økt, se ADR-057.
---
## ADR-057: Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10
Direkte oppfølging av ADR-056: bruker valgte "enkel versjonssjekk +
tydelig feilmelding" for `update_hole`/`update_side_hole` sin
last-write-wins-oppførsel (ADR-028, bevisst den gangen, men brukeren
ønsket det endret nå).
**Beslutning A -- én ny `version`-kolonne på `round_hole` (migrasjon
062), dekker begge eierskapstyper.** `round_hole` er allerede delt
mellom deltaker- og side-eide hull (XOR-constraint) -- én kolonne holder
for begge `update_hole`/`update_side_hole`. `DEFAULT 1`, økes med 1 for
hver skrivning. Klienten sender `expected_version` tilbake (valgfritt --
`None`/utelatt hopper over sjekken, samme bakoverkompatible mønster som
resten av appens valgfrie felt).
**Beslutning B -- atomisk sjekk i selve UPDATE-en, ikke en separat
"les så skriv".** `WHERE ... AND ($N::int IS NULL OR version = $N::int)`
i samme setning som selve skrivningen -- ingen TOCTOU-vindu mellom sjekk
og skrivning. Et `None`-resultat skilles fra "hullet finnes ikke" via
enten en allerede-utført eksistenssjekk lenger opp i funksjonen
(`update_hole`, som uansett må slå opp par/stroke-index før en
"plukket opp"-beregning) eller en egen fallback-eksistenssjekk
(`update_side_hole`, som ikke hadde noen forhåndssjekk fra før) -- aldri
tvetydig hvilken feil som returneres.
**Beslutning C -- offline-køen kjeder versjonen fremover innad i én
flush-runde.** Reelt funn under design (før noe ble bygget, ikke en bug
funnet i ettertid): uten dette ville en spillers EGNE påfølgende
offline-redigeringer av samme hull avvist HVERANDRE som falske
konflikter, siden lokal state (og dermed enqueue-tidspunktets
`expected_version`) aldri oppdateres mellom to sekvensielle
avspillinger i samme batch. `flushQueue` (`offline-queue.ts`) holder nå
et lite `Map<url, siste kjente versjon>` gjennom hele flush-runden og
overstyrer `expected_version` på påfølgende oppføringer til samme URL
med den nyeste kjente verdien.
**Reelt UX-hull funnet UNDER scratch-verifisering, rettet før
utrulling.** Første implementasjon gjenbrukte komponentens
eksisterende `error`-tilstand for konfliktmeldingen. Viste seg (kun ved
ekte to-enhets-test i nettleser, ikke synlig fra kode-lesing alene) at
`error` er en FATAL, hele-siden-erstattende tilstand (brukt for f.eks.
"runden finnes ikke") -- en forbigående hull-konflikt tok dermed over
HELE skjermen og tvang brukeren til å navigere bort, i stedet for en
liten varsel. Rettet med en egen, dismissbar `conflictNotice`-tilstand
(banner øverst i siden, "Skjul"-knapp, resten av UI-et forblir fullt
brukbart) -- gjenbrukt også for den eksisterende (fra før denne runden)
kø-synk-feilmeldingen, som hadde nøyaktig samme fullskjerm-problem.
**Scratch-verifisert i tre lag, alle i ekte nettleser/API, ingen
enhetstester på TS-siden (ingen testinfrastruktur for det i
prosjektet ennå):**
1. Backend, `update_hole` OG `update_side_hole` hver for seg (ekte
API-kall): enhet A skriver først (lykkes, versjon 1→2), enhet B med
samme (nå utdaterte) `expected_version` avvist med 409 og eksakt
forventet feiltekst, A sin verdi bekreftet fortsatt lagret (ikke B
sin avviste), B henter ny versjon og lykkes på nytt forsøk. Bekreftet
at PATCH uten `expected_version` fortsatt fungerer (bakoverkompatibelt).
`update_side_hole` i tillegg bekreftet at et ikke-eksisterende hull
fortsatt gir 404, ikke feilaktig 409.
2. To ISOLERTE nettleser-kontekster (`isolatedContext`, egne
informasjonskapsel-rom -- ekte to-enheter-simulering, ikke bare to
faner som deler økt) som samme flight-medlem: enhet B med en allerede
åpen, utdatert veiviser fikk 409 ved innsending, veiviseren viste
automatisk den ferske (A sin) verdien i stedet for B sitt avviste
forsøk, banneret vist og dismissbart, RESTEN AV SIDEN forble
fullt brukbar (dette avdekket UX-hullet over).
3. Offline-kø-kjeding: ekte nettverks-emulering (DevTools "Offline"),
to påfølgende redigeringer av SAMME hull mens frakoblet, tilbake på
nett -- begge synkroniserte korrekt (endte på siste verdi), INGEN
falsk konflikt seg imellom, "venter på synk"-indikatoren forsvant helt.
`tsc --noEmit` og `py_compile` rene gjennom hele runden. Scratch-
ressurser ryddet opp fullstendig hver gang, `teecup_db`s ACL bekreftet
uendret.
**Bevisst utenfor omfang:** ORG-TURNERINGENES match-scoring
(`session-scorecard.tsx`, `/orgs/{id}/matches/{id}/hole-scores`) er et
HELT separat system (annen tabell, annen router) med trolig samme
last-write-wins-egenskap -- IKKE undersøkt eller endret denne runden,
siden verken den opprinnelige eksterne vurderingen eller brukerens
oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det
trengs der også.
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -10030,3 +10030,212 @@ Neste steg:
dette var en engangsopprydding av kontoer fra FØR den regelen gikk dette var en engangsopprydding av kontoer fra FØR den regelen gikk
live. Et automatisert varsel/rutine for dette kan vurderes som egen, live. Et automatisert varsel/rutine for dette kan vurderes som egen,
fremtidig sak om det blir aktuelt. fremtidig sak om det blir aktuelt.
62. **Fjernet e-postvarsel for "venn startet en runde du kan følge" —
2026-08-10.** Bruker ba eksplisitt om å fjerne kun e-posten for denne
hendelsen (`type="round"`-notifikasjonen sendt fra `create_round` i
`rounds.py` til venner som kan følge runden) — in-app-varselet
(klokke-ikonet) og push-varselet skal fortsatt fungere som før.
**Kompleksitet:** samme `type="round"` deles av EN ANNEN, fortsatt
ønsket e-post ("du ble lagt til som medspiller", sendt fra
`add_participant`) — å slå av e-post for hele `"round"`-typen ville
fjernet begge. Løst med et nytt `allow_email: bool = True`-parameter
`create_notification()` (`app/routers/notifications.py`), satt
til `False` KUN på kallstedet i `create_round`. In-app-innsettingen
og `_push_for_notification` kjører uendret uansett — kun selve
e-post-blokken hopper over. Ingen migrasjon, ingen endring i
`user_notification_email_pref`-skjemaet.
`frontend/components/account-settings.tsx`: "Runder"-varselvalgets
beskrivelsestekst rettet ("Du blir lagt til som medspiller, eller en
venn starter en runde du kan følge." → "Du blir lagt til som
medspiller på en runde.") — teksten lovet tidligere en e-post som nå
aldri sendes.
**Scratch-verifisert** (egen scratch-DB/rolle/MinIO/API-container,
`TEECUP_DEV_LOG_MAGIC_LINKS=true` + tomme SMTP-variabler for å
observere e-post-forsøk som en tydelig loggmelding i stedet for en
ekte utsending): to testbrukere, akseptert vennskap, `watcher` opt-et
inn på `round`-e-post. (1) `owner` opprettet en offentlig synlig
runde — in-app-varsel opprettet for `watcher`, INGEN "[DEV]
Varsel-e-post"-linje i loggen. (2) `owner` la `watcher` til som
medspiller på samme runde — in-app-varsel opprettet OG nøyaktig én
"[DEV] Varsel-e-post"-linje, bekreftet riktig melding. Begge
in-app-radene bekreftet i `notification`-tabellen etterpå. `tsc
--noEmit` og `python3 -m py_compile` begge rene. Scratch-ressurser
ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Ja, kjør") —
begge containere bygget/restartet rent, bekreftet live på
`teecup.golf` uten konsollfeil utover den harmløse PWA-infomeldingen.
63. **Kartretning ved slagmåling: "opp = fremover på hullet", uten
green-koordinater — 2026-08-10, se ADR-054.** Bruker spurte om
kartet kunne roteres slik at man "går oppover" under slagmåling,
uten å registrere green-koordinater (bekreftet tidligere at ingen
slike finnes noe sted) — og ba deretter eksplisitt om at det bygges.
Løsning: bearingen (kompassretningen) kartet roteres til regnes ut
fra spillerens EGET forrige slag på samme hull (`round_shot` sine
allerede lagrede start-/sluttkoordinater), ikke fra en lagret
hull-linje. Ny `bearingDegrees()` i `frontend/lib/geo.ts` (ren
funksjon, standard forward-azimuth). Satt ÉN gang ved
kart-initialisering (`bearing` i Mapbox-konstruktøren), aldri
løpende oppdatert under gange — vurdert og bevisst avvist, se
ADR-054 Beslutning B (GPS-heading er for støyete ved lav fart til
kontinuerlig rotasjon). Første slag på et hull (ingen forrige å
regne fra) faller tilbake til ett engangs-forsøk på enhetens
`coords.heading`, deretter nord.
Rent klientside: `lib/geo.ts`, `components/shot/map-point-picker.tsx`,
`components/shot/shot-measurement-sheet.tsx`,
`components/round-detail.tsx` (`ShotMeasurementEntry`, som allerede
har slag-listen for hullet fra sin eksisterende henting). Ingen
migrasjon, ingen backend-endring.
**Verifisert i to lag:** `bearingDegrees()` unit-testet frittstående
(fire kjente himmelretninger — nord/øst/sør/vest ga 0/90/180/270
eksakt). Ende-til-ende i ekte nettleser (eget scratch-miljø): seedet
ett slag rett øst på hull 1 via ekte API-kall, midlertidig
konsollogg (fjernet igjen etter verifisering) bekreftet kartet fikk
`bearing≈90` på hull 1 og `bearing=0` (nord-fallback) på hull 2 (uten
tidligere slag). `tsc --noEmit` rent. Scratch-ressurser ryddet opp
fullstendig, `teecup_db`s ACL bekreftet uendret.
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør den.") —
ren omstart av `teecup_frontend`, bekreftet live på `teecup.golf`
uten konsollfeil utover den harmløse PWA-infomeldingen.
64. **Nytt app-ikon (ball/pokal/tee), full-bleed, hakk-feil rettet —
2026-08-10, se ADR-055.** Bruker: dagens ikon var "fremdeles et
gammelt utkast" — bekreftet ved å åpne `icon-512.png`/
`icon-maskable-512.png`/`apple-icon.png` direkte: riktig motiv, men
grov beskjæring med enorm hvit padding rundt en liten grafikk,
nesten ulesbart ved 32×32.
V0-prompt (samme motiv, ny komposisjon: full-bleed bakgrunn,
sentrert i safe-zone, ingen egen avrunding) ga et første svar med to
synlige hakk i pokal-formen. Claude sammenlignet FØRST mot feil
kildefil og konkluderte feilaktig at V0 hadde "tegnet på nytt etter
øyemål" — bruker rettet dette ved å dele den faktiske kilde-SVG-en
(`TeeCup-logo-kun.svg`) direkte. Rendret stort viste at hakkene var
en ekte, pre-eksisterende unøyaktighet i selve kildekunsten (to
overlappende oransje former som ikke dekker hverandre helt i to
punkter) — usynlig på hvit bakgrunn, synlig mot mørk. V0s "tatt
verbatim"-påstand var korrekt; Claudes første mistanke var feil.
Presis rettemelding til V0 (identifiserte de to path-fyllfargene)
ga en fiks (tettet gapet med en `stroke` i samme farge som fyllet,
ikke en omtegning) — verifisert visuelt før integrering, hakkene
bekreftet borte, ingen nye artefakter.
Integrert: ett 1024×1024 master-SVG er eneste kilde. Alle
pikselstørrelser (`icons/icon-192.png`, `icons/icon-512.png`,
`icons/icon-maskable-512.png`, `apple-icon.png` 180×180,
`icon-light/dark-32x32.png`) rastret av Claude selv via ekte
nettleser-rendring (`devicePixelRatio=1`, bekreftet eksakte
pikseldimensjoner etterpå) — ikke V0-genererte PNG-er, for å unngå
eksport-unøyaktighet. `public/icon.svg` (SVG-favikon) erstattet med
master-SVG-et. `app/manifest.ts` sin `theme_color`/`background_color`
(fortsatt gamle Forest Green-farger, `#8BC24A`/`#ffffff`) oppdatert
til clubhouse (`#2f6b1e`/`#f3f6ec`).
**Scratch-verifisert**: `tsc --noEmit` rent, egen frontend-only
scratch-container (ekte produksjonsbuild), `GET
/manifest.webmanifest` bekreftet nye farger, ikonfilene bekreftet
200 og visuelt korrekte i ekte nettleser. Scratch-ressurser ryddet
opp fullstendig.
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt ("Kjør") — ren
omstart av `teecup_frontend`, bekreftet live på `teecup.golf`:
`icon.svg` og `manifest.webmanifest` (nye farger) begge korrekte,
ingen konsollfeil utover den harmløse PWA-infomeldingen.
65. **WHS Rule 3.1b-unntak (>54 banehandicap + 4 mottatte slag → par+5)
implementert og fasit-testet + `round.tee_name_snapshot` synk-bug
rettet — 2026-08-10, se ADR-056.** Bruker delte en ekstern vurdering
av appens to "harde kjerner" (WHS-beregning, live-scoring/
samtidighet) og ba om en ærlig sjekk. To Explore-agenter gransket
hver sin kjerne parallelt.
**WHS-funn:** `handicap_engine.py` manglet et reelt, publisert
unntak (verifisert direkte mot kilde-PDF-en, side 37, ikke bare
agentens gjenfortelling): banehandicap over 54 OG 4+ mottatte slag
på ett hull → maks hullscore par+5, ikke den vanlige
par+2+mottatte-slag. Lagt til som et nytt, bakoverkompatibelt
`course_handicap`-parameter på `max_hole_score_for_handicap`/
`adjusted_gross_score`, wired inn i de to produksjonskallstedene i
`rounds.py`. Fire nye tester i `test_handicap_engine.py` (eksplisitte
grensetilfeller: eksakt 54, eksakt 3 slag, samt end-til-ende) — 117/117
grønne. Scratch-verifisert også via ekte API-kall (deltaker med
banehandicap 63, "plukket opp" på et hull med 4 slag ga korrekt 9,
ikke det gamle 10).
**Egen, urelatert bug funnet samtidig** (bruker viste skjermdump av
en live runde der toppheaderens utslag ikke fulgte et utslagsbytte):
`PATCH .../participants/{id}` skrev kun til `round_participant`,
aldri til `round.tee_name_snapshot` (som selve header-visningen
leser). Fikset: skriver nå begge når det er EIERENS EGEN
deltaker-rad som endres (ikke en gjests — ulike deltakere kan
bevisst ha ulikt utslag). Scratch-verifisert: eierens bytte
oppdaterer runden, en gjests bytte gjør det ikke.
`tsc`/`py_compile` rene der aktuelt. Scratch-ressurser ryddet opp
fullstendig, `teecup_db`s ACL bekreftet uendret gjennom hele
verifiseringen.
**Ikke rettet ennå:** den konkrete live runden brukeren viste (feil
historisk data i ekte `teecup_db`) — venter på egen bekreftelse for
en engangs datakorreksjon. Samtidighets-/konflikt-halvparten av
vurderingen er bevisst IKKE besluttet i denne runden — krever en
designbeslutning fra bruker først, se ADR-056.
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
`teecup_api` bygges/restartes mot ekte miljø.
66. **Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10,
se ADR-057.** Direkte oppfølging av punkt 65: bruker fikk valget
mellom tre tilnærminger til samtidighets-halvparten av den eksterne
vurderingen (versjonssjekk / feltvis skrivning / la det ligge), valgte
"enkel versjonssjekk + tydelig feilmelding".
Migrasjon 062: ny `version`-kolonne på `round_hole` (dekker begge
eierskapstyper, deltaker og side, én tabell). `update_hole`/
`update_side_hole` sjekker nå `expected_version` ATOMISK i selve
UPDATE-en (`WHERE ... AND (expected_version IS NULL OR version =
expected_version)`, ingen separat les-så-skriv-race), 409 med tydelig
norsk feiltekst ved konflikt, skiller korrekt fra 404 (hull finnes
ikke). `expected_version` valgfri — bakoverkompatibelt.
Offline-køen (`offline-queue.ts`) kjeder nå versjonen fremover
innad i én flush-runde — uten dette ville en spillers EGNE
påfølgende offline-redigeringer av samme hull avvist hverandre som
falske konflikter (funnet under design, før noe ble bygget).
**Reelt UX-hull funnet under scratch-verifisering, rettet før
utrulling:** første versjon gjenbrukte komponentens eksisterende
`error`-tilstand for konfliktmeldingen — viste seg (kun synlig ved
ekte to-enhets-test i nettleser) å være en FATAL, hele-siden-
erstattende tilstand. En forbigående hull-konflikt tok dermed over
hele skjermen. Rettet med en egen, dismissbar `conflictNotice`-
banner (gjenbrukt også for den eksisterende kø-synk-feilmeldingen,
som hadde samme problem fra før).
**Scratch-verifisert i tre lag, alt i ekte nettleser/API:** (1)
begge backend-endepunktene hver for seg — riktig 409/404-skille,
vinnerens data bekreftet bevart, bakoverkompatibilitet uten
`expected_version` bekreftet. (2) To ISOLERTE nettleser-kontekster
(ekte to-enheter-simulering) — konflikt ga 409, veiviseren viste
automatisk riktig (den andres) verdi, banner dismissbart, RESTEN AV
SIDEN forble fullt brukbar (dette avdekket UX-hullet over). (3)
Ekte offline-emulering — to redigeringer av samme hull frakoblet,
begge synkroniserte korrekt ved reconnect, ingen falsk konflikt.
`tsc`/`py_compile` rene. Scratch-ressurser ryddet opp fullstendig,
`teecup_db`s ACL bekreftet uendret.
**Bevisst utenfor omfang:** org-turneringenes match-scoring (helt
separat system/tabell) ikke undersøkt eller endret denne runden.
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
`teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø.

View file

@ -49,12 +49,21 @@ _PUSH_TITLES: dict[str, str] = {
} }
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None: async def create_notification(
conn, *, user_id: str, type: NotificationType, message: str, link_path: str, allow_email: bool = True
) -> None:
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon -- """Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
akkurat DENNE typen, OG et push-varsel til enhver enhet mottakeren har akkurat DENNE typen, OG et push-varsel til enhver enhet mottakeren har
abonnert (uavhengig av e-post-valget -- se _push_for_notification).""" abonnert (uavhengig av e-post-valget -- se _push_for_notification).
`allow_email=False` (2026-08-10) lar en enkelt kallsted permanent
utelukke e-post for akkurat DEN hendelsen, selv om brukeren har krysset
av for typen generelt -- brukt av "venn startet en runde du kan følge"
i rounds.py, som deler `type="round"` med "lagt til som medspiller"
(som fortsatt skal kunne e-postes). In-app-varselet og push forblir
uendret uansett -- kun selve e-postutsendingen droppes."""
await conn.execute( await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)", "INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id, user_id,
@ -65,6 +74,9 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=link_path) await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=link_path)
if not allow_email:
return
wants_email = await conn.fetchval( wants_email = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)", "SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
user_id, user_id,

View file

@ -1629,6 +1629,10 @@ async def create_round(
type="round", type="round",
message=f"{owner_name} startet en runde du kan følge ({round_label}).", message=f"{owner_name} startet en runde du kan følge ({round_label}).",
link_path=f"/watch/{round_id}", link_path=f"/watch/{round_id}",
# 2026-08-10: bruker ba eksplisitt om å fjerne e-post
# for akkurat denne hendelsen -- in-app-varselet og
# push beholdes uendret, se create_notification.
allow_email=False,
) )
return await _load_round_out(conn, round_id, user.user_id) return await _load_round_out(conn, round_id, user.user_id)
@ -3055,6 +3059,24 @@ async def update_participant(
*values, *values,
) )
# Reelt funn 2026-08-10 (bruker rapporterte at rundens eget utslag,
# vist i toppheaderen på tvers av Score/Scorekort/Leaderboard, ikke
# fulgte med etter et utslagsbytte): `round.tee_name_snapshot` settes
# KUN her (deltaker-PATCH) OG i update_round sin egen bane-bytte-gren
# -- de to var ikke holdt i synk. Speiler create_round/_create_participant
# sin oppførsel ved opprettelse, der eierens deltaker-rad og selve
# runden alltid får samme tee_name samtidig: når DENNE PATCH-en endrer
# utslaget for eierens EGEN deltaker-rad (ikke en medspillers/gjests),
# oppdateres round.tee_name_snapshot til samme verdi. Rører ikke
# runde-raden ved endring av en annen deltakers utslag -- ulike
# deltakere kan bevisst spille ulike utslag (se ParticipantCreate.tee_name),
# så kun eierens egen rad er en meningsfull "rundens utslag"-kilde.
if "tee_name" in updates and current["user_id"] == current["round_owner_user_id"]:
await conn.execute(
"UPDATE round SET tee_name_snapshot = $2 WHERE id = $1",
round_id, new_tee_name,
)
# Side-tildeling ELLER rating (HCP/utslag/kjønn) endret (ADR-039) -- # Side-tildeling ELLER rating (HCP/utslag/kjønn) endret (ADR-039) --
# regn playing_handicap for begge sider på nytt FØR raden under # regn playing_handicap for begge sider på nytt FØR raden under
# leses, slik at responsen reflekterer den ferske verdien (billig # leses, slik at responsen reflekterer den ferske verdien (billig
@ -3161,6 +3183,10 @@ class RoundHoleOut(BaseModel):
# deltakeren ikke har en beregnet course handicap (f.eks. gjest uten # deltakeren ikke har en beregnet course handicap (f.eks. gjest uten
# HCP). # HCP).
strokes_received: int | None strokes_received: int | None
# Optimistisk versjonssjekk (ADR-057, migrasjon 062) -- klienten sender
# denne tilbake som expected_version ved neste PATCH. Økes med 1 for
# hver skrivning, aldri direkte redigerbar.
version: int
async def _build_participant_holes(conn, round_id: str, participant_id: str) -> list[RoundHoleOut]: async def _build_participant_holes(conn, round_id: str, participant_id: str) -> list[RoundHoleOut]:
@ -3177,7 +3203,7 @@ async def _build_participant_holes(conn, round_id: str, participant_id: str) ->
""" """
SELECT hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee, SELECT hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes penalty_strokes, first_putt_distance_bucket, anyway_strokes, version
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""", """,
participant_id, participant_id,
@ -3237,6 +3263,8 @@ class RoundSideHoleOut(BaseModel):
# ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet # ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet
# gir ikke mening for RoundHoleOut. # gir ikke mening for RoundHoleOut.
selected_participant_id: str | None = None selected_participant_id: str | None = None
# Optimistisk versjonssjekk (ADR-057, migrasjon 062) -- se RoundHoleOut.
version: int
async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]: async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]:
@ -3246,7 +3274,8 @@ async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSide
if not side_exists: if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.") raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
rows = await conn.fetch( rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score, selected_participant_id::text AS selected_participant_id " "SELECT hole_number, par, stroke_index, played, score, "
"selected_participant_id::text AS selected_participant_id, version "
"FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number", "FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number",
side_id, side_id,
) )
@ -3287,6 +3316,10 @@ class SideHoleUpdate(BaseModel):
# sender alltid gjeldende verdi, null = ikke registrert -- IKKE en # sender alltid gjeldende verdi, null = ikke registrert -- IKKE en
# delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under. # delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under.
selected_participant_id: str | None = None selected_participant_id: str | None = None
# Optimistisk versjonssjekk (ADR-057). None = hopp over sjekken (samme
# bakoverkompatible mønster som resten av appens valgfrie felt) --
# klienten sender den alltid i praksis.
expected_version: int | None = None
@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut) @router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
@ -3315,15 +3348,29 @@ async def update_side_hole(
) )
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
UPDATE round_hole SET played = $3, score = $4, selected_participant_id = $5 UPDATE round_hole SET played = $3, score = $4, selected_participant_id = $5,
version = version + 1
WHERE round_side_id = $1 AND hole_number = $2 WHERE round_side_id = $1 AND hole_number = $2
AND ($6::int IS NULL OR version = $6::int)
RETURNING hole_number, par, stroke_index, played, score, RETURNING hole_number, par, stroke_index, played, score,
selected_participant_id::text AS selected_participant_id selected_participant_id::text AS selected_participant_id, version
""", """,
side_id, hole_number, body.played, body.score, body.selected_participant_id, side_id, hole_number, body.played, body.score, body.selected_participant_id,
body.expected_version,
) )
if row is None: if row is None:
# Skiller "hullet finnes ikke" fra "noen andre skrev først" --
# UPDATE-en over kan returnere None av begge grunner.
exists = await conn.fetchval(
"SELECT 1 FROM round_hole WHERE round_side_id = $1 AND hole_number = $2",
side_id, hole_number,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.") raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
return RoundSideHoleOut(**dict(row)) return RoundSideHoleOut(**dict(row))
@ -4990,6 +5037,10 @@ class HoleUpdate(BaseModel):
penalty_strokes: int | None = Field(default=None, ge=0) penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
anyway_strokes: int | None = Field(default=None, ge=0) anyway_strokes: int | None = Field(default=None, ge=0)
# Optimistisk versjonssjekk (ADR-057). None = hopp over sjekken (samme
# bakoverkompatible mønster som resten av appens valgfrie felt) --
# klienten sender den alltid i praksis.
expected_version: int | None = None
@router.patch( @router.patch(
@ -5045,7 +5096,9 @@ async def update_hole(
"Kan ikke registrere «plukket opp» før handicap er beregnet for denne deltakeren.", "Kan ikke registrere «plukket opp» før handicap er beregnet for denne deltakeren.",
) )
played = True played = True
score = max_hole_score_for_handicap(this_hole_par, strokes_received) score = max_hole_score_for_handicap(
this_hole_par, strokes_received, course_handicap=participant_row["course_handicap_snapshot"]
)
async with translate_db_errors(): async with translate_db_errors():
row = await conn.fetchrow( row = await conn.fetchrow(
@ -5054,20 +5107,28 @@ async def update_hole(
played = $3, score = $4, picked_up = $5, putts = $6, club_off_tee = $7, played = $3, score = $4, picked_up = $5, putts = $6, club_off_tee = $7,
tee_shot_result = $8, approach_result = $9, chip_count = $10, tee_shot_result = $8, approach_result = $9, chip_count = $10,
bunker_shot_count = $11, penalty_strokes = $12, first_putt_distance_bucket = $13, bunker_shot_count = $11, penalty_strokes = $12, first_putt_distance_bucket = $13,
anyway_strokes = $14 anyway_strokes = $14, version = version + 1
WHERE round_participant_id = $1 AND hole_number = $2 WHERE round_participant_id = $1 AND hole_number = $2
AND ($15::int IS NULL OR version = $15::int)
RETURNING hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee, RETURNING hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_bucket, anyway_strokes penalty_strokes, first_putt_distance_bucket, anyway_strokes, version
""", """,
participant_id, hole_number, participant_id, hole_number,
played, score, body.picked_up, body.putts, body.club_off_tee, played, score, body.picked_up, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count, body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket, body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
body.anyway_strokes, body.anyway_strokes, body.expected_version,
) )
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") # this_hole_row over har allerede bekreftet at hullet FINNES --
# et None-resultat her betyr dermed alltid versjonskonflikt, ikke
# "ikke funnet" (ingen tvetydighet å skille, ulikt update_side_hole
# som ikke har en tilsvarende forhåndssjekk).
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
)
await broadcast_round_update(round_id) await broadcast_round_update(round_id)
return RoundHoleOut(**dict(row), strokes_received=strokes_received) return RoundHoleOut(**dict(row), strokes_received=strokes_received)
@ -5462,7 +5523,9 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
[h["stroke_index"] for h in holes], [h["stroke_index"] for h in holes],
) )
scores = [h["score"] if h["played"] else None for h in holes] scores = [h["score"] if h["played"] else None for h in holes]
ags = adjusted_gross_score(scores, pars, strokes_received) ags = adjusted_gross_score(
scores, pars, strokes_received, course_handicap=_course_handicap_from_row(p)
)
differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"]) differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"])
await conn.execute( await conn.execute(

View file

@ -1,9 +1,12 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
// PWA-manifest (ADR-028). Ikonene under er beskåret fra den ekte TeeCup- // PWA-manifest (ADR-028). Ikonene under er et ordentlig, polert app-ikon
// logoen (2026-08-06, se public/teecup-wordmark.svg for hele ordmerket // (ADR-055, 2026-08-10) -- ekte logo-vektor (ball+pokal+tee), full-bleed
// ikon+tekst) -- erstatter den tidligere midlertidige grønne // mørkegrønn bakgrunn, motiv sentrert innenfor maskerings-safe-zone --
// golfflagg-placeholderen. // erstatter den forrige, dårlig beskårne versjonen (enorm hvit padding
// rundt en liten sentrert grafikk, se ADR-055 for detaljer).
// theme_color/background_color oppdatert til clubhouse-paletten (ADR-052)
// -- sto fortsatt på de gamle Forest Green-fargene.
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: "TeeCup", name: "TeeCup",
@ -12,8 +15,8 @@ export default function manifest(): MetadataRoute.Manifest {
start_url: "/", start_url: "/",
scope: "/", scope: "/",
display: "standalone", display: "standalone",
background_color: "#ffffff", background_color: "#f3f6ec",
theme_color: "#8BC24A", theme_color: "#2f6b1e",
icons: [ icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },

View file

@ -1378,7 +1378,7 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
// venne-kategoriseringen), ikke en delvis PATCH. // venne-kategoriseringen), ikke en delvis PATCH.
const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [ const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [
{ value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." }, { value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." },
{ value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." }, { value: "round", label: "Runder", description: "Du blir lagt til som medspiller på en runde." },
{ value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." }, { value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." }, { value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
] ]

View file

@ -48,6 +48,7 @@ import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ClubPicker } from "@/components/teecup/club-picker" import { ClubPicker } from "@/components/teecup/club-picker"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { bearingDegrees } from "@/lib/geo"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types ----------------------------------------------------------------- // --- Types -----------------------------------------------------------------
@ -345,6 +346,9 @@ type ApiHole = {
first_putt_distance_bucket: PuttBucket | null first_putt_distance_bucket: PuttBucket | null
anyway_strokes: number | null anyway_strokes: number | null
strokes_received: number | null strokes_received: number | null
// Optimistisk versjonssjekk (ADR-057) -- sendes tilbake som
// expected_version ved neste PATCH, se updateWizardStat.
version: number
} }
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble) // ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
@ -363,6 +367,8 @@ type ApiSideHole = {
// fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her // fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her
// før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut"). // før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut").
strokes_received: number | null strokes_received: number | null
// Optimistisk versjonssjekk (ADR-057) -- se ApiHole.
version: number
} }
function apiHoleToStat(h: ApiHole): HoleStat { function apiHoleToStat(h: ApiHole): HoleStat {
@ -433,6 +439,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score" const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
const [round, setRound] = useState<ApiRound | null>(null) const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// ADR-057 (2026-08-10): `error` over er en FATAL, hele-siden-erstattende
// tilstand (se early return under -- "runden finnes ikke" e.l.). En
// versjonskonflikt på ETT hull (eller en avvist kø-synk) er forbigående
// og skal IKKE ta over hele skjermen -- brukeren skal bare varsles og
// fortsette å bruke resten av runden. Egen, dismissbar banner-tilstand
// i stedet for å gjenbruke `error` her.
const [conflictNotice, setConflictNotice] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null) const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({}) const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
// Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et // Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
@ -691,7 +704,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
body: ReturnType<typeof statToPatchBody>, body: ReturnType<typeof statToPatchBody>,
) { ) {
const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}` const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId }) // expected_version = versjonen vi SIST kjenner til lokalt (ADR-057) --
// flushQueue kjeder den videre selv for flere køede skrivinger til
// samme hull i én batch, se offline-queue.ts.
const currentVersion = holesByParticipant[participantId]?.find((h) => h.hole_number === holeNumber)?.version
const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`)) setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`))
setPendingCount((c) => c + 1) setPendingCount((c) => c + 1)
setHolesByParticipant((prev) => ({ setHolesByParticipant((prev) => ({
@ -706,7 +724,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
body: { played: boolean; score: number | null; selected_participant_id: string | null }, body: { played: boolean; score: number | null; selected_participant_id: string | null },
) { ) {
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}` const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId }) const currentVersion = holesBySide[sideId]?.find((h) => h.hole_number === holeNumber)?.version
const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`)) setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
setPendingCount((c) => c + 1) setPendingCount((c) => c + 1)
setHolesBySide((prev) => ({ setHolesBySide((prev) => ({
@ -751,7 +771,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
} }
const failed = outcomes.filter((o) => !o.ok) const failed = outcomes.filter((o) => !o.ok)
if (failed.length > 0) { if (failed.length > 0) {
setError( // ADR-057: en avvist kø-synk (f.eks. en versjonskonflikt, se under)
// er forbigående -- IKKE `setError`, som ville tatt over hele
// siden. Hullene det gjaldt er allerede hentet på nytt over.
setConflictNotice(
`${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`, `${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
) )
} }
@ -914,13 +937,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body) await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
return return
} }
// Optimistisk versjonssjekk (ADR-057) -- sendes med selv på den
// direkte (online) skrive-veien, ikke bare via offline-køen.
const bodyWithVersion = { ...body, expected_version: wizardApiHole?.version ?? null }
let res: Response let res: Response
try { try {
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, { res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(bodyWithVersion),
}) })
} catch { } catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
@ -928,7 +954,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body) await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
return return
} }
if (!res.ok) return if (!res.ok) {
// 409 = en annen enhet skrev til akkurat dette hullet først (ADR-057).
// Vis det tydelig og hent inn den andres versjon i stedet for å la
// brukeren tro sin egen, nå avviste, endring gjaldt.
if (res.status === 409) {
setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
void loadHoles(wizardPlayerId)
}
return
}
const updated: ApiHole = await res.json() const updated: ApiHole = await res.json()
setHolesByParticipant((prev) => ({ setHolesByParticipant((prev) => ({
...prev, ...prev,
@ -1042,19 +1077,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
await queueSideHoleWrite(wizardSideId, activeHole, body) await queueSideHoleWrite(wizardSideId, activeHole, body)
return return
} }
// Optimistisk versjonssjekk (ADR-057) -- se updateWizardStat.
const bodyWithVersion = { ...body, expected_version: wizardSideHole?.version ?? null }
let res: Response let res: Response
try { try {
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, { res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(bodyWithVersion),
}) })
} catch { } catch {
await queueSideHoleWrite(wizardSideId, activeHole, body) await queueSideHoleWrite(wizardSideId, activeHole, body)
return return
} }
if (!res.ok) return if (!res.ok) {
if (res.status === 409) {
setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
void loadSideHoles(wizardSideId)
}
return
}
const updated: ApiSideHole = await res.json() const updated: ApiSideHole = await res.json()
setHolesBySide((prev) => ({ setHolesBySide((prev) => ({
...prev, ...prev,
@ -1257,10 +1300,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
return ( return (
<RoundPageShell roundId={roundId} activeTab="score"> <RoundPageShell roundId={roundId} activeTab="score">
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8"> <main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
{error && ( {conflictNotice && (
<p role="alert" className="mb-4 text-base font-medium text-destructive"> <div
{error} role="alert"
</p> className="mb-4 flex items-start justify-between gap-3 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3"
>
<p className="text-base font-medium text-destructive">{conflictNotice}</p>
<button
type="button"
onClick={() => setConflictNotice(null)}
className="shrink-0 text-sm font-semibold text-destructive underline underline-offset-2"
>
Skjul
</button>
</div>
)} )}
{/* To faner: "Score" (default, KUN hull-navigasjon + registrering -- {/* To faner: "Score" (default, KUN hull-navigasjon + registrering --
@ -2099,6 +2152,26 @@ function ShotMeasurementEntry({
}, [urlBase]) }, [urlBase])
const count = shots.length const count = shots.length
// Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen"
// følger spillerens EGEN gangretning på hullet, uten lagrede green-
// koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette
// hullet (start→slutt-retningen) -- `shots` er allerede sortert
// stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i
// rounds.py), så siste element ER forrige slag. `undefined` (ingen
// rotasjon regnet ut ennå) for det aller første slaget på hullet --
// MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord,
// se den komponentens dokumentasjon. Bevisst en STATISK retning, satt
// idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
// gangfart), se map-point-picker.tsx.
const initialBearing = useMemo(() => {
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots])
async function deleteShot(shotId: string) { async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" }) const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
if (res.ok) { if (res.ok) {
@ -2286,6 +2359,7 @@ function ShotMeasurementEntry({
holeNumber={holeNumber} holeNumber={holeNumber}
existingShotCount={count} existingShotCount={count}
ownBagClubs={ownBagClubs} ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError} submitError={submitError}
submitting={submitting} submitting={submitting}
onSubmit={submitShot} onSubmit={submitShot}

View file

@ -33,6 +33,7 @@ export function MapPointPicker({
onConfirm, onConfirm,
referencePoint, referencePoint,
referenceLabel = "Utslag", referenceLabel = "Utslag",
initialBearing,
}: { }: {
/** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps" /** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps"
* ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */ * ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */
@ -47,6 +48,21 @@ export function MapPointPicker({
*/ */
referencePoint?: LngLat referencePoint?: LngLat
referenceLabel?: string referenceLabel?: string
/**
* Kompassgrader (0-360) kartet roteres til ved oppstart, "opp
* skjermen" følger spillerens egen gangretning hullet i stedet for
* alltid å peke nord -- brukerønske 2026-08-10, løst UTEN lagrede
* green-koordinater (se bearingForShotMap() i shot-measurement-sheet.tsx,
* som regner denne ut fra spillerens EGET forrige slag hullet).
* Satt ÉN gang ved kart-initialisering, aldri oppdatert løpende -- en
* kontinuerlig rotasjon etter live GPS-heading ville vært urolig/svimlende
* i lav fart, dette er bevisst en statisk, ikke en sanntids-rotasjon.
* Udefinert/`undefined` betyr "ingen kjent retning ennå" -- kartet
* forsøker da ett engangs-fall-tilbake til enhetens `coords.heading` fra
* det første posisjons-oppslaget (se initMap under), og lander til slutt
* nord (0) hvis heller ikke det finnes.
*/
initialBearing?: number
}) { }) {
const containerRef = useRef<HTMLDivElement | null>(null) const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null) const mapRef = useRef<mapboxgl.Map | null>(null)
@ -87,7 +103,7 @@ export function MapPointPicker({
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet // kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
// for bare å sentrere på ett av dem, slik at referansepunktet garantert // for bare å sentrere på ett av dem, slik at referansepunktet garantert
// er synlig med det samme. // er synlig med det samme.
function initMap(center: [number, number], fitTo?: [[number, number], [number, number]]) { function initMap(center: [number, number], fitTo?: [[number, number], [number, number]], bearing = 0) {
if (cancelled || !container) return if (cancelled || !container) return
mapboxgl.accessToken = token as string mapboxgl.accessToken = token as string
const map = new mapboxgl.Map({ const map = new mapboxgl.Map({
@ -95,6 +111,7 @@ export function MapPointPicker({
style: "mapbox://styles/mapbox/satellite-v9", style: "mapbox://styles/mapbox/satellite-v9",
center, center,
zoom: referencePoint ? 17 : 16, zoom: referencePoint ? 17 : 16,
bearing,
attributionControl: false, attributionControl: false,
}) })
mapRef.current = map mapRef.current = map
@ -149,21 +166,34 @@ export function MapPointPicker({
const fallbackCenter: [number, number] = referencePoint const fallbackCenter: [number, number] = referencePoint
? [referencePoint.lng, referencePoint.lat] ? [referencePoint.lng, referencePoint.lat]
: OSLO_FALLBACK : OSLO_FALLBACK
// Retning kartet roteres til: kjent spillretning (initialBearing, regnet
// ut av kalleren fra spillerens eget forrige slag på hullet) vinner
// alltid. Uten den: ett engangs-forsøk på enhetens `coords.heading` fra
// AKKURAT dette posisjonsoppslaget (kun populert når enheten beveger
// seg -- ofte null/NaN for et enkeltstående oppslag, og det er en
// akseptert, bevisst fallback til nord (0) i så fall, ikke en feil).
function resolveBearing(headingFromFix: number | null | undefined): number {
if (typeof initialBearing === "number" && Number.isFinite(initialBearing)) return initialBearing
if (typeof headingFromFix === "number" && Number.isFinite(headingFromFix)) return headingFromFix
return 0
}
if (typeof navigator !== "undefined" && navigator.geolocation) { if (typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => { (pos) => {
const here: [number, number] = [pos.coords.longitude, pos.coords.latitude] const here: [number, number] = [pos.coords.longitude, pos.coords.latitude]
const bearing = resolveBearing(pos.coords.heading)
if (referencePoint) { if (referencePoint) {
initMap(here, [here, [referencePoint.lng, referencePoint.lat]]) initMap(here, [here, [referencePoint.lng, referencePoint.lat]], bearing)
} else { } else {
initMap(here) initMap(here, undefined, bearing)
} }
}, },
() => initMap(fallbackCenter), () => initMap(fallbackCenter, undefined, resolveBearing(null)),
{ enableHighAccuracy: true, timeout: 8000 }, { enableHighAccuracy: true, timeout: 8000 },
) )
} else { } else {
initMap(fallbackCenter) initMap(fallbackCenter, undefined, resolveBearing(null))
} }
// Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint // Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint

View file

@ -34,6 +34,16 @@ export type ShotMeasurementSheetProps = {
holeNumber: number holeNumber: number
existingShotCount: number existingShotCount: number
ownBagClubs: string[] ownBagClubs: string[]
/**
* Kompassgrader (0-360) kartet roteres til, regnet ut av kalleren fra
* spillerens EGET forrige slag dette hullet (startslutt-retningen) --
* gir "opp på skjermen = fremover på hullet" uten lagrede green-
* koordinater (brukerønske 2026-08-10). `undefined` når dette er det
* første slaget som måles hullet (ingen forrige slag å regne fra) --
* MapPointPicker faller da selv tilbake til enhetens GPS-heading eller
* nord, se den komponentens egen dokumentasjon.
*/
initialBearing?: number
/** /**
* Computed by the caller (Haversine) once both points are known. Optional * Computed by the caller (Haversine) once both points are known. Optional
* here so the sheet stays previewable; when absent a rough preview-only * here so the sheet stays previewable; when absent a rough preview-only
@ -71,6 +81,7 @@ export function ShotMeasurementSheet({
holeNumber, holeNumber,
existingShotCount, existingShotCount,
ownBagClubs, ownBagClubs,
initialBearing,
distanceMeters, distanceMeters,
submitError, submitError,
submitting, submitting,
@ -279,10 +290,15 @@ export function ShotMeasurementSheet({
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid {/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
"map_tap" start-steget siden ingen referencePoint er satt. */} "map_tap" start-steget siden ingen referencePoint er satt. */}
{step === "map" ? <MapPointPicker onConfirm={(p) => { {step === "map" ? (
<MapPointPicker
initialBearing={initialBearing}
onConfirm={(p) => {
setStartPoint(p) setStartPoint(p)
setStep("end") setStep("end")
}} /> : null} }}
/>
) : null}
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08, {/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
brukerønske: "jeg skal se start og slutt et satelittfoto... brukerønske: "jeg skal se start og slutt et satelittfoto...
@ -295,6 +311,7 @@ export function ShotMeasurementSheet({
<MapPointPicker <MapPointPicker
referencePoint={startPoint ?? undefined} referencePoint={startPoint ?? undefined}
referenceLabel="Utslag" referenceLabel="Utslag"
initialBearing={initialBearing}
onConfirm={(p, method) => { onConfirm={(p, method) => {
setEndPoint(p) setEndPoint(p)
setEndMethod(method) setEndMethod(method)

View file

@ -14,3 +14,20 @@ export function haversineMeters(a: LatLng, b: LatLng): number {
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2 Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h)) return 2 * R * Math.asin(Math.sqrt(h))
} }
/**
* Retningsvinkel (kompassgrader, 0-360, 0 = nord) fra a til b. Brukes til å
* rotere slagmålings-kartet slik at "opp på skjermen" følger spillerens
* egen gangretning hullet, uten å trenge lagrede green-koordinater --
* se bearingForShotMap() i shot-measurement-sheet.tsx.
*/
export function bearingDegrees(a: LatLng, b: LatLng): number {
const toRad = (d: number) => (d * Math.PI) / 180
const toDeg = (r: number) => (r * 180) / Math.PI
const lat1 = toRad(a.lat)
const lat2 = toRad(b.lat)
const dLng = toRad(b.lng - a.lng)
const y = Math.sin(dLng) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng)
return (toDeg(Math.atan2(y, x)) + 360) % 360
}

View file

@ -83,25 +83,47 @@ export type FlushOutcome = { entry: QueueEntry; ok: boolean; message?: string }
export async function flushQueue(matchId: string): Promise<FlushOutcome[]> { export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
const entries = await listQueue(matchId) const entries = await listQueue(matchId)
const outcomes: FlushOutcome[] = [] const outcomes: FlushOutcome[] = []
// Kjeder expected_version fremover for flere køede skrivinger til SAMME
// URL i én flush-runde (2026-08-10, ADR-057 versjonssjekk). Uten dette
// ville en spillers EGNE påfølgende offline-redigeringer av samme hull
// avvist hverandre som falske konflikter: kun den FØRSTE køede
// oppføringen har en expected_version som fortsatt stemmer med serveren
// -- lokal state (og dermed enqueue-tidspunktets versjon) oppdateres
// aldri mellom to sekvensielle avspillinger i samme batch, så oppføring
// 2 ville ellers sendt den samme (nå utdaterte) versjonen som 1.
const latestVersionByUrl = new Map<string, number>()
for (const entry of entries) { for (const entry of entries) {
let body: unknown = entry.body
if (
body !== null &&
typeof body === "object" &&
"expected_version" in body &&
latestVersionByUrl.has(entry.url)
) {
body = { ...body, expected_version: latestVersionByUrl.get(entry.url) }
}
let res: Response let res: Response
try { try {
res = await fetch(entry.url, { res = await fetch(entry.url, {
method: entry.method, method: entry.method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify(entry.body), body: JSON.stringify(body),
}) })
} catch { } catch {
break // fortsatt offline -- stopp, resten prøves igjen senere break // fortsatt offline -- stopp, resten prøves igjen senere
} }
if (res.ok) { if (res.ok) {
const updated: { version?: number } | null = await res.json().catch(() => null)
if (updated && typeof updated.version === "number") {
latestVersionByUrl.set(entry.url, updated.version)
}
await removeFromQueue(entry.id) await removeFromQueue(entry.id)
outcomes.push({ entry, ok: true }) outcomes.push({ entry, ok: true })
} else { } else {
const body: { detail?: { message?: string } } | null = await res.json().catch(() => null) const respBody: { detail?: { message?: string } } | null = await res.json().catch(() => null)
await removeFromQueue(entry.id) await removeFromQueue(entry.id)
outcomes.push({ entry, ok: false, message: body?.detail?.message ?? `Feil ${res.status}` }) outcomes.push({ entry, ok: false, message: respBody?.detail?.message ?? `Feil ${res.status}` })
} }
} }
return outcomes return outcomes

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 650 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -128,6 +128,7 @@ def max_hole_score_for_handicap(
strokes_received: int, strokes_received: int,
*, *,
index_established: bool = True, index_established: bool = True,
course_handicap: int | None = None,
) -> int: ) -> int:
"""Maks hull-score for HCP-formål (Rule 3.1). """Maks hull-score for HCP-formål (Rule 3.1).
@ -135,9 +136,21 @@ def max_hole_score_for_handicap(
(Rule 3.1b). Før en indeks er etablert i det hele tatt (spillerens aller (Rule 3.1b). Før en indeks er etablert i det hele tatt (spillerens aller
første score(r)): par + 5 (Rule 3.1a) enklere cap fordi ingen første score(r)): par + 5 (Rule 3.1a) enklere cap fordi ingen
handicapslag ennå er kjent å fordele. handicapslag ennå er kjent å fordele.
Unntak (Rule 3.1b, verifisert direkte mot WHS Rules of Handicapping
2024, side 37 -- ikke bare koden sin egen gjenfortelling av regelen):
"Where a Course Handicap is calculated at more than 54 and a player
receives 4 or more strokes on a hole, the maximum hole score is par + 5
for handicap purposes." Overstyrer den vanlige par+2+slag-cappen for
akkurat denne kombinasjonen (høyt banehandicap + mange slag ett
hull) -- uten `course_handicap` sendt inn anvendes IKKE unntaket
(bakoverkompatibelt, samme oppførsel som før for alle kall som ikke
oppgir det).
""" """
if not index_established: if not index_established:
return par + 5 return par + 5
if course_handicap is not None and course_handicap > 54 and strokes_received >= 4:
return par + 5
return par + 2 + strokes_received return par + 2 + strokes_received
@ -147,12 +160,14 @@ def adjusted_gross_score(
strokes_received: Sequence[int], strokes_received: Sequence[int],
*, *,
index_established: bool = True, index_established: bool = True,
course_handicap: int | None = None,
) -> int: ) -> int:
"""18-hulls Adjusted Gross Score (Rule 3), grunnlaget for Score Differential. """18-hulls Adjusted Gross Score (Rule 3), grunnlaget for Score Differential.
- `hole_scores[i]` = spilt bruttoscore hull i, eller `None` for et - `hole_scores[i]` = spilt bruttoscore hull i, eller `None` for et
uspilt hull (fylles med Net Par, se moduldoc). uspilt hull (fylles med Net Par, se moduldoc).
- Spilte hull capped til `max_hole_score_for_handicap`. - Spilte hull capped til `max_hole_score_for_handicap` -- `course_handicap`
videreført dit uendret, for Rule 3.1b sitt >54/4+-slag-unntak (se der).
- Forventer nøyaktig 18 hull i alle tre lister (bruk `None` for uspilte, - Forventer nøyaktig 18 hull i alle tre lister (bruk `None` for uspilte,
ikke kortere lister) en 9-hulls-runde sendes inn som 18 elementer der ikke kortere lister) en 9-hulls-runde sendes inn som 18 elementer der
9 av dem er `None`. 9 av dem er `None`.
@ -168,7 +183,9 @@ def adjusted_gross_score(
if score is None: if score is None:
total += net_par(par, strokes) total += net_par(par, strokes)
else: else:
cap = max_hole_score_for_handicap(par, strokes, index_established=index_established) cap = max_hole_score_for_handicap(
par, strokes, index_established=index_established, course_handicap=course_handicap
)
total += min(score, cap) total += min(score, cap)
return total return total

View file

@ -405,6 +405,64 @@ def test_max_hole_score_for_handicap():
assert max_hole_score_for_handicap(5, strokes_received=0, index_established=False) == 10 assert max_hole_score_for_handicap(5, strokes_received=0, index_established=False) == 10
def test_max_hole_score_for_handicap_high_course_handicap_exception_rule_3_1b():
# Rule 3.1b, ordrett sitert fra WHS Rules of Handicapping 2024, side 37
# (verifisert direkte mot PDF-en, ikke bare kodens egen gjenfortelling):
# "Where a Course Handicap is calculated at more than 54 and a player
# receives 4 or more strokes on a hole, the maximum hole score is
# par + 5 for handicap purposes." Dette OVERSTYRER den vanlige
# par+2+slag-cappen -- funnet som et reelt hull i motoren (var verken
# implementert, testet, eller nevnt i noen ADR før denne testen).
# Unntaket slår inn: banehandicap 55 (> 54) OG 4 mottatte slag ->
# par + 5, IKKE par + 2 + 4 (som ville gitt 10).
assert max_hole_score_for_handicap(4, strokes_received=4, course_handicap=55) == 9
# Grense A -- eksakt 54 er IKKE "mer enn 54": vanlig cap gjelder fortsatt.
assert max_hole_score_for_handicap(4, strokes_received=4, course_handicap=54) == 4 + 2 + 4
# Grense B -- 3 mottatte slag er IKKE "4 eller flere", selv med høyt
# banehandicap: vanlig cap gjelder fortsatt.
assert max_hole_score_for_handicap(4, strokes_received=3, course_handicap=60) == 4 + 2 + 3
# Flere mottatte slag enn 4 (f.eks. et par-3-hull med 5+ slag ved svært
# høyt banehandicap) rammes også -- fortsatt par + 5, ikke par+2+5.
assert max_hole_score_for_handicap(3, strokes_received=6, course_handicap=70) == 8
# course_handicap ikke oppgitt (standard None) -- uendret oppførsel fra
# FØR denne fiksen, selv med 4+ slag. Bakoverkompatibilitet for alle
# eksisterende kallsteder som ikke (ennå) sender parameteren.
assert max_hole_score_for_handicap(4, strokes_received=4) == 4 + 2 + 4
def test_adjusted_gross_score_applies_high_course_handicap_exception():
# Samme unntak (Rule 3.1b) verifisert på hele adjusted_gross_score-veien
# (course_handicap videreført til max_hole_score_for_handicap per hull),
# ikke bare på selve cap-funksjonen isolert.
pars = [4] * 18
stroke_index = list(range(1, 19))
strokes_received = allocate_strokes_by_index(55, stroke_index) # banehandicap 55
# 55 = 3*18 + 1 (base=3, extra=1) -- KUN hull med SI 1 mottar 4 slag,
# alle 17 andre hull mottar 3 slag hver (se allocate_strokes_by_index).
assert strokes_received[0] == 4 # SI 1 -- det ENESTE hullet med 4+ slag
assert strokes_received[9] == 3 # SI 10 -- representativt for resten
assert strokes_received.count(4) == 1 and strokes_received.count(3) == 17
scores = [12] * 18 # høyt nok til å treffe enhver rimelig cap på hvert hull
ags_with_hcp = adjusted_gross_score(scores, pars, strokes_received, course_handicap=55)
ags_without_hcp = adjusted_gross_score(scores, pars, strokes_received)
# Med course_handicap=55: hullet med 4 slag (SI 1) capped til par+5=9
# via unntaket. De 17 andre hullene (3 slag) capped til par+2+3=9 --
# samme tall, men via den VANLIGE regelen, uendret av unntaket.
assert ags_with_hcp == 1 * 9 + 17 * 9 # = 162
# Uten course_handicap (gammel oppførsel, unntaket slår aldri inn):
# SI 1-hullet capped til par+2+4=10 i stedet for 9 -- de 17 andre
# hullene er uendret (unntaket gjaldt dem uansett aldri, siden de har
# under 4 mottatte slag).
assert ags_without_hcp == 1 * 10 + 17 * 9 # = 163
assert ags_with_hcp == ags_without_hcp - 1
def test_adjusted_gross_score_diagram_3_1b_worked_example(): def test_adjusted_gross_score_diagram_3_1b_worked_example():
# John Smith, HCP 16, Diagram 3.1b. Front-9 (par/SI/score) er lest # John Smith, HCP 16, Diagram 3.1b. Front-9 (par/SI/score) er lest
# tydelig og eksakt fra diagrammet (Out = 35 par / 43 gross, begge # tydelig og eksakt fra diagrammet (Out = 35 par / 43 gross, begge