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';
|
|
@ -465,7 +465,8 @@
|
|||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.golf/)",
|
||||
"Bash(echo \"EXIT CODE: $?\")",
|
||||
"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": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
16
062_round_hole_version.sql
Normal 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;
|
||||
|
|
@ -5219,6 +5219,320 @@ ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
|
|||
**Bevisst utenfor omfang:** samme som ADR-052 -- ren fargeovertagelse,
|
||||
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"`)
|
||||
på `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:
|
||||
|
||||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
||||
|
|
|
|||
209
CHANGELOG.md
|
|
@ -10030,3 +10030,212 @@ Neste steg:
|
|||
dette var en engangsopprydding av kontoer fra FØR den regelen gikk
|
||||
live. Et automatisert varsel/rutine for dette kan vurderes som egen,
|
||||
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
|
||||
på `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ø.
|
||||
|
|
|
|||
|
|
@ -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 --
|
||||
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
|
||||
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(
|
||||
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
|
||||
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)
|
||||
|
||||
if not allow_email:
|
||||
return
|
||||
|
||||
wants_email = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
|
||||
user_id,
|
||||
|
|
|
|||
|
|
@ -1629,6 +1629,10 @@ async def create_round(
|
|||
type="round",
|
||||
message=f"{owner_name} startet en runde du kan følge ({round_label}).",
|
||||
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)
|
||||
|
|
@ -3055,6 +3059,24 @@ async def update_participant(
|
|||
*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) --
|
||||
# regn playing_handicap for begge sider på nytt FØR raden under
|
||||
# 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
|
||||
# HCP).
|
||||
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]:
|
||||
|
|
@ -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,
|
||||
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
|
||||
""",
|
||||
participant_id,
|
||||
|
|
@ -3237,6 +3263,8 @@ class RoundSideHoleOut(BaseModel):
|
|||
# ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet
|
||||
# gir ikke mening for RoundHoleOut.
|
||||
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]:
|
||||
|
|
@ -3246,7 +3274,8 @@ async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSide
|
|||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
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",
|
||||
side_id,
|
||||
)
|
||||
|
|
@ -3287,6 +3316,10 @@ class SideHoleUpdate(BaseModel):
|
|||
# sender alltid gjeldende verdi, null = ikke registrert -- IKKE en
|
||||
# delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under.
|
||||
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)
|
||||
|
|
@ -3315,15 +3348,29 @@ async def update_side_hole(
|
|||
)
|
||||
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
|
||||
AND ($6::int IS NULL OR version = $6::int)
|
||||
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,
|
||||
body.expected_version,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
|
||||
# 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(
|
||||
409, "STALE_VERSION",
|
||||
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
return RoundSideHoleOut(**dict(row))
|
||||
|
||||
|
|
@ -4990,6 +5037,10 @@ class HoleUpdate(BaseModel):
|
|||
penalty_strokes: int | None = Field(default=None, ge=0)
|
||||
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
|
||||
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(
|
||||
|
|
@ -5045,7 +5096,9 @@ async def update_hole(
|
|||
"Kan ikke registrere «plukket opp» før handicap er beregnet for denne deltakeren.",
|
||||
)
|
||||
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():
|
||||
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,
|
||||
tee_shot_result = $8, approach_result = $9, chip_count = $10,
|
||||
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
|
||||
AND ($15::int IS NULL OR version = $15::int)
|
||||
RETURNING hole_number, par, stroke_index, played, score, picked_up, putts, club_off_tee,
|
||||
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,
|
||||
played, score, body.picked_up, body.putts, body.club_off_tee,
|
||||
body.tee_shot_result, body.approach_result, body.chip_count,
|
||||
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:
|
||||
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)
|
||||
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],
|
||||
)
|
||||
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"])
|
||||
|
||||
await conn.execute(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import type { MetadataRoute } from "next"
|
||||
|
||||
// PWA-manifest (ADR-028). Ikonene under er beskåret fra den ekte TeeCup-
|
||||
// logoen (2026-08-06, se public/teecup-wordmark.svg for hele ordmerket
|
||||
// ikon+tekst) -- erstatter den tidligere midlertidige grønne
|
||||
// golfflagg-placeholderen.
|
||||
// PWA-manifest (ADR-028). Ikonene under er et ordentlig, polert app-ikon
|
||||
// (ADR-055, 2026-08-10) -- ekte logo-vektor (ball+pokal+tee), full-bleed
|
||||
// mørkegrønn bakgrunn, motiv sentrert innenfor maskerings-safe-zone --
|
||||
// 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 {
|
||||
return {
|
||||
name: "TeeCup",
|
||||
|
|
@ -12,8 +15,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: "#8BC24A",
|
||||
background_color: "#f3f6ec",
|
||||
theme_color: "#2f6b1e",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
|
||||
|
|
|
|||
|
|
@ -1378,7 +1378,7 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
|
|||
// venne-kategoriseringen), ikke en delvis PATCH.
|
||||
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: "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: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { cn } from "@/lib/utils"
|
||||
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
||||
import { bearingDegrees } from "@/lib/geo"
|
||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||
|
||||
// --- Types -----------------------------------------------------------------
|
||||
|
|
@ -345,6 +346,9 @@ type ApiHole = {
|
|||
first_putt_distance_bucket: PuttBucket | null
|
||||
anyway_strokes: 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)
|
||||
|
|
@ -363,6 +367,8 @@ type ApiSideHole = {
|
|||
// 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").
|
||||
strokes_received: number | null
|
||||
// Optimistisk versjonssjekk (ADR-057) -- se ApiHole.
|
||||
version: number
|
||||
}
|
||||
|
||||
function apiHoleToStat(h: ApiHole): HoleStat {
|
||||
|
|
@ -433,6 +439,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
|
||||
const [round, setRound] = useState<ApiRound | 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 [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
|
||||
// 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>,
|
||||
) {
|
||||
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}`))
|
||||
setPendingCount((c) => c + 1)
|
||||
setHolesByParticipant((prev) => ({
|
||||
|
|
@ -706,7 +724,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
body: { played: boolean; score: number | null; selected_participant_id: string | null },
|
||||
) {
|
||||
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}`))
|
||||
setPendingCount((c) => c + 1)
|
||||
setHolesBySide((prev) => ({
|
||||
|
|
@ -751,7 +771,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
}
|
||||
const failed = outcomes.filter((o) => !o.ok)
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
|
|
@ -914,13 +937,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||||
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
|
||||
try {
|
||||
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(bodyWithVersion),
|
||||
})
|
||||
} catch {
|
||||
// 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)
|
||||
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()
|
||||
setHolesByParticipant((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -1042,19 +1077,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||||
return
|
||||
}
|
||||
// Optimistisk versjonssjekk (ADR-057) -- se updateWizardStat.
|
||||
const bodyWithVersion = { ...body, expected_version: wizardSideHole?.version ?? null }
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(bodyWithVersion),
|
||||
})
|
||||
} catch {
|
||||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||||
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()
|
||||
setHolesBySide((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -1257,10 +1300,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
return (
|
||||
<RoundPageShell roundId={roundId} activeTab="score">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 text-base font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
{conflictNotice && (
|
||||
<div
|
||||
role="alert"
|
||||
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 --
|
||||
|
|
@ -2099,6 +2152,26 @@ function ShotMeasurementEntry({
|
|||
}, [urlBase])
|
||||
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) {
|
||||
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
|
||||
if (res.ok) {
|
||||
|
|
@ -2286,6 +2359,7 @@ function ShotMeasurementEntry({
|
|||
holeNumber={holeNumber}
|
||||
existingShotCount={count}
|
||||
ownBagClubs={ownBagClubs}
|
||||
initialBearing={initialBearing}
|
||||
submitError={submitError}
|
||||
submitting={submitting}
|
||||
onSubmit={submitShot}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function MapPointPicker({
|
|||
onConfirm,
|
||||
referencePoint,
|
||||
referenceLabel = "Utslag",
|
||||
initialBearing,
|
||||
}: {
|
||||
/** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps"
|
||||
* ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */
|
||||
|
|
@ -47,6 +48,21 @@ export function MapPointPicker({
|
|||
*/
|
||||
referencePoint?: LngLat
|
||||
referenceLabel?: string
|
||||
/**
|
||||
* Kompassgrader (0-360) kartet roteres til ved oppstart, så "opp på
|
||||
* skjermen" følger spillerens egen gangretning på 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 på 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, så 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
|
||||
* på nord (0) hvis heller ikke det finnes.
|
||||
*/
|
||||
initialBearing?: number
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | 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
|
||||
// for bare å sentrere på ett av dem, slik at referansepunktet garantert
|
||||
// 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
|
||||
mapboxgl.accessToken = token as string
|
||||
const map = new mapboxgl.Map({
|
||||
|
|
@ -95,6 +111,7 @@ export function MapPointPicker({
|
|||
style: "mapbox://styles/mapbox/satellite-v9",
|
||||
center,
|
||||
zoom: referencePoint ? 17 : 16,
|
||||
bearing,
|
||||
attributionControl: false,
|
||||
})
|
||||
mapRef.current = map
|
||||
|
|
@ -149,21 +166,34 @@ export function MapPointPicker({
|
|||
const fallbackCenter: [number, number] = referencePoint
|
||||
? [referencePoint.lng, referencePoint.lat]
|
||||
: 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) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const here: [number, number] = [pos.coords.longitude, pos.coords.latitude]
|
||||
const bearing = resolveBearing(pos.coords.heading)
|
||||
if (referencePoint) {
|
||||
initMap(here, [here, [referencePoint.lng, referencePoint.lat]])
|
||||
initMap(here, [here, [referencePoint.lng, referencePoint.lat]], bearing)
|
||||
} else {
|
||||
initMap(here)
|
||||
initMap(here, undefined, bearing)
|
||||
}
|
||||
},
|
||||
() => initMap(fallbackCenter),
|
||||
() => initMap(fallbackCenter, undefined, resolveBearing(null)),
|
||||
{ enableHighAccuracy: true, timeout: 8000 },
|
||||
)
|
||||
} else {
|
||||
initMap(fallbackCenter)
|
||||
initMap(fallbackCenter, undefined, resolveBearing(null))
|
||||
}
|
||||
|
||||
// Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint
|
||||
|
|
|
|||
|
|
@ -34,6 +34,16 @@ export type ShotMeasurementSheetProps = {
|
|||
holeNumber: number
|
||||
existingShotCount: number
|
||||
ownBagClubs: string[]
|
||||
/**
|
||||
* Kompassgrader (0-360) kartet roteres til, regnet ut av kalleren fra
|
||||
* spillerens EGET forrige slag på dette hullet (start→slutt-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 på 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
|
||||
* here so the sheet stays previewable; when absent a rough preview-only
|
||||
|
|
@ -71,6 +81,7 @@ export function ShotMeasurementSheet({
|
|||
holeNumber,
|
||||
existingShotCount,
|
||||
ownBagClubs,
|
||||
initialBearing,
|
||||
distanceMeters,
|
||||
submitError,
|
||||
submitting,
|
||||
|
|
@ -279,10 +290,15 @@ export function ShotMeasurementSheet({
|
|||
|
||||
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
|
||||
"map_tap" på start-steget siden ingen referencePoint er satt. */}
|
||||
{step === "map" ? <MapPointPicker onConfirm={(p) => {
|
||||
setStartPoint(p)
|
||||
setStep("end")
|
||||
}} /> : null}
|
||||
{step === "map" ? (
|
||||
<MapPointPicker
|
||||
initialBearing={initialBearing}
|
||||
onConfirm={(p) => {
|
||||
setStartPoint(p)
|
||||
setStep("end")
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
|
||||
brukerønske: "jeg skal se start og slutt på et satelittfoto...
|
||||
|
|
@ -295,6 +311,7 @@ export function ShotMeasurementSheet({
|
|||
<MapPointPicker
|
||||
referencePoint={startPoint ?? undefined}
|
||||
referenceLabel="Utslag"
|
||||
initialBearing={initialBearing}
|
||||
onConfirm={(p, method) => {
|
||||
setEndPoint(p)
|
||||
setEndMethod(method)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 på 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,25 +83,47 @@ export type FlushOutcome = { entry: QueueEntry; ok: boolean; message?: string }
|
|||
export async function flushQueue(matchId: string): Promise<FlushOutcome[]> {
|
||||
const entries = await listQueue(matchId)
|
||||
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) {
|
||||
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
|
||||
try {
|
||||
res = await fetch(entry.url, {
|
||||
method: entry.method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(entry.body),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
break // fortsatt offline -- stopp, resten prøves igjen senere
|
||||
}
|
||||
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)
|
||||
outcomes.push({ entry, ok: true })
|
||||
} 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)
|
||||
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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 909 B After Width: | Height: | Size: 650 B |
|
Before Width: | Height: | Size: 909 B After Width: | Height: | Size: 650 B |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 19 KiB |
|
|
@ -128,6 +128,7 @@ def max_hole_score_for_handicap(
|
|||
strokes_received: int,
|
||||
*,
|
||||
index_established: bool = True,
|
||||
course_handicap: int | None = None,
|
||||
) -> int:
|
||||
"""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
|
||||
første score(r)): par + 5 (Rule 3.1a) — enklere cap fordi ingen
|
||||
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 på 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:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -147,12 +160,14 @@ def adjusted_gross_score(
|
|||
strokes_received: Sequence[int],
|
||||
*,
|
||||
index_established: bool = True,
|
||||
course_handicap: int | None = None,
|
||||
) -> int:
|
||||
"""18-hulls Adjusted Gross Score (Rule 3), grunnlaget for Score Differential.
|
||||
|
||||
- `hole_scores[i]` = spilt bruttoscore på hull i, eller `None` for et
|
||||
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,
|
||||
ikke kortere lister) — en 9-hulls-runde sendes inn som 18 elementer der
|
||||
9 av dem er `None`.
|
||||
|
|
@ -168,7 +183,9 @@ def adjusted_gross_score(
|
|||
if score is None:
|
||||
total += net_par(par, strokes)
|
||||
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)
|
||||
return total
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
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():
|
||||
# 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
|
||||
|
|
|
|||