diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 201c22b..742a162 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/062_round_hole_version.sql b/062_round_hole_version.sql new file mode 100644 index 0000000..9300986 --- /dev/null +++ b/062_round_hole_version.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index fcc5a31..e6ca875 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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` 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å diff --git a/CHANGELOG.md b/CHANGELOG.md index cf80c80..27714d7 100644 --- a/CHANGELOG.md +++ b/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ø. diff --git a/app/routers/notifications.py b/app/routers/notifications.py index aa93ae9..050ac41 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -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, diff --git a/app/routers/rounds.py b/app/routers/rounds.py index d6ac2a5..dae3a7e 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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( diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts index 384a700..ce3b6bf 100644 --- a/frontend/app/manifest.ts +++ b/frontend/app/manifest.ts @@ -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" }, diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index c58f90b..38e9940 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -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." }, ] diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index cca0ea7..65a91e5 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -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(null) const [error, setError] = useState(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(null) const [activePlayerId, setActivePlayerId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) // Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et @@ -691,7 +704,12 @@ export function RoundDetail({ roundId }: { roundId: string }) { body: ReturnType, ) { 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 (
- {error && ( -

- {error} -

+ {conflictNotice && ( +
+

{conflictNotice}

+ +
)} {/* 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} diff --git a/frontend/components/shot/map-point-picker.tsx b/frontend/components/shot/map-point-picker.tsx index 051a2e3..39924e4 100644 --- a/frontend/components/shot/map-point-picker.tsx +++ b/frontend/components/shot/map-point-picker.tsx @@ -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(null) const mapRef = useRef(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 diff --git a/frontend/components/shot/shot-measurement-sheet.tsx b/frontend/components/shot/shot-measurement-sheet.tsx index 0df420c..39c9b9e 100644 --- a/frontend/components/shot/shot-measurement-sheet.tsx +++ b/frontend/components/shot/shot-measurement-sheet.tsx @@ -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" ? { - setStartPoint(p) - setStep("end") - }} /> : null} + {step === "map" ? ( + { + 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({ { setEndPoint(p) setEndMethod(method) diff --git a/frontend/lib/geo.ts b/frontend/lib/geo.ts index 5e9137b..4deb2b3 100644 --- a/frontend/lib/geo.ts +++ b/frontend/lib/geo.ts @@ -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 +} diff --git a/frontend/lib/offline-queue.ts b/frontend/lib/offline-queue.ts index 96cd6ac..ee03fb3 100644 --- a/frontend/lib/offline-queue.ts +++ b/frontend/lib/offline-queue.ts @@ -83,25 +83,47 @@ export type FlushOutcome = { entry: QueueEntry; ok: boolean; message?: string } export async function flushQueue(matchId: string): Promise { 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() 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 diff --git a/frontend/public/apple-icon.png b/frontend/public/apple-icon.png index 8a8cdc4..947d605 100644 Binary files a/frontend/public/apple-icon.png and b/frontend/public/apple-icon.png differ diff --git a/frontend/public/icon-dark-32x32.png b/frontend/public/icon-dark-32x32.png index eb0f16e..69bb875 100644 Binary files a/frontend/public/icon-dark-32x32.png and b/frontend/public/icon-dark-32x32.png differ diff --git a/frontend/public/icon-light-32x32.png b/frontend/public/icon-light-32x32.png index eb0f16e..69bb875 100644 Binary files a/frontend/public/icon-light-32x32.png and b/frontend/public/icon-light-32x32.png differ diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index 9ec673b..d286e8b 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,394 +1,22 @@ - - - - - AABCHWp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAEH3anVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo1ZTI5N2RiMi02NmU2LTQ1ZjAtYTNjMy1jMzE1MTZjZWE5NzMAAAAB5Gp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAO5qdW1iAAAAQWp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuYWN0aW9ucy52MgAAAAAYYzJzaKXkx8y3IMAJsMr8Ye/qsVoAAAClY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmxjMnBhLmNyZWF0ZWRtc29mdHdhcmVBZ2VudGhDYW52YSBBSXFkaWdpdGFsU291cmNlVHlwZXhTaHR0cDovL2N2LmlwdGMub3JnL25ld3Njb2Rlcy9kaWdpdGFsc291cmNldHlwZS9jb21wb3NpdGVXaXRoVHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAADFanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaC58ECUaO7UOTEYs77pAvg4AAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQESZmxlbmd0aBlYKGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggBCzizGdIaYuVMnolayoF9+WgI7Bw64+ppItmbumwGUpjcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAAB3GNib3Knamluc3RhbmNlSUR4LHhtcDppaWQ6NDliYTViNWQtNmVhNS00ZmJlLTgyYWMtMzg1YTcxZmUyZjY1dGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lZ2MycGEtcnNndmVyc2lvbmUwLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2UwLjAuMGlzaWduYXR1cmV4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6NWUyOTdkYjItNjZlNi00NWYwLWEzYzMtYzMxNTE2Y2VhOTczL2MycGEuc2lnbmF0dXJlcmNyZWF0ZWRfYXNzZXJ0aW9u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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQIAlL7V4NQY7Jwjk2Y0dnoUfBHcfvIx0jMriJD8CXXcY12XQcsJCC/wCLWLZFzLKtCXH18SLeSFH9S+vwL5CCSrs+PeUE2woT/jQ1LgYIy8GiJw7P/YZiNcOFT5E6yc/aEZWd6nFW4YrYQ+MKuCainGazZQfOe0NZC26Ae4KuLVDo+eFf41uXcI64I+yPWVPxWoWiLoeUljQ26PvgJlIGqippcTiVKwFPlOcxub6pctmPR8Y4UfHSj5dCVpMvu6OeBmOVtzdQ8Nw+bOtPup4HOA5ohStm5z09/1BIb8Z3oiDNFtmeyMAXdB29BkndOUaLjy7RSZGZYSRDqz/m1dn/UmkiH6FJlqcmEl+0A4ndRiLP/xzNgdmGBTiC+90GuRNkasFENd+lpW49U1rM6WX06CV1JSjy4MrYxzbkpCYx/jMsmBNE0M9LA8Mu19Q8fhTT/wW7aMy7pPDWhZdvmmA123WYJNK/qJLIC+vDzAAP2QDHIv5ozxe/KaLOGSac1pLcSV+n4BHRxzmZhfaQwqPMesizsukxtta6r/1bnFVXs9RMOqTLx0m2hGd3Mq2grmUMcv/i/EQd98ZWUUmKF1c3fbiFjohYHgydHAe+cmHWfZxHhWsUJu90qSkrSgjEzjS0siPBtsrgM+xBtvUyTYQWqUgcqHvt/s3hQ6p6Vpv+99VVM= - Yes - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png index b25090c..a065361 100644 Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png index af2c2f5..48a119d 100644 Binary files a/frontend/public/icons/icon-512.png and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/icon-maskable-512.png b/frontend/public/icons/icon-maskable-512.png index 5190a12..48a119d 100644 Binary files a/frontend/public/icons/icon-maskable-512.png and b/frontend/public/icons/icon-maskable-512.png differ diff --git a/handicap_engine.py b/handicap_engine.py index b6236df..e11139c 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -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 diff --git a/test_handicap_engine.py b/test_handicap_engine.py index dff7ec5..8d1e625 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -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