diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5634117..201c22b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -451,7 +451,21 @@ "Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')", "Bash(awk -F: '$1 >= 2047 && $1 <= 2260')", "Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)", - "Bash(sort -t/ -k4 -n)" + "Bash(sort -t/ -k4 -n)", + "Bash(echo \"building in bg, pid $!\")", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18124/)", + "Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -3 \"{}\"')", + "Bash(grep -n \"\\\\|$\" frontend/components/teecup/velkommen.tsx)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18126/)", + "Bash(xargs -I{} sh -c 'echo \"--- {} ---\"')", + "Bash(grep -iE \"\\\\.env$|\\\\.pem$|secret|credential\")", + "Bash(grep -B3 \": str$\")", + "Bash(python3 -m py_compile app/rate_limit.py app/routers/auth.py)", + "Bash(curl -sI https://teecup.golf/)", + "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/)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index f73cca1..54b7fdd 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -4696,6 +4696,70 @@ endret beslutning får et tillegg, ikke en retusjert original. `start_method=gps, end_method=map_tap` i databasen, verifisert med direkte SQL). Se CHANGELOG.md for full detalj. +**Tillegg 2026-08-08 (del 2) — ball-steget viser nå ALLTID kartet, med +løpende (sanntids) posisjon og avstand, og allerede målte slag viser nå +satellittfoto i listen.** Umiddelbart etter tillegget over, bruker: "Jeg +får ikke sett slagene jeg allerede har målt... jeg ønsker å kunne se på et +satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om jeg +velger 'min posisjon nå', så skal jeg se start og slutt på et +satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se lengden +så langt i sanntid mens jeg nærmer meg ballen." Dette endrer Beslutning B +sitt "kart lastes kun ved eksplisitt 'velg punkt på kart'"-prinsipp +BEVISST for ball-steget spesifikt (fortsatt uendret for start-steget): + +- **Ball-steget slo sammen "GPS vs. kart"-valget til ÉN alltid-synlig + kart-visning.** Den forrige separate GPS-only-idle-skjermen (bare en + "Jeg er ved ballen nå"-knapp, ingen kart) og det egne `end_map`-steget + er fjernet -- `MapPointPicker` rendres nå direkte når "end"-steget nås, + alltid med `referencePoint=startPoint`. `MapPointPicker` viste + opprinnelig BEGGE bekreftelsesknappene når `referencePoint` var satt: + "Bekreft ballens posisjon" (trykket punkt, `end_method: "map_tap"`) og + "Jeg er ved ballen nå" (siste sporede posisjon, `end_method: "gps"`). + **Korrigert 2026-08-09** (samme dag, etter faktisk bruk): bruker, + "bekreft ballens posisjon er unødvendig" -- trykk-for-å-plassere-ballen + fjernet igjen. Ball-steget bekrefter nå UTELUKKENDE via sporet + GPS-posisjon; kartet der er rent informativt (referansepunkt + + sanntidsposisjon/-avstand), ikke lenger klikkbart for plassering. + Backendens `end_method`-kolonne/CHECK aksepterer fortsatt begge verdier + uendret (historiske `map_tap`-rader fra den korte perioden dette var + aktivt skal fortsatt leses korrekt) -- kun frontend-UI-et endret, ingen + migrasjon. Start-steget er UPÅVIRKET: trykk-for-å-plassere fungerer der + fortsatt akkurat som før. +- **Løpende posisjonssporing (`watchPosition`), kun på ball-steget.** + Dette er et bevisst, avgrenset unntak fra "ingen løpende watchPosition" + -- den opprinnelige begrunnelsen for det forbudet var KART- + lastnings-kostnad (Mapbox fakturerer initialisering), og `watchPosition` + i seg selv utløser ALDRI en ny kartinitialisering eller et nytt + Mapbox-API-kall -- det er ren nettleser-GPS som kun oppdaterer en + eksisterende markørs posisjon på det ALLEREDE lastede kartet. Samme + ETT-kart-instans-prinsipp står dermed fortsatt fast; kun selve + markør-/avstandsdataene oppdateres kontinuerlig. `watchPosition` ryddes + opp (`clearWatch`) når komponenten avmonteres. +- **Sanntids-avstand** vises som et stort tall nederst på kartet, + beregnet med `haversineMeters(referencePoint, livePosition)` (samme + rene funksjon som allerede fantes i `frontend/lib/geo.ts`, tidligere + brukt kun for forhåndsvisningen -- `shot-measurement-sheet.tsx` sin + egen duplikate Haversine-implementasjon fjernet til fordel for denne, + ren opprydding uten atferdsendring). +- **Kostnadskonsekvens, eksplisitt notert:** ball-steget laster nå + Mapbox-kartet for HVERT målt slag, uansett om brukeren til slutt + bekrefter via GPS eller kart-trykk (siden kartet uansett vises for at + brukeren skal se referansepunktet + sanntids-avstand mens de går). + Start-steget er UENDRET (kart lastes fortsatt kun ved eksplisitt "velg + punkt på kart") -- kun halvparten av et målt slags to punkter utløser nå + alltid en kartlastning. Vurdert og akseptert: 50 000 gratis + kartlastninger/mnd (Beslutning A) gir god margin for forventet volum. +- **Allerede målte slag i listen** (`round-detail.tsx`, `ShotList`) viser + nå et lite satellitt-thumbnail (160×160, samme offentlige, URL- + restrikterte token og samme `pin-s-a`/`pin-s-b`-fargekonvensjon som + forhåndsvisningen) bygget direkte fra `ShotRecord`s allerede-returnerte + `start_lat`/`start_lng`/`end_lat`/`end_lng` (utvidet fra kun + `id`/`club`/`distance_meters`/`shared_round_message_id`) -- løser "jeg + burde jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke + bare klubbe/avstand som tekst, men faktisk HVOR slaget ble slått. Ingen + backend-endring nødvendig -- `ShotOut` returnerte allerede alle fire + koordinatene, kun frontend-typen/rendringen manglet dem. + **Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn` og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share), alle bygget på den eksisterende `_get_accessible_round_or_404` (samme @@ -4729,6 +4793,257 @@ Beslutning B). og mål på nytt i stedet), en historisk "vis alle slag på kart etter runden"-visualisering. +## ADR-049: `/velkommen` — landingsside for innlogget-men-ikke-fullført-profil + +Reist av brukeren 2026-08-09: "Skjemaet med personlig informasjon vises +for tidlig for ikke registrerte spillere etter at de logger seg inn... +Jeg tror det beste er at man kommer til en side med oppfordring til å +installere som app (som vanlig) og med deaktiverte knapper for runde, +turneringen og bli med med kode. Trykker man på noen av disse skal man +få beskjed om at personlig informasjon må fylles ut først, med lenke til +skjemaet. Under dette bør TeeCup presenteres." Bekreftet: ny, avgrenset +side (ikke en full erstatning av `/dashboard`, se drøftingen i chatten) +— dashbordet for FULLFØRT profil er uendret. + +**Problemet:** en innlogget bruker med ufullstendig profil ble tidligere +sendt RETT til `/account` sitt påtvungne skjema ("Fullfør profilen din"), +uten noen kontekst om hva de nettopp logget seg inn på. Brukeren +observerte dette som en reell feil i praksis (antas: forvirring/frafall), +ikke bare en teoretisk innvending. + +**Løsning: ny side `frontend/app/velkommen/page.tsx` + +`frontend/components/teecup/velkommen.tsx`,** satt inn i redirect-kjeden +mellom innlogging og `/account`: +- `frontend/app/page.tsx` og `frontend/app/logg-inn/page.tsx` sine + server-side redirects, OG `dashboard.tsx` sin klient-side + `profile_complete`-vakt (`loadMe()`), peker nå til `/velkommen` i + stedet for `/account` når profilen er ufullstendig. `/velkommen` selv + redirecter videre til `/dashboard` (komplett profil) eller `/logg-inn` + (ikke innlogget) — kan altså ikke nås "feil" via direkte URL. +- Siden viser, i rekkefølge: TeeCup-ordmerke + "Logg ut", en + personlig hilsen (`me.first_name ?? me.display_name`, samme + navneformat-regel som dashbordets hilsen, CLAUDE.md), den + eksisterende `InstallPrompt`-komponenten UENDRET (samme PWA- + oppfordring som dashbordet allerede bruker), en primær "Fullfør + profilen din"-CTA, TRE synlige men visuelt låste hurtighandlinger + ("Ny runde"/"Ny turnering"/"Bli med med kode" — hengelås-ikon, + `aria-disabled`, IKKE HTML `disabled` siden de fortsatt skal være + klikkbare for å forklare hvorfor), og en presentasjon av TeeCup + (innhold hentet fra `teecup-beskrivelse.md`, skrevet om til kort + UI-tekst — ikke limt inn rått som markdown). +- Trykk på en låst handling viser en delt påminnelse + (`role="alert"`, skjermleser-varslet automatisk) med lenke videre til + `/account`, i stedet for å navigere — samme "forklar, ikke bare + blokkér"-prinsipp brukeren ba om. +- Bygget med samme "clubhouse"-palett-tokens (`--tee-strong`, + `--clubhouse-*`) og samme `TeeCupWordmark`/`InstallPrompt`-komponenter + som `/logg-inn` og det allerede reskinnede dashbordet (2026-08-08, + se CHANGELOG.md) — ikke funnet opp på nytt, gjenbruker den etablerte + retningen for akkurat denne delen av appen. + +**Bevisst avgrenset omfang:** ingen ny server-side håndheving av +`profile_complete` er lagt til på andre sider (`/my-rounds`, +`/my-friends` osv. sjekker i dag kun innlogging, ikke profil-status — +en pre-eksisterende, ikke relatert inkonsistens, ikke rørt her). Kun de +tre inngangspunktene som faktisk styrte hvor en ufullstendig-profil- +bruker havnet (root, `/logg-inn`, dashbordets egen klient-vakt) er +endret. + +## ADR-050: Sikkerhetsgjennomgang 2026-08-09 — rate limiting, sikkerhetshoder, inputvalidering + +Brukeren ba om en full sikkerhetsgjennomgang ("sjekk alle felter som kan +fylles ut, og sjekk alle URL-er... lar siden/appen seg hacke?"). En +dedikert agent gjennomgikk autentisering, HELE autorisasjonslaget på +tvers av 16 routere, input-validering, filopplasting, CORS/nettverk og +hemmeligheter i git-historikken. Konklusjon: autorisasjons-/IDOR-laget er +uvanlig grundig og konsekvent (ingen bekreftet IDOR i noe skrive- +endepunkt) — de reelle funnene var manglende rate limiting (HØY), +manglende sikkerhetshoder (LAV-MIDDELS), inkonsekvent input-validering +(LAV), og én liten informasjonslekkasje i ett BBB-endepunkt +(informativt). Bruker: "tett sikkerhetshullene først." + +**Beslutning A — Rate limiting, i minnet, ikke Redis (ennå).** Ny +`app/rate_limit.py`: en enkel fast-vindu-teller (`RateLimiter`) brukt +enten som FastAPI-dependency (IP-basert, via `client_ip()` som stoler på +`X-Forwarded-For` — trygt KUN fordi appen ikke er nåbar unntatt gjennom +Caddy, samme tillitsmodell som `should_use_secure_cookies()`) eller kalt +direkte med en egendefinert nøkkel. Lagt til på fem `app/routers/auth.py` +-endepunkter: `request-link`/`login-password`/`2fa/email/request` +(IP-basert, 5-10 forsøk/10 min — ressursen er ikke entydig knyttet til én +konto), og `2fa/verify`/`2fa/setup/confirm` (nøkkel = pending-/innlogget +bruker-ID, 8 forsøk/5 min — her ER ressursen én bestemt kontos kode, en +angriper med en gyldig pending-sesjon kunne ellers omgått IP-basert +begrensning ved å bytte IP). Trygt i minnet KUN fordi `teecup_api` kjører +som ÉN uvicorn-prosess (ingen `--workers`-flagg) — samme kjente +begrensning som andre in-memory-cacher i appen (se punkt 2 i listen +under). Ved fremtidig skalering til flere workers/containere MÅ dette +flyttes til Redis. + +**Beslutning B — Sikkerhetshoder i Caddy, ikke Next.js-middleware.** +Lagt til i `/opt/teeoff/deploy/Caddyfile` sin `teecup.golf`-blokk (delt +fil med `teeoff.no`, KUN teecup.golf-blokken endret): `X-Frame-Options: +DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, +`Strict-Transport-Security`, og en `Content-Security-Policy`. CSP-en er +bevisst IKKE en streng nonce-basert policy — appen bruker inline +`style`-attributter mange steder (React `style={{...}}`, hele +"clubhouse"-paletten) og Next.js sin hydrering kan trenge inline script, +så `'unsafe-inline'` er beholdt for script-src/style-src for å unngå å +knekke appen blindt. Verdien ligger i det som ER strammet inn: +`object-src`/`frame-ancestors`/`base-uri` blokkerer hele angrepsklasser, +og `connect-src`/`img-src` er begrenset til KUN Mapbox (slagmåling, +ADR-048) — en fremtidig XSS-bug kan ikke enkelt eksfiltrere data til en +vilkårlig tredjeparts-vert. Bekreftet i ekte nettleser mot produksjon: +ingen CSP-brudd i konsollen, et ekte Mapbox-kall (200 OK) fungerer +uendret. + +**Reell driftsfallgruve funnet under utrulling, verdt å dokumentere:** +`docker exec teeoff_caddy caddy reload` (OG en direkte admin-API-`/load` +-PUT) rapporterte begge suksess uten feil, men endringen slo likevel +ALDRI igjennom — `md5sum` av filen inne i containeren avvek fra filen på +verten. Rotårsak: Docker sin bind-mount av EN ENKELT FIL (`./deploy/ +Caddyfile:/etc/caddy/Caddyfile:ro`) er bundet til INODE-en filen hadde +ved containerens oppstart. Et skriveverktøy som lagrer atomisk (skriv til +midlertidig fil + `rename()`, vanlig og trygt mønster generelt) bytter ut +inode-en på verten — den kjørende containerens mount fortsetter da å +referere den GAMLE, nå frikoblede inode-en, usynlig for `reload`/admin- +API (som begge leser filen på nytt, men containerens FILSYSTEM-syn av +stien er allerede feil). Løsning: `docker restart teeoff_caddy` (ikke +bare `reload`) — dette er en STÅENDE fallgruve for enhver fremtidig +Caddyfile-endring, ikke unikt for denne runden. Kort, ufarlig avbrudd for +BEGGE sidene (teeoff.no og teecup.golf) ved restart, siden de deler +samme Caddy-container. + +**Beslutning C — Inputvalidering, konsistens fremfor nye regler.** Ingen +nye valideringsprinsipper — kun manglende `max_length`/`ge`/`le` lagt til +der de manglet, etter EKSAKT samme mønster som allerede fantes andre +steder i samme fil (f.eks. `PlayerCreate.handicap_index` fikk samme +`ge=-10, le=54` som `auth.py` sin `ProfileUpdate.handicap_index` allerede +hadde). Én reell feil unngått underveis: `PersonalCourseTeeRatingIn.par` +ble først satt til `ge=3, le=6` (feilaktig antatt å være per-hull-par, som +`PersonalCourseHoleIn.par`) — DB-skjemaet (`020_personal_rounds.sql`, +ingen CHECK-constraint på den kolonnen) avslørte at det faktisk er +BANENS TOTALE par brukt i WHS-beregningen, rettet til `ge=54, le=90` før +utrulling. Endret: `rounds.py` (`PersonalCourseTeeRatingIn`/ +`PersonalCourseTeeIn.name`/`PersonalCourseCreate.name`), `players.py` +(`PlayerCreate`/`PlayerUpdate`, begge — `handicap_index`/`mobile`/ +`nickname`/`country`/`club`/`club_member_number`), `round_messages.py` +(`CommentIn.body`, delt/importert av `messaging.py` sine to bruksseder — +IKKE duplisert), samt tre `Form(default=None)`-felt (`round_messages.py` ++ to i `messaging.py`) som fikk `max_length=2000` (tekst) eller +`max_length=20` (invitasjonskode, som er 6 tegn generert, se +`tournaments.py._generate_join_code`). + +**Beslutning D — BBB-endepunkt: autorisasjon flyttet FØR formatsjekk.** +`update_bbb_hole` i `rounds.py` kalte `_get_accessible_round_or_404` +ETTER å ha lest og validert `play_format`/`holes_planned` — en bruker +UTEN tilgang til en fremmed runde kunne dermed skille "runden finnes +ikke" (404) fra "runden finnes, feil format" (400) via feilmeldingen, +uten å oppnå noen skrivetilgang. Byttet rekkefølge: autorisasjon leses +FØRST, deretter format/hull-omfang. `round_row` kan ikke lenger være +`None` etter en vellykket autorisasjonssjekk, så den separate +null-sjekken er fjernet (var uansett aldri nåbar etter omorganiseringen). + +**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten): rate +limiting bekreftet å faktisk utløse 429 ved riktig terskel (10./11. forsøk +på login-password, 6. forsøk på request-link), gyldig/ugyldig +input-validering bekreftet begge veier (HCP over 54 avvist, gyldig HCP +akseptert, for lang mobil avvist), BBB-endepunktet bekreftet å returnere +403 NOT_AUTHORIZED (ikke lenger 400 format-lekkasje) for en bruker uten +tilgang, OG fortsatt 200 OK for den faktiske eieren (regresjonssjekk). +`teecup_db`s ACL bekreftet uendret før/etter, scratch-ressurser ryddet +opp fullstendig. CSP verifisert i ekte nettleser mot produksjon (se +Beslutning B). + +**Bevisst utenfor omfang denne runden** (nevnt av agenten, ikke fulgt +opp): full linje-for-linje-gjennomgang av `individual_tournaments.py`/ +`tournaments.py`/`order_of_merit.py`/`courses.py` (kun stikkprøvd), +Web Push-abonnement-kapring-teori (praktisk risiko vurdert svært lav, +push-endepunkt-URL-er er ugjettbare), noen faktisk penetrasjonstest (kun +statisk kodegjennomgang). + +## ADR-051: 13-årsgrense for kontoregistrering + presentasjonstekst-korrigeringer + +Brukeren, 2026-08-10, etter å ha lest PDF-vedlegget "Barns personvern i +golfapp" (en Gemini-samtale om GDPR/personvernkrav for mindreårige i en +GPS+UGC-app): "Vi må gjøre noe med de som er barn. For det første: +Tillatt aldri de som er under 13 år å registrere seg i appen." Samtidig, +to relaterte presentasjonskorrigeringer: et annet PDF-vedlegg ("Installere +PWA fra Chrome på iPhone") viste at installasjonsteksten feilaktig +hevdet at PWA-installasjon KREVER Safari på iOS -- siden iOS 16.4 gjelder +ikke det lenger. Og: "Jeg tror ikke vi skal nevne golfklubber i +beskrivelsen av TeeCup," pluss et ønske om å fremheve at +turneringsadministrasjon/-presentasjon fungerer like godt på PC. + +**Beslutning A — 13-årsgrense håndheves i `PATCH /auth/profile`, ikke i +et eget registreringssteg.** Siden `profile_complete` (fødselsdato er ett +av de obligatoriske feltene) allerede er den ENESTE veien forbi +`/velkommen`-sperren (ADR-049) og inn i resten av appen, er dette det +naturlige, allerede-eksisterende knutepunktet -- en under-13-åring kan +rett og slett aldri fullføre profilen sin, og kommer dermed aldri forbi +`/velkommen`s låste hurtighandlinger. Ingen ny tabell/kolonne, ingen nytt +flagg. Håndhevet SERVER-SIDE i `app/routers/auth.py` sin `update_profile` +(alder beregnet fra `body.birth_date`, avvist med `400 UNDER_MINIMUM_AGE` +hvis under 13 -- eksakt dagsgrense, ikke bare årstall), PLUSS +klientside i BEGGE skjemaer som setter fødselsdato +(`ProfileOnboarding` og `ProfileSection` i `account-settings.tsx`, delt +`computeAge()`-hjelpefunksjon) for umiddelbar tilbakemelding før +serveren i det hele tatt kontaktes. + +**Bevisst IKKE utvidet til `player`-tabellen** (organisasjonens +spillerpool, `players.py`/`registration.py`). Dette er en annen +datamodell: en `player`-rad representerer en ROSTER-oppføring en +ARRANGØR/klubb legger inn om en person, IKKE nødvendigvis en person med +egen app-innlogging (`player.user_id` er nullable, akkurat denne +frikoblingen er selve poenget). Brukerens instruks var "de som er under +13 år å registrere seg" -- altså SELV opprette en app-konto, ikke at en +voksen arrangør registrerer et barn som deltaker i en turnering (det +siste er faktisk PDF-vedleggets EGEN anbefalte, tryggere løsning for +yngre spillere -- "sub-accounts" ført av en voksen, se PDF-ens punkt 2 +under "Anbefalte løsninger"). Å blokkere `player`-tabellen ville brutt +akkurat den mekanismen. + +**Beslutning B — PWA-installasjonstekst skiller nå Safari/Chrome på +iOS.** `install-prompt.tsx` sin `isIOS()`-sjekk fanger ALLE iOS- +nettlesere (deler WebKit-motor), men Del-ikonets PLASSERING er ulik +(Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet) -- +generisk "i Safari"-tekst var dermed direkte feil for en Chrome-bruker, +ikke bare unøyaktig. Ny `iosBrowser()`-funksjon skiller på `CriOS` +(Chrome) i user agent-strengen (vanlig "Chrome"-sniffing ville feilaktig +også truffet Safari, som deler samme motor). Ukjente/andre iOS- +nettlesere får en nøytral "i nettleseren din"-tekst i stedet for å gjette. + +**Beslutning C — presentasjonstekst korrigert i to filer.** +`teecup-beskrivelse.md` OG `velkommen.tsx` sin "Hva er TeeCup?"-seksjon +(sistnevnte var bevisst skrevet om fra førstnevnte, ikke limt inn rått, +se ADR-049) -- begge fikk "golfklubber" fjernet fra målgruppe- +beskrivelsen (både "hva det er i dag" og "hva det skal bli"-seksjonene +i beskrivelses-dokumentet), og et nytt punkt lagt til om at +turneringsadministrasjon/-presentasjon fungerer minst like godt på PC +som på telefon. "Klubbhus-stemning" (personlighet/tone-beskrivelsen, +navnet på selve designretningen) er UENDRET -- det er ikke en +målgruppe-påstand, kun en stemningsbeskrivelse. + +**Scratch-verifisert** (tjueåttende scratch-miljø denne økten): eksakt +dagsgrense bekreftet med tre testtilfeller mot ekte API -- 10-åring +avvist (400 UNDER_MINIMUM_AGE), nøyaktig 13 år i dag akseptert (200, +`profile_complete: true`), én dag under 13 år avvist (400) -- samme +grensesnitt-endepunkt, kun fødselsdatoen endret mellom kallene. Bekreftet +i ekte nettleser: klientside-feilmeldingen vises umiddelbart med rød +kant ved en 2018-fødselsdato, "Fortsett"-knappen forblir deaktivert. +Presentasjonsteksten på `/velkommen` bekreftet uten "golfklubber" og med +det nye PC-punktet. `tsc --noEmit`/`py_compile` rene, ingen konsollfeil. +`teecup_db`s ACL bekreftet uendret, scratch-ressurser ryddet opp +fullstendig. + +**Bevisst utenfor omfang denne runden** (drøftet med bruker, avtalt som +egen fremtidig runde): rapportering/blokkering/moderering av +brukergenerert innhold (bilder/kommentarer) og "privat som standard for +mindreårige"-personvern for GPS-/rundedeling -- begge reelle, +substansielle krav fra PDF-vedlegget for at appen skal godkjennes i App +Store/Google Play med UGC+GPS, men et eget, avgrenset prosjekt (admin- +panel, databasefiltrering, 24-timers responstid), ikke noe som hører +hjemme i samme runde som en enkel aldersgrense. + 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 364f42b..fa0c7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9571,6 +9571,317 @@ Neste steg: beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og frontend-container/-images) ryddet opp fullstendig etterpå. - **Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse - før migrasjon/deploy mot ekte `teecup_db`/`teecup_api`/ - `teecup_frontend`, samme rutine som punkt 48. + **Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") — migrasjon + 061 kjørt mot ekte `teecup_db`, `teecup_api` og `teecup_frontend` + bygget/restartet rent, `teecup_db`s ACL bekreftet uendret før/etter, + verifisert live på `teecup.golf` uten konsollfeil. + +54. **Ball-steget viser nå alltid kartet med løpende (sanntids) posisjon + og avstand, og allerede målte slag viser satellittfoto i listen — + 2026-08-08, umiddelbar brukeroppfølging etter punkt 53.** Bruker, + etter å ha sett skjermbilder av den nye funksjonen: "Jeg får ikke sett + slagene jeg allerede har målt... jeg ønsker å kunne se på et + satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om + jeg velger 'min posisjon nå', så skal jeg se start og slutt på et + satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se + lengden så langt i sanntid mens jeg nærmer meg ballen." Se + "Tillegg 2026-08-08 (del 2)" i ARCHITECTURE_DECISIONS.md (ADR-048) for + full arkitektur-begrunnelse, inkl. det bevisste, avgrensede unntaket + fra "ingen løpende watchPosition"-prinsippet. + + **Endringer, kun frontend (ingen migrasjon, `ShotOut` hadde allerede + alle koordinatene):** + - `map-point-picker.tsx`: `onConfirm` tar nå `(lngLat, method)` i + stedet for bare `(lngLat)`. Når `referencePoint` er satt (ball- + steget) startes `navigator.geolocation.watchPosition()` ved mount, + med en egen blå "du er her"-markør som oppdateres løpende og et + stort sanntids-avstand-tall ("AVSTAND SÅ LANGT") nederst på kartet, + beregnet med `haversineMeters(referencePoint, livePosition)`. + Footeren viser nå TO knapper når `referencePoint` er satt: "Bekreft + ballens posisjon" (trykket punkt, kun aktiv etter tap) og "Jeg er + ved ballen nå" (siste sporede posisjon, aktiv så snart første + GPS-fix er mottatt). `watchPosition` ryddes opp (`clearWatch`) ved + avmontering. + - `shot-measurement-sheet.tsx`: `end_map`-steget fjernet igjen (varte + kun én runde) — "end"-steget rendrer nå ALLTID `MapPointPicker` + direkte med `referencePoint={startPoint}`, ingen egen GPS/kart-valg- + skjerm lenger (den valget ligger nå inne i selve kartkomponentens + footer, se over). `measureEndPoint()`/`endStatus` fjernet (ikke + lenger i bruk). Egen duplikat Haversine-implementasjon + (`previewDistance`) erstattet med `haversineMeters` fra + `frontend/lib/geo.ts` (som alt fantes, men var ubrukt inntil nå) — + ren opprydding, ingen atferdsendring. + - `round-detail.tsx`: `ShotRecord`-typen utvidet med + `start_lat`/`start_lng`/`end_lat`/`end_lng` (allerede returnert av + API-et, kun frontend-typen manglet dem). `ShotList` viser nå et + 160×160 satellitt-thumbnail per slag (samme offentlige token og + `pin-s-a`/`pin-s-b`-fargekonvensjon som forhåndsvisningen), bygget + client-side direkte fra de lagrede koordinatene — løser "jeg burde + jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke + bare klubbe/avstand som tekst, men HVOR slaget faktisk ble slått. + + `tsc --noEmit` rent (ingen backend-endring denne runden). + + **Scratch-verifisert** (tjuefjerde scratch-miljø denne økten, ingen + ny migrasjon å teste denne runden — samme 61 migrasjoner + `teecup_db` + ACL-forsiktighet som før): full nettleser-gjennomgang via Chrome + DevTools MCP med BÅDE `getCurrentPosition`- og `watchPosition` + monkey-patchet (sistnevnte simulerer en spiller som beveger seg fra + start mot ballen over ~5 sekunder via et `setInterval`). Bekreftet: + (1) sanntids-avstanden i kartet regner nøyaktig samme tall som en + uavhengig Python Haversine-kontroll (315,04 m); (2) "Jeg er ved ballen + nå" bruker siste sporede posisjon og lagret korrekt med + `end_method=gps` i databasen; (3) tap på kartet plasserer en grønn + markør og aktiverer "Bekreft ballens posisjon"; (4) en reell + grensesnitt-verifisering av EKSISTERENDE feilhåndtering (fra punkt 50) + skjedde underveis helt av seg selv: et tap som (ved et scratch- + test-uhell) landet ~0 m fra referansepunktet ble korrekt avvist av + backendens `distance_meters > 0`-sjekk (422), og arket viste riktig + feilmelding i stedet for å late som suksess — bekrefter at den + beskyttelsen fortsatt virker uendret gjennom hele denne + ombyggingen; (5) slag-listens nye satellitt-thumbnails lastet korrekt + for begge tidligere lagrede slag. Ingen uventede konsollfeil (kun + kjente scratch-miljø-artefakter: WebGL-fallback-advarsel, en + irrelevant WebSocket-tidsavbrudd, og den FORVENTEDE 422-en fra punkt + 4). Scratch-miljøet (DB, rolle, MinIO, API- og frontend-container/ + -images) ryddet opp fullstendig etterpå, `teecup_db`s ACL bekreftet + uendret. + + **Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") — + `teecup_frontend` bygget/restartet rent (ingen migrasjon eller + backend-endring denne runden, `teecup_api` uendret), verifisert live + på `teecup.golf` uten konsollfeil. + +55. **Ny landingsside `/velkommen` for innlogget-men-ikke-fullført-profil + — 2026-08-09, eksplisitt brukerønske (ADR-049, se + ARCHITECTURE_DECISIONS.md for full begrunnelse og drøfting).** Bruker: + "Skjemaet med personlig informasjon vises for tidlig for ikke + registrerte spillere etter at de logger seg inn... Jeg tror det beste + er at man kommer til en side med oppfordring til å installere som app + (som vanlig) og med deaktiverte knapper for runde, turneringen og bli + med med kode. Trykker man på noen av disse skal man få beskjed om at + personlig informasjon må fylles ut først, med lenke til skjemaet." + Midtveis presisert: "Alle knappene bør egentlig være der, deaktivert. + bortsett fra til profilen" — dvs. HELE den vanlige bunn-navigasjonen + skal vises, ikke bare de tre hurtighandlingene. + + **Rotårsak til det opprinnelige problemet:** tre steder rutet en + innlogget-men-ikke-fullført-profil-bruker RETT til `/account` sitt + påtvungne skjema uten noen kontekst: `app/page.tsx` (root-redirect), + `app/logg-inn/page.tsx` (post-innlogging-redirect), og + `dashboard.tsx` sin egen klient-side `profile_complete`-vakt + (`loadMe()`, fyres hvis en bruker skulle lande direkte på + `/dashboard`, f.eks. via `verify-form.tsx` som ALLTID sender dit + etter innlogging uansett profil-status). + + **Bygget:** + - Ny `frontend/app/velkommen/page.tsx` (server-komponent, samme + autentiserings-/redirect-mønster som `/logg-inn/page.tsx` -- + `redirect()`-kall bevisst holdt UTENFOR try/catch, samme fallgruve + som ble funnet og fikset i `/logg-inn/page.tsx` 2026-08-08 unngås + her fra start) + `frontend/components/teecup/velkommen.tsx` + (klient-komponent). Alle tre stedene over pekt om til `/velkommen` + i stedet for `/account`. `/velkommen` selv redirecter videre til + `/dashboard` (komplett profil) eller `/logg-inn` (ikke innlogget) + -- kan ikke nås "feil" via direkte URL. + - Siden gjenbruker eksisterende, allerede etablerte komponenter + uendret: `InstallPrompt` (samme PWA-oppfordring som dashbordet), + `TeeCupWordmark`, og samme "clubhouse"-palett-tokens som + `/logg-inn`/dashbordet (reskinnet 2026-08-07/08). Personlig + hilsen bruker `first_name ?? display_name`, samme navneformat-regel + som dashbordets hilsen (CLAUDE.md). + - Tre synlige, LÅSTE hurtighandlinger ("Ny runde"/"Ny turnering"/"Bli + med med kode" -- hengelås-ikon, `aria-disabled`, IKKE HTML + `disabled` siden de fortsatt skal være klikkbare for å forklare + hvorfor de er låst). + - `frontend/components/teecup/bottom-nav.tsx` utvidet med valgfrie + `disabledHrefs`/`onDisabledClick`-props (bakoverkompatibelt -- + andre 6 sider som allerede bruker `BottomNav` uendret, ingen prop + sendt). Når en fane er i `disabledHrefs`, rendres den som en + ` diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 28b5aeb..15c6a86 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -311,8 +311,10 @@ export function Dashboard() { return } const data: Me = await res.json() + // Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg + // 2026-08-09: /velkommen viser kontekst før skjemaet. if (!data.profile_complete) { - router.replace("/account") + router.replace("/velkommen") return } setMe(data) diff --git a/frontend/components/install-prompt.tsx b/frontend/components/install-prompt.tsx index 855e2b3..f662f25 100644 --- a/frontend/components/install-prompt.tsx +++ b/frontend/components/install-prompt.tsx @@ -23,6 +23,23 @@ function isIOS() { return /iphone|ipad|ipod/i.test(window.navigator.userAgent) } +// Siden iOS 16.4 kan en PWA installeres fra Chrome på iPhone også, ikke +// bare Safari (brukerønske 2026-08-10, tidligere tekst her hevdet feilaktig +// at man MÅTTE til Safari -- se PDF-vedlegg "Installere PWA fra Chrome på +// iPhone"). Del-ikonets PLASSERING er derimot ulik mellom nettleserne +// (Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet), +// så instruksjonsteksten må skille dem for å faktisk stemme -- ikke bare +// fjerne Safari-nevnelsen og la den bli vag. Chrome-på-iOS har "CriOS" i +// user agent-strengen (deler ellers WebKit-motoren med Safari, så vanlig +// "Chrome"-sniffing ville feilaktig truffet Safari også). +function iosBrowser(): "safari" | "chrome" | "other" { + if (typeof window === "undefined") return "other" + const ua = window.navigator.userAgent + if (/crios/i.test(ua)) return "chrome" + if (/safari/i.test(ua) && !/crios|fxios|edgios/i.test(ua)) return "safari" + return "other" +} + function isDismissedForNow() { if (typeof window === "undefined") return true const raw = window.localStorage.getItem(DISMISS_KEY) @@ -41,17 +58,20 @@ function dismissForNow() { // reell installasjonsvei: // - Android/Chrome-familien: ekte "Installer"-knapp via det fangede // beforeinstallprompt-eventet (lib/pwa-install.ts). -// - iOS Safari: Apple har ALDRI implementert beforeinstallprompt -- kun et +// - iOS (Safari ELLER Chrome, siden iOS 16.4 -- se iosBrowser() over): +// Apple har ALDRI implementert beforeinstallprompt der -- kun et // instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig. export function InstallPrompt() { const [dismissed, setDismissed] = useState(true) const [platform, setPlatform] = useState<"android" | "ios" | "none">("none") + const [iosBrowserKind, setIosBrowserKind] = useState<"safari" | "chrome" | "other">("other") const [deferredEvent, setDeferredEvent] = useState(null) useEffect(() => { if (isStandalone() || isDismissedForNow()) return if (isIOS()) { setPlatform("ios") + setIosBrowserKind(iosBrowser()) setDismissed(false) return } @@ -117,8 +137,13 @@ export function InstallPrompt() { {platform === "ios" ? (

- Trykk

) : ( diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 2114f3a..cca0ea7 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -2049,6 +2049,10 @@ type ShotRecord = { club: string distance_meters: number shared_round_message_id: string | null + start_lat: number + start_lng: number + end_lat: number + end_lng: number } function ShotMeasurementEntry({ @@ -2292,6 +2296,17 @@ function ShotMeasurementEntry({ ) } +// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg +// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen +// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme +// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt +// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token, +// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord. +function shotThumbnailUrl(shot: ShotRecord): string | null { + if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null + return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}` +} + function ShotList({ shots, onDelete, @@ -2302,41 +2317,52 @@ function ShotList({ onShare?: (shot: ShotRecord) => void }) { return ( -