# TeeCup — Arkitektur-beslutningslogg (ADR)

> Dette dokumentet er den autoritative kilden til *hva* som er bestemt og *hvorfor*.
> Det leses av mennesker og av AI-modeller (Claude, Gemini) i starten av hver økt.
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.

**Status:** Levende dokument
**Sist oppdatert:** 2026-07-22

---

## Kontekst

TeeCup er en kommersiell (SaaS) webapplikasjon for å opprette, administrere og
gjennomføre golfturneringer i «Ryder Cup»-format. Den driftes på samme VPS som
`teeoff.no`, men skal fungere som et isolert økosystem med eget subdomene
(`teecup.teeoff.no`) og egen database (`teecup_db`).

Målgruppe: klubber, bedrifter og vennegjenger som arrangerer turneringer over tid.

---

## ADR-001 — Tenant = Organisasjon

**Beslutning:** Isolasjonsenheten («tenant») er en **organisasjon**, ikke en turnering.

En organisasjon er et bevisst nøytralt begrep som dekker klubb, bedrift *og*
vennegjeng. Ved å ikke kalle den «klubb» i datamodellen unngår vi refaktorering
den dagen første bedriftskunde kommer.

**Hierarki:**

```
Organisasjon        (tenant — det som isoleres og faktureres)
  ├── Medlemmer/spillere   (gjenbrukbare på tvers av turneringer)
  ├── Egendefinerte baner
  └── Turneringer
        ├── Lag
        └── Matcher
              └── Scores
```

**Begrunnelse:** En turnering er en hendelse med start og slutt, ikke en kunde.
Gjenbrukbare ting (spillere, baner, historikk) må leve over turneringens levetid.
Turnering er derfor en entitet *inne i* en organisasjon.

**Konsekvens:** `tenant_id` i alle skjemaer heter `organization_id`.

---

## ADR-002 — Bruker og organisasjonsmedlemskap er adskilt

**Beslutning:** Identitet (innlogging) og medlemskap i en organisasjon er to
forskjellige ting. Én `user` kan ha flere `organization_memberships`.

**Begrunnelse:** Samme person spiller ofte både klubbturneringen og
jobbturneringen. En bruker må kunne krysse organisasjoner med samme innlogging.
Dette er lett å bygge inn fra start og smertefullt å legge til senere.

**Konsekvens:** Utelukker «database-per-tenant» (se ADR-003), fordi en bruker som
krysser organisasjoner da måtte eksistere i flere databaser samtidig.

---

## ADR-003 — Isolasjonsstrategi: Shared schema + Row-Level Security

**Beslutning:** Én database (`teecup_db`), delte tabeller, isolert på
`organization_id`-kolonne, håndhevet av PostgreSQL **Row-Level Security (RLS)**.

**Vurderte alternativer:**

| Strategi | For | Mot | Valgt |
|---|---|---|---|
| Shared schema + `organization_id` + RLS | Enkel drift, billig, skalerer til mange org. | Krever disiplin; RLS må settes riktig | **Ja** |
| Database/schema per tenant | Sterk isolasjon | Tung migrering; bryter med ADR-002 | Nei |

**Begrunnelse:** RLS flytter isolasjonen fra applikasjonskoden (der én glemt
`WHERE organization_id = ...` lekker data mellom kunder) ned til databasen, som
håndhever den uansett hva koden gjør. Kombinert med ADR-002 er dette det eneste
praktiske valget.

**Konsekvens / oppgave:** Hver økt/tilkobling må sette `SET app.current_org` (eller
tilsvarende) slik at RLS-policyen kan filtrere. Dette må inn i tilkoblingslaget
tidlig, ikke ettermonteres.

---

## ADR-004 — Banedata fra `teeoff_db` via enveis, lesende API-kontrakt

**Beslutning:** TeeCup henter offisielle banedata fra hovedplattformen gjennom et
veldefinert, lesende API — **ikke** via direkte databasekobling på tvers.

**Begrunnelse:** En direkte kobling ville låst TeeCup til hovedplattformens
skjemaendringer, og et brudd ett sted ville tatt ned begge produktene.
`teeoff_db` er «master» for offisielle banedata; alt brukergenerert innhold
(egendefinerte baner, turneringer, brukere) forblir strengt i `teecup_db`.

**Konsekvens:** API-kontrakten mot TeeOff må versjoneres og behandles som en
ekstern avhengighet, selv om den kjører på samme server.

---

## ADR-005 — Handicap-motoren er et frittstående, testet bibliotek

**Beslutning:** All handicap- og score-logikk bygges som en ren Python-modul uten
avhengigheter til database, API eller web-rammeverk. Den testes grundig i
isolasjon.

**Begrunnelse:** Dette er produktets hjerte og den mest risikofylte biten. Feil
her koster mest troverdighet. Ved å isolere den kan reglene enhetstestes mot kjente
fasitverdier uavhengig av resten av systemet.

**Konsekvens / viktig presisering:** Prosentbaserte «allowances» (f.eks. 75 %,
90 %, 3/4) er **konfigurasjon, ikke hardkodet logikk**. Motoren vet ikke at
«fourball = 90 %»; den mottar tildelingen som parameter. De konkrete
standardprosentene per format må verifiseres mot gjeldende WHS/lokale regler før
produksjon — de skal ikke antas fra hukommelse.

---

## ADR-006 — Teknologistack

**Beslutning:**
- **Backend:** Python + FastAPI (gjenbruker stacken fra TeeOff → enklere vedlikehold).
- **Database:** PostgreSQL (+ PostGIS der banegeometri trengs).
- **Frontend:** React (Vite) som PWA, offline-first (Service Workers + IndexedDB).

**Begrunnelse:** Offline-first er *kjernefunksjonalitet*, ikke luksus — golfbaner har
ofte dårlig mobildekning, og score må kunne registreres uten nett og synkes senere.

---

## ADR-007 — Miksede formater via økter, og spiller-pool

**Beslutning:** En turnering er en **ordnet sekvens av økter** (sessions), ikke ett
enkelt format. Hver økt bærer format, hullomfang og allowance. Ryder Cup =
foursome/18 → fourball/18 → singles/18 er tre økter.

Hvert lag har en **pool** (`team_roster`) som kan være større enn antall
matchplasser. Deltakelse avgjøres per match via `match_participant`. En reserve
er en spiller i poolen uten deltaker-rad i en gitt økt; en spiller kan spille
kun enkelte økter (f.eks. bare singelen).

**Begrunnelse:** Dette er selve Ryder Cup-strukturen. Å modellere format på
turneringsnivå ville gjort miksede formater umulig; å modellere deltakelse på
turneringsnivå ville gjort reserver og delvis deltakelse umulig.

**Konsekvens:** Handicap-snapshot fryses per turnering i `team_roster`
(`handicap_index_snapshot`) for reproduserbare resultater.

---

## ADR-008 — 9-hulls-slag: "slagene som faller på 18-hulls-kortet"

**Beslutning:** For 9-hulls-økter (front/back) brukes spillerens fulle
18-hulls-tildeling, og slagene fordeles over **hele** 18-hulls stroke index —
deretter tas kun de spilte hullene ut. Spilleren mottar slag på de spilte hullene
der SI ≤ mottatte slag.

**Vurdert alternativ:** WHS' formelle 9-hulls course handicap (eget 9-hulls
rating, grovt sagt halvparten). Kan gi et litt annet totaltall. Ikke valgt som
standard fordi ikke alle baner publiserer 9-hulls-ratinger, og den valgte
metoden er den vanlige i vennegjeng-/klubbmatch-spill.

**Kritisk implementasjonsdetalj:** Man må IKKE sende bare de ni spilte hullene inn
i slagfordelingen med et 18-hulls slagtall — det gir feil ved høye slagtall
(12 slag på back-9 blir da 10 i stedet for riktige 6). Motoren har derfor
`allocate_over_played_holes(...)` som fordeler over alle 18 og så tar ut de spilte.
Dekket av test `test_nine_hole_twelve_strokes_is_six_not_ten`.

**Konsekvens:** `tee_rating` beholder likevel front/back-omfang, slik at WHS'
alternativ kan tilbys senere som konfig per turnering uten skjemaendring.

---

## ADR-009 — Egen innlogging, uavhengig av teeoff

**Beslutning:** TeeCup har egne brukerkontoer (`app_user`), uavhengig av teeoffs
innlogging.

**Begrunnelse:** TeeCup selges kommersielt til klubber/bedrifter som kanskje aldri
har hørt om teeoff.no. Delt innlogging ville vært forvirrende for dem og koblet de
to systemenes auth tettere sammen enn ellers ønskelig. Styrker isolasjonen fra
ADR-004.

**Konsekvens:** Egen auth (registrering, Google/magic-link e.l.), egne secrets
(jf. åpent spørsmål 1), egen sesjonshåndtering. Kaptein- og tilskuer-roller
defineres innenfor TeeCups eget auth-lag.

---

## ADR-010 — Feed krever innlogging (ingen anonym lesesti i v1)

**Beslutning:** Den «offentlige» runde-feeden er felles for begge lag i
turneringen, men bak innlogging. Ingen verdenssynlig lenke i v1.

**Begrunnelse:** «Offentlig» betyr her «alle innloggede deltakere i turneringen»,
ikke «hvem som helst med en lenke». Dette fjerner den uautentiserte lesestien og
den tunge modereringen (som ellers kreves når innhold er synlig for verden) fra
v1.

**Konsekvens:** Synlighet modelleres som kanal-scope (lag / turnering). En ekte
verdenssynlig delingslenke kan legges til senere som egen bryter, med moderering,
uten å bygge om.

---

## ADR-011 — Turneringsstruktur: lagformat i v1, bracket som egen fremtidig type

**Beslutning:** v1 bygges for Ryder Cup-lagformatet (økter → uavhengige matcher →
summerte poeng), og låses til **nøyaktig to lag**.

**Begrunnelse:** En match/flight er alltid to-sidig. Tre lag ville krevd
kryss-oppgjør (A–B, A–C, B–C), nok spillere til å møte to motstandere samtidig,
balansert oppsett, og tre-veis blind draw — mye kompleksitet for lite gevinst. To
lag er både enklere og riktigere for formatet. Et knockout-bracket (f.eks. 128
spillere, utslagsmatcher, finale + bronsefinale) er en helt egen turneringstype:
rundene er *avhengige* (vinner av match A møter vinner av match B), og krever
progresjon mellom matcher, seeding, fripass og bronsegren — noe dagens modell ikke
har.

**Konsekvens:** To-lags-grensen håndheves i app-laget (validering ved
turneringsoppsett), ikke med DB-trigger. Match-modellen holdes generell (to sider,
vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme senere
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
Knockout er fanget som egen type i FEATURE_BACKLOG.md.

**Merk (2026-07-19):** brukeren har reist ønske om flere turneringsformater
UTOVER Ryder Cup-lagformatet (f.eks. «Københavner» — se
FEATURE_BACKLOG.md sitt eget punkt for alle fire eksemplene). Minst ett av
disse («Københavner»: alle-mot-alle poengfordeling i et felt av spillere,
ikke to lag i det hele tatt) passer IKKE inn i denne ADR-ens to-lags-modell —
en fremtidig, egen ADR trengs når/hvis dette tas fatt på, samme mønster som
knockout-punktet over. Kun notert her ennå, ikke designet eller bygget.

**Merk (2026-07-26), presisert og utvidet:** brukeren bekreftet at ønsket
er STØRRE enn Københavner som ett format blant flere — TeeCup skal etter
hvert støtte ekte INDIVIDUELLE turneringer (helt uten lag) som eget
generelt tilfelle, disse skal kunne gå over FLERE RUNDER, og det skal
være mulig å sette opp et Order of Merit (sesong-sammenlagt på tvers av
flere separate arrangementer). Full analyse, inkl. en reell strukturell
kollisjon med ADR-033 sitt bevisst org-uavhengige rundesystem, i
FEATURE_BACKLOG.md ("Utvidelse 2026-07-26: individuelle turneringer,
flerrunde-turneringer, og Order of Merit"). Ren notat-runde, ingen ADR
skrevet ennå.

---

## ADR-012 — To scoring-moduser per økt

**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per
hull) eller `hole_result` (registrer bare hvem som vant hullet / delt).

**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller
raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang.

**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og
hull-resultat). `hole_result` skriver til ny tabell `match_hole_result`
(vinnende side per hull, NULL = delt). Begge mater samme
`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus.

---

## ADR-013 — Blind draw via lås per lag per økt

**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har
låst. Modellert med tabell `lineup_lock` (én rad per lag per økt).

**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og
oppgjørene avsløres samtidig.

**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men
motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge
lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet
(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme
organisasjon (jf. ADR-010).

---

## ADR-014 — Konfigurerbar handicap-pipeline: fire uavhengige brytere

**Beslutning:** Handicap-anvendelsen i en økt er FIRE uavhengige, valgfrie steg —
ikke bare én allowance-prosent:

1. **Bruk handicap** (av/på) — helt av gir scratch-spill.
2. **Bruk course handicap** (av/på) — om slope/rating-justeringen
   (`course_handicap_raw`) påføres, eller om rå `handicap_index` brukes direkte.
3. **HCP-prosent** (allowance-strategien, allerede dekket av ADR-005 /
   `session.allowance_override`).
4. **Bruk matchplay-handicap** (av/på) — om resultatet konverteres til relative
   slag (`match_play_strokes`: beste enhet spiller «av 0», resten får
   differansen), eller brukes som absolutt Playing Handicap.

**Begrunnelse:** Skjermbilder fra Golf GameBook (referanseprodukt, delt
2026-07-16) viser nøyaktig denne firedelte bryter-strukturen per runde/format i
deres oppsettsdialog. Deres standard-prosenter (foursome 50 %, better ball/
fourball 90 %, singles 100 %) stemmer eksakt med `DEFAULT_MATCHPLAY_ALLOWANCES`
i `handicap_engine.py` — uavhengig bekreftelse på at defaultene er riktige,
slik ADR-005 krever verifisert. Motoren støtter allerede alle fire som atskilte,
komponerbare funksjonskall (`course_handicap_raw`, en `AllowanceStrategy`,
`match_play_strokes`) — hver kan hoppes over uavhengig av de andre — men
API-et/skjemaet eksponerer dem ikke som brytere ennå.

**Konsekvens:** `session.allowance_override` (jsonb) skal utvides til å bære
alle fire bryterne, ikke bare prosent, når motor-/scoring-integrasjonen bygges
(se FEATURE_BACKLOG.md). Ingen skjemaendring nødvendig (feltet er allerede
jsonb), men API-lagets validering og motor-kall må håndtere: `bruk_handicap =
false` → hopp over hele kjeden (brutto = netto), `bruk_course_handicap = false`
→ bruk `handicap_index`/`handicap_index_snapshot` direkte uten
slope/rating-justering, `bruk_matchplay_handicap = false` → bruk absolutt
Playing Handicap i stedet for å kalle `match_play_strokes`.

---

## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt

**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session`
får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes`
(minutter mellom hver "flight"). En matchs `tee_time` er
`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved
lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak:
`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må
justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien
når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av
`hole_config`).

**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier
at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver
flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett
tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av
synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der
den avledede regelen ikke holder.

**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon
`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir
`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet
PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved
verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher.

**Beslutning B — én uniform feilrespons på tvers av HELE API-et:**
`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster
`HTTPException` (39 steder ved innføring), via en liten factory
`app_error(status_code, code, message)` i `app/errors.py` — ingen egen
exception-klasse, ingen global exception handler. Kodene er en liten,
gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) —
f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`,
`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst
UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`.

**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne
runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend —
den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et
forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt
kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk
inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å
måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å
ettermontere når klientkode allerede har begynt å parse tekststrenger.

**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke
`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se
`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer
noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en
semantisk feil kode.

**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:**
`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en`
foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten
sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side
(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra
forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som
ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse
overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen
endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå
(egen, senere sak — profilinnstillinger).

**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens
valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP.
At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en
enhet) IKKE skal endre den lagrede preferansen er bevisst: en
innloggingshandling bør ikke ha den overraskende bivirkningen å endre
brukerens varige profilinnstilling.

---

## ADR-016 — Frontend som eneste offentlige overflate, API-et server-side proxyet

**Beslutning:** `teecup.teeoff.no` peker (Caddy) på Next.js-frontenden
(`teecup_frontend`), IKKE direkte på FastAPI-et. Frontenden proxyer selv
kjente API-sti-prefikser (`/auth/*`, `/orgs/*`, `/health` — som til sammen
dekker HELE dagens API-overflate, verifisert med et grep av samtlige
rutedefinisjoner) videre til `teecup_api:8000` server-side, via Next.js sin
egen `rewrites()`-mekanisme (`frontend/next.config.mjs`). API-et er ikke
lenger separat Caddy-rutet eller offentlig eksponert under eget navn.

**Begrunnelse:** Alt kjører dermed under samme opprinnelse (origin) sett fra
nettleseren — ingen CORS-konfigurasjon trengs, og HttpOnly-sesjonscookien
(ADR-009) fungerer helt uendret enten kallet "egentlig" går til frontend
eller API. Alternativet (eget subdomene for API-et, `SameSite=None`-cookie
eller CORS-hull) ville svekket cookie-sikkerhetsmodellen som allerede var
bevisst bygget stram. Samme mønster generaliserer til alle fremtidige
skjermer uten videre Caddy-endringer — nye API-ruter under `/auth`, `/orgs`
trenger ingen ny proxy-regel, kun nye Next.js-sider som kaller dem med
relative URL-er.

**Reell fallgruve funnet og fikset ved bygging (2026-07-17):** Next.js sin
`rewrites()` løses ved BUILD-tid for `output: "standalone"` (bakes inn i
server-bunten), ikke ved container-oppstart. En `docker run -e
TEECUP_API_ORIGIN=...` ved kjøretid ble derfor stille ignorert (falt tilbake
til default `localhost:8000`, som ikke fantes i containeren — proxy-kall
feilet med `ECONNREFUSED`). Løst med en Docker build-time `ARG
TEECUP_API_ORIGIN` (default `http://teecup_api:8000`, matcher alltid det
delte nettverkets tjenestenavn) i `frontend/Dockerfile`, satt via
`docker-compose.yml` sin `build.args`. Generell lærdom for fremtidige
Next.js/Docker-oppsett i dette prosjektet: alt som brukes inne i
`next.config.mjs` er en BUILD-tids verdi, ikke en runtime-verdi, med mindre
det eksplisitt leses på nytt et sted som faktisk kjører per request (en
route handler, ikke selve config-filen).

**Konsekvens:** Enhver fremtidig ny API-sti-prefiks (utenfor `/auth`,
`/orgs`, `/health`) MÅ legges til i `frontend/next.config.mjs` sin
`rewrites()`-liste, ellers blir den utilgjengelig fra nettleseren selv om
API-et selv fungerer (kun nåbar internt på Docker-nettverket). `teecup_api`
sin port er ikke lenger tenkt nåbar direkte utenfra i prod.

---

## ADR-017 — Selvregistrering og utvidet spillerprofil

**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live.
Dagens modell antar at organisator kjenner og legger inn alle spillere selv
— i praksis vet organisator ofte ikke hvem som faktisk blir med før de
melder seg på selv.

**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En
delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede
uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen
magic-link, ingen konto kreves for å melde seg på.

**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig
friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer
gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen.

**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator-
forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på
eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved
påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en
duplikat. `player.user_id` kobles først når noen med matchende e-post
faktisk logger inn via magic-link — `verify_magic_link` utvides til også å
slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`.

**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint —
familier deler av og til e-post (forelder melder på barn), en hard unik-
regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt,
applikasjonslags-oppslag.

**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra
`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny
tabell `tournament_registration` (status: `pending`/`confirmed`/
`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`:
`registration_capacity` (nullable — organisators valg om det i det hele
tatt skal være en grense), `registration_overflow_policy`
(`waitlist`/`closed`, kun relevant når kapasitet er satt),
`registration_requires_approval` (boolean). Rekkefølge ved en ny
påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd →
`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller
`confirmed`, avhengig av godkjenningsbryteren.

**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag".
Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville
gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som
aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige
organisator-beslutninger — å låse ett svar for alle turneringer ville vært
feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være
konfigurerbare valg, ikke faste regler).

**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`:
`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år,
fødselsdato er ikke det), `nickname`, `country`, `club`,
`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et
avviser innsending (400) uten `consent: true`, og
`tournament_registration.consent_given_at` er beviset på at det faktisk ble
gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på
`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen
samtykket knytter seg til.

**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis
allerede dekket av `match_participant.tee_id` (per match, ikke per
spillerprofil, siden det kan variere fra runde til runde).

**Beslutning E — `public_tournament_org()`: den eneste broen fra en
uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig
påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører
til — og uten `app.current_org` satt slipper RLS ingen rader gjennom
(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever
`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt
med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot
kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster
som den selvrefererende org-bootstrapen (`app/routers/organizations.py`),
nå for et lese-oppslag i stedet for en innsetting.

**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner
deretter en vanlig `org_connection(org_id)` og fortsetter med normal
RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en
uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst
smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.

**Migrasjon:** `007_registration_and_player_fields.sql`. Kontosammenkoblingen
i Beslutning B (e-post → `player.user_id` ved innlogging) fikk sin egen
`SECURITY DEFINER`-bro (`link_player_by_email()`, samme mønster som
Beslutning E) i en oppfølgende migrasjon, `008_link_player_by_email.sql`.

---

## ADR-018 — Landingssider: synlighet, org-profil, delbart innhold

**Kontekst:** Reist av brukeren rett etter at registrerings-API-et
(ADR-017) var live — turneringer og organisasjoner bør ha egne, delbare
landingssider (hero, tekst, program, sponsorer, påmelding for turnering;
klubbprofil + turneringsliste for organisasjon). Brukeren krevde eksplisitt
at synlighet må være et VALG: offentlig, kun org-medlemmer, eller kun
turnering-deltakere.

**Beslutning A — Trenivås synlighet, ett felt per nivå.**
`tournament.visibility` (`'public'`/`'org'`/`'participants'`, default
`'org'`) og `organization.public_profile` (boolean, default `false`).
Trygg standard: ingen eksisterende eller nyopprettet turnering/org blir
offentlig av seg selv. "Kun deltakere" gir ikke mening på org-nivå (en
organisasjon har ikke "deltakere"), derfor en enklere bryter der, ikke
samme trenivå-enum som turnering.

**Beslutning B — RLS beskytter TENANT-grenser, ikke INNHOLDS-synlighet.**
`org_isolation`-policyen håndhever kun at org A aldri ser org B sin data —
den sier ingenting om hvem INNENFOR riktig org-kontekst som får se hva.
Det viste seg at det eksisterende `GET /public/tournaments/{id}` (ADR-017)
allerede leser fullt turneringsinnhold uten noen synlighetssjekk i det
hele tatt, fordi RLS er fornøyd så snart org-konteksten er riktig satt —
uansett hvem (eller om noen) som spør.

**Konsekvens:** `visibility` MÅ håndheves eksplisitt i applikasjonslaget på
hvert offentlig lese-endepunkt (og på registrering, se Beslutning D), ikke
forventes løst av RLS.

**Beslutning C — Ny autorisasjonsvei: "deltaker".** For
`visibility='participants'` må leseren enten være org-medlem, ELLER
innlogget med en `player`-rad (koblet via `player.user_id`, ADR-017
Beslutning B) som har en `tournament_registration`- eller
`team_roster`-rad for NØYAKTIG denne turneringen. Ny
`get_current_user_optional`-avhengighet i `app/auth.py` — som
`get_current_user`, men returnerer `None` i stedet for å kaste 401, siden
et offentlig endepunkt skal fungere for en anonym leser også (bare med
`visibility='public'`-tilgang).

**Beslutning D — Registrering følger SAMME synlighetsgrense som
landingssiden.** Kan du ikke se turneringen, kan du heller ikke melde deg
på den — ingen særbehandling av `/register`-endepunktet.

**Beslutning E — Turnering-landingsside: innhold nå, bilder som inerte
felt.** Nye felt: `tournament.description` (presenterende tekst),
`tournament.hero_image_key` (inert til MinIO-runden — kun kolonnen, ingen
opplastingslogikk, sparer en fremtidig migrasjon for null kostnad nå). Ny
tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert av samme
grunn). Program vises via `GET /public/tournaments/{id}/sessions`, som
gjenbruker `_fetch_sessions()` (nytt uttrekk fra `tournaments.py` sin
`list_sessions`, delt mellom den innloggede og den offentlige ruten) —
blind draw-hemmelighold (ADR-013) arves automatisk, ikke reimplementert.
**Bevisst utenfor omfang denne runden:** lag-/roster-visning på
landingssiden (må også respektere blind draw-lås) — egen, senere sak.

**Beslutning F — Org-landingsside: slug + tredje SECURITY DEFINER-bro.**
`organization.slug` (unik når satt, `CHECK (NOT public_profile OR slug IS
NOT NULL)`). `GET /public/orgs/{slug}` løser org via
`public_org_by_slug(slug)` — samme mønster som `public_tournament_org()`/
`link_player_by_email()` (007/008), nå tredje instans. Returnerer `NULL`
for BÅDE "finnes ikke" og "finnes, men er privat" — samme
anti-enumerering som magic-link (ADR-009), ikke en tilfeldighet at
mønsteret gjentas.

**Bevisst utsatt til egen, senere runde:** hero-/sponsorbilder (krever
MinIO — arkitektur-invarianten finnes fra før, se CLAUDE.md, men er aldri
satt opp i praksis).

**Migrasjon:** `009_landing_pages_and_visibility.sql`.

---

## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag

**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt
i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner
fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter
program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator
må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff.

**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun
lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og
returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`,
`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/
`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i
`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities?
view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`,
…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig
baneregistrering) — teecups `hole`-tabell krever begge NOT NULL.

**Beslutning A — Import ved organisators eksplisitte valg, ikke live
oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger
én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en
vanlig `course`-rad med `source='official'` og
`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import
er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden
(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN
endring.

**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL
midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til
teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs
oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på
importtidspunktet — samme reproduserbarhets-prinsipp som handicap-
snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer
teeoff en rating i etterkant, skal ikke en allerede opprettet turnering
plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige
"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en
teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte
baner.

**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny
hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme
`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det
offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse
endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på
teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun
nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx`
(async HTTP-klient), lagt til i `app/requirements.txt`.

**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen
skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en
tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil
(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis
importert bane med hull som senere feiler i handicap-beregning. Hele
importen kjører i én DB-transaksjon (`org_connection` sin eksisterende
`conn.transaction()`), så en feil midtveis ruller automatisk tilbake.

**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen
front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt).
Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den
formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak
av spilte hull dekker front/back-økter.

**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en
duplikat-rad.** Ny partiell unik indeks `(organization_id,
external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon
`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det
med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme
banedata.

**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen
db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
`app/routers/courses.py` utvides med `GET .../courses/official-search` og
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_
ref.sql`.

**Tillegg 2026-08-03 (research, ikke bygget):** samme import-ved-eksplisitt-
valg-prinsipp er identifisert som riktig mal for en fremtidig internasjonal
banekilde (golfapi.io, for baner utenfor Norge/`teeoff_db`s dekning) — en
tredje `course.source`-verdi + `app/golfapi_client.py` ved siden av
`app/teeoff_client.py`, ikke en ny arkitektur. Ingen ADR-runde startet ennå,
kun notert. Full detalj i FEATURE_BACKLOG.md, «Internasjonale baner».

---

## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling

**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen
ting brukes"-serien var ferdig. Tre relaterte, men separate mangler:

1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller
   org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare
   har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i
   det hele tatt — dette var reelt ikke gjennomtenkt tidligere.
2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva
   stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som
   de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup).
3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående
   match — brukeren viste et skjermbilde av referanseproduktets fargekodede
   matchrader (rød/beige etter ledende side) som ønsket retning.

**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som
OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra
et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,
altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik
(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som
`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget
kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og
sender brukeren til `/t/{id}?code=...`.

**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt
muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org-
medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering
ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse.
Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men
6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind
gjetting upraktisk uten separat rate-limiting — akseptabelt for et
tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som
`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md).

**Konsekvens:** `GET /public/tournaments/{id}` og
`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter;
matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige
`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro
`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde
instans av samme mønster som `public_tournament_org()` (007),
`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen
kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut
en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir
etterspurt.

**Beslutning B — Projisert stilling: pågående matchers nåværende leder får
full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort
match brukes `match.leading_side` (se Beslutning C) til å tildele hele
øktens `points_per_match` til den ledende siden i den PROJISERTE summen —
"AS" (all square) eller en match som ennå ikke har noen registrerte hull
splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte
matcher bidrar likt til både faktisk og projisert sum (de er jo allerede
det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser
"hvis det sluttet nå"-tavler.

**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som
`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()`
(`app/routers/scoring.py`) beregner den allerede tilgjengelige
`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending
uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i
stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen
("2 UP (A)"), slik at frontend kan style etter et strukturert felt
(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til
BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister
(Beslutning D).

**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny
backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere
steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE
`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme
fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen
ny fargemodell innført.

**Migrasjon:** `011_join_code_and_leading_side.sql`.

---

## ADR-021 — Passord (valgfritt tillegg) + valgfri 2FA (TOTP eller e-post)

**Kontekst:** Reist av brukeren 2026-07-19, sammen med et konkret sesjons-
problem: dagens magic-link-cookie (ADR-009, 30 dager) fungerte visstnok ikke
som tiltenkt — brukeren måtte be om ny innloggingskode ved HVERT besøk til
`teecup.teeoff.no`. **Diagnostisert og FIKSET samme dag** (ikke en del av
ADR-021s videre omfang, men verdt å nevne her siden det var starten på denne
tråden): kodegjennomgangen av `app/auth.py`/`app/routers/auth.py` fant ingen
feil i selve cookie-settingen, og brukerens ekte, ferske cookie (hentet fra
nettleseren på forespørsel) bekreftet 30 dagers levetid, `Secure`/`HttpOnly`/
`SameSite=Lax` alt korrekt. Rot-årsaken var derfor IKKE en cookie-/backend-
bug, men en manglende sjekk i frontend: `app/page.tsx` (rot-siden) viste
ALLTID innloggingsskjemaet uten noensinne å sjekke om en gyldig sesjon
allerede fantes — `Dashboard` sjekket `/auth/me` og sendte til `/` ved
MANGLENDE sesjon, men ingenting gjorde det motsatte. Fikset med en
server-side sesjonssjekk (leser cookien via `next/headers`, kaller
`/auth/me` direkte mot `TEECUP_API_ORIGIN` server-til-server, samme mønster
som `generateMetadata` i `app/t/[id]/page.tsx`) som sender en allerede
innlogget bruker rett til `/dashboard`. Verifisert med brukerens ekte cookie:
uten cookie → 200 (skjema), med gyldig cookie → 307 til `/dashboard`. Rullet
ut live, kun `teecup_frontend`, ingen migrasjon, `teeoff.no` upåvirket.

Utover selve bugen ønsket brukeren eksplisitt: e-post/brukernavn+passord
(må håndtere spesialtegn og mellomrom korrekt) SOM ET TILLEGG til
(ikke erstatning for) den passordløse innloggingen, samt topartsautentisering.

**Beslutning A — Passord er et valgfritt, sidestilt alternativ, ikke
påkrevd.** Login-skjermet får en tredje modus (ved siden av e-post-magic-link
og invitasjonskode, ADR-020) for e-post+passord. En bruker setter selv et
passord når hen ønsker det (egen "sett passord"-handling, krever en allerede
gyldig sesjon — samme "du må bevise identitet FØRST" som andre sensitive
endringer) — INGEN eksisterende eller ny bruker tvinges til å sette passord.
Magic-link fortsetter å fungere uendret for alle, uavhengig av om et passord
i tillegg er satt.

**Beslutning B — Passord-hashing: Argon2id, ikke bcrypt.** Bcrypt trunkerer
stille ved 72 BYTES (et kjent fallgruve-mønster — to ulike passord som deler
de første 72 bytene hasher likt) og har historiske NUL-byte-kvirker i enkelte
implementasjoner. Siden brukeren eksplisitt ber om korrekt håndtering av
spesialtegn/mellomrom (dvs. lengre, mer varierte passord/passfraser er
forventet brukt), velges Argon2id (`argon2-cffi`) — minnehardt, OWASPs
anbefalte standard i dag, ingen lengde-fallgruve. Lagres i ny
`app_user.password_hash` (nullable — NULL betyr "ikke satt", faller da
tilbake til kun magic-link).

**Beslutning C — 2FA: brukeren velger metode selv, TOTP ELLER e-post-
engangskode.** `app_user.two_factor_method` (nullable, `'totp'`/`'email'`).
- **TOTP:** `app_user.totp_secret` (bare satt når metoden er `'totp'`),
  `pyotp` for generering/verifisering (RFC 6238-standard, virker med Google
  Authenticator/Authy/1Password etc. uten videre). QR-kode for oppsett
  generert server-side (`qrcode`-biblioteket + eksisterende Pillow-
  avhengighet, samme mønster som AVIF-konverteringen) — ingen ny ekstern
  tjeneste, ingen løpende kostnad.
- **E-post-engangskode:** gjenbruker eksisterende SMTP-oppsett (`app/email.py`,
  ADR-009) — ny, kort 6-sifret kode, samme hash-og-utløp-mønster som
  `magic_link_token` (ny tabell `two_factor_code`, 5 minutters gyldighet).
  Svakere som eneste faktor (e-post-kontoen blir da reelt sett den ENESTE
  hemmeligheten), men krever ingen ny infrastruktur og er brukerens eget,
  informerte valg mellom de to metodene.
- **Bevisst UTENFOR omfang:** SMS-2FA — krever en betalt tredjeparts
  SMS-leverandør (f.eks. Twilio), løpende kostnad per melding, ny ekstern
  avhengighet. Ikke bygget denne runden; kan legges til som en tredje metode
  senere uten å røre TOTP/e-post-sporene, siden `two_factor_method` allerede
  er et åpent tekstfelt, ikke en hardkodet to-verdi-enum i skjemaet.

**Beslutning D — 2FA er PÅKREVD for org-eier/admin, valgfritt ellers.**
Ved innlogging (uansett om via magic-link eller passord): har brukeren
`organization_membership.role IN ('owner','admin')` i MINST én organisasjon
OG `two_factor_method IS NULL`, gis IKKE en full sesjon — brukeren tvinges
inn i et "sett opp 2FA nå"-steg først. En vanlig `member`-rolle (eller en
bruker uten noe org-medlemskap ennå) kan fortsette å bruke appen helt uten
2FA om hen ønsker det. **Begrunnelse:** organisator-roller har skrivetilgang
til andre spilleres personopplysninger (ADR-017) og kontroll over hele
turneringer — et kompromittert organisator-passord/magic-link er en langt
alvorligere hendelse enn en kompromittert spillerkonto. Håndheves ved HVER
innlogging (ikke bare første gang), siden en bruker kan BLI eier av en ny
org (`POST /orgs`, ADR ingen restriksjon — se avklaringen under) etter at
kontoen allerede eksisterer uten 2FA.

**Beslutning E — To-stegs innlogging via en `stage`-claim i sesjons-JWT-en,
ikke en egen tabell for "pågående innlogging".** Når 2FA kreves (enten fordi
brukeren selv har slått det på, eller fordi Beslutning D tvinger det),
utsteder primær-autentisering (magic-link-verifisering ELLER passord-innlogging)
et KORTLEVD (5 min) JWT med `stage: "pending_2fa"` i stedet for en full
30-dagers sesjon. En ny avhengighet (`get_pending_2fa_user`, speiler
`get_current_user`) godtar KUN denne mellomtilstanden, og eksponeres bare på
2FA-verifiserings-/oppsett-endepunktene — `get_current_user` (brukt av ALLE
andre endepunkter) avviser eksplisitt en `pending_2fa`-claim som ugyldig,
slik at en ufullstendig innlogging ALDRI gir reell tilgang til noe. Først når
riktig TOTP-/e-post-kode verifiseres, byttes denne inn mot den vanlige fulle
sesjonscookien (samme 30-dagers levetid som i dag).

**Avklaring underveis, ikke en ny beslutning:** brukeren spurte samtidig om
«kan en bruker eie flere organisasjoner» er tenkt gjennom. Bekreftet: JA,
dette var alltid en del av modellen (ADR-002 — bruker og org-medlemskap er
bevisst atskilt nettopp for at én bruker skal kunne krysse flere
organisasjoner). `POST /orgs` (`app/routers/organizations.py`) har INGEN
begrensning på hvor mange organisasjoner én bruker kan opprette/eie —
hver ny org gir automatisk `role='owner'` for oppretteren, uavhengig av
eksisterende medlemskap. Allerede testet i praksis: dashbordets org-bytter,
kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
(«N = antall organisasjoner brukeren tilhører») forutsetter alle nettopp
dette. Ingen kodeendring nødvendig — kun bekreftelse.

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CHANGELOG.md for
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
fikset under scratch-testing).

---

## ADR-022 — Organisasjonseierskap: dele, invitere, frasi seg, superadmin

**Kontekst:** Reist av brukeren 2026-07-19, rett etter ADR-021. Et reelt,
mer FUNDAMENTALT hull ble synlig under gjennomgangen: `organization_
membership` har i dag INGEN vei til å legge til et nytt medlem i det hele
tatt etter at organisasjonen er opprettet — den ENESTE raden som noensinne
settes inn er grunnleggerens egen `owner`-rad (`POST /orgs`,
`app/routers/organizations.py`). Det finnes ingen invitasjon, ingen
rollestyring, ingen måte å fjerne noen på. «Del eierskap»-ønsket er derfor
bare den mest synlige kanten av et bredere manglende felt: hele
medlemskaps-livssyklusen etter opprettelse.

**Beslutning A — Flere eiere er allerede støttet av SKJEMAET, kun API-et
mangler.** `organization_membership.role` har ingen unikhetsbegrensning som
hindrer flere `owner`-rader for samme organisasjon — dette var aldri en
sperre, bare et ubrukt hull. Ingen skjemaendring nødvendig for selve
flereeiere-støtten, kun nye endepunkter.

**Beslutning B — E-post-basert invitasjon, samme mønster som spiller-
sammenkobling (ADR-017 Beslutning B), ikke et helt nytt konsept.** Ny
`POST /orgs/{id}/invitations {email, role}` oppretter en
`organization_invitation`-rad (e-post, rolle, token-hash, utløper, hvem som
inviterte) og sender en e-post via eksisterende SMTP-infrastruktur
(`app/email.py`). Den inviterte trenger IKKE ha en konto fra før — lenken
tar dem til innlogging (magic-link, evt. passord etter ADR-021), og
`verify_magic_link` utvides til å sjekke ventende invitasjoner på e-posten
sin (samme "kjør på hver innlogging, idempotent" mønster som
`link_player_by_email`) og sette inn `organization_membership`-raden da.
**Rolle-grense ved invitasjon:** en `owner` kan invitere til ENHVER rolle
(owner/admin/member); en `admin` kan KUN invitere til `member` — å la en
admin invitere en ny eier ville vært en reell privilegie-eskaleringsvei
(en admin gir seg selv/en alliert eierskap). Dette er en bevisst, ikke
åpen, avgrensning — minste-privilegium-prinsippet, samme resonnement som
`team_authz.py` sin eksisterende owner/admin-splitt.

**Beslutning C — Rollestyring og fjerning, med et «siste eier»-vern.**
Ny `PATCH /orgs/{id}/memberships/{membership_id} {role}` (kun `owner`,
UNNTATT at en bruker alltid kan senke SIN EGEN rolle selv — «frasi seg
eierskapet» er en selvbetjent handling, ikke noe som må be en annen eier om
lov) og `DELETE /orgs/{id}/memberships/{membership_id}` (kun `owner`, eller
selv for å forlate organisasjonen). Begge avviser handlingen med en klar
409 (`LAST_OWNER`) hvis den ville latt organisasjonen stå igjen med NULL
eiere — samme «TOCTOU-trygg med `FOR UPDATE`»-mønster som ADR-011s
to-lags-grense. En enslig eier må altså enten forfremme noen andre til
eier FØRST, eller be en superadmin om hjelp (Beslutning D) hvis
organisasjonen skal forlates helt.

**Beslutning D — Superadmin er et manuelt tildelt, ikke selvbetjent, flagg.**
Ny `app_user.is_super_admin` (boolean, default `false`). INGEN API-endepunkt
lar noen sette dette flagget på seg selv ELLER andre — det settes kun
direkte i databasen av en driftsansvarlig (samme tillitsnivå som å kjøre en
migrasjon), bevisst utenfor appens eget autorisasjonssystem. Årsak: et
selvbetjent «bli superadmin»-endepunkt ville vært selve
sikkerhetshullet det er ment å ikke være. Med flagget satt får brukeren en
NY autorisasjonssti (`get_current_user_or_superadmin`, parallell til
`get_authorized_org` — sjekker `is_super_admin` FØR det vanlige
org-medlemskaps-kravet) som lar dem kalle de samme rolle-/medlemskaps-
endepunktene på ENHVER organisasjon, ikke bare de de selv er medlem av.
**Bevisst avgrenset:** superadmin-stien dekker KUN medlemskap/rolle-
styring i denne runden (nøyaktig det brukeren spurte om — «sette hvem som
helst som eiere av hvilken som helst organisasjon»), ikke generell
skriveadgang til turnering-/spiller-data i andres organisasjoner. En
bredere «support/drift kan se alt»-rolle er en egen, senere beslutning om
den blir etterspurt.

**Beslutning E — Fjernet/frasigende eiers roster-/spillerdata forblir
URØRT.** Bekreftet av bruker 2026-07-19. Kun `organization_membership`-raden
endres/fjernes ved rollestyring eller fjerning — `team_roster`/`player`-rader
(deltakelse-historikk) røres aldri av disse handlingene. Organisasjons-
STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
det.

**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
migrasjon `012`). Se CHANGELOG.md for full byggerunde.

---

## ADR-023: Brukerroller — kaptein som reell autorisasjon, deltaker-avgrenset scoring

Reist 2026-07-19, direkte oppfølging av det lenge åpne «Brukerroller»-punktet
i FEATURE_BACKLOG.md (der siden prosjektets start beskrevet som turnerings-
admin/lagkaptein/spiller/tilskuer, men aldri fullt ut avgjort). Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.

**Beslutning A — Kaptein gir eksklusive rettigheter til å sette opp/låse
laget, ikke lenger «hvem som helst rostret».** `app/team_authz.py` sin
`user_is_team_captain` (erstatter `user_may_act_for_team`) krever
`team_roster.is_captain = true` for brukeren (eller org-eier/admin, uendret
fra 2026-07-18-runden) — brukt av `matches.py` sin `add_participant`/
`remove_participant`/`lock_lineup`. Ny feilkode `NOT_TEAM_CAPTAIN` (403,
erstatter `NOT_ROSTERED_ON_TEAM` for disse tre stedene).

**Bevisst unntak, funnet ved å faktisk sjekke ekte produksjonsdata FØR
utrulling, ikke antatt:** har et lag INGEN utpekt kaptein i det hele tatt,
godtas enhver rostret spiller i stedet — uten dette ville en kaptein-only-
regel umiddelbart LÅST ute et helt lag fra å sette opp seg selv. Sjekket mot
ekte `teecup_db`: laget «De Unge» i «De Gamle er Eldst» har i dag 0 av 2
roster-rader merket kaptein — dette er altså ikke et hypotetisk
kantscenario, det ville rammet en reell, allerede opprettet turnering med
en gang. Har laget FØRST fått en kaptein, gjelder utelukkende den.

**Beslutning B — «Kun én kaptein per lag» håndheves nå.** `PATCH .../
roster/{id}` og `POST .../roster` (`app/routers/tournaments.py`) fjerner
automatisk kapteinmerket fra enhver annen roster-rad på SAMME lag når en ny
kaptein settes, i samme transaksjon. Nødvendig konsekvens av Beslutning A —
uten dette ville det vært uklart hvem som faktisk har myndighet hvis flere
er merket. Sjekket mot ekte data: ingen eksisterende lag hadde flere
kapteiner (kun opprydningsbehovet «0 kapteiner» fantes reelt), så ingen
data-migrering var nødvendig.

**Beslutning C — Score-føring/-korrigering begrenses til matchens faktiske
deltakere.** `app/team_authz.py` sin nye `user_is_match_participant` krever
en `match_participant`-rad for brukeren i AKKURAT den matchen (valgfritt
begrenset til én side via `team_side` — brukt for `stroke`-modus sin
side-spesifikke innsending, `None`/hvilken som helst side for `hole_result`-
modus siden begge sider kan rapportere). Erstatter den brede
«rostret på laget»-sjekken i `app/routers/scoring.py` sin
`submit_hole_score`/`submit_hole_result`. UAVHENGIG av kapteinmerket — å
være kaptein gir ikke i seg selv rett til å føre score for en match man
selv ikke spiller; org-eier/admin har samme unntak som før. Ny feilkode
`NOT_MATCH_PARTICIPANT` (403, erstatter `NOT_ROSTERED_ON_TEAM` her).

**Beslutning D — «Tilskuer»-rollen utsettes bevisst.** Ingen kode denne
runden. Offentlig/deltaker-lesetilgang finnes allerede
(`tournament.visibility` + `get_current_user_optional`, ADR-018) — et
formelt tilskuer-begrep bør defineres sammen med det ennå ubesluttede
synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
autorisasjonslogikk, ingen skjemaendring). Se CHANGELOG.md for
scratch-verifisering og utrulling.

---

## ADR-024: Walkover/konsesjon

Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
delspørsmål, alle avklart eksplisitt med bruker før bygging.

**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
`user_is_team_captain`.

**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
varslingsmekanisme bygget.

**Beslutning C — Både match- og turnering-nivå, i samme runde.**
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
match. `POST /orgs/{id}/tournaments/{id}/concede`
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
dekker det allerede (rapporter bare hvem som vant hullet).

**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
spilt før konsesjonen.

**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
egne strenger (f.eks. "9&7 (A)").

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
ingen skjemaendring). Se CHANGELOG.md for scratch-verifisering og
utrulling.

---

## ADR-025: Kommunikasjon — lag-chat + offentlig runde-feed

Reist 2026-07-19, rett etter turnering-status-runden. Dekker to ganske ulike
ting under samme paraply: lag-intern chat («det hemmelige rommet») og en
offentlig runde-feed («Banter Board»). Fire beslutninger avklart eksplisitt
med bruker (AskUserQuestion) før bygging.

**Beslutning A — Begge bygges i samme runde**, ikke lag-chat først som egen
runde. Deler mye infrastruktur (meldingsmodell, sanntid-levering), så
designes/bygges sammen selv om feeden isolert sett hadde flere åpne
spørsmål.

**Beslutning B — Sanntid via WebSockets**, ikke polling. Løser samtidig det
tidligere åpne "sanntid vs. polling"-spørsmålet i FEATURE_BACKLOG.md
generelt (samme mekanisme kan gjenbrukes for leaderboard/andre skjermer
senere, selv om denne runden kun kobler den til meldinger).
**Viktig driftsbegrensning, videreført fra et allerede kjent åpent
arkitekturspørsmål (se «Åpne spørsmål» punkt 2 i dette dokumentet):**
tilkoblingsregisteret er en in-memory Python-struktur i `teecup_api`-
prosessen. Med kun én container/prosess (dagens oppsett) er dette trygt;
skaleres API-et til flere prosesser/containere senere, må broadcast flyttes
til noe delt (Redis pub/sub e.l.) — samme klasse begrensning som den
allerede aksepterte in-memory-cachen.

**Beslutning C — Lag-chat er EKTE privat: kun rostrede spillere på laget,
INGEN unntak for org-eier/admin.** Et bevisst avvik fra appens ellers
gjennomgående mønster (kaptein-/deltaker-sjekkene i `team_authz.py` har
alltid en org-admin-fallback, se ADR-023). Ny, egen autorisasjonsfunksjon
`user_is_rostered_on_team` (uten fallback) brukt KUN her — de eksisterende
funksjonene med org-admin-unntak røres ikke, siden de fortsatt er riktige
for sine egne bruksområder (oppsett/scoring, der en organisator uten
dette ville stått fast tidlig i en turnering).

**Beslutning D — Bilder med fra start**, ikke utsatt. Gjenbruker
`app/storage.py` sin allerede byggede og bevist MinIO+AVIF-konverterings-
pipeline (samme mønster som turnering-hero-bilder/sponsorlogoer, ADR-018) —
ingen ny opplastingsinfrastruktur trengs, kun en ny `prefix="messages"`.

**Datamodell:** delt `message`-tabell (migrasjon `013_messaging.sql`) med
en `scope`-diskriminator (`team`/`tournament_feed`) i stedet for to separate
tabeller — meldingsformen (tekst + valgfritt bilde) er identisk, kun
synlighet/autorisasjon skiller dem. `author_display_name` FRYSES ved
skrivetidspunkt (samme prinsipp som `handicap_index_snapshot`, ADR-007) —
utledet server-side fra avsenderens `player`-rad i org-en (hvis den finnes),
ellers e-postens lokaldel som fallback (en org-ansatt uten egen spillerprofil
kan fortsatt poste i den offentlige feeden).

**Offentlig feed — synlighet og posterett, to atskilte spørsmål:**
LESING gjenbruker `registration.py` sitt eksisterende trenivå-mønster
(`tournament.visibility` + `get_current_user_optional` + deltaker-sjekk,
ADR-018) uendret — ingen ny synlighetsmekanisme. POSTING er derimot
STRENGERE enn lesing: en anonym leser på en `public`-synlig turnering kan
lese feeden, men må logge inn OG være enten org-medlem eller faktisk
deltaker/registrert i NØYAKTIG denne turneringen for å få poste — hindrer at
en helt urelatert innlogget bruker (konto et helt annet sted i systemet) kan
poste på en fremmed offentlig turnering-side bare fordi den er synlig.
Moderering: forfatteren selv, ELLER org-eier/admin, kan slette et
feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
moderere det).

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CHANGELOG.md for scratch-
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).

---

## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort

Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
som nå finnes).

**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
trenivå-visibility + `get_current_user_optional`-mønster som all annen
offentlig lesing, gjenbrukt helt uendret.

**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
visibility-sjekk.

**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
kun leaderboard+matchliste som opprinnelig anbefalt).

**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
`None`-bruker.

**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
   NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
   TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
   denne sjekken kunne noen med tilgang til én offentlig turnering i en
   organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
   (inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
   samme klasse hull som ADR-018 Beslutning B advarte om generelt
   ("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
   låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
   matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
   scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
   inneholde registrerte hull før reveal, selv om det ikke er normal flyt).

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
endepunkter + refaktorering av eksisterende spørringer til delte
funksjoner). Se CHANGELOG.md for scratch-verifisering og utrulling.

---

## ADR-027: Sanntid for "Følg live"-siden

Reist 2026-07-19, rett etter ADR-026 (tilskuer-rolle) — brukeren valgte min
egen anbefaling: `/t/[id]/live` krevde omlasting for å se nye resultater,
litt selvmotsigende for en side som heter "Følg live". Løser samtidig det
tidligere åpne "koble leaderboard til sanntid"-punktet i FEATURE_BACKLOG.md.

**Gjenbruker WebSocket-mekanismen fra ADR-025 (meldinger), men "noe endret
seg, hent på nytt"-signal i stedet for å sende selve dataene** — å bygge/
sende hele leaderboard+matchliste+scorekort-formen over WS ville duplisert
betydelig beregningslogikk (leaderboardets projeksjons-regnestykke, blind
draw-filtrering osv.). Klienten reagerer på signalet ved å kalle de samme
REST-endepunktene på nytt (ADR-026), akkurat som ved førstegangslasting —
kun for det som faktisk er synlig/åpent på skjermen (leaderboard alltid,
en økts matcher kun hvis økten er utvidet, et scorekort kun hvis det er
åpnet).

**Ny, RUTEFRI modul `app/realtime.py`** for selve tilkoblingsregisteret og
kringkastingsfunksjonen — verken i `messaging.py`, `scoring.py` eller
`tournaments.py`. Årsak: `registration.py` (som eier selve
`/public/...`-endepunktene) importerer allerede fra `scoring.py`/
`tournaments.py`/`matches.py`, og `messaging.py` importerer fra
`registration.py` — å plassere kringkastingsfunksjonen i noen av routerne
ville skapt en sirkulær import. `app/realtime.py` ligger bevisst BAK alle
routere i importgrafen.

**Kringkastingen er lagt INN I de delte funksjonene selv**
(`recompute_and_cache_match_state`, `apply_concession`), ikke som noe
kallerne må huske å gjøre etterpå — samme selv-ansvarlig-mønster som andre
sentrale funksjoner i prosjektet. `apply_concession` fikk en ny påkrevd
`tournament_id`-parameter kun for dette formålet.

**Samme kjente in-memory-per-prosess-begrensning som ADR-025** (se «Åpne
spørsmål» under) — trygt med dagens ene `teecup_api`-container.

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon, ingen ny
Caddy-rute (gjenbruker `/ws/*`-ruten fra ADR-025 uendret). Se CLAUDE.md-
status for scratch-verifisering og utrulling.

---

## ADR-028: PWA — installasjon + offline scoreregistrering

Reist 2026-07-19, brukeren valgte å ta fatt på PWA (stod som "neste steg" i
CLAUDE.md siden ADR-006 vedtok prinsippet helt i starten av prosjektet — aldri
bygget). To beslutninger avklart eksplisitt med bruker (AskUserQuestion) før
bygging.

**Beslutning A — Full offline scoreregistrering i v1, ikke bare
installasjon.** Bruker valgte det mest ambisiøse alternativet: BÅDE
manifest/ikoner/service worker (installerbar app) OG at scorekort-skjermen
skal fungere uten nett — skriv til en lokal IndexedDB-kø, synk automatisk når
nettet er tilbake. Matcher ADR-006s opprinnelige formulering ordrett
("score må kunne registreres uten nett og synkes senere").

**Beslutning B — Omfang av selve offline-skrivingen: kun `hole-scores`/
`hole-results`, ingenting annet.** De to eksisterende scoreregistrerings-
endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke
glemt:
- Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett.
- Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer
  komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen
  ADR-006 faktisk siktet til.
- Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter
  nett i v1, uendret.

**Beslutning C — Køen lever i klientkoden (IndexedDB, `lib/offline-
queue.ts`), IKKE i service workeren, og bruker `window` sitt `online`-
event, IKKE Background Sync API.** To bevisste forenklinger:
1. Å gi umiddelbar, presis UI-tilbakemelding ("lagret lokalt, venter på
   synk", pending-antall) er enklere og mer testbart fra selve
   React-komponenten enn fra en service worker sin `fetch`-handler.
2. Background Sync API (som ville gitt synk selv om appen er lukket) støttes
   IKKE av iOS Safari i det hele tatt — en stor andel av klubb-/
   vennegjeng-brukerne er trolig på iPhone, og et rent
   Background-Sync-avhengig design ville derfor vært brutt for dem. Et
   `window.addEventListener("online", ...)`-mønster (pluss en manuell
   "Synkroniser nå"-knapp i UI-et som reserve) fungerer overalt, på
   bekostning av at synk krever at appen faktisk er åpen når nettet kommer
   tilbake — akseptabelt for v1.

`submitStroke`/`submitHoleResult` (`components/session-scorecard.tsx`)
sjekker `navigator.onLine` FØRST (unngår en unødvendig ventetid på et
nettverkskall som uansett vil feile), og fanger ellers en EKTE nettverksfeil
i fetch-kallet separat fra et avvist HTTP-svar (`res.ok === false`, f.eks.
409 "matchen er avgjort") — kun den førstnevnte køordner, sistnevnte viser
fortsatt den vanlige feilteksten uendret. Et lokalt overlay
(`pendingStrokes`/`pendingResults`, ikke persistert i selve
`scorecard`-staten) viser køede verdier umiddelbart i UI-et, merket
"Lagret lokalt · venter på synk", inkludert i hull-navigasjonens
registrert-markering.

**Konfliktmodell: samme tillitsnivå som resten av appen, ingen ny
mekanisme.** Server-siden er allerede en upsert per hull (`ON CONFLICT ...
DO UPDATE`) — siste innsending vinner, ingen audit-trail (kjent, tidligere
dokumentert åpent spørsmål, se «Scoring-autorisasjon» i FEATURE_BACKLOG.md).
Køen sender i rekkefølge (aldri parallelt) for å respektere lokal
innsendingsrekkefølge ved flere endringer av samme hull offline. Et
definitivt HTTP-avvist forsøk ved synk (typisk: matchen ble avgjort på en
ANNEN enhet mens denne var offline) fjernes fra køen og vises som en
feilmelding — blir ALDRI hengende for alltid.

**Service worker-strategi: nettverk først, cache som fallback — bevisst
IKKE stale-while-revalidate.** `public/sw.js` cacher (a) side-navigasjon og
(b) GET-kall under `/orgs/*`. Begge prøver ekte nettverk FØRST og faller
kun tilbake til cache ved reell feil. En stale-while-revalidate-strategi ble
vurdert og avvist: scorekortet gjør et `refetchScorecard()`-kall RETT ETTER
hver innsending, og må da alltid få fersk data — en umiddelbart utdatert
cache ville vist feil matchstatus/hull-tall rett etter en vellykket
innsending. `/auth/*` og `/public/*` caches bevisst ikke — omfanget er
begrenset til akkurat det scoreregistrerings-flyten trenger.

**Kjent, akseptert begrensning (ikke løst i v1):** Cache Storage er nøklet
på URL, ikke på innlogget bruker — deler flere kontoer samme enhet/
nettleser, kan en offline-fallback teoretisk vise data cachet av en
TIDLIGERE innlogget bruker på samme enhet. Ingen cache-tømming ved
utlogging bygget. Samme tillitsnivå/trusselmodell som appen ellers opererer
med (tillitsbasert klubb-/vennegjeng-verktøy, jf. `team_authz.py` sin
begrunnelse i ADR-023/025).

**Ikoner: enkelt, midlertidig sett generert programmatisk (grønt
golf-flagg), ikke endelig design.** Bruker valgte å generere nå fremfor å
vente på ekte design, MED eksplisitt beskjed om at disse skal erstattes
senere — notert i FEATURE_BACKLOG.md. Erstattet samtidig den gamle
`public/apple-icon.png` (v0.app sin generiske plassholder-logo, ikke
TeeCup-merkevare i det hele tatt) med samme nye ikon, av konsistens.

**Ikke testet i ekte nettleser (viktig, ikke bare en formalitet):**
verifisert med typesjekket produksjonsbuild (samme `Dockerfile` som
deployes) og en kort container-boot med `curl` (manifest/service worker/
ikoner/offline.html svarer riktig), men INGEN faktisk browser-basert
offline-test (DevTools "Offline"-modus, "Legg til på hjemskjerm") er
gjennomført denne runden — ingen nettleserverktøy tilgjengelig i denne
økten. Anbefales sterkt at brukeren selv tester scorekort-siden med Chrome
DevTools sin Offline-bryter før tillit legges til flyten i skarp bruk.

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Bruker bekreftet eksplisitt, ingen
migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
`teecup_api` som en bivirkning av `docker compose up --build` sin
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
200 over ekte https, `teeoff.no` upåvirket. Se CHANGELOG.md for full
byggerunde.

**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
nettleser-basert offline-test (Chrome DevTools Offline-modus, ekte "Legg
til på hjemskjerm") er fortsatt ikke gjort — ingen nettleserverktøy
tilgjengelig i byggeøkten. Brukeren har bedt om at dette noteres eksplisitt
for oppfølging, se FEATURE_BACKLOG.md sin PWA-tabell og CLAUDE.md sin
"Neste steg"-liste.

---

## ADR-029: Kjønn hører til tee-RATINGEN, ikke selve utslaget

Reist av brukeren 2026-07-19, som del av en runde med fire rapporterte
UX-/korrekthetshull fra faktisk testing av blind draw og scorekort. Startet
som en antatt frontend-fiks ("tee-valget bør følge spillerens kjønn
automatisk"), men brukeren presiserte at premisset mitt var feil: en
golfbane har IKKE fysisk kjønnsdelte utslag -- begge kjønn kan som regel
spille fra ethvert utslag. Det eneste som faktisk varierer per kjønn er om
klubben har VALGT å slope (rate) et gitt utslag for det respektive kjønnet
(noen klubber sloper bevisst ikke det lengste utslaget for damer).

**Bekreftet problem, ikke antatt:** `tee.gender` (migrasjon 001) la kjønn på
selve utslaget, ikke ratingen. Sjekket mot ekte, importert produksjonsdata
(Tjøme Golfklubb, ADR-019): alle fire utslagene lå som RENE navnepar --
"32"/m + "32"/f, "44"/m + "44"/f, osv. -- to separate `tee`-rader for
akkurat samme fysiske utslag. Blind draw-skjermen viste dette som "velg
Dame- eller Herre-tee", som om det var to ulike steder å slå fra.

**Beslutning A -- `gender` flyttes fra `tee` til `tee_rating`.** Ett fysisk
utslag (`tee`, identifisert kun ved navn) kan ha 0, 1 eller 2
kjønnsspesifikke ratinger. `tee_rating` sin unikhet endres fra
`(tee_id, scope)` til `(tee_id, scope, gender)`. `gender` på `tee_rating`
er `NOT NULL CHECK IN ('m','f')` -- 'x' (gyldig på PLAYER-nivå) gir ikke
mening for en WHS-rating, som alltid er for ett bestemt kjønn.

**Beslutning B -- tee-valget blir helt automatisk, ingen manuell
kjønnsvelger (bekreftet med bruker, valgte det anbefalte alternativet).**
Organisator/kaptein velger KUN fysisk utslag i blind draw
(`session-blind-draw.tsx`, ingen "H"/"D"-suffiks lenger). Riktig
kjønnsspesifikk rating løses AUTOMATISK server-side fra spillerens
registrerte `player.gender` -- `app/handicap.py` sin
`compute_and_store_side_handicaps` joiner nå `tee_rating` på
`(tee_id, scope='full_18', gender = player.gender)` i tillegg til den
tidligere ADR-028-fiksen (alltid full_18, uavhengig av hole_config).

**Beslutning C -- manglende rating/kjønn feiler tydelig FØR innsetting,
ingen stille fallback (bekreftet med bruker, valgte det anbefalte
alternativet).** `matches.py` sin `add_participant` validerer, når
`config.use_handicap` er sann: (1) spilleren har et registrert kjønn --
avvist med `VALIDATION_FAILED` hvis ikke ("sett kjønn på spilleren
først"), (2) valgt utslag har faktisk en `tee_rating` for NØYAKTIG det
kjønnet -- avvist med `VALIDATION_FAILED` hvis ikke ("dette utslaget har
ingen dame/herre-rating -- velg et annet utslag"). Samme "fail loudly"-
prinsipp som ADR-019 sin importvalidering og den eksisterende
handicap_index-sjekken (2026-07-18) rett ved siden av. `_remap_course`
(bane-bytte midt i en økt) fikk samme presisering: matcher fortsatt på
tee-navn, men sjekker nå at den NYE tee-en har en rating for hver berørte
spillers kjønn, ikke bare at navnet finnes.

**Konsekvens for import/opprettelse:** ADR-019 sin teeoff-import
(`import_official_course`) lager nå ÉN `tee`-rad per fysisk teeoff-utslag
(tidligere: to, én per kjønn), med inntil to `tee_rating`-rader under.
Manuell tee-opprettelse (`POST .../courses/{id}/tees`) redesignet
tilsvarende: `TeeCreate.ratings` er nå en liste (1-2 elementer, distinkte
kjønn) i stedet for ett flatt kjønn+rating-sett -- ingen reell
bruker-/produksjonsdata brukte dette endepunktet fra før (kun Tjøme, som
er offisielt importert), så ingen bakoverkompatibilitet var nødvendig.

**Migrasjon `014_tee_gender_to_rating.sql`:** flytter `gender` til
`tee_rating`, slår deretter sammen eksisterende kjønns-par-tee-rader til
én fysisk tee-rad per (bane, navn) -- velger laveste id som "beholder",
flytter alle `tee_rating`- og `match_participant.tee_id`-referanser dit,
sletter duplikatene. Bekreftet TRYGT å kjøre mot ekte data FØR skriving
(read-only sjekk): alle 8 eksisterende tee-rader (Tjøme) er rene m/f-par,
ingen `gender='x'`-rader, ingen gruppe med mer enn to rader.

**Scratch-verifisert grundig, flere runder:** (1) selve
fletting-migrasjonen kjørt mot syntetisk data som gjenskaper Tjøme-
mønsteret nøyaktig, inkl. én `match_participant`-rad som pekte til
DUPLIKATEN (ikke beholderen) -- bekreftet korrekt reparert til beholderens
id etterpå, og et utslag med KUN én rating (ingen duplikat) forblir
urørt. (2) Manuell tee-opprettelse: to ratinger på ett utslag, kun én
rating (simulerer "klubben har ikke slopet dette kjønnet"), duplikat
kjønn i samme innsending avvist. (3) `GET tees` viser riktig sammenslått
struktur (2 fysiske utslag, ikke 3 rader). (4) `add_participant`: kvinne
+ utslag med dame-rating lykkes; mann + utslag som KUN har dame-rating
avvist tydelig; spiller uten registrert kjønn avvist tydelig; en full
kjønnsblandet singel-match scoret korrekt end-to-end. (5) Offisiell
import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb) -- bekreftet
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8 doble
rader. (6) `_remap_course`: bane-bytte til en bane UTEN matchende
kjønnsrating avvist tydelig, bane-bytte til en bane MED matchende
rating lykket.

**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon 014 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt: Tjømes 8 tee-rader slått sammen
til 4 fysiske utslag, alle 8 `tee_rating`-rader fikk riktig `gender`,
`match_participant`-referansene forble gyldige (0 brutte fremmednøkler
etter migrasjonen, bekreftet med en direkte spørring). Begge containere
(`teecup_api`, `teecup_frontend`) bygget og redeployet, `/health`/
`/dashboard` → 200, `teeoff.no` upåvirket.

---

## ADR-030: Dashbord-datoer utledes fra øktene, ikke et separat felt

Reist av brukeren 2026-07-19/20, direkte oppfølging av det tidligere
dokumenterte UI-hullet ("Dashboard-turneringskortet viser 'Ingen datoer
satt'", notert 2026-07-19). Brukeren viste et faktisk skjermbilde: en
turnering med en økt tydelig planlagt til "lør. 11. juli, 10:50" på
Program-fanen, mens dashbord-kortet fortsatt viste "Ingen datoer satt".

**Root cause, bekreftet ved kodegjennomgang (samme konklusjon som forrige
runde, nå faktisk fikset):** dashbord-kortet leser `tournament.start_date`/
`end_date` — et eget felt på selve turneringen (ADR-015) — som INGEN UI
noensinne har hatt en vei til å sette. Fullstendig atskilt fra
`session.scheduled_at` (som Program-fanen korrekt viser).

**Beslutning: utled datospennet fra øktenes `scheduled_at` i
`list_tournaments`, ikke bygg et manuelt datofelt-skjema.** To kilder til
sannhet (et manuelt "turnering-dato"-felt OG faktiske økt-tidspunkter) ville
uunngåelig kommet ut av synk — turneringens reelle datoer ER ganske enkelt
når rundene faktisk er satt til å spilles. `GET /orgs/{id}/tournaments`
gjør nå `COALESCE(t.start_date, MIN(økt.scheduled_at))` /
`COALESCE(t.end_date, MAX(økt.scheduled_at))` — et eksplisitt satt
`start_date`/`end_date` (om det noensinne blir gitt en skrivevei senere)
vinner fortsatt over det utledede spennet, men i praksis er det alltid det
utledede spennet som vises i dag. En turnering uten noen tidsplanlagte
økter viser fortsatt riktig "Ingen datoer satt" (ikke en feil).

**Konsekvens:** kun `list_tournaments` sin spørring endret (ikke
`_TOURNAMENT_COLUMNS`, som fortsatt brukes uendret av opprett-/
PATCH-endepunktene sine `RETURNING`-klausuler — de trenger ikke
sesjons-utledningen rett etter en skriveoperasjon). Ingen migrasjon.

**Scratch-verifisert:** turnering uten økter → `null`/`null` (ikke feil);
turnering med to økter (11./12. juli) → riktig utledet spenn; en turnering
med et EKSPLISITT satt `start_date`/`end_date` ved opprettelse beholder
fortsatt sin egen verdi selv med økter senere lagt til (bekrefter
COALESCE-prioriteringen).

**Status: ✅ BYGGET OG LIVE 2026-07-20.** Bruker bekreftet eksplisitt, ingen
migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
11. juli 2026 (utledet fra dens økt) i stedet for "Ingen datoer satt".

---

## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil

Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
hjemmeklubb).

**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
org-medlem — også for en bruker som KUN er spiller (koblet via
`player.user_id`, ADR-017 B), aldri organisator.

**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
— ingen tvungen valg mellom to identiteter.

**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
konsepter, ikke ett duplisert.

**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).

**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
brukes for `organizations`).

**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
finnes intet scenario der en organisator ønsker å skjule en turnering for
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.

**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
til lagets private chat eller det organisator-vendte scorekortet — disse
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
strengere sperre enn deltaker-status alene, brukt av dusinvis av
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.

**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.

**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
CHANGELOG.md for full byggerunde.

---

## ADR-032: Verifisert e-postbytte + mobil på personlig profil

Reist av brukeren 2026-07-20, samme dag og rett etter ADR-031: "identifikatoren"
(e-post) manglet i den nye personlige profilen, og mobil (med landsnummer)
burde være en opsjon.

**Beslutning A — mobil er et rent, enkelt tillegg til `ProfileUpdate`
(samme PATCH som resten av profilen), splittet i to felt.**
`mobile_country_code` (f.eks. `"+47"`) og `mobile_number` er separate
kolonner på `app_user` (migrasjon `016_profile_contact.sql`) — ikke én
sammensatt streng — slik at frontend kan tilby en egen landsnummer-
velger uten å måtte parse en fritekststreng i etterkant.

**Beslutning B — e-post er IKKE en del av den vanlige profil-PATCH-en, og
kan det aldri bli.** E-post er innloggings-identifikatoren (magic-link-
mål) — en enkel PATCH (som de andre feltene) ville latt en skrivefeil
ELLER en kapret sesjon stjele kontoen for godt, ingen verifisering av at
den NYE adressen faktisk eies av noen. Løst med et eget, to-stegs
bekreftelsesløp, samme `token_hash`+`expires_at`+`consumed_at`-mønster
som `magic_link_token` (004), gjenbrukt for et nytt formål: ny tabell
`email_change_token` (bruker_id, ny e-post, token-hash, utløp).
`POST /auth/profile/email` (krever gyldig sesjon — du må bevise at du
ER kontoen i dag) genererer tokenet og sender en bekreftelseslenke til
DEN NYE adressen (ikke den gamle — beviser eierskap av MÅLET, ikke bare
at avsenderen fortsatt er innlogget). `POST /auth/profile/email/confirm`
(ingen sesjon påkrevd — samme mønster som selve magic-link-verifiseringen,
siden lenken kan åpnes på en annen enhet/nettleser enn den som ba om
byttet) forbruker tokenet atomisk og gjennomfører selve byttet. E-posten
endres IKKE før lenken faktisk åpnes.

**Konsekvens:** duplikat-sjekk (er den ønskede adressen allerede en annen
kontos?) gjøres TO ganger — én gang ved forespørsel (rask
tilbakemelding), én gang igjen rett før selve `UPDATE`-en ved bekreftelse
(kan ha blitt tatt av noen andre i mellomtiden) — pluss den eksisterende
unike indeksen (`app_user_email_unique`, migrasjon 004) som siste
bakstopper via `translate_db_errors()`.

**Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig
profil-PATCH endrer aldri e-post; bytte til en allerede brukt adresse
avvist (409); e-post FORBLIR uendret helt til lenken bekreftes; ugyldig
bekreftelseskode avvist; gyldig kode fullfører byttet; SAMME kode kan
ikke brukes to ganger; en helt ny innlogging med den GAMLE adressen
oppretter nå en fersk, tom konto (beviser byttet er reelt og fullstendig,
ikke kosmetisk). Ekte typesjekket produksjonsbuild kjørt og bekreftet
(ny `/verify-email`-rute listet).

**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 016 kjørt mot ekte
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
upåvirket.

---

## ADR-033: Frittstående rundeføring med detaljert statistikk

Reist av brukeren 2026-07-21 (se FEATURE_BACKLOG.md), utdypet 2026-07-22 med
konkrete statistikk-felt og en eksplisitt ambisjon: **dette skal bli
appens hovedfokus** — når rundeføring med detaljert statistikk er på
plass, skal turneringsoppsett deretter gjøres ekstremt enkelt. Dette er
den største enkeltbeslutningen i prosjektet siden ADR-001, fordi den
direkte utfordrer tenant-invarianten ("organisasjon er isolasjonsenheten")
CLAUDE.md hittil har krevd en ny ADR for å bryte.

Fire load-bærende delbeslutninger ble avklart eksplisitt med bruker
(AskUserQuestion) FØR resten av denne ADR-en ble skrevet. De tre
HCP-PDF-ene (se referanse i CLAUDE.md) ble lest i sin helhet som
forberedelse til Beslutning F/G, i tråd med prosjektets stående instruks
om å ikke anta HCP-regler fra hukommelse.

### Beslutning A — Eierskapsmønster: nytt, parallelt, IKKE en skjult organisasjon

En frittstående runde eies av en BRUKER (`app_user.id`), ikke en
organisasjon. Ingen ny RLS-policy-familie trengs for dette — prosjektet
har ALLEREDE et etablert, bevist mønster for nøyaktig denne typen data:
personlig profil (migrasjon 015), sekundær e-post (017) og
HCP-historikk (018) bruker alle `plain_connection()` (ingen
`app.current_org` satt) med eksplisitt `WHERE user_id = $1`-filtrering i
hver spørring, i stedet for RLS. Frittstående runder gjenbruker dette
mønsteret uendret — nye tabeller (`round`, `round_participant`,
`round_hole_stat`, se Beslutning B) har en `owner_user_id`-kolonne, INGEN
`organization_id`, og INGEN RLS-policy — autorisasjon håndheves i
app-laget (eier ser/redigerer egne runder; en deltaker uten konto har
ingen egen tilgang, se Beslutning D).

**Begrunnelse for å avvise "usynlig personlig organisasjon"-alternativet:**
en skjult organisasjon måtte for alltid filtreres bort fra ENHVER
org-listing/-bytter/-medlemsside/fremtidig fakturering — en varig
lekkasjerisiko som vokser for hver ny org-scopet skjerm som bygges
fremover. Det parallelle mønsteret er ikke bare konseptuelt riktigere,
det er også ALLEREDE bygget og bevist for personlig data — dette er
mindre nytt arbeid enn først antatt i brainstorm-runden.

### Beslutning B — Statistikk-datamodell: fast sett navngitte felt, ikke fri slag-for-slag-logg

Per hull, i tillegg til slagtall (som i dag): kølle brukt ved utslag,
utslags-resultat (fairway/høyre/venstre — kun relevant på par 4/5),
innspill-resultat (traff/lang/kort/høyre/venstre), antall putter, lengde
på første putt, antall chip, antall bunkerslag, antall straffeslag.

**Presis definisjon av "innspillsslaget"** (nødvendig for at GIR skal
kunne beregnes automatisk, uavhengig av hullets par): SISTE slag før
første putt. På en par 3 er dette utslaget selv; på en par 4 normalt
2. slag; på en par 5 kan det være 2. ELLER 3. slag (f.eks. ved en
layup) — modellen trenger ikke vite hvilket slagnummer det var, kun at
det faktisk er det siste FØR putting startet.

**GIR er DERIVERT, ikke tastet inn direkte:** Green In Regulation = sant
hvis innspill-resultat er "traff" OG antall slag brukt til da er ≤
(hullets par − 2). Utslag-resultat og innspill-resultat er derimot
OBSERVERTE felt spilleren selv taster inn — appen har ingen GPS og kan
ikke oppdage dette selv.

**UX-prinsipp, ikke bare et skjema-valg:** ALLE detalj-felt er valgfrie
per hull. Rask "bare slagtall"-registrering skal alltid fungere uendret
— dette er bevisst for å unngå at "hovedfokus" i praksis blir for
tungvint til daglig bruk (samme klasse avveining som gjorde at
detaljerte felt ble utsatt fra scorekort-rundene tidligere i prosjektet).

**Avvist:** fri slag-for-slag-logging (hvert slag = egen rad). Ville
dekket samme behov, men med vesentlig tyngre registrering og et mer
komplekst skjema fra dag én, uten at brukerens beskrevne behov faktisk
krever det.

### Beslutning C — Banedata for frittstående runder: LIVE oppslag mot teeoff, bekreftet av bruker

Custom-baner er i dag org-scopet (`course.organization_id`) — dette
fungerer ikke uten organisasjon. **Bekreftet med bruker 2026-07-22:**
offisielle baner slås opp LIVE mot teeoff sitt API ved behov (samme
lesende API som ADR-019, IKKE en importert/kopiert kopi som i
turnering-flyten). ADR-019 sin "importer, ikke slå opp live"-beslutning
var begrunnet i turneringers behov for reproduserbarhet (et resultat skal
ikke endre seg retroaktivt hvis banedata oppdateres) — en frittstående
statistikk-runde har ikke samme behov, og live oppslag unngår i tillegg
dagens duplisering (hver org som importerer "Tjøme Golfklubb" i dag lager
sin egen kopi). Egendefinerte baner som ikke finnes i teeoff: global
banekatalog uten organisasjonstilknytning, søk-før-opprett for å begrense
duplikater (denne delen — selve den globale katalogen for CUSTOM baner —
er fortsatt kun min anbefaling, ikke eksplisitt bekreftet punkt for
punkt, men følger naturlig av at live-oppslag-beslutningen er tatt).

### Beslutning D — Deltakere i flighten uten TeeCup-konto: gjenbruk eksisterende mønster

`round_participant` får en nullable `user_id` (ekte TeeCup-bruker som
spiller med) og et `guest_name`-fritekstfelt (ingen konto). Samme
konsept som `player` uten `user_id` i org-sammenheng. E-post-basert
kobling i etterkant (samme mønster som `link_player_by_email`,
migrasjon 008) er en naturlig, men IKKE besluttet, senere utvidelse —
notert som åpent punkt under, ikke bygget i første omgang.

### Beslutning E — Hullantall og starthull: ingen tvang, kun standardvalg

18/første ni/siste ni er standardvalg i UI-et, ikke en database-
begrensning. Spilleren velger starthull fritt (gjenbruk av samme
`start_hole`-konsept som `session.start_hole`, ADR-015). En runde kan
avsluttes etter et hvilket som helst antall hull uten et forhånds-
deklarert mål — statistikk og par-sum regnes alltid fra hullene FAKTISK
spilt.

### Beslutning F — HCP-tellende runde: presist kildebelagt fra WHS Rules of Handicapping 2024

Brukeren lastet opp den offisielle kilden 2026-07-22 ("WHS Rules of
Handicapping 2024", USGA/R&A — IKKE en tredjeparts-blogg som de to andre
PDF-ene). Dette erstatter den tidligere, mer omtrentlige "minst 9
hull"-antakelsen med presise regler (**Rule 2.2**):

- **18-hulls-type runde:** minimum **10 av 18 hull** må spilles for at
  scoren skal være akseptabel. De resterende (inntil 8) uspilte hullene
  fylles med en "expected score" (se under), IKKE net par (net par-
  metoden ble erstattet av expected-score-metoden i 2024-revisjonen —
  et prinsipielt skifte fra tidligere WHS-versjoner, verdt å merke seg
  siden eldre kilder/hukommelse fortsatt kan referere net par).
- **9-hulls-type runde:** ALLE 9 hull i det spesifikke, ratede 9-hulls-
  settet (front ELLER back, de eneste to som normalt har egen Course/
  Slope Rating) må spilles. Færre enn 9 hull totalt → scoren er IKKE
  akseptabel for HCP-formål i det hele tatt, uansett grunn.
- **Konsekvens for Beslutning E (fritt valgt starthull):** den frie
  starthull-friheten gjelder fullt ut for CASUAL, ikke-HCP-tellende
  logging. For at en runde skal telle mot HCP, må de spilte hullene
  derimot samsvare med enten (a) et sammenhengende 10-18-hulls-utsnitt av
  banens 18-hulls-rating, eller (b) nøyaktig banens ratede front-9 eller
  back-9 — en vilkårlig 9-hulls-strekning (f.eks. hull 5-13) har ingen
  egen Course/Slope Rating og kan derfor aldri bli HCP-tellende. Dette må
  kommuniseres tydelig i UI-et, ikke bare håndheves stille i motoren.

### Beslutning G — HCP-indeksberegning bygges NÅ, presist kildebelagt (WHS Rules of Handicapping 2024)

Full kjede, alle tall/formler hentet direkte fra kilden (Rule 3/5/6),
ikke hukommelse:

1. **Net Double Bogey** (maks hull-score for HCP-formål) = hullets par +
   2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
   dekket av eksisterende `allocate_strokes_by_index`/
   `allocate_over_played_holes`, ingen endring.
2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik
   fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme
   (Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av
   sertifisert WHS-programvare, UTEN at selve formelen er publisert i
   regelboken (samme mønster som PCC) — kan derfor ikke bygges presist.
   **Brukeren instruerte eksplisitt** å bruke WHS sin egen, presist
   DEFINERTE "Net Par"-term i stedet (Rule 3.2b/2 — normalt reservert for
   spesielle godkjente tilfeller, men her vedtatt som TeeCups generelle
   policy): for hvert uspilt hull antas spilleren å ha skåret sin Net Par
   = hullets par + mottatte handicapslag på det hullet (samme formel som
   Net Double Bogey, uten +2-leddet) — tilsvarer 2 Stableford-poeng per
   uspilt hull. Summeres inn i Adjusted Gross Score FØR standard 18-hulls
   Score Differential-formelen brukes. **Viktig konsekvens:** dette gjør
   at en 9-hulls-runde nå KAN telle fullt mot HCP-indeksen (de resterende
   9 hullene fylles med Net Par, hele runden går gjennom SAMME 18-hulls-
   formel) — det tidligere spørsmålet om en egen 9-hulls-differensial-
   formel (Rule 5.1b) er dermed ikke lenger nødvendig å bygge separat.
   Fortsatt gyldig kun når minimumsantallet (Beslutning F) er oppfylt.
3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av
   "most likely score" (allerede tatte slag + sannsynlig antall til
   fullføring, tabell basert på ballens avstand fra hullet + eventuelle
   straffeslag) eller net double bogey.
4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) ×
   (Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til
   nærmeste tidel (,5 rundes opp).
5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.**
   Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå
   som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel
   som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt
   bort, ikke oversett.
6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste
   20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20
   runder i historikken brukes en egen opptrappingstabell (f.eks. 3
   runder → laveste 1 med justering −2,0; 9-11 runder → snitt av laveste
   3, ingen justering; osv. — full tabell i Rule 5.2a, IKKE en enkel
   "gjennomsnitt av alt"-tilnærming for nye spillere).
7. **Low Handicap Index** (Rule 5.7): laveste indeks siste 365 dager —
   nødvendig referansepunkt for cap-mekanismen under, må lagres/spores
   per bruker.
8. **Soft cap / hard cap** (Rule 5.8): øker en oppdatert indeks mer enn
   3,0 slag over Low Handicap Index, begrenses overskytende beløp til
   50 %; mer enn 5,0 slag over Low Handicap Index er et absolutt tak.
   Ingen nedre grense på hvor mye indeksen kan SYNKE.
9. **Maksimal indeks** (Rule 5.3) = 54,0 — samsvarer med det allerede
   satte `le=54`-taket i `ProfileUpdate` fra profil-fullførings-runden
   (2026-07-22), god konsistens-bekreftelse.
10. **9-hulls Course Handicap** (Rule 6.1b) — **NY, presis detalj,
    AVVIKER fra 18-hulls-formelen:** `(Index ÷ 2, avrundet til nærmeste
    tidel) × (9-hulls Slope ÷ 113) + (9-hulls Course Rating − 9-hulls
    Par)`. Dette er IKKE det samme som å bruke full indeks mot en
    9-hulls rating — indeksen halveres først. **Denne formelen gjelder
    frittstående 9-hulls-RUNDER (denne ADR-en), IKKE det eksisterende
    front_9/back_9-øktoppsettet i turnering-flyten** (ADR-008/2026-07-19-
    fiksen, som bevisst bruker full_18-rating for HELT ANDRE grunner —
    slagfordeling innad i en turneringsmatch, ikke offisiell HCP-
    runde-innsending. De to må IKKE forveksles eller slås sammen uten en
    egen vurdering.)
11. **18-hulls Course Handicap** (Rule 6.1a) = `Index × (Slope ÷ 113) +
    (Course Rating − Par)` — allerede korrekt implementert
    (`course_handicap_raw`, verifisert ved grep), ingen endring.
12. **Playing Handicap** (Rule 6.2) — uendret, allerede korrekt dekket av
    eksisterende allowance-strategier (ADR-014).

**Dette er i hovedsak en HELT NY komponent i `handicap_engine.py`**
(punktene 4-9), ikke en utvidelse av det eksisterende — dagens motor tar
alltid indeksen som et KJENT input; den har aldri regnet UT en indeks fra
en historie av runder. Punkt 10 er en presisering/utvidelse av
eksisterende kode for 9-hulls-tilfellet. Holdes ren og testet isolert,
som resten av `handicap_engine.py` (ADR-005).

**Bevisst UTENFOR omfang i første byggerunde, til tross for at kilden nå
finnes** (for å holde v1 håndterbar — presist avgrenset, ikke bare
utsatt i vage vendinger):
- **Playing Conditions Calculation (PCC)** (Rule 5.6) — full prosedyre
  funnet og forstått (statistisk sammenligning av dagens faktiske scorer
  mot forventet, justering −1,0 til +3,0, krever minst 8 aksepterte
  scorer på banen samme dag blant spillere med indeks ≤36,0). Ikke
  bygget nå — krever et helt annet datagrunnlag (ALLE spilte runder på
  en bane en gitt dag på tvers av ALLE brukere) enn det en enkelt
  frittstående runde naturlig gir. Runder telles inn UTEN PCC-justering
  til dette tas som egen, senere runde.
- **Exceptional Score-reduksjon** (Rule 5.9) — funnet og forstått (en
  differensial 7,0-9,9 slag bedre enn gjeldende indeks gir automatisk
  −1,0 på de siste 20 differensialene, 10,0+ gir −2,0). Ikke bygget i
  v1, notert for senere presisjon.
- **Initial indeks fra færre enn 3 runder, Handicap Committee-skjønn**
  (Rule 5.2a) — TeeCup har ingen "Handicap Committee"-rolle; en
  forenklet, automatisk variant av opptrappingstabellen brukes i stedet,
  uten menneskelig overstyring i v1.

**Åpent, ikke besluttet:** når en reell WHS-indeks kan beregnes fra
runder, skal manuell redigering av `app_user.handicap_index`
(eksisterende `PATCH /auth/profile`, ADR-031) fortsatt tillates ved
siden av (f.eks. for en spiller uten noen TeeCup-runder ennå), eller skal
feltet bli read-only/auto-beregnet så snart minst én HCP-tellende runde
finnes? Påvirker om `handicap_history` (018) skal gjenbrukes uendret
eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra
runde".

**Skjemaet (migrasjon `020_personal_rounds.sql`) er ✅ SKREVET OG
SCRATCH-VERIFISERT 2026-07-22,** som andre byggesteg (etter motoren).
Sju nye tabeller: `round` (header, `owner_user_id`-eid, ingen RLS),
`round_participant` (deltakere — lenket bruker ELLER gjestenavn, XOR-
håndhevet via CHECK; maks én markert eier per runde via partiell unik
indeks), `round_hole` (rating-SNAPSHOT + statistikk per deltaker per
hull — GIR er bevisst IKKE en egen kolonne, kun deriverbar ved lesing:
`approach_result='hit' AND (score-putts) <= par-2`, verifisert eksakt
mot ekte testdata), pluss fire tabeller for den globale banekatalogen
(`personal_course`/`_hole`/`_tee`/`_tee_rating` — sistnevnte BEVISST uten
`scope`-kolonne, ulikt org-tabellen, siden Net-Par-tilnærmingen gjør
9-hulls-spesifikk rating overflødig). `round`/`personal_course_*` har
INGEN RLS (Beslutning A) — autorisasjon i app-laget.
**Scratch-verifisert, 9 sjekker** (kjørt som `teecup_app_scratch`, ikke
superbruker): duplikat kjønn på samme utslag avvist, teeoff+custom-felt
samtidig avvist (CHECK), verken/begge user_id+guest_name avvist (XOR-
CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå
urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på
eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,**
bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet,
`test_isolation.sql` fortsatt 12/12 mot ekte database. **Gjenstår:**
API-lag og frontend — ingen av disse er startet.

**API-laget er ✅ BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-22,**
som tredje byggesteg. Ny `app/routers/rounds.py` (registrert i `main.py`):
`POST/GET /rounds` (opprett/list egne runder), `GET/DELETE /rounds/{id}`,
`POST/DELETE /rounds/{id}/participants` (kun gjester i v1, se moduldoc),
`PATCH /rounds/{id}/participants/{pid}/holes/{n}` (hull-for-hull-
registrering), `POST /rounds/{id}/complete` (kjører hele motor-kjeden:
Adjusted Gross Score → Score Differential → `counts_for_handicap` via
`round_counts_for_handicap`), pluss `GET/POST /personal-courses` for den
globale banekatalogen. Ny motor-funksjon lagt til underveis:
`round_counts_for_handicap(played_holes_count, holes_planned)` — to
distinkte terskler (Rule 2.2a: min 10/18 ved 18-hulls-intensjon; Rule
2.2b: ALLE 9 ved 9-hulls-intensjon, ikke "minst 9"), testet (2 nye
tester, 43/43 totalt i `handicap_engine.py`).
**Reelt hull funnet OG fikset FØR API-et kunne fullføres:**
`round_participant` manglet kolonner for selve rating-tallene (Course
Rating/Slope Rating/Par) brukt til å beregne Course Handicap — kun
`round.tee_name_snapshot` (navn) fantes, ikke tallene. Ny migrasjon
`021_round_participant_rating_snapshot.sql` (tre nye nullable kolonner)
skrevet, scratch-verifisert sammen med resten, og rullet ut.
**Scratch-verifisert grundig, 26 sjekker** (isolert scratch-rolle+MinIO+
engangs API-container): full livssyklus for en custom-bane-runde
(course_handicap_snapshot regnet riktig — Index 15/Slope 128/Rating
71.5/Par 72 → 16, verifisert for hånd), en gjest UTEN HCP (ingen
snapshot/differensial, teller aldri), 18/18 spilt → tellende med korrekt
differensial (16.3, verifisert for hånd), 9 av 18 spilt ved 18-hulls-
intensjon → IKKE tellende, 9 av 9 spilt ved 9-hulls-intensjon → TELLENDE
(Net Par fyller resten av de 18, se Beslutning G punkt 2), full
autorisasjons-isolasjon (en fremmed bruker avvist 403 fra både lesing og
hull-oppdatering, egen runde-liste tom), kan ikke fjerne eieren, slett-
runde-kaskade, ufullstendig profil avvist fra å opprette runde. **Egen,
separat verifisering av teeoff-LIVE-oppslaget** (Beslutning C) mot den
ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
anlegg som ADR-019s opprinnelige verifisering) — bekreftet at INGEN
`course`/`hole`/`tee`-rad skrives noe sted, kun et navn-snapshot
("Borregaard Golfklubb – Hovedbanen") og et rating-snapshot; en andre
deltaker lagt til samme runde utløste et FERSK, uavhengig live-oppslag
mot teeoff (ikke gjenbruk av cachet data). `test_isolation.sql` fortsatt
12/12.
**Rullet ut mot ekte systemer 2026-07-22:** migrasjon 021 kjørt mot ekte
`teecup_db` (kolonner bekreftet, `test_isolation.sql` fortsatt 12/12),
`docker compose up -d --build teecup_api` (kun backend — ingen frontend-
skjerm bygget for dette ennå), boot-et rent, `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket. `frontend/next.config.mjs` sin `rewrites()` fikk
`/rounds/*` og `/personal-courses/*` lagt til proaktivt (samme lærdom som
ADR-016/medlemsside-hendelsen — enhver ny API-prefiks MÅ inn her FØR en
frontend-side bygges) — denne ENDRINGEN ligger IKKE deployet ennå (ingen
frontend-kode bruker den), tas med i neste frontend-runde.
**Frontend BYGGET OG RULLET UT 2026-07-23** — fjerde og siste lag
(engine → skjema → API → frontend). Tre nødvendige tillegg til
`app/routers/rounds.py` funnet og bygget UNDER frontend-designet, ikke
antatt på forhånd: `GET /rounds/official-search`/`{slug}` (samme
teeoff-søkemønster som `courses.py`, men uten org-kontekst — frittstående
runder har ingen), `GET /personal-courses/{id}` (detalj med
utslag+kjønn — søk-endepunktet returnerte kun navn), og
`GET .../participants/{id}/holes` (et reelt hull: `RoundOut` bar aldri
hull-nivå-data, så ingen skjerm kunne vise gjeldende tilstand ved
gjenlasting). Hull-PATCH endret til å returnere hele den oppdaterte raden
i stedet for `{"ok": true}`.
**Reelt kontraktsfunn, bekreftet i scratch FØR frontend stolte på det:**
hull-PATCH-endepunktet er IKKE et ekte delvis-PATCH — det skriver ALLE
felt ved hvert kall (arvet fra hvordan `HoleUpdate`-modellen alltid har
defaultverdier for utelatte felt). Et PATCH som kun sender `score` ville
derfor stille NULLSTILT `putts` og alle andre allerede lagrede felt.
Løst ved at `round-detail.tsx` alltid slår sammen med gjeldende
hull-data før hver PATCH, aldri sender et isolert feltnavn alene —
verifisert eksplisitt i en egen scratch-test som FØRST beviste
nullstillings-oppførselen uten merge, DERETTER beviste at
merge-mønsteret unngår den.
**Nye sider:** `/rounds` (liste over egne runder), `/rounds/new`
(bane-kilde teeoff vs. egen — for egen bane: søk-før-opprett, samme idé
som org-banenes gjenbrukbare katalog; utslag filtrert til kun de som har
rating for brukerens registrerte kjønn, ADR-029s automatikk-prinsipp
gjenbrukt her selv om HCP-motoren er en helt annen), `/rounds/[id]`
(deltaker-faner — eier + gjester, ingen ekte kontokobling i v1 per
Beslutning D; hull-navigasjon fra runde-ens starthull; slag/putt-
tallvelgere i samme visuelle stil som `session-scorecard.tsx` sin
`StrokePicker`; kølle/retning/innspill/chip/bunker/straffeslag/
putt-avstand bak en «flere detaljer»-utvidelse; GIR utledet og vist
KLIENTSIDE ved lesing, aldri lagret — nøyaktig Beslutning B sitt prinsipp;
fullfør-runde med HCP-differensial-sammendrag, låser videre redigering).
Lenket fra dashbordet som «Egne runder» (header-lenke + en egen kort på
forsiden) — bevisst adskilt navn fra det eksisterende «Mine runder»
(ADR-031, turnering-deltakelse) for å unngå at de to konseptene blandes
sammen i UI-et, selv om begge bokstavelig talt handler om "runder".
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container, samme mønster som resten
av ADR-033): 22 automatiserte sjekker (egendefinert-bane-opprettelse med
to utslag/to kjønn, søk, detalj, PATCH-kontraktsbeviset over,
GIR-derivering for et konstruert par4/score3/putt1-tilfelle, gjest-
fjerning, kryss-bruker-autorisasjon 403, full fullføring med differensial)
PLUSS en separat, egen test av HELE teeoff-baserte opprettelsesløpet mot
den ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
anlegg som tidligere ADR-019/033-verifiseringer) som bekreftet
`course_handicap_snapshot` ble beregnet riktig fra live-hentet
rating. Ekte typesjekket PRODUKSJONSBUILD kjørt via
`docker build --target builder` (nøyaktig samme steg `Dockerfile` bruker
i prod, ikke `next dev`) — kompilerte rent, alle nye ruter listet.
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: ingen
migrasjon i denne del-runden, `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard`/
`/rounds` → 200 over ekte https, `teeoff.no` upåvirket.
**Bevisst utenfor omfang, ikke bygget denne runden:** ekte kontokobling
for gjeste-deltakere (Beslutning D), automatisk oppdatering av
`app_user.handicap_index`/`handicap_history` ved fullført tellende runde
(åpent spørsmål i Beslutning G, fortsatt ubesvart), shotgun-start
(egen, separat ADR-034), GPS/avstandsmåling (se eget notat i
FEATURE_BACKLOG.md, krever data ingen kilde har i dag).

**Frontend ERSTATTET med V0-designet versjon 2026-07-23, samme dag som
den hånd-bygde frontend-en over ble rullet ut:** brukeren påpekte
(berettiget) at de tre nye skjermene var hånd-kodet av meg direkte i
stedet for designet i V0 -- et avvik fra prosjektets etablerte mønster
gjennom HELE resten av appen. Bekreftet med bruker at arbeidsmåten
fortsatt skal være: JEG skriver V0-prompten, BRUKEREN kjører den i
v0.app og sender koden tilbake, JEG integrerer (samme flyt som alltid,
ikke endret). Skrev tre detaljerte prompter (liste/opprett/hull-
registrering, inkl. eksplisitt tilgjengelighetskrav i hver) -- bruker
lastet opp tre zip-eksporter (`tee-cup-login-screen (10/11/12).zip`).
**Samme "full re-eksport hver gang"-mønster som ALLE tidligere V0-runder:**
diffet mot levende tre FØR noe ble tatt inn -- kun fire filer var reelt
nye/relevante (`round-card.tsx`, `own-rounds.tsx`, `new-round.tsx`,
`round-detail.tsx` + tre `app/rounds*/page.tsx`-ruter), resten
(login-form, dashboard, config, ui/*) var forventede full-reverts og ble
IKKE tatt inn. **Samme kjente V0-feil dukket opp igjen, hoppet bevisst
over:** `app/clubs/[id]/page.tsx` med feilnavngitt `[id]`-parameter
(egentlig en slug) -- identisk feil som ble rettet i klubbside-runden
under ADR-018, V0 gjenskaper den tydeligvis når prosjektet re-genereres.
**Mine tre hånd-bygde komponenter (`personal-rounds.tsx`, `round-new.tsx`,
og min opprinnelige `round-detail.tsx`) er ERSTATTET, ikke supplert** --
V0s presentasjon beholdt, datalaget skrevet om fra mock til ekte fetch
(samme "V0 leverte mock, jeg kabler ekte data"-mønster som enhver annen
skjerm i appen). Reelle tilpasninger utover ren om-kabling:
1. V0s teeoff-søkemodell antok hele bane+utslag-lista lå ferdig i
   søkeresultatet -- det ekte API-et er et to-stegs oppslag (søk →
   facility-detalj). Løst med en `resolving`-tilstand i
   `OfficialSearchStep` (eneste strukturelle tillegg til V0s JSX).
2. La til et tredje kjønnsvalg "Annet" i gjeste-skjemaets `ChoiceRow`
   (V0 hadde kun Mann/Kvinne) -- matcher appens ellers etablerte
   Herre/Dame/Annet-konvensjon (`account-settings.tsx` m.fl.), og
   API-ets `GuestParticipantCreate.gender` støtter allerede `x`.
3. Beholdt merge-før-PATCH-sikringen fra forrige rundes kontraktsfunn
   (hull-PATCH skriver alle felt, ikke bare det endrede) -- V0 kjente
   naturligvis ikke til denne kontraktsdetaljen, lagt inn i
   `updateStat()` uendret i prinsipp fra min opprinnelige versjon.
4. Fjernet V0s dev-only forhåndsvisningskontroller (`ReviewToggle` i
   `own-rounds.tsx`, den nederste "Forhåndsvis: Pågår/Fullført"-linjen i
   `round-detail.tsx`) -- samme opprydningsmønster som ALLE tidligere
   V0-runder i prosjektet.
Ingen backend-endring i denne del-runden (samme API-kontrakt som allerede
var scratch-verifisert). **Verifisert:** ekte typesjekket
produksjonsbuild (`docker build --target builder`) kompilerte rent, alle
ruter listet. Ingen ny interaktiv nettleser-test utført (intet slikt
verktøy tilgjengelig i denne økten) -- kun kodegjennomgang + typesjekk,
flagget eksplisitt til bruker.
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: `docker
compose up -d --build teecup_frontend` (gjenskapte også `teecup_api` som
vanlig compose-bivirkning, ingen backend-kode rørt), begge containere
boot-et rent, `/health`/`/dashboard`/`/rounds`/`/rounds/new` → 200 over
ekte https, `teeoff.no` upåvirket. De tre V0-zip-ene slettet fra
prosjektroten etter fullført integrering.

**Reell produksjonsbug funnet OG FIKSET 2026-07-23, rapportert av bruker
med to skjermbilder (`/rounds` ga "Klarte ikke å hente rundene dine",
`/rounds/new` sitt siste steg ga "Klarte ikke å opprette runden"):**
rot-årsak var en EKSAKT navnekollisjon mellom frontend-sidens toppnivå-
prefiks (`/rounds`) og backend-APIets ressursprefiks (samme `/rounds`,
`app/routers/rounds.py`) -- en verre variant av den allerede dokumenterte
"afterFiles"-fellen fra ADR-016s medlemsside-hendelse, denne gangen
rammende BEGGE retninger samtidig:
- `/rounds` (eksakt sti): en STATISK frontend-side. Statiske sider
  sjekkes FØR rewrites, så siden vant presedens -- klientens
  `fetch("/rounds")`/`POST /rounds` traff ALDRI backend, fikk Next sin
  egen HTML tilbake i stedet for JSON (stille `res.json()`-parsefeil,
  fanget av try/catch, viste den generiske feilteksten).
- `/rounds/[id]` (DYNAMISK side): her sjekkes rewrites FØR dynamiske
  sider, så rewrite-regelen vant presedens i stedet -- selve
  rundedetalj-SIDEN var dermed fullstendig UOPPNÅELIG (ville vist rå
  backend-JSON i stedet for UI-et), bekreftet direkte med `curl` FØR
  fiksen (anonymt `GET /rounds/00000000-...` ga ekte backend-JSON i
  stedet for Next sin HTML).
Det andre skjermbildets utslagsnavn "55/50/44/32" ble UNDERSØKT OG
BEKREFTET Å IKKE VÆRE EN BUG -- lest direkte fra ekte `teeoff_api` (`GET
tjome-golfklubb`): Tjøme Golfklubb sine faktiske utslagsnavn i teeoff ER
bokstavelig talt disse tallene (lengde i hundremeter, ikke fargenavn) --
data gjengitt korrekt, ingen kode-endring nødvendig for dette punktet.
**Fikset ved samme prinsipp som medlemsside-hendelsen: flytt siden, ikke
APIet.** Alle tre frontend-rutene flyttet til et helt nytt, ikke-
overlappende toppnivå-prefiks `/my-rounds/*` (`app/rounds/` →
`app/my-rounds/`), API-et (`/rounds/*`) uendret. Åtte interne
navigasjonsreferanser oppdatert på tvers av `round-card.tsx`/
`own-rounds.tsx`/`new-round.tsx`/`round-detail.tsx`/`dashboard.tsx` --
ekte `fetch()`-kall til API-et (samme filer) bevisst latt urørt, kun
`<Link href>`/`router.push`/`router.replace` endret. Utvidet
`next.config.mjs` sin allerede eksisterende advarselskommentar med denne
nye, verre varianten av samme fellesklasse, som en fremtidig påminnelse:
et rewrite-prefiks og en frontend-sides toppnivå-segment må ALDRI være
identisk streng.
**Verifisert presist FØR og ETTER utrulling** (ikke bare "bygget uten
feil"): et `curl` mot ekte produksjon FØR fiksen bekreftet nøyaktig
mekanismen i begge retninger (se over). Ekte typesjekket
produksjonsbuild etterpå viste selv at rutetreet nå lister `/my-rounds`,
`/my-rounds/[id]`, `/my-rounds/new` i stedet for de gamle `/rounds`-
rutene. **Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: kun
`teecup_frontend` (gjenskapte `teecup_api` som vanlig bivirkning, ingen
backend-kode rørt). Verifisert ETTERPÅ med et nytt sett `curl`-kall mot
ekte https: anonymt `GET /rounds` ga nå korrekt backend-JSON
(`NOT_AUTHENTICATED`, IKKE Next sin HTML som før), anonymt `GET
/rounds/<uuid>` fortsatt korrekt backend-JSON (uendret, som forventet),
og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/<uuid>` ga nå
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.

**Nok en runde brukerrapporterte punkter, ALLE BYGGET OG LIVE 2026-07-24,
samme dag:** (1) Utslagstidspunkt (`round.started_at`, migrasjon
`023_round_start_time.sql`, valgfritt) + tidsbruk beregnet klientside
som `completed_at − started_at` når runden fullføres. Bekreftet
eksplisitt med bruker: "Ferdig"-tidspunktet er den ALLEREDE eksisterende
"Fullfør runde"-knappen, ingen ny handling/kolonne. (2) "Idx" i hull-
overskriften byttet til "Hcp". (3) Slag-tastaturets numpad merker nå
knappen som tilsvarer hullets par med en liten "par"-bildetekst.
(4) Hull-navigasjonens "Forrige"/"Neste" respekterte tidligere ALLTID
18 hull uansett `holes_planned` -- en 9-hulls runde 10-18 hoppet feilaktig
til hull 9 ved "Forrige" fra hull 10. Fikset: navigasjonsrekkefølgen
bygges nå med `holes_planned` som lengde, ikke hardkodet 18.
(5) Putter/Chip/Bunker/Straffeslag/Anywayslag kan nå aldri velges høyere
enn antall registrerte slag på hullet (`NumberPicker` fikk en
`maxValue`-prop, `Stepper` en `max`-prop) -- ingen vits i å tilby et
selvmotsigende tall. (6) "Slett runde" bygget i UI-et (backendens
`DELETE /rounds/{id}` fantes fra før, men hadde aldri fått en
frontend-knapp) -- bekreftelsesdialog, sletter for alle (kaskade
fjerner automatisk alle deltakere/hull, guest-spillere har uansett ingen
egen konto å bevare noe for).
(7) **Ny `PATCH /rounds/{id}`** -- retter opp feil bane/utslag eller
feil antall hull ETTER opprettelse, uten å røre allerede registrerte
slag/putter/etc. Speiler samme filosofi som turnering-øktenes
`_remap_course` (ADR-tidligere runde), men enklere: ingen tee-navn-
matching på tvers av kjønn siden hver deltaker valideres eksplisitt mot
den NYE banens rating for sitt eget kjønn FØR noe skrives (hele byttet
avvises 400 hvis ÉN deltaker ville mistet HCP-sporing). Bevisst
AVVIST (409) etter at runden er fullført -- ulikt turnering-øktenes
bane-bytte, som bevisst tillater dette selv etter avgjørelse; her er
omfanget mindre (ingen re-beregning av differensial bygget for dette
tilfellet). Frontend: ny "Rediger runde"-seksjon på rundesiden (antall
hull som enkelt 9/18-valg, "Bytt bane" som en kompakt søke-flyt --
teeoff-søk ELLER egen-bane-søk, samme mønster som ved opprettelse, bare
kondensert). Etter et vellykket bytte hentes hull-data på nytt for
ALLE allerede lastede deltakere (par/stroke-index kan ha endret seg for
alle, ikke bare aktiv spiller).
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container): 22 sjekker for
rediger/slett-rundene (bane-bytte med FAKTISK ulike par/stroke-index-
verdier mellom to egendefinerte baner, bekreftet at allerede registrerte
slag på hull 1-3 var UENDRET etter byttet mens par/stroke-index OG
course_handicap_snapshot var oppdatert; avvist bytte til en bane uten
rating for deltakerens kjønn, bekreftet at INGENTING ble endret ved
avvisning; avvist bane/hull-endring etter fullføring, 409; slett-runde
+ idempotent 404 + kryss-bruker-autorisasjon 403) pluss 8 sjekker for
utslagstid/tidsbruk. `test_isolation.sql` 12/12. Ekte typesjekket
produksjonsbuild kompilerte rent begge ganger.
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
migrasjon 023 kjørt mot ekte `teecup_db` (kolonne bekreftet,
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
--build teecup_api teecup_frontend`, begge containere boot-et rent,
`/health`/`/dashboard`/`/my-rounds` → 200 over ekte https, `teeoff.no`
upåvirket.

**To til brukerpunkter, ALLE BYGGET OG LIVE 2026-07-25, samme dag:**
(1) `PATCH /rounds/{id}` utvidet med `start_hole`/`started_at`/
`completed_at` -- start_hole kan rettes uansett (ren metadata, aldri
sperret av fullført-status), started_at kan justeres når som helst,
completed_at kan KUN justeres på en runde som allerede ER fullført
(avvist 400 ellers -- denne PATCH-en fullfører aldri runden selv, kun
korrigerer et allerede satt tidspunkt fra "Fullfør runde"). Ny
validering: completed_at må være etter started_at, ellers 400. Løser
brukerens konkrete case: glemmer å trykke "Fullfør runde" i flere timer,
vil rette opp tidsbruken i etterkant. `EditRoundPanel` sin "Bane/antall
hull"-blokk forblir sperret post-fullføring (uendret fra forrige runde),
mens et nytt tidspunkt-skjema (Utslagstid alltid, Fullført-tidspunkt kun
hvis fullført) nå vises uansett fullført-status.
(2) **Nærmeste offisielle baner** i "Ny runde"-flyten -- nytt
`GET /rounds/official-search/nearby?lat=&lng=&limit=` (Haversine-formel,
sortert stigende, MÅ registreres FØR `/rounds/official-search/{slug}` i
routeren, samme presedens-lærdom som ADR-020s "by-code"). Henter ALLE
174 teeoff-anleggenes lat/lng via samme `search_facilities("")`-kall som
allerede finnes (ingen ny teeoff-avhengighet), beregner avstand i Python
per forespørsel (ingen cache -- datasettet er lite nok). Frontend: ny
`NearbyClubs`-komponent i `OfficialSearchStep`, ber om
`navigator.geolocation` ved mount, viser inntil 5 nærmeste med
nærmeste tydelig merket + avstand (m under 1 km, ellers km) -- rett
FØR søkefeltet, som bedt om. Avslått/manglende posisjon feiler helt
stille (ingen feilmelding), søket fungerer uendret som fallback.
**Scratch-verifisert:** 14 sjekker (start_hole-endring, tidspunkt-
korreksjon i begge retninger inkl. de to nye valideringsreglene,
`holes_planned` fortsatt sperret post-fullføring uendret, OG et ekte
`nearby`-kall mot den kjørende `teeoff_api`-containeren fra Tjømes egne
koordinater som korrekt fant Tjøme selv som nærmeste/nest-nærmeste
treff). `test_isolation.sql` 12/12 (ingen skjemaendring). Ekte
typesjekket produksjonsbuild kompilerte rent.
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
→ 200, `teeoff.no` upåvirket.

**Reell UX-bug funnet OG fikset, samme dag 2026-07-25, rapportert av
bruker med skjermbilder av en konkurrerende golf-app (Golf Game Book)
som referanse:** brukeren rapporterte at "All statistikk" ikke viste
noe utover slag/putter under selve registreringen. **Bekreftet direkte
mot ekte `teecup_db` (kun lesing) at dette IKKE var en datafeil** --
brukerens faktiske runde hadde `stat_level='full'` lagret korrekt. Rot-
årsaken var et REELT presentasjonsproblem: alle detaljfeltene (kølle,
utslag, innspill, chip, bunker, straffeslag, putt-avstand, anywayslag)
lå bak en "Score"/"Statistikk"-fane (V0-runden 2026-07-24s bevisste
designvalg) som brukeren aldri oppdaget -- "aktivert full statistikk"
ga i praksis ingen synlig endring uten et ekstra, ikke-annonsert
tastetrykk. **Fikset ved å fjerne fane-løsningen helt:** alle feltene
vises nå ALLTID samlet under slag/putter i én sammenhengende scroll når
`statLevel==="full"` (samme prinsipp brukeren opprinnelig ba om FØR
V0-rundens sveip/fane-vurdering) -- fjerner enhver tvetydighet om
hvorvidt statistikken faktisk er slått på. `PanelTabs`-komponenten og
`panelTab`-state fjernet som død kode.
**Samtidig bygget: "Så langt i runden"-oversikt**, direkte etterspurt
("jeg trenger et grensesnitt som viser scoren min så langt") med
referansebildene som inspirasjon for FUNKSJONEN (ikke kopiert
utseendemessig). Ny komponent `ScoreSoFar` i `round-detail.tsx`: en
kompakt alltid-synlig linje ("Så langt: X hull · Y slag · +Z til par")
rett under spillerfanene, pluss en "Vis full oversikt"-knapp (samme
etablerte mønster som `session-scorecard.tsx` sin `HoleSummaryTable`
for turnering-scoring) som åpner en tabell: hull, par, score, netto,
løpende sum. **Ny backend-beregning for å muliggjøre netto-kolonnen:**
`RoundHoleOut` fikk et nytt felt `strokes_received` (`list_holes` i
`app/routers/rounds.py`) -- utledet fra deltakerens
`course_handicap_snapshot` + hullenes `stroke_index` via den
ALLEREDE eksisterende `allocate_strokes_by_index()` fra
`handicap_engine.py` (samme allokeringsalgoritme som brukes overalt
ellers i appen, ingen ny logikk) -- `None` for en deltaker uten
beregnet HCP (f.eks. en gjest uten oppgitt handicap), aldri lagret,
kun beregnet ved lesing.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container): 11 sjekker, inkl. et
presist talleksempel (course rating 72.0/slope 113/HCP 10.0 → course
handicap nøyaktig 10, bekreftet at de 10 laveste stroke-indeksene fikk
nøyaktig 1 slag hver og de resterende 8 fikk 0, sum(strokes_received)
== course_handicap), et registrert hull som ga korrekt netto (score 5 −
1 mottatt slag = 4), og en gjest UTEN HCP som korrekt fikk
`strokes_received: null` på alle 18 hull. `test_isolation.sql` 12/12
(ingen skjemaendring). Ekte typesjekket produksjonsbuild kompilerte
rent.
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
migrasjon, kun `teecup_api`+`teecup_frontend` redeployet.
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.

**Reell produksjonsregresjon rapportert AV BRUKEREN samme dag, rett
etter utrulling over, funnet og fikset umiddelbart:** "Ingenting er
klikkbart i den avanserte statistikken." Første antagelse (frontend-
CSS/event-håndtering) ble IKKE bekreftet ved kodegjennomgang -- all
onClick-kabling i `DirectionCross`/`Stepper`/`ChoiceRow`/`NumberPicker`
var korrekt. Root cause funnet ved å faktisk GJENSKAPE brukerens
klikk-sekvens mot en fersk, isolert scratch-container (samme mønster
som resten av runden) i stedet for å gjette videre: `update_hole`
(PATCH-endepunktet for hull-registrering) konstruerte fortsatt
`RoundHoleOut(**dict(row))` UTEN det nye påkrevde
`strokes_received`-feltet fra runden rett over -- en Pydantic
`ValidationError` (500) på HVER ENESTE hull-lagring, ikke bare de
"avanserte" feltene. Frontend sin `updateStat()` svelger `!res.ok`
stille uten feilmelding, så symptomet fremsto nøyaktig som "ingenting
skjer når jeg trykker" for ALLE felt (Slag/Putter inkludert) -- brukeren
merket det trolig først på de avanserte feltene siden Slag/Putter fra
TIDLIGERE runder allerede hadde lagrede verdier som så riktige ut ved
åpning.
**Fikset:** `update_hole` beregner nå `strokes_received` for akkurat det
oppdaterte hullet (henter deltakerens `course_handicap_snapshot` +
ALLE 18 sine `stroke_index` -- samme allokeringsalgoritme som
`list_holes`, siden fordelingen avhenger av hele rundens
stroke-indeks-rekkefølge, ikke bare ett hull) og sender den med i
responsen.
**Scratch-verifisert på nytt, presist mot akkurat denne regresjonen:**
14 sjekker som gjenskaper brukerens EKSAKTE klikk-rekkefølge (Slag →
kølle → Utslag-retning → Innspill-retning → Chip → første putt-bøtte →
Anywayslag, pluss et klikk på et avansert felt FØR Slag i det hele tatt
er satt, og en gjest uten HCP) -- alle 200 med riktig ekko, `GET` etterpå
bekrefter faktisk lagring, gjest uten HCP gir korrekt `strokes_received:
null` uten å krasje. `test_isolation.sql` uendret (ingen skjemaendring,
ren Python-fiks). **Rullet ut live 2026-07-25**, bruker bekreftet
eksplisitt: kun `teecup_api` redeployet, `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket.

**Rediger/Fullfør/Slett tonet ned og flyttet til toppen + auto-scroll ved
hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at
"Fullfør runde"/"Slett runde" lå som store, fremtredende knapper RETT
under "Neste hull"-navigasjonen nederst i hull-panelet -- altfor lett å
trykke feil ved et uhell mens man bare skulle bla mellom hull. Flyttet
alle tre (Rediger/Fullfør/Slett) til en samlet rad ØVERST på siden,
tonet ned til samme nøytrale "trigger"-stil (ikke lenger store,
fargede knapper) -- brukeren må nå aktivt scrolle OPP forbi hele
hull-registreringen for å nå dem. Samtidig rapportert, i samme runde:
"Neste hull"/"Forrige" lastet nytt innhold, men brukeren ble stående
scrollet nede der knappene er, med det nye hullets Slag-felt utenfor
skjermen. Løst med `holePanelRef` + `scrollIntoView({block:"start"})`
kalt fra `goPrev`/`goNext` OG fra hull-navigasjonens direkte hull-valg,
med `scroll-mt-28` på panelet for å unngå at den sticky headeren dekker
toppen. Ren frontend-endring, ingen backend/migrasjon. Ekte typesjekket
build kjørt og bekreftet (alle 19 ruter), rullet ut, `teeoff.no`
upåvirket.

**"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle-
statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme
dag (2026-07-25):** brukeren etterspurte flere detaljer i
rundeoversikten: netto- og stableford-sum, hvor man kan se totalt antall
putter/chip/bunker/straffeslag/anywayslag for runden, grafisk fremstilt
prosentvis fordeling av fairwaytreff og innspillstreff, og gjennomsnittlig
score til par (to desimaler) splittet på fairwaytreff-vs-bom og
innspill-treff-vs-bom.
**Ingen backend-endring nødvendig** -- alt beregnes klientside fra data
`GET .../holes` allerede returnerer (score/par/strokes_received/putts/
chip_count/bunker_shot_count/penalty_strokes/tee_shot_result/
approach_result/anyway_strokes). `ScoreSoFar`-komponenten
(round-detail.tsx) utvidet med: en `StatPill`-rutenett (Slag/Til par/
Netto/Stableford/Putt/Chip/Bunker/Straffeslag/Anywayslag -- hver vist
kun hvis det faktisk finnes registrert data for akkurat det feltet), en
ny Stableford-kolonne i hull-for-hull-tabellen (kun når netto er
beregnbart), og to nye `DistributionBar`-seksjoner (Fairwaytreff/
Innspill) -- generalisert N-kategori-variant av samme visuelle idé som
`SegmentedBar` i tournament-leaderboard.tsx (ett fargesegmentert
rektangel + prosent-legend), ikke noe chart-bibliotek lagt til.
**Presisert i kode-kommentar, bevisst valg:** appen har ingen egen
"spilleform"-innstilling for frittstående runder -- stableford beregnes
derfor alltid ut fra netto score når det er mulig (krever registrert
HCP, samme forutsetning som netto), uavhengig av om brukeren
"egentlig" spiller slagspill eller stableford. Standard stableford-
poengtabell brukt (netto par = 2 poeng, ett poeng mer/mindre per slag
bedre/dårligere enn par, gulv på 0).
Gjennomsnittlig score-til-par (fairway/innspill, treff-vs-bom) bruker
BRUTTO score (ikke netto) relativt til par, formatert med fortegn og to
desimaler (`formatSignedAvg`), matcher brukerens eksplisitte
spesifikasjon. Fairwaytreff ekskluderer par-3-hull (samme regel som
registrerings-skjemaet, som aldri viser Utslag-retning der).
**Logikken verifisert manuelt mot et regnet eksempel** (4 hull, blandet
par 3/4/5, ulike utslag-/innspillresultater) FØR utrulling -- stableford-
sum, netto-sum og begge gjennomsnitts-splittene stemte med
håndregning. Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle
19 ruter). **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt:
ren frontend-endring, ingen migrasjon, `/health`/`/my-rounds` → 200,
`teeoff.no` upåvirket.

**Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video,
BYGGET OG LIVE samme dag (2026-07-25):** brukeren lastet opp en 26
sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og
ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat.
Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker-
container, ikke installert på verten -- ryddet opp etterpå). Innhold
identifisert: score-fordeling (par/bogey/dobbel bogey/verre),
snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut +
per-hulltype + kryss med fairway + score-splitt, bom-retning på green
som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype
+ med/uten GIR, én-putt% etter puttlengde + lengdefordeling hit/miss,
chip-fordeling, scrambling%/sand save%, bunker/straffeslag per runde +
score-splitt.
**Bevisste valg for å unngå plagiat, skrevet inn i selve V0-promptet:**
egen visuell identitet (TeeCups presise grønn/oransje, ikke konkurrentens
fargekoding), fritt valg av chart-type/layout/rekkefølge til V0 selv,
INGEN kopiering av konkurrentens eksakte ordlyd/fargekoding/ikonografi.
Puttlengde-bøttene i promptet er TeeCups EGNE seks bøtter
(<1m/<2m/<3m/<5m/<8m/8m+, samme som ADR-033 allerede lagrer) -- bevisst
ANDRE enn videoens fem bøtter (<1m/1-2/2-4/4-8/+), både fordi det unngår
en direkte kopi og fordi det er det datamodellen faktisk allerede
produserer. "Lengste drive" (krever GPS/avstandsmåling appen ikke har)
utelatt fra promptet.
**Zip 14 mottatt og integrert samme dag:** diffet mot live-treet FØR noe
ble tatt inn (samme rutine som alltid) -- kun to reelt nye filer
(`components/round-stats.tsx`, en ny `RoundStats`-skjerm bygget med
kollapsbare `StatCard`-seksjoner, conic-gradient-donuter med sentertall,
avviks-stolper fra en null-linje, og et kompass-rutenett for bom-retning
-- en tydelig ANNEN visuell løsning enn konkurrentens skjermbilder, ikke
en klone). Resten av eksporten var V0s vanlige uvitende reverts, inkl.
sin egen `/rounds/[id]`-ruteversjon fra FØR `/my-rounds`-omdøpingen
(se lenger opp, "Reell produksjonsbug...") -- korrekt hoppet over. Ny
rute lagt til som `app/my-rounds/[id]/stats/page.tsx` (ikke
`app/rounds/[id]/stats` som eksporten foreslo), samme
kollisjon-unngåelse. `app/globals.css` sin nye `--chart-1..6`
data-viz-fargeskala (god->dårlig-skala, brukt sammen med
tekst-/tall-etiketter) slått sammen inn i alle tre temablokkene;
V0s egne, mer omtrentlige `--primary`/`--ring`/`--brand-orange`-verdier
i SAMME diff ble bevisst IKKE tatt inn -- beholdt de presise OKLCH-
verdiene utregnet i ADR-016.
**Datalag skrevet fullstendig om fra mock:** ny `computeStats()`-
funksjon i `round-stats.tsx` regner ut alt fra rå `GET .../holes`-data
(samme endepunkt round-detail.tsx allerede bruker -- ingen ny backend).
Hver seksjon skjules helt når det ikke finnes nok data for den (samme
"vis kun det som faktisk finnes"-prinsipp som `ScoreSoFar`). Ny enkel
spillervelger (pill-rad) lagt til når runden har flere deltakere,
default til eieren. `CompletedBanner` i round-detail.tsx fikk en "Se
full rundestatistikk"-lenke (fantes i V0s egen, ellers reverterte fil --
portert manuelt inn i vår live versjon i stedet for å ta hele filen).
**Matematikken verifisert FØR utrulling, ikke bare "kompilerer":**
`computeStats()`-logikken portert til et frittstående Node-script og
kjørt mot et hånd-etterregnet 6-hulls syntetisk datasett (blandet par
3/4/5, fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle utledede
tall (score-kategorier, snitt-til-par per hulltype, fairway-splitt,
GIR%/kryss-fairway/score-splitt, bom-retning, putt-fordeling,
scrambling%, sand save%) stemte med manuell utregning. Ekte typesjekket
produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet som
ny rute).
**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon,
`/health`/`/my-rounds` → 200, `teeoff.no` upåvirket.

**Rundeliste-info + scorekort-redesign, BYGGET OG LIVE samme dag
(2026-07-25):** brukeren delte en ny skjermopptaksvideo (video 2, 18
frames analysert ved 1,5 fps) av to problemer: "Egne runder"-listen
manglet ALL score-informasjon (kun tee/hull/dato/spillerantall/status),
og selve scorekort-registreringen (rapportert "veldig dårlig
designet") stablet fem ulike kontrolltyper (numpad, retningskors,
steppere, pill-rutenett, pill-rad) uten hierarki, pluss at "Runde
fullført"-siden gjentok NØYAKTIG samme tall tre ganger (CompletedBanner-
differensial, "Så langt"-oppsummeringslinjen, og StatPill-rutenettet
under). Bedt om å reflektere kort og eventuelt skrive V0-prompt(er).
**Reflektert og handlet i samme runde** (ikke bare skrevet ned): pekte ut
at score/til-par mangler helt fra listekortet som det klareste,
konkrete hullet; anbefalte å samle alt utover Slag/Putter bak én
kollapsbar "Flere detaljer"-seksjon som den viktigste enkeltfiksen for
scorekort-rotet; og fikset duplikat-problemet PÅ "Runde fullført"-siden
DIREKTE (ikke via V0) siden det bare er snakk om å SKJULE innhold, ikke
designe noe nytt: `ScoreSoFar` skjules nå helt når runden er fullført
(`{!completed && <ScoreSoFar .../>}`) -- CompletedBanner sin "Se full
rundestatistikk"-lenke dekker akkurat det samme, langt grundigere.
**Ny backend-beregning FØR V0-prompten** (slik at prompten kunne
referere til ekte, tilgjengelig data): `_load_round_out`
(`app/routers/rounds.py`) kjører nå en liten aggregatspørring mot
`round_hole` for eierens egen deltaker-rad ved hver lasting --
`owner_holes_played`/`owner_total_score`/`owner_score_to_par` (null
til minst ett hull er registrert). Verifisert med 10 scratch-sjekker,
inkl. at en gjests egen score IKKE lekker inn i eierens aggregat.
**To V0-prompter skrevet** (round-card-redesign med prominent
score-flis/hull-fremdrift-bar/differensial; scorekort-redesign med
Slag+Putter+Avstand-første-putt alltid synlig og alt annet bak "Flere
detaljer", pluss en bevisst ikke-bygget reservasjon av layout-plass for
en fremtidig avstandsmåling-funksjon brukeren bekreftet er planlagt).
**Zip 15 og 16 mottatt samme dag** (samme v0.app-prosjekt, kontinuerlig
-- `round-card.tsx`/`own-rounds.tsx` byte-for-byte identiske i begge
zip-ene; zip 16s `round-detail.tsx` var den nyeste med selve
kollaps-redesignet, zip 15s tilsvarende fil manglet det og ble derfor
forkastet til fordel for zip 16).
`round-card.tsx`: ny `ScoreTile` -- prominent resultat+til-par-flis
(fargekodet under/over par uten å stole på farge alene, tekst+tall
alltid med) for fullførte/scorede runder, en hull-fremdrift-progressbar
("6/18 hull spilt") for runder som fortsatt pågår, pluss en HCP-
differensial-chip i metadata-raden når runden faktisk telte. Fullt
`aria-label` på hele kortet for skjermlesere. `own-rounds.tsx` sitt
datalag skrevet om fra mock til ekte fetch, kobler de nye `owner_*`-
feltene fra backend + eierens `score_differential` (kun vist når
`counts_for_handicap`).
`round-detail.tsx`: ny kollapsbar "Flere detaljer"-seksjon (lukket som
default, `ChevronDown`-rotasjon på toggle) som nå rommer kølle/utslag-
retning/innspill-retning/chip-bunker-straffeslag/anywayslag -- Slag,
Putter og Avstand første putt forblir alltid synlig over kollapsen,
uendret rekkefølge fra den forrige runden. **Viktig integrasjonsdisiplin:**
V0s eksport hadde reversert til en MYE eldre mock-baseline (manglet
StatLevel-gating, Anywayslag, bøtte-basert puttlengde, kølle-bag fra
profil, maxValue-capping, "Hullet er spilt"-avkrysningen som bevisst BLE
FJERNET en tidligere runde, "Tid brukt" i CompletedBanner) -- kun selve
kollaps-mekanismen og plasseringen av "Flere detaljer" ble hentet ut og
lagt oppå den fullt oppdaterte, LIVE koden. Ingen av de tidligere
byggede funksjonene gikk tapt. Lagt til en kort kode-kommentar (ikke
noe bygget UI) som reserverer plass ved siden av hull-headeren til en
fremtidig avstandsmåling-indikator.
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet
(alle 19 ruter). Ingen ny scratch-backend-runde nødvendig utover
owner-score-testen over (ingen ny domenelogikk i selve rundeliste-/
scorekort-visningen, kun lesing av allerede-testede felt).
**Rullet ut live 2026-07-25**, ren frontend-endring + den lille
backend-tilføyelsen over, ingen migrasjon, `/health`/`/my-rounds` →
200, `teeoff.no` upåvirket.

**Manglende scorekort på statistikk-siden, funnet og fikset SAMME dag
(2026-07-25):** brukeren spurte rett etter forrige runde: "Hvor er
scorekortet? (Gjerne også med statistikk?)" -- et ekte, uforutsett hull
i forrige rundes endring. Da `ScoreSoFar` ("Så langt i runden") ble
skjult for fullførte runder (for å fjerne dobbel informasjon, se over),
forsvant OGSÅ det eneste stedet den rå hull-for-hull-tabellen (Hull/
Par/Score/Netto/Sum) fantes -- ingen tilsvarende tabell ble noensinne
lagt til på den nye `round-stats.tsx`-siden, som kun inneholdt UTLEDET/
aggregert statistikk (donuter, kategori-stolper, avviks-visualiseringer),
aldri de faktiske rå tallene per hull. Med andre ord: brukeren fikk
riktignok fjernet duplikatet, men mistet samtidig tilgang til selve
scorekortet -- en reell regresjon, ikke bare en presentasjonsdetalj.
**Fikset ved å legge til en ny "Scorekort"-seksjon FØRST på
`round-stats.tsx`** (før "Scorer"-kategoriseksjonen), åpen som default
(i motsetning til resten av seksjonene som starter kollapsbare men
åpne -- denne er det brukeren eksplisitt spurte etter, så den skal ikke
kreve et ekstra trykk). Samme tabellmønster som `ScoreSoFar` sin
tidligere tabell hadde (Hull/Par/Score/Netto/Stableford/Sum, Stableford-
kolonnen vist kun når netto er beregnbart). To type-tilføyelser var
nødvendig: `start_hole: number` lagt til `round-stats.tsx` sin
`ApiRound`-type (manglet fra før, siden ingen tidligere seksjon på
denne siden trengte rundens faktiske start-/rekkefølge) og
`strokes_received: number | null` lagt til `ApiHole`-typen (feltet kom
allerede fra backend -- lagt til i forrige runde for `list_holes` --
men ble aldri lest av denne siden før nå). Ny `holeOrder`/
`orderedHoles`-beregning i `RoundStats` gjenbruker EKSAKT samme
sirkulære start_hole-formel som round-detail.tsx, slik at en 9-hulls
runde som starter på hull 10 vises i riktig spillerekkefølge (10-18),
ikke bare rå hullnummer 1-9.
**Lærdom notert for fremtidige runder:** når et helt panel flyttes/
skjules for å fjerne duplisert informasjon, må hver del av det gamle
panelet spores til et nytt hjem FØR det fjernes -- ikke bare de delene
som åpenbart var "statistikk". Denne runden ble oppdaget kun fordi
brukeren faktisk lette etter scorekortet rett etterpå, ikke ved egen
verifisering før utrulling.
Ekte typesjekket produksjonsbuild kjørt og bekreftet. **Rullet ut live
2026-07-25**, ren frontend-endring, ingen backend/migrasjon,
`teeoff.no` upåvirket.

**Scorekort-visning: ny presentasjonsregel + V0-prompt skrevet, IKKE
bygget ennå (2026-07-25):** brukeren delte et referansebilde av et
tradisjonelt fysisk golf-scorekort (horisontal layout, hull 1-9/10-18
som KOLONNER, Slope/Par/Score/Net som rader, "Ut"/"Inn"-sum-kolonne
YTTERST TIL HØYRE for hver ni-hulls-halvdel) og formulerte en generell
presentasjonsregel: **listes hullene horisontalt (som kolonner), skal
summeringen stå TIL HØYRE; listes hullene vertikalt (som rader), skal
summeringen stå UNDER.** Den nylig byggede "Scorekort"-seksjonen på
`round-stats.tsx` (se punktet rett over) lister hull VERTIKALT (én rad
per hull) med en løpende sum-KOLONNE innimellom hver rad -- ikke i tråd
med regelen (en vertikal liste burde hatt en avsluttende sumrad
UNDERST, ikke en kolonne). Bedt om å tenke gjennom dette og skrive et
V0-prompt for et "fabelaktig" scorekort inspirert av (ikke et plagiat
av) referansebildet.
**Bevisste avvik fra referansebildet for å unngå plagiat, skrevet inn i
selve promptet:** egen visuell identitet (TeeCups grønn/oransje, ikke
bildets rød-sirkel/blå-firkant-fargekoding for birdie/bogey), egen
term ("Hcp" for hullets slag-fordelings-rangering -- IKKE "Slope", som
i golf-terminologi betyr banens/tee-ens helhetlige vanskelighetsgrad,
et helt annet tall enn det bildet faktisk viser per hull), en ekstra
Stableford-rad (finnes ikke i referansen, men vi beregner det allerede
andre steder), og INGEN kopiering av bildets spiller-header-komposisjon
(navn/HCP/posisjon-boksen) -- kun selve tabell-strukturen er
inspirasjonskilden.
**Presist håndtert i promptet, IKKE triviell:** appen støtter allerede
vilkårlig `start_hole` + `holes_planned` (9 ELLER 18, sirkulær
rekkefølge, se "Manglende scorekort"-punktet over) -- en STIV
fysisk "hull 1-9 er alltid Ut" ville vært feil for en 9-hulls runde som
starter på hull 10. Promptet ber derfor om ÉN 9-kolonners blokk når
`holes_planned=9`, TO blokker (første/andre halvdel AV SPILLEREKKEFØLGEN,
ikke nødvendigvis fysisk hull 1-9/10-18) når `holes_planned=18` -- den
faktiske hull-til-blokk-tildelingen løses av meg i datalaget ved
integrering, som med alle tidligere V0-runder.
**Venter på V0-eksport** før noe bygges. Når den kommer, erstatter den
den eksisterende vertikale "Scorekort"-tabellen på `round-stats.tsx`
(bygget rett over samme dag) -- ikke en ny, separat skjerm.
**Notert som en generell prinsipp-lærdom, relevant utover akkurat dette
scorekortet:** samme horisontal-til-høyre/vertikal-til-under-regel bør
vurderes senere for turnering-scorekortet (`session-scorecard.tsx`,
match-play) den dagen det scorekortet også skal pusses -- ikke i
omfang nå, bare notert for konsistens.

**Presisering av promptet samme dag, FØR noe ble sendt til V0:**
brukeren spurte eksplisitt om et "if REALLY necessary"-unntak for
horisontal scroll (opprinnelig formulering) faktisk fanget opp målet
om ALDRI å måtte scrolle. Vurdert og svart nei -- reell breddekonflikt,
ikke bare en formulering-detalj: 9 hull + 1 sum-kolonne (+ en
label-kolonne) får ikke plass på en telefonskjerm med normal
"lesbar uten briller"-tekststørrelse, og et unntak formulert som en
myk fallback ville sannsynligvis latt V0 falle tilbake til scroll uansett,
siden tilgjengelighetskravet rett under ga et påskudd. **Løst ved å
gjøre "ingen scroll" til et HARDT krav i promptet, OG eksplisitt fortelle
V0 hvordan det oppnås** (kompakte, fete, høykontrast-siffer i selve
rutenettet -- samme konvensjon som et fysisk scorekort bruker, også
synlig i brukerens eget referansebilde -- mens "lesbar uten
briller"-kravet eksplisitt avgrenses til labels/knapper/løpetekst, ikke
hvert enkelt rutenett-siffer). Smale forkortede rad-labels ("Hcp",
"Par", "Score", "Netto") i en trang venstre-gutter i stedet for en bred
tekstkolonne, for å frigjøre bredde til de 9 hull-kolonnene.

**Zip 17 mottatt, horisontalt scorekort BYGGET OG LIVE samme dag
(2026-07-25):** V0 leverte akkurat det det reviderte promptet ba om --
en EKTE HTML `<table>` med `<colgroup>` (fast `w-9`-label-kolonne, auto
for hver hull-kolonne, `w-[11%]` sum-kolonne) og `table-fixed`, ingen
scroll-container noe sted. Score-cellene bruker FORM (sirkel = under
par, firkant = over par) + fylt/ufylt (fylt = 2+ slag av) i stedet for
farge alene, pluss en egen liten symbolforklaring under rundesammendraget
-- tilfredsstiller "aldri stole på farge alene" uten å kopiere
referansebildets rød-sirkel/blå-firkant-konvensjon.
**Egen, ny dedikert side** `/my-rounds/[id]/scorecard`
(`components/round-scorecard.tsx`, ny `app/my-rounds/[id]/scorecard/
page.tsx`) -- IKKE slått sammen inn i `round-stats.tsx`, siden V0
designet komponenten med sin egen fulle side-chrome (sticky header,
rundesammendrag-strip), ikke som et innebygd tabell-fragment. Dette gir
en klar arbeidsdeling: `round-scorecard.tsx` = det rå scorekortet,
`round-stats.tsx` = utledet/aggregert statistikk -- samme prinsipp som
allerede etablert for `ScoreSoFar` vs. `round-stats.tsx` tidligere denne
økten.
**Bevisst forkastet fra V0s eksport:** V0s egen `strokesReceived()`-
funksjon var en generisk `Math.floor(hcp/18) + (index <= hcp%18 ? 1 :
0)`-modulo-formel -- byttet ut med backend sin ALLEREDE beregnede
`strokes_received` per hull (samme `allocate_strokes_by_index()` som
resten av appen bruker), for å unngå to parallelle, potensielt
avvikende implementasjoner av HCP-slagfordeling i samme app. Samme
sirkulære `start_hole`-rekkefølge som `round-detail.tsx`/
`round-stats.tsx` bruker for Ut/Inn-blokkene. Ny spillervelger
(pill-rad) lagt til for runder med flere deltakere, samme mønster som
`round-stats.tsx`.
**Opprydning i `round-stats.tsx`:** den midlertidige vertikale
"Scorekort"-tabellen (bygget tidligere samme dag som en rask fiks for
"Hvor er scorekortet?") er FJERNET og erstattet med en enkel, prominent
"Se scorekort"-lenke til den nye siden, rett under rundesammendraget --
`round-stats.tsx` er dermed nå rendyrket aggregert/utledet statistikk,
det rå scorekortet finnes kun ett sted. `stablefordPoints()`-
hjelpefunksjonen og `holeOrder`/`orderedHoles`-beregningen i
`round-stats.tsx`, som kun eksisterte for den fjernede tabellen, fjernet
som dødt kode.
**V0 la selv til en to-knappers layout i `CompletedBanner`**
(round-detail.tsx) -- "Se scorekort" (primær, fylt) ved siden av den
eksisterende "Se full rundestatistikk" (sekundær, omrisset) -- tatt inn
uendret bortsett fra å rette `/rounds/...`-hrefs til `/my-rounds/...`.
**Samtidig, urelatert, rapportert av bruker midt i integreringen:**
Anywayslag manglet helt fra `round-stats.tsx` sin "Chip, bunker og
straffeslag"-seksjon (verken `anyway_strokes` i `ApiHole`-typen eller
noe utledet total fantes). Lagt til `anyway_strokes` i typen, ny
`anywayPerRound`-aggregat i `computeStats()`.
**Feilrettet SAMME dag, rett etterpå:** min første fiks slo sammen
Anywayslag-tallet INN I den eksisterende "Chip, bunker og
straffeslag"-seksjonen og omdøpte HELE seksjonen til "Annet" -- feil,
påpekt av bruker. Rettet: "Chip, bunker og straffeslag" beholder sitt
opprinnelige navn og innhold UENDRET (Bunkerslag/Straffeslag-flisene
tilbake til to, ikke tre). Ny, EGEN "Annet"-seksjon lagt til RETT ETTER
den (ikke slått sammen), med kun Anywayslag-statistikk: total per runde
+ andel hull med minst ett anywayslag (samme "andel hull med..."-mønster
som straffeslag-statistikken). Ny `pctHolesWithAnyway`-beregning i
`computeStats()`. Den midlertidige `StatTileRow`-komponenten (bygget for
den feilaktig sammenslåtte tre-flis-varianten) fjernet igjen som dødt
kode -- `StatTilePair` (fast to fliser) er tilstrekkelig for begge
seksjonene nå. Notert for senere: brukeren ser for seg at et
fritekst-notatfelt havner i "Annet"-seksjonen etter hvert.
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet,
ny rute `/my-rounds/[id]/scorecard` listet. Ingen ny backend-endring
(`anyway_strokes`/`strokes_received` fantes allerede i `RoundHoleOut`).
**Rullet ut live 2026-07-25** (to runder, feilrettingen rullet ut rett
etter originalen), ren frontend-endring, ingen migrasjon, `teeoff.no`
upåvirket.

**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
`handicap_engine.py`: `net_par`, `max_hole_score_for_handicap`,
`adjusted_gross_score`, `round_half_up_decimal`, `score_differential`,
`handicap_index_from_differentials`, `low_handicap_index`,
`apply_index_caps`, `course_handicap_9_raw`/`course_handicap_9`.
**Bevisst avvik fra opprinnelig plan, instruert av bruker 2026-07-22:**
"Expected Score" (Rule 3.2b) sin upubliserte formel erstattes gjennomgående
av WHS sin egen, presist definerte "Net Par"-term (par + mottatte
handicapslag = 2 Stableford-poeng) for uspilte hull — gjelder BÅDE
ufullstendige 18-hulls-runder og konvertering av en 9-hulls-runde til
18-hulls-ekvivalent (Rule 5.1b sin egen separate 9-hulls-differensial-
formel er dermed bevisst IKKE implementert, se punkt 5 over).
**41/41 tester bestått** (`test_handicap_engine.py`, kjørt uten pytest —
ikke installert i miljøet, kun den innebygde selvsjekk-runneren), 17 nye
i tillegg til de 24 eksisterende. Flere verifisert mot regelbokens EGNE
tallregneeksempler, ikke bare intern konsistens: Rule 5.2a sine to
initial-indeks-eksempler (13,2 og 34,1, samt oppfølgingen til 37,4),
Rule 5.1c sine tre avrundingseksempler, Diagram 5.8 sin soft-/hard-cap-
oppførsel, og Diagram 3.1b sin Net-Double-Bogey-capping (der front-9 ble
verifisert eksakt mot diagrammet, mens back-9 sine åtte ikke-annoterte
scorer bevisst ble egenkomponert pga. usikker bilde-lesing av akkurat de
sifrene — se testens egen kommentar for full transparens om hva som er
kildebelagt og hva som ikke er det).
**Gjenstår:** migrasjon (nye tabeller for runde/deltaker/statistikk),
API-lag, frontend — ingen av disse er startet.

### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034)

Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per
match i stedet for per økt, samme klokkeslett for alle grupper), uten
reell avhengighet til rundeførings-arkitekturen over. Ikke behandlet
videre her.

### Ikke besluttet, gjenstår før bygging kan starte

- Den globale banekatalogen for EGENDEFINERTE (ikke-teeoff) baner
  (del av Beslutning C) — naturlig konsekvens av live-oppslag-
  beslutningen, men ikke bekreftet punkt for punkt ennå.
- Gjest-e-post-kobling (Beslutning D) — notert, ikke designet i detalj.
- Manuell vs. auto-beregnet HCP-indeks etter at motoren finnes
  (Beslutning G) — notert, ikke avgjort.
- Skal turnering-scoring til slutt bruke SAMME statistikk-modell (reist i
  brainstorm-runden 2026-07-22, FEATURE_BACKLOG.md) — ikke avgjort, ingen
  konsekvens for denne ADR-ens omfang uansett.
- Eksakte tabellnavn/skjema (`round`/`round_participant`/
  `round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig
  fastlagt) — avgjøres ved migrasjonsskriving.

**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.**
Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:**
(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget
og testet i `handicap_engine.py` (43/43 tester), (2) full
databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_
participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot
ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget,
scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og
redeployet (`teecup_api`). **Gjenstår: HELE frontend-en** — ingenting
bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.

**Sju punkter fra faktisk bruk, BYGGET OG LIVE 2026-07-24** (rapportert av
bruker som selv testet scorekort-skjermen): (1) `currentHole` respekterte
aldri `round.start_hole` (`useState<number>(1)` + `prev || start_hole` —
`1` er truthy i JS, så `||` ble en no-op), fikset ved å bruke `null` som
"ikke satt ennå"-tilstand og en sirkulær 18-hulls navigasjonsrekkefølge
fra starthullet. Trolig rot-årsak til det samtidig rapporterte GIR-
avviket: selve formelen (`slag − putter ≤ par − 2`) var allerede
matematisk identisk med regelen brukeren beskrev, men feil hull i fokus
ga feil par inn i en ellers korrekt formel. (2) Kølle-bag på personlig
profil — 28 faste kølletyper (`BAG_CLUBS` i `app/routers/auth.py`,
speilet i frontend), maks 14 (den ekte golfregelen, håndhevet i både
Pydantic og en CHECK-constraint), brukt som knapp-utvalg for "kølle
brukt ved utslaget" for runde-eieren (gjester har ingen profil, beholder
fritekst). (3) Nytt statistikkfelt "Anywayslag", siste punkt i "Flere
detaljer", samme tallvelger-stil som slag/putter (ikke en liten
stepper). (4) Valgfritt statistikknivå per deltaker
(`strokes_only`/`strokes_and_putts`/`full`, ny kolonne `round_participant.
stat_level`, default `strokes_only` — brukerens egen presisering: kun
slag er strengt tatt nødvendig for resultat/HCP). Nytt PATCH-endepunkt
`/rounds/{id}/participants/{id}` for å endre nivået underveis. (5) Putt-
avstand endret fra fritekst-tall til seks faste bøtter (`<1m`…`8m+`) —
`round_hole.first_putt_distance_m` (numeric) erstattet med
`first_putt_distance_bucket` (text+CHECK) i migrasjon
`022_round_stats_and_bag.sql`, ingen produksjonsdata å bevare (0 rader
hadde verdi). (6) "Hullet er spilt"-avkrysningen FJERNET helt — reelt
overflødig, `played` settes allerede automatisk når et slagtall velges.
(7) Direkte spørsmål om avkrysningens funksjon avdekket at brukeren
egentlig ville vite om `played` (bevisst enkel avledning, uendret) — i
samme svar reiste brukeren "plukket opp"-behovet for et fremtidig
Stableford-format (fanget i FEATURE_BACKLOG.md, IKKE bygget — krever en
helt egen scoring-format-designrunde).
**Punkt 6 (numpad-layout + retningskors + sveip-vurdering) BYGGET OG
LIVE 2026-07-24, samme dag** — V0-prompten (se FEATURE_BACKLOG.md)
kjørt av bruker, zip 13 mottatt og integrert. **V0s egen designbeslutning
på sveip-spørsmålet:** IKKE et sveip-panel, men trykk-baserte
"Score"/"Statistikk"-faner (`PanelTabs`) inni samme kort — begrunnet
med at skjermen allerede har to horisontalt scrollende rader
(spillerfaner, hull-navigasjon), så en tredje sveiperetning ville vært
forvirrende. Vurdert som et godt, veloverveid valg, beholdt uendret.
Retningskors (`DirectionCross`/`DirButton`, piler + `Target`-ikon)
brukt for Utslag (venstre/senter/høyre) og Innspill (fullt 5-veis
kors), tekstlabel beholdt på hver knapp (ikke ikon-only). Tallvelgerne
fikk ny numpad-layout (3 kolonner, `h-16`-knapper, fyller bredden).
**Reelt integreringsarbeid, ikke ren om-kabling:** siden V0 ikke kjente
til dagens datalag (bygget i en tidligere, separat runde samme dag),
måtte `PanelTabs`/`DirectionCross` flettes inn i EKSISTERENDE, allerede
fungerende kode — ikke erstatte den. Konkret: `PanelTabs` vises kun når
`statLevel==="full"` (ingen "Statistikk"-fane å bytte til ellers),
"Score"-innholdet (Slag, evt. Putter) vises direkte uten fane-UI når
nivået er lavere. `DirectionCross` erstattet kun de to `ChoiceRow`-
kallene for Utslag/Innspill — `ChoiceRow` selv beholdt uendret til
resten (Kjønn, Statistikknivå, putt-avstand-bøtter). Kølle-bag-picker,
anywayslag, putt-bøtter, `stat_level`-gating, merge-før-PATCH,
starthull-fiksen og alle `/my-rounds`-lenker fra tidligere samme dag
ALLE bevart uendret -- kun presentasjonslaget for tallvelgere/retning
byttet ut. V0s egen `PlayedToggle` (som den ikke visste var fjernet)
ble bevisst IKKE tatt inn igjen. Ekte typesjekket produksjonsbuild
kompilerte rent. **Rullet ut live**, kun `teecup_frontend` (+ vanlig
`teecup_api`-bivirkning), ingen migrasjon, `teeoff.no` upåvirket.
Zip 13 slettet fra prosjektroten.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container): 18 sjekker — kølle-bag
lagret/hentet riktig, ukjent kølletype avvist (422), mer enn 14 køller
avvist (422), `stat_level`-default og eksplisitt verdi ved
opprettelse/gjest-tilføyelse, PATCH av `stat_level` vedvarer etter
refetch, `anyway_strokes`/`first_putt_distance_bucket` lagret riktig,
ugyldig bøtte-verdi avvist (422), OG en bevisst re-bekreftelse av
merge-før-PATCH-kontrakten (et PATCH uten `anyway_strokes` nuller den
fortsatt — samme grunnkontrakt som forrige runde, ikke endret av denne
utvidelsen). `test_isolation.sql` fortsatt 12/12. Ekte typesjekket
produksjonsbuild kompilerte rent.
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
migrasjon 022 kjørt mot ekte `teecup_db` (alle nye kolonner/CHECK-er
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
compose up -d --build teecup_api teecup_frontend`, begge containere
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
ekte https, `teeoff.no` upåvirket.

**Tre nye brukerpunkter, ALLE BYGGET, SCRATCH-VERIFISERT OG LIVE
2026-07-25:**
1. **Enkeltbane-anlegg dropper det overflødige banenavnet.** Brukeren
   påpekte at Tjøme Golfklubb (og de aller fleste andre norske anlegg —
   kartlagt ved å skanne alle 174 teeoff-anlegg: kun 15 har mer enn én
   bane) fikk et unødvendig sammensatt navn ("Tjøme Golfklubb –
   Hovedbanen") siden anlegget uansett bare har ÉN bane. Fikset i BEGGE
   stedene navnet bygges (`app/routers/courses.py` sin
   `import_official_course`, `app/routers/rounds.py` sin
   `_resolve_teeoff_course`): har `facility["courses"]` nøyaktig ett
   element, brukes kun anleggsnavnet; har det flere (bekreftet fortsatt
   riktig for f.eks. Ålesund Golfklubb, som har "Solnør Gaard" og "Moa
   Golfsenter"), beholdes det kombinerte "Anlegg – Bane"-navnet uendret.
   Ingen migrasjon (ren navnelogikk, ingen lagret data endret av seg selv
   — kun FREMTIDIGE importer/runde-opprettelser får det nye navnet).
2. **Runder kan nå navngis** (`round.name`, migrasjon
   `024_round_name.sql`, nullable). Valgfritt felt lagt til
   `RoundCreate`/`RoundUpdate`/`RoundOut` i `app/routers/rounds.py` — PATCH-
   kontrakten følger samme "tom streng = fjern, utelatt = ikke rør"-mønster
   som resten av `RoundUpdate` sine felt (ikke et ekte `exclude_unset`,
   konsistent med hvordan `holes_planned`/`start_hole` allerede
   håndteres i samme endepunkt). Vises på tvers av `round-card.tsx`
   (rundeliste), `round-detail.tsx` (header + redigerbart i "Rediger
   runde"-panelet), `round-scorecard.tsx` og `round-stats.tsx` — alle
   fire faller tilbake til `course_name_snapshot` når navn ikke er satt,
   og viser banenavnet som sekundær informasjon når et eget navn ER satt
   (ikke bare erstattet det, siden banen fortsatt er relevant
   informasjon).
3. **Land+hjemmeklubb i profilen, redesignet (ADR-031/032s
   `ProfileOnboarding`/`ProfileSection`).** Brukeren ba om at Land skal stå
   FØR Hjemmeklubb, og at Hjemmeklubb skal være en nedtrekksliste fra
   teeoff, filtrert på valgt land. Avklart eksplisitt med bruker
   (AskUserQuestion, begge anbefalte valg): "Land" er nå en ekte
   `<select>` (`COUNTRIES = ["Norge"]`, ett element foreløpig — bevisst
   klargjøring for fremtidig flerspråklighet, ingen backend-endring
   trengs når flere land legges til, kun listen utvides), og "Hjemmeklubb"
   er en søkbar kombinasjons-boks (`HomeClubField`, debounce 250ms) som
   gjenbruker det ALLEREDE EKSISTERENDE `/rounds/official-search`-
   endepunktet (org-uavhengig, tilgjengelig for enhver innlogget bruker
   siden ADR-033) — ingen ny backend-kode i det hele tatt. Teeoff filtrerer
   allerede bort upubliserte/nedlagte anlegg server-side (`is_published`),
   så "nedlagte og ikke operative klubber skal ikke inkluderes" er dekket
   uten egen filtrering på teecup-siden. Begge komponentene delt mellom
   `ProfileOnboarding` (obligatorisk profil-fullføring) og `ProfileSection`
   (kontoinnstillinger) i `account-settings.tsx`, ingen duplisert
   implementasjon.
   **Bevisst IKKE bygget:** hard validering som avviser fritekst utenfor
   søkeresultatene — eksisterende, allerede lagrede `home_club`-verdier
   (fritekst fra før denne runden) forblir gyldige og redigerbare, feltet
   oppfører seg som "skriv for å filtrere, klikk for å velge" fremfor en
   strengt låst `<select>`, for å unngå å gjøre eksisterende profiler
   utilgjengelige.
   **Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
   isolert scratch-MinIO + engangs API-container, samt et direkte
   engangs-oppslag mot ekte `teeoff_api` for å kartlegge hvilke anlegg som
   faktisk har mer enn én bane): 22 sjekker — enkeltbane-navn uten suffiks
   (Tjøme), flerbane-navn med suffiks uendret (Ålesund), rundenavn lagret
   ved opprettelse, rundenavn med i rundeliste, PATCH omdøper, PATCH med
   tom streng fjerner navnet, PATCH uten navn-felt lar det urørt,
   `official-search`-endepunktet (brukt av det nye profilfeltet) fortsatt
   fungerer identisk. `test_isolation.sql` fortsatt 12/12. Ekte
   typesjekket produksjonsbuild kompilerte rent, alle 21 ruter listet.
   **Rullet ut mot ekte systemer 2026-07-25**, bruker bekreftet
   eksplisitt: migrasjon 024 kjørt mot ekte `teecup_db` (kolonne
   bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
   compose up -d --build teecup_api teecup_frontend`, begge containere
   boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
   ekte https, `teeoff.no` upåvirket.

### Oppdatering 2026-07-26: utslag/HCP per deltaker, gjeste-kontaktinfo, og scoringsflyten redesignet (v2)

Tre beslutninger denne dagen utvider ADR-033 uten å endre grunn-
arkitekturen (Beslutning A-G står uendret).

**1. Utslagssted er nå PER DELTAKER, ikke per runde.** Reelt hull
oppdaget ved bruk: alle deltakere delte tidligere `round.
tee_name_snapshot` uansett kjønn/faktisk valg — en medspiller kunne
aldri spille fra et annet utslag enn eieren. Migrasjon `028_round_
participant_tee.sql` gir `round_participant` sitt eget `tee_name_
snapshot`, backfylt fra rundens eksisterende utslag for eksisterende
rader. `ParticipantCreate`/`ParticipantUpdate` fikk et valgfritt
`tee_name`; et bane-bytte (eksisterende `RoundUpdate`-mekanisme)
nullstiller nå eksplisitt ALLE deltakeres per-deltaker-utslag til det
nye standardutslaget (et bane-bytte gjør individuelle valg fra den
gamle banen meningsløse). HCP kan nå også redigeres per deltaker for
KUN denne runden (samme reproduserbarhets-unntak som resten av
`round_participant` — endrer aldri spillerens faktiske profil),
avvist etter at runden er fullført (samme presedens som bane-bytte).

**2. Gjester (`user_id IS NULL`) fikk et valgfritt kontaktfelt +
redigerbart navn/kjønn.** Migrasjon `029_round_participant_guest_
email.sql` (`guest_email`, nullable). `ParticipantUpdate` fikk `guest_
name`/`gender`/`guest_email` — eksplisitt avvist (400) for en LENKET
deltaker (disse feltene gir ingen mening for noen med egen konto).
`gender`-endring inngår i samme rating-rekalkulering som utslag/HCP
(påvirker hvilken rating som er gyldig).

**3. Scoringsflyten redesignet til en skjermovertagende veiviser --
to iterasjoner, andre erstattet den første samme dag.** Brukeren delte
en skjermopptaksvideo av en konkurrentapp (Golf GameBook) sin
scoreregistrering og spurte om prinsippene kunne forbedre TeeCup.
Videoen ble analysert bilde for bilde (ffmpeg i en engangs Docker-
container) og avdekket et "samlebånd"-mønster: taltastatur med
kontekstuelle golf-termer (Eagle/Birdie/Par/Bogey ut fra hullets par),
og at fullført registrering for én spiller automatisk åpner NESTE
spillers registrering for samme hull.
**Første forsøk (v1), BEVISST avvist fra en full modal:** vurdert som
unødvendig risikofylt å bygge en skjermovertagende steg-for-steg-flyt
korrekt uten visuell testing (intet nettleserverktøy tilgjengelig i
denne økten). I stedet: golf-term-taltastatur lagt til der det allerede
var (`NumberPicker`s nye `showGolfTerms`-modus), pluss en "Neste:
{navn}"/"Neste hull"-knapp nederst på den EKSISTERENDE, lange
inline-siden.
**Brukeren testet v1 live og avviste den eksplisitt:** "ingen forbedring
i det hele tatt", "visuelt like overveldende og rotete" — den
forsiktige, risikoreduserte tilnærmingen var utilstrekkelig; en ekte
skjermovertagende veiviser var det som faktisk krevdes, sammen med et
nytt, eksplisitt krav: akkumulert score-så-langt for RUNDEN synlig for
HVER spiller samtidig (ikke bare én valgt), matchende konkurrentappens
vedvarende "E"/"+1"-visning ved siden av hvert navn.
**v2 bygget samme dag, ERSTATTER v1 helt:** ny `ScoringWizard`-komponent
(fullskjerm, egen DOM-stack utenfor `<main>`), tre steg maks drevet av
`stat_level` ("strokes_only" → kun Slag; "strokes_and_putts" → + Putter/
avstand første putt; "full" → + ett samlet detalj-steg). Bevisst FÆRRE,
grovere steg enn konkurrentens egne 5-6 skjermer — en risikoreduksjon
uten visuell testing, og TeeCups stat_level-modell gjør en finere
oppdeling mindre naturlig uansett. Alle spillerne vises som en fast,
ikke-trykkbar kontekst-rad øverst i veiviseren (speiler konkurrent-
appens "mist aldri oversikten"-prinsipp — bevisst IKKE trykkbar, siden
GameBooks egen video heller ikke viste spillerbytte midt i et steg).
Hovedsiden forenklet radikalt: det gamle Slag/Putter/"flere detaljer"-
skjemaet er fjernet, erstattet med én kompakt liste (navn, "HCP X ·
{til-par} så langt (N hull)", en stor rund knapp som åpner veiviseren).
Datalasting utvidet til å hente ALLE deltakeres hull med det samme
(ikke lenger lat lasting kun for aktiv spiller), nødvendig for at
akkumulert score kan vises for alle samtidig.
**Reell driftsfeil funnet OG rettet SAMME dag, rapportert av bruker som
testet live:** den gamle `ScoreSoFar`("Så langt i runden")-boksen ble
ved en feil IKKE fjernet/flyttet i selve v2-bygget — den lå fortsatt
øverst, uendret, og fikk brukeren til å rapportere "ingen synlig
endring i det hele tatt" (toppen av siden var reelt uendret). Bekreftet
presist ved å hente den FAKTISK kjørende JS-bunten i produksjon og
søke i den — begge de gamle OG de nye tekst-strengene lå i samme bunt,
som beviste dette var en ekte plasseringsfeil, ikke en cache-/
utrullingsfeil. Rettet ved å flytte `ScoreSoFar` til UNDER scorings-
seksjonen (fortsatt tilgjengelig, bare ikke lenger i veien for
hovedoppgaven) — samme klasse feil hele denne rundens redesign forsøkte
å løse.
**Bevisst utenfor omfang, IKKE bygget:** de mest granulære trekkene fra
konkurrentappen (separate skjermer per detalj-felt, en illustrert
"bommet green"-grafikk, spillerbytte midt i et steg) — vurdert som for
stor risiko å bygge presist uten visuell testing, og TeeCups egen
stat_level-gruppering gjør en så fin oppdeling mindre naturlig uansett.
**Ingen ekte nettleser-interaksjonstest utført av noen av iterasjonene**
(intet slikt verktøy tilgjengelig denne økten) — kun logikk (Node-
simulering av stegmaskinen, rangering, akkumulert-score-beregning) og
et ekte API-rundtur-script (nøyaktig samme feltkombinasjon som
veiviseren sender) er verifisert. Full detalj i CLAUDE.md sin
statuslogg (2026-07-26).

---

## ADR-035: Dashbord-redesign — organisasjon blir implisitt

Direkte oppfølging av en refleksjonsrunde 2026-07-25: brukeren spurte
eksplisitt hvorfor organisasjon i det hele tatt trengs, gitt at en enkelt
bruker som vil sette opp ÉN turnering for en vennegjeng ikke får noe igjen
for opprettelses-seremonien. To alternativer ble veid opp mot hverandre:

- **A — bruker-eide turneringer** (samme mønster som ADR-033 sine
  frittstående runder, `user_id`-eierskap, ingen RLS): AVVIST. Praktisk
  talt HELE turnering-apparatet (lag/roster/blind draw/scoring/chat/
  leaderboard/offentlig side, se CLAUDE.md sin arkitektur-invariant om
  `organization_id` på alle domenetabeller) er bygget rundt RLS og
  org-medlemskap. Å gjøre turneringer bruker-eide ville krevd enten en
  full duplisering av dette apparatet i en parallell, RLS-fri variant,
  eller å gjøre `organization_id` valgfri overalt — et brudd på
  invarianten CLAUDE.md eksplisitt krever en ny ADR for, med en
  reverseringskostnad som går GALT begge veier (migrere eksisterende
  bruker-eide turneringer inn i organisasjoner senere, eller gjenoppfinne
  medadministrasjon fra bunnen om den viser seg nødvendig uansett).
- **B — organisasjon beholdes, men opprettelsen gjøres usynlig/automatisk**:
  VALGT. Rører verken skjema, RLS eller noen av de ni routerne som
  allerede er bygget rundt organisasjon — alt fortsetter å virke uendret.
  Kun UX-seremonien fjernes.

### Beslutning A — Hva "usynlig organisasjon" betyr konkret

`POST /orgs` (`app/routers/organizations.py`) krever i dag KUN `name` —
verifisert direkte i koden: `slug`/`public_profile` settes separat via
`PATCH /orgs/{id}` (ADR-018), ikke ved opprettelse. Dette betyr at
"usynlig opprettelse" krever **null backend-endring** — kun en endret
frontend-orkestrering:

- Trykker en bruker "Ny turnering" og har PRESIS ÉN organisasjon fra før:
  den gjenbrukes direkte, ingen synlig org-steg.
- Har brukeren INGEN organisasjon ennå: `POST /orgs` kalles automatisk med
  et generert navn (`"{fornavn} {etternavn}s turneringer"`, eller
  `"Mine turneringer"` som fallback hvis navn mangler) FØR turnering-
  opprettelsen vises — brukeren ser aldri et eget "opprett organisasjon"-
  skjema.
- Har brukeren FLERE organisasjoner (f.eks. fordi de også er invitert inn
  i en ekte klubb, ADR-022): et lett org-valg vises FØRST da — samme
  `OrganizationView`-mønster som i dag, men kun synlig i dette ene
  tilfellet, ikke som standard.
- Alt annet er UENDRET: en bruker som ønsker en ekte klubb-identitet
  (navn, slug, offentlig side, medadministratorer) kan fortsatt navngi
  orgen sin og bygge den ut senere — B fjerner kun ceremonien ved
  FØRSTE opprettelse, ikke noen av de eksisterende org-funksjonene.

### Reversibilitet

B kan reverseres på en ettermiddag: legg navnesteget tilbake i UI-flyten
FØR "Ny turnering" fullføres. Ingen datamigrering — eksisterende org-rader
er identiske uansett om de ble navngitt av brukeren eller auto-generert.

**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se
"Dashbord: tom-tilstand"-seksjonen i FEATURE_BACKLOG.md for det fulle
blokk-forslaget og V0-prompten som ble skrevet i samme runde.

---

## ADR-036: Venner, kategorisert deling av runder, og tiered personsøk for medspillere

Reist av brukeren 2026-07-25, samme runde som ADR-035. To sammenhengende
behov: (1) når man legger til en medspiller på en frittstående runde, skal
det søkes opp EKTE personer — venner FØRST, deretter samme hjemmeklubb,
deretter samme land, til slutt globalt, uavhengig av om for- eller
etternavn skrives først, i samme UI-mønster som bane-/klubbsøket; (2)
appen skal ha et fullverdig vennekonsept, der venner (med mindre runden er
satt til "Privat") kan se livescoren din, og der DU kan kategorisere hver
venn i én eller flere av et fast sett grupper — uten at vennen selv vet
hvilke grupper du har puttet dem i.

**Direkte oppfølging av et allerede notert, men avvist punkt:** ADR-033
Beslutning D satte `round_participant.user_id` i skjemaet, men avgrenset
bevisst til KUN gjeste-deltakere i v1 ("en deltaker med `user_id` satt får
IKKE egen tilgang til runden"), med e-post-kobling notert som en mulig,
ikke-designet senere utvidelse. Denne ADR-en erstatter e-post-kobling-
ideen med noe som passer bedre til det brukeren faktisk ba om: ekte
personsøk (samme mønster som bane/klubb), ikke en e-postadresse man må
kjenne på forhånd.

### Beslutning A — Vennskap er gjensidig; kategorisering er privat og ensidig

Et vennskap krever en forespørsel + aksept (samme grunnmønster som
organisasjon-invitasjoner, ADR-022) — "venner ser livescoren din" gir kun
mening som et GJENSIDIG, bekreftet forhold, ikke en ensidig følging.
Kategoriseringen ("Make", "Golfvenner" osv.) er derimot et HELT SEPARAT,
privat attributt EIET av den som kategoriserer — A kan sette B i
"Golfvenner" uten at B noensinne får vite det, og B kategoriserer A helt
uavhengig (kan sette A i en annen gruppe, eller ingen). Dette er bevisst
samme idé som Facebooks "nære venner"-lister: vennskapet er symmetrisk,
men grupperingen er det ikke.

**Datamodell (arbeidsnavn, avgjøres ved migrasjonsskriving):**
- `friendship`: `requester_user_id`, `addressee_user_id`, `status`
  (`pending`/`accepted`/`declined`), tidsstempler. Et UNIK
  uttrykks-indeks på `(LEAST(requester_user_id, addressee_user_id),
  GREATEST(...))` hindrer duplikate forespørsler i begge retninger
  samtidig.
- Kategori er IKKE en egen tabell — et fast, CHECK-constrained sett
  (samme mønster som `BAG_CLUBS`/`gender`/`stat_level` ellers i appen),
  ikke brukerdefinerbart: `spouse` (Make), `close_family` (Nær familie),
  `extended_family` (Storfamilie), `close_friends` (Nære venner),
  `golf_friends` (Golfvenner), `colleagues` (Kollegaer), `business`
  (Forretningsforbindelser), `classmates` (Studiekamerater),
  `acquaintances` (Perifere bekjente), `other` (Ymse).
- `friend_categorization`: `owner_user_id`, `friend_user_id`, `category`
  — én rad PER kategori en venn er satt i (en venn kan ha flere rader,
  "de skal kunne være tilknyttet forskjellige kategorier"). Håndheves i
  app-laget (skriving krever en `accepted`-vennskapsrad mellom de to),
  ikke en direkte FK til `friendship` (unngår å måtte holde styr på
  requester/addressee-retning to steder).

### Beslutning B — Rundevisibilitet: tre nivåer, samme struktur som turnering, men egen mekanisme

`round` får `visibility_mode` (`public`/`private`/`friends`, default
`private` — trygg standard, samme filosofi som RLS-policyenes "se
ingenting" ved manglende kontekst). Når `friends` er valgt: hvilke
KATEGORIER som får se runden velges eksplisitt (`round_visible_category`,
`round_id`+`category`) — ikke "alle venner", siden brukeren eksplisitt ba
om å velge blant gruppene sine ved rundestart.

**Viktig presisering, unngår en reell forvekslingsfelle:** dette er
ADSKILT fra om noen er lagt til som faktisk MEDSPILLER (Beslutning C
under). En medspiller du har lagt til ser ALLTID runden dere spiller
sammen, uavhengig av `visibility_mode` — visibility-nivået styrer kun
TREDJEPARTS innsyn (venner/offentligheten), ikke de som faktisk er med i
flighten.

**Autorisasjonssjekk** (app-lag, `plain_connection()`-mønsteret fra
ADR-033 Beslutning A — INGEN RLS, samme begrunnelse som der):
1. `viewer == round.owner_user_id` → alltid tilgang.
2. `viewer` er en lenket medspiller (`round_participant.user_id ==
   viewer`) → alltid tilgang til DEN runden.
3. `visibility_mode == 'public'` → alle, også anonyme (samme mønster som
   turneringers offentlige side).
4. `visibility_mode == 'private'` → kun 1+2.
5. `visibility_mode == 'friends'` → krever innlogget bruker MED en
   `accepted`-vennskapsrad til eieren OG minst én
   `friend_categorization`-rad (`owner_user_id=eier, friend_user_id=
   viewer`) hvor kategorien finnes i `round_visible_category` for akkurat
   denne runden. Legg merke til retningen: det er EIERENS kategorisering
   av VIEWER som brukes, ikke omvendt — riktig, siden det er eieren som
   begrenser hvem som får se, basert på eierens egen gruppering.

**"Livescore"** — forstått som sanntidsoppdatering mens runden pågår,
samme idé som turnering sin `/t/[id]/live` (ADR-027). Gjenbruker samme
kringkastingsmønster (`app/realtime.py`, `broadcast_live_update`) — en
tilsvarende funksjon for runder, kringkastet ved hver hull-PATCH når
`visibility_mode != 'private'`, med et WS-endepunkt gated av samme
autorisasjonssjekk som over.

### Beslutning C — Tiered personsøk: delt mellom "finn venn" og "legg til medspiller"

Samme underliggende endepunkt brukes til BEGGE formål (finn en venn å
sende forespørsel til, OG søk opp en medspiller å legge til på en runde)
— begge er i essens "finn en person", kun hva som skjer ETTER valg
skiller dem. Foreslått: `GET /people/search?q=...`.

**Navnerekkefølge-uavhengig matching:** spørringen splittes i tokens
(mellomrom-separert). For HVER token må minst ett av `first_name`/
`last_name` matche (`ILIKE token||'%'`) — ALLE tokens må matche (AND på
tvers av tokens, OR på tvers av feltene per token). Dette gjør at "Erol
Haagenrud" og "Haagenrud Erol" gir identisk treff, uten noen spesiell
"gjett rekkefølgen"-logikk — det faller naturlig ut av at hvert ord kan
matche HVILKET SOM HELST av de to feltene.

**Tiered rangering, én spørring:** en beregnet prioritet per kandidat —
0 hvis venn (uavhengig av kategorisering — ALLE venner, ikke bare de i
en bestemt gruppe, siden dette er søk-for-å-legge-til, ikke visibility-
sjekken over), 1 hvis samme `home_club` som søkeren (nå en pålitelig
eksakt streng siden ADR-025-runden 2026-07-25 gjorde Hjemmeklubb til en
ekte dropdown-verdi i stedet for fritekst — retroaktivt en god
begrunnelse for den endringen), 2 hvis samme `country`, 3 ellers.
`ORDER BY prioritet, fornavn, etternavn LIMIT 20` gir venner-først uten
behov for separate spørringer per nivå.

**Personvernhensyn, bevisst innebygd i designet, ikke tilføyd i
etterkant:**
- Svaret inneholder KUN navn, avatar, hjemmeklubb — ALDRI e-post/mobil/
  fødselsdato.
- Foreslått minimum 2 tegn i søket før noe returneres i det hele tatt
  (hindrer triviell enumerering av alle brukere via ett enkelt
  bokstavsøk) — MIN anbefaling, ikke bekreftet med bruker.
- Kun innloggede brukere kan søke (`get_current_user`, ikke anonymt).

### Beslutning D — Byggerekkefølge (foreslått, IKKE bekreftet)

Gitt omfanget (nytt vennekonsept + ny rundevisibilitet + nytt delt
søkeendepunkt + sanntidsutvidelse) foreslås tre uavhengig leverbare faser,
samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet:

1. **Venner-kjernen**: `friendship`+`friend_categorization`, forespørsel/
   aksept/fjern-endepunkter, `/people/search`, en ny `/friends`-side
   (søk, forespørsler, kategoriser). Leverbar og nyttig helt alene.
2. **Rundevisibilitet**: `round.visibility_mode`+`round_visible_category`,
   valg ved rundestart, `can_view_round`-gatede lese-endepunkter for
   ikke-eiere, sanntidsutvidelse for live-visning. Avhenger av fase 1.
3. **Ekte medspillere** (ikke bare gjester): utvid `POST .../participants`
   til å godta et søkt `user_id` i tillegg til `guest_name`. Avhenger av
   fase 1 (samme søk). **Avklart med bruker 2026-07-25: JA** — en
   lagt-til ekte medspiller skal se runden i SIN EGEN "Egne runder"-liste,
   ikke bare eieren.
   **Konkret teknisk konsekvens, presisert her siden det ikke er
   opplagt:** `list_rounds` filtrerer i dag KUN på `owner_user_id`, og
   `RoundOut` sine `owner_holes_played`/`owner_total_score`/
   `owner_score_to_par`-felt (lagt til 2026-07-25, se status) er alltid
   utledet fra EIERENS `round_participant`-rad. For en medspiller som
   ser runden i SIN liste må disse tallene i stedet vise DERES EGEN
   score i runden, ikke eierens — feltene må derfor bli
   "viewer-relative" (utledet fra HVILKEN SOM HELST deltaker-rad som
   matcher innlogget bruker, enten `is_owner` eller lenket `user_id`),
   ikke hardkodet til `is_owner=true`-raden. `list_rounds`-spørringen må
   utvides til `WHERE owner_user_id = $1 OR id IN (SELECT round_id FROM
   round_participant WHERE user_id = $1)`. Ingen endring i selve
   eierskapet (`round.owner_user_id` er fortsatt entydig én person).
   **Skrivetilgang avklart med bruker 2026-07-25: en medspiller skal
   kunne registrere score for ALLE i flighten**, ikke bare sin egen rad
   — samme skrivetilgang som eieren allerede har i dag (via
   `_get_owned_round_or_404`), nå utvidet til å gjelde ENHVER lenket
   ekte deltaker (`round_participant.user_id`), ikke kun eieren. Praktisk
   presisering: dette gjelder KUN hull-registrering
   (`PATCH .../participants/{id}/holes/{hole}`) og legg-til-deltaker —
   sletting av selve runden og bane-/utslagsbytte (`DELETE /rounds/{id}`,
   `PATCH /rounds/{id}` sine bane-felt) forblir eier-eksklusivt, siden
   disse er destruktive/strukturelle handlinger uavhengig av hvem som
   fører score. Autorisasjonssjekken for hull-PATCH blir dermed:
   `viewer == owner_user_id OR viewer IN (SELECT user_id FROM
   round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`.

**Status: 🔨 FASE 1 (venner-kjernen) BACKEND + FRONTEND BYGGET
2026-07-25.** Backend: migrasjon `025_friends.sql` + `app/routers/
friends.py` (`GET /people/search`, `POST /friends`, `POST /friends/{id}/
accept`, `DELETE /friends/{id}`, `GET /friends`, `PUT /friends/
{friend_user_id}/categories`) — 31 scratch-sjekker bestått, inkl.
presist bevist tiered rangering (venn→klubb→land→globalt) og at
kategorisering faktisk er PRIVAT (B ser aldri kategoriene A har satt B
i). `test_isolation.sql` fortsatt 12/12. Rullet ut mot ekte `teecup_db`/
`teecup_api` samme dag, bruker bekreftet eksplisitt.
Frontend: V0-zip (zip 19) integrert som `components/friends.tsx` + ny
rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo —
det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra
start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i
samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. Rullet
ut live 2026-07-25, bruker bekreftet eksplisitt.

**Fase 3 (ekte medspillere på en frittstående runde) BYGGET OG SCRATCH-
VERIFISERT 2026-07-26**, utløst av at brukeren rapporterte at "+ Gjest"-
skjemaet ikke søkte etter spillere i det hele tatt (rent tekstfelt,
`/people/search` var aldri koblet på). Bygget den delen av fase 3 som
faktisk var etterspurt: søk-og-legg-til en ekte bruker (gjenbruker
tiered `/people/search`, samme endepunkt som vennesøket) PLUSS den
tidligere avklarte "skriv for hele flighten"-regelen (bekreftet av
brukeren 2026-07-26 — "full tilgang nå", ikke bare rask utfylling).
**IKKE** bygget i denne runden: selve rundevisibilitet (fase 2,
offentlig/privat/delt-med-venner-i-grupper) — det er fortsatt et
separat, senere steg.

Konkret: `round_participant.user_id` (fantes i skjemaet siden ADR-033,
aldri eksponert via API) er nå skrivbar via `POST /rounds/{id}/
participants` (kjønn/HCP hentes automatisk fra den valgte personens
egen profil, ikke tastet manuelt). Ny migrasjon `027_round_participant_
user_unique.sql` (partiell unik indeks, hindrer dobbel-lenking). Rundens
tilgang delt i to nivåer: `_get_accessible_round_or_404` (eier ELLER en
lenket medspiller — lesing, hull-scoring for HELE flighten, fullføring)
vs. `_get_owned_round_or_404` (fortsatt strengt eier-only — rediger/
slett runde, legg til/fjern medspillere). `RoundOut` sine `owner_*`-
statistikkfelt omdøpt til `my_*` og gjort VIEWER-relative (regnes nå fra
den spørrende brukerens egen deltaker-rad, ikke alltid eierens — løser
et teknisk konsekvens-punkt notert allerede 2026-07-25 da "medspiller
ser runden i egen liste" ble bekreftet). `RoundParticipantOut` fikk et
nytt, alltid utfylt `display_name`-felt (levende oppslått, ikke
snapshot) — fanget OG fikset en reell latent bug i samme runde:
leaderboard-endepunktet (bygget 2026-07-26, samme dag) ville vist et
tomt navn for enhver lenket medspiller, siden dets `display_name`-
utledning den gang kun sjekket `is_owner`.
**Scratch-verifisert grundig (39/39 sjekker i to testløp):** søk-og-
legg-til (kjønn/HCP auto-fylt, avvist duplikat/selv/ukjent bruker/
ufullstendig profil), lenket medspiller kan se runden+registrere score
for BÅDE egen OG andres rad (whole-flight), men KAN IKKE forvalte runden
(rediger/slett/legge til/fjerne/endre stat_level — alle 403), lenket
medspiller KAN fullføre runden, `/rounds`-listen viser nå runden for en
lenket medspiller (med DERES egen fremdrift, ikke eierens), en helt
urelatert bruker fortsatt 403/ikke i listen, leaderboardets navn stemmer
for alle tre deltakertyper (eier/medspiller/gjest). `test_isolation.sql`
12/12 uendret. Ekte typesjekket produksjonsbuild av frontend (ny
søk-UI i `round-detail.tsx`, "Gjest" omdøpt til "Medspiller" gjennomgående,
samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/
`round-scorecard.tsx`, som hadde samme latente bug).

### Beslutning E — Kategori-match ved rundevisibilitet: MINST ÉN, ikke ALLE

Beslutning B punkt 5 (over) spesifiserte fra start "minst én
`friend_categorization`-rad ... hvor kategorien finnes i
`round_visible_category`". En senere presisering 2026-07-29 (kun
dokumentert i kode-kommentarer, aldri i denne ADR-en — selve
dokumentasjonshullet som gjorde denne avviket vanskelig å spore) strammet
dette til at ALLE en venns kategorier måtte være i rundens synlige sett,
ikke bare én.

**Reversert til original Beslutning B-regel 2026-08-08**, etter at
brukeren rapporterte at en runde delt med kategorien "Make" ikke ble
synlig for vedkommendes ektefelle. Årsak: ektefellen var i tillegg
kategorisert "Storfamilie" (Beslutning A tillater flere kategorier per
venn), og siden "Storfamilie" ikke var huket av på DENNE runden, blokkerte
den strenge ALLE-regelen hele synligheten -- til tross for at den
relevante kategorien ("Make") faktisk var valgt. I praksis gjorde dette
det nærmest umulig å dele pålitelig med noen som var tagget i mer enn én
kategori, uten å huke av samtlige av dem hver gang.

**Ny/gjeninnført regel:** en venn ser runden hvis MINST ÉN av kategoriene
eieren har satt dem i, er i rundens synlige sett -- flere kategorier på
samme venn er bare flere sjanser til å matche, aldri en ekstra
begrensning. En venn med INGEN kategorier vises fortsatt aldri (uendret).

Rettet i fire duplikate SQL-steder (`app/routers/rounds.py`:
`_can_view_round`, `_friends_who_can_see_round`,
`list_friends_on_course`; `app/routers/round_messages.py`: feed-
listingen) -- samme sted-for-sted-duplisering som opprinnelig omtalt i
Beslutning B, nå alle konsistent på MINST ÉN-varianten.

---

## ADR-037: Individuelle turneringer, flerrunde-turneringer — grunnstruktur

**Kontekst:** reist 2026-07-26, som følge av en avklaringsrunde om et
ønsket turnering-leaderboard (se FEATURE_BACKLOG.md sin "Utvidelse
2026-07-26"-seksjon for hele forhistorien). TeeCup skal etter hvert
kunne arrangere turneringer for INDIVIDUELLE spillere (f.eks.
«Københavner» — se FEATURE_BACKLOG.md sitt eget punkt om flere
turneringsformater), ikke bare dagens Ryder Cup-lagformat (ADR-011),
og disse skal kunne gå over FLERE RUNDER med sammenlagt resultat. Et
fremtidig Order of Merit (sesong-sammenlagt på tvers av flere
turneringer, brukerens eksempel: "klubbdager") er identifisert som et
beslektet, men separat, SISTE steg — se eget avsnitt nederst, ikke
designet i denne runden.

Denne ADR-en dekker KUN grunnstrukturen (datamodell-plassering,
turnering-type, flerrunde-støtte, poengmodell). Selve de fem konkrete
formatene (Københavner, High-low-high, Robbins, Try all,
Flaggturnering) fra FEATURE_BACKLOG.md designes hver for seg OVENPÅ
denne grunnstrukturen, ikke i denne runden.

**Viktig presisering underveis, som endrer en tidligere antakelse
(2026-07-25-runden om "flere flighter i én frittstående runde"):** i en
formell, org-arrangert individuell turnering er "flight" KUN en
tee-tid-/spilletempo-gruppering (som i dag for lag-turneringer) — IKKE
en leaderboard-grense. Leaderboardet spenner alltid HELE feltet,
uavhengig av hvem som spilte sammen. Dette er strukturelt ulikt den ad
hoc "flere flighter i en frittstående runde"-ideen (der leaderboardet
bevisst skal avgrenses til det man selv satte opp) — de to
"flight"-begrepene ligner i UI, men er IKKE samme konsept. Holdes
bevisst adskilt, ikke forent, selv om forrige runde antydet det motsatte.

### Beslutning A — Ny, parallell org-scopet datamodell (IKKE gjenbruk av `round`)

En individuell/flerrunde-turnering får sin EGEN, RLS-beskyttede
tabellstruktur under `organization_id` — IKKE en utvidelse av de
frittstående rundetabellene (`round`/`round_participant`/`round_hole`,
ADR-033).

**Begrunnelse:** ADR-033 Beslutning A var et BEVISST valg om at
frittstående runder er 100 % org-uavhengige (`plain_connection()`,
ingen RLS i det hele tatt — autorisasjon håndheves med
`WHERE owner_user_id = $1` i app-laget). Å gi `round` en valgfri
`organization_id`/`tournament_id` ville krevd HYBRID RLS (håndhevet kun
når organisasjon er satt) — en helt ny klasse sikkerhetslogikk som
IKKE finnes noe sted ellers i systemet i dag (`organization_id på ALLE
domenetabeller, håndhevet av RLS` er en ufravikelig invariant i
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
unntaket). Dette prosjektet har allerede én dokumentert hendelse
(RLS-tomstreng-bugen, se CHANGELOG.md 2026-07-16) som kom av
nettopp denne typen RLS-finesse — vi unngår bevisst å introdusere en
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
registrering ligner mye på `round_hole`), men den delte regnelogikken
(`handicap_engine.py`) er allerede rammeverk-uavhengig og gjenbrukes
uendret uansett hvilken tabell dataene ligger i.

### Beslutning B — Samme `tournament`-tabell, ny type-diskriminator

`tournament` får en ny kolonne, f.eks. `format_type` (`'team'` |
`'individual'`, default `'team'` for bakoverkompatibilitet med alle
eksisterende rader).

**Begrunnelse:** en individuell turnering trenger fortsatt navn, status,
datoer, synlighet (ADR-018), invitasjonskode (ADR-020), org-eierskap —
ALT dette er allerede bygget og fungerer uendret på `tournament`-raden,
uavhengig av format. Å lage en helt ny toppnivå-entitet ville betydd å
gjenoppbygge synlighet/join-kode/landingssider fra bunnen for et andre
system — ren duplisering uten reell gevinst. Lag-tabellene
(`team`/`team_roster`) blir ganske enkelt ubrukte for
`format_type = 'individual'` — håndheves i app-laget (samme mønster som
ADR-011s to-lags-grense), ikke med en tung DB-constraint på tvers av
tabeller.

### Beslutning C — Flerrunde via en ny, økt-lignende tabell

Individuelle turneringer kan ha FLERE runder fra start, via en ny
`tournament_round`-tabell (org+tournament-scopet, samme rolle som
`session` har for lag-turneringer i dag — dato/bane/hullomfang per
runde). Sammenlagt resultat på tvers av rundene i turneringen summeres
ved lesing, SAMME "summer på ekte deltaker-id, aldri på en løs
side-label"-prinsipp som det eksisterende lag-leaderboardet allerede
bruker (`fetch_leaderboard`, `app/routers/tournaments.py`) for å unngå
å blande sammen feil rader.

**Bane-referanse:** `tournament_round.course_id` peker til den
EKSISTERENDE org-scopede `course`-tabellen (samme som `session` bruker
i dag) — INGEN snapshot-mekanisme som i frittstående runder
(ADR-033s `course_name_snapshot`). Organisasjonens egen banedata er
allerede stabil, admin-kontrollert data; snapshot-behovet i ADR-033 kom
av at frittstående runder leser LIVE fra en ekstern, ikke-org-kontrollert
kilde (teeoff) — samme begrunnelse gjelder ikke her.

**Deltaker-identitet:** en ny `tournament_participant`-tabell (org+
turnering-scopet, samme rolle som `team_roster` — refererer til
EKSISTERENDE `player`-tabellen, fryser `handicap_index_snapshot` ved
uttak, samme reproduserbarhetsprinsipp som ADR-007) — IKKE en ny,
frittstående gjeste-modell slik `round_participant` har. En turnering
har alltid en organisator som administrerer en kjent spillerpool
(samme som lag-turneringer i dag), ulikt en privatpersons frittstående
runde.

### Beslutning D — Rå slag lagres, poeng caches per format (samme mønster som match-play)

Hver `tournament_round`-deltaker sin hull-for-hull-registrering lagrer
RÅ BRUTTO SLAGTALL per hull (samme grunnform som `round_hole.score`) —
scorekortet trenger dette uansett, og det gir handlefrihet til å vise
flere ulike visninger (brutto/netto/stableford/Københavner-poeng) av
SAMME underliggende data.

I TILLEGG caches et FERDIG UTREGNET poengtall per format — samme
etablerte mønster som `match.status_text`/`points_side_a/b` i dag:
motoren (`handicap_engine.py`, ny funksjon per scoringsmetode) regner
poeng ved HVER hull-innsending, resultatet lagres i en egen kolonne
(f.eks. på en ny `tournament_round_score`-rad, én per deltaker per
runde) — rask leaderboard-lesing uten å måtte regne ut alt på nytt for
hele feltet ved hver visning, konsistent med hvordan matchstatus
allerede caches og regnes på nytt ved hver innsending
(`recompute_and_cache_match_state`).

`tournament.scoring_method` (eller ev. per `tournament_round`, hvis et
fremtidig format trenger å blande metoder — ikke avklart, default: fast
per turnering) avgjør HVILKEN motorfunksjon som brukes til å regne
poeng fra de rå slagene — ny CHECK-verdi + ny ren funksjon i
`handicap_engine.py` per format som legges til (`stroke_play_gross`,
`stroke_play_net`, `stableford`, `copenhagen_points`, ...). Hvert nytt
format fra FEATURE_BACKLOG.md sin liste blir dermed i hovedsak: én ny
motorfunksjon (testet isolert, samme "test i isolasjon FØR resten"-
prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring.

### Konsekvens — hva denne ADR-en IKKE avgjør ennå

- **De fem konkrete formatene** (Københavner, High-low-high, Robbins,
  Try all, Flaggturnering) — hver trenger sin egen, mindre design-runde
  oppå denne grunnstrukturen (poengformel, evt. spesielle
  paringsregler for High-low-high/Robbins som IKKE er rent individuelle
  poeng-per-hull).
- **Order of Merit** (sesong-sammenlagt på tvers av FLERE separate
  turneringer). Bekreftet av brukeren som et beslektet, men SISTE steg
  — forutsetter at individuelle turneringer med et poengresultat
  finnes først. Krever sannsynligvis et helt nytt overordnet konsept
  (en "sesong"/"serie", org-scopet, RLS som ellers) som grupperer flere
  `tournament`-rader og akkumulerer poeng — IKKE designet i denne
  runden.
- **Migrasjon/kode — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-30, IKKE ENNÅ
  RULLET UT.** Migrasjon `040_individual_tournaments.sql` (alle fem nye
  tabeller + `tournament.format_type`/`scoring_method`), nye motorfunksjoner
  i `handicap_engine.py` (`stroke_play_gross_total`/`stroke_play_net_total`/
  `stableford_points_for_hole`/`stableford_total`, 8 nye tester), og et
  fullt CRUD-API (`app/routers/individual_tournaments.py`: runder/
  turnering-deltakere/rundedeltakere/hull-for-hull-scoring/leaderboard).
  Se FEATURE_BACKLOG.md ("Utvidelse 2026-07-26"-seksjonens oppdatering
  2026-07-30) for full detalj om design, funn under bygging og
  verifisering (52 API-sjekker + 63 motor-tester). Frontend er bevisst
  IKKE bygget i denne runden — neste steg i samme "motor → skjema → API →
  frontend"-rekkefølge som ADR-033/038/039.

---

## ADR-038: Faktisk (beregnet) HCP vs. manuelt satt HCP

**Kontekst:** reist 2026-07-28, rett etter en gjennomgang av frittstående
rundeføring (ADR-033) som avdekket et reelt, ubesluttet hull: hele
WHS-indeksmotoren (`handicap_index_from_differentials`/
`low_handicap_index`/`apply_index_caps` i `handicap_engine.py`, testet
41/41) kalles ALDRI fra noe API-endepunkt — `round_participant.
score_differential` regnes og lagres per runde, men `app_user.
handicap_index` endres kun manuelt (`PATCH /auth/profile`). Bekreftet
eksplisitt i `rounds.py` sin egen moduldoc som "eksplisitt uavklart punkt
i ADR-033".

Brukeren avklarte at TeeCup skal operere med TO tall, ikke ett:

### Beslutning A — To atskilte HCP-verdier

- **Manuelt satt HCP** (eksisterende `app_user.handicap_index`,
  ADR-031) — UENDRET betydning og bruk: dette er tallet som brukes til
  Course Handicap-beregning i enhver runde/deltaker med mindre
  eksplisitt overstyrt per runde (allerede slik i dag via
  `handicap_index_snapshot`). Redigeres fritt av brukeren selv, som i
  dag.
- **Faktisk HCP** (ny `app_user.computed_handicap_index` +
  `computed_handicap_index_updated_at`) — WHS Handicap Index (Rule 5.2),
  regnet automatisk fra beste 8 av de ≤20 nyeste TELLENDE Score
  Differential-ene, med den offisielle opptrappingstabellen for færre
  enn 20 runder (`_INDEX_TABLE_UNDER_20`, allerede implementert og
  testet — ingen ny motorkode trengs, kun at den faktisk kalles). Aldri
  direkte redigerbar — kun avledet.

### Beslutning B — Uttrykkelig "overfør til manuelt HCP"-handling

Golferen får en egen, eksplisitt knapp ("Bruk som mitt HCP") i
`/account` som kopierer gjeldende `computed_handicap_index` inn i
`handicap_index` (samme skrivevei/historikk-logging som en vanlig
manuell PATCH). INGEN automatisk synkronisering noen vei — de to
tallene lever bevisst uavhengig av hverandre helt til brukeren selv
trykker knappen.

### Beslutning C — Eksplisitt eksklusjon per deltaker, kontrollert av HVER innlogget deltaker selv

Ny `round_participant.exclude_from_handicap boolean DEFAULT false` —
uavhengig av `counts_for_handicap` (som fortsatt er den AUTOMATISKE
WHS-kvalifiseringen, minimum spilte hull). Endelig medregning i faktisk
HCP krever BEGGE: `counts_for_handicap AND NOT exclude_from_handicap`.

**Avklart eksplisitt med bruker (AskUserQuestion), IKKE gjettet:** hver
INNLOGGET deltaker (eier ELLER en lenket medspiller, ADR-036 fase 3)
styrer SIN EGEN rads eksklusjon — ikke bare runde-eieren. Autorisasjon i
`PATCH .../participants/{id}` utvidet presist til akkurat dette ene
feltet: en ikke-eier kan KUN sende `exclude_from_handicap` (ethvert
annet felt i samme kall avvises 403) og KUN på sin egen rad (`rp.user_id
== requester`) — alle andre felt (tee/HCP/kjønn/navn/stat_level) forblir
strengt eier-only, uendret. Feltet er bevisst IKKE en del av
`rating_changed`-sperren i `update_participant` — kan endres uansett
fullført-status, siden det ikke påvirker AGS/differensial-matematikken
i seg selv, kun om resultatet TELLER i det HELE tatt.

### Beslutning D — Spilleform (slagspill/matchspill), selvdeklarert

Ny `round.play_format text DEFAULT 'stroke' CHECK (IN ('stroke',
'match'))`. Frittstående runder har i dag INGEN uavhengig måte å
"oppdage" matchspill på (ingen egen match-motor, ulikt turnering-siden)
— feltet er derfor selvdeklarert av brukeren ved oppsett (og fritt
redigerbart senere, som ren metadata, uendret av fullført-status).

**WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024.
pdf`, Rule 3.3 "When a Hole is Started But Player Does Not Hole Out"):
matchspill-scorer ER teknisk et gyldig HCP-grunnlag under WHS (Rule
2.1a), MEN et hull som avgjøres/konsederes før utspilt krever en
subjektiv "most likely score" (avgrenset til netto dobbel bogey) — noe
TeeCups rene tallregistrering ikke har noen UI-vei til å representere
presist. Dette begrunner brukerens instinkt ("man bruker vanligvis ikke
matchspill til å beregne hcp") uten å hardkode et forbud: WHS tillater
det teknisk, men TeeCup kan ikke garantere et pålitelig grunnlag for
det i dag.

**Derfor: spør, ikke tving.** Når `play_format = 'match'` velges (ved
opprettelse, ELLER ved senere tillegg av en lenket medspiller), foreslår
frontend `exclude_from_handicap = true` som FORHÅNDSVALGT verdi med en
forklarende tekst — brukeren kan uansett overstyre til `false` (f.eks.
en fullstendig utspilt vennskapelig 18-hulls "matchspill" der alle hull
faktisk ble spilt ut). Ingen server-side tvang — `play_format` og
`exclude_from_handicap` er to uavhengige felt i skjemaet; kun
frontendens forhåndsutfylling kobler dem sammen.

### Beslutning E — Datagrunnlag: designet for BEGGE kilder, bygget for én

**Avklart eksplisitt med bruker:** "faktisk HCP" skal på sikt kunne
telle scorer fra BÅDE frittstående runder OG organisasjons-/
turneringsscoring, men v1 bygger KUN kilden for frittstående runder.
Løst med ett tynt, bevisst uabstrahert skjøtepunkt — IKKE en ny,
generell "scoring record"-tabell (ville vært for tidlig abstraksjon for
en kilde som ikke finnes ennå, se CLAUDE.md sin regel mot design for
hypotetiske fremtidige krav): en enkelt funksjon
`_gather_qualifying_differentials(conn, user_id)` i `rounds.py` henter
i dag KUN fra `round_participant JOIN round`, med en kommentar som
peker ut nøyaktig dette som skjøtepunktet for en fremtidig UNION mot
turnering-siden (som uansett trenger sin egen ADR-037-baserte
poeng-/differensial-motor FØRST, se ADR-037 Beslutning D).

### Beslutning F — Low Handicap Index / cap-historikk gjenbruker `handicap_history`, ikke en ny tabell

Rule 5.7 (Low Handicap Index, laveste indeks i 365 dager) og Rule 5.8
(soft/hard cap) krever en historikk av TIDLIGERE beregnede indeksverdier
å sammenligne mot — samme grunnform som den eksisterende
`handicap_history`-tabellen (ADR-031-oppfølging, migrasjon 018), men
den logger i dag KUN manuelle profilendringer. Løst med én ny kolonne
`handicap_history.source text DEFAULT 'manual' CHECK (IN ('manual',
'computed'))` fremfor en parallell tabell — samme tabell, to kilder,
`low_handicap_index()` filtrerer på `source = 'computed'`. Det aller
første beregnede indekstallet for en bruker har ingen tidligere
`computed`-historikk å låses mot — capping hoppes bevisst over da
(Rule 5.8 sitt "Low HI" er per definisjon udefinert før en første indeks
er etablert), ikke behandlet som en feil.

### Konsekvens — hva denne ADR-en IKKE dekker

- Turnering-/organisasjonsscoring teller fortsatt IKKE mot faktisk HCP
  (Beslutning E) — venter på ADR-037s videre arbeid.
- Ingen automatisk periodisk "aging" av gamle differensialer utover de
  20 nyeste (Rule 5.5) — løst implisitt ved at spørringen alltid henter
  KUN de 20 nyeste, ikke en egen bakgrunnsjobb.
- Stableford-format for frittstående runder (se FEATURE_BACKLOG.md) er
  fortsatt ikke bygget — påvirker ikke denne ADR-en, siden Score
  Differential regnes fra Adjusted Gross Score uavhengig av
  poengformat.

---

## ADR-039: Ekte spillformer for frittstående runder (match/skins/par-lag)

**Kontekst:** reist 2026-07-28, rett etter ADR-038: "Når en singlerunde
settes opp: Er dette en slagspillsrunde, en match mellom to spillere,
skins, eller en par- eller lag-konkurranse. Avhengig av svaret så må hcp
beregnes forskjellig, og også scorekortet vil se annerledes ut." Viktig
presisering: `round.play_format` (ADR-038) styrer i dag KUN et
HCP-eksklusjonsforslag — ikke selve scorings- eller scorekort-modellen.
Dette utvider `play_format` til å faktisk STYRE begge deler.

Fire load-bærende beslutninger avklart eksplisitt med bruker
(AskUserQuestion, to runder) før denne ADR-en ble skrevet.

### Beslutning A — To sider per runde, gjenbruker ADR-011s antakelse

Match/fourball/foursome/greensome/scramble trenger et «hvem spiller
mot hvem»-konsept `round_participant` ikke har i dag (en flat liste
uten gruppering). Løst med nøyaktig TO SIDER per runde (ny
`round_side`-tabell), håndhevet i app-laget — samme mønster som
ADR-011s to-lags-grense for organisasjons-turneringer, ikke en
DB-constraint. En side er 1 spiller (match) eller 2+ (fourball/
foursome/greensome/scramble_2/scramble_4). `round_participant.
round_side_id` (nullable — kun satt for to-sidede formater) knytter
deltakeren til sin side.

### Beslutning B — Alle fire par-/lag-underformater fra start, samme motor som turneringer

Bruker valgte bredt omfang: fourball, foursome, greensome OG begge
scramble-variantene (`scramble_2`/`scramble_4`) — ikke bare fourball
(som alene ville passet uendret inn i dagens skjema).

**Gjenbruker den EKSISTERENDE, testede match-play-motoren fullstendig
uendret** (`handicap_engine.py` sin `Format`-enum,
`AllowanceStrategy`-familien, `match_play_strokes`,
`allocate_over_played_holes`, `compute_match_state`, `HoleResult`) —
ingen ny motorkode for disse fire formatene. Selve API-laget
(`app/routers/rounds.py`) porterer det allerede bevisste,
produksjonskjørte mønsteret fra `app/handicap.py`/`app/routers/
scoring.py` (`compute_and_store_side_handicaps` →
`relative_strokes_for_match` → `_side_net`/`_compute_hole_results` →
`recompute_and_cache_match_state`), tilpasset `round`/`round_side` i
stedet for `match`/`team_side`. Samme `_SIDE_IS_UNIT`/`FORMAT_UNIT_SIZE`-
tabeller (hvor mange spillere som kreves per side før handicap kan
beregnes) gjenbrukes identisk.

### Beslutning C — Delt-ball-formater: `round_hole` gjenbruker `hole_score`s nullable-nøkkel-mønster

Foursome/greensome/scramble har ÉN kombinert score per SIDE per hull,
ikke én per spiller — akkurat samme situasjon som org-scopet
`hole_score` allerede løser (`match_participant_id` NULLABLE, delt-ball-
rader identifisert av `team_side` alene, to partielle unike indekser).
`round_hole.round_participant_id` gjøres NULLABLE, ny
`round_hole.round_side_id` lagt til, med samme
`(participant XOR side)`-CHECK og to partielle unike indekser som
originalen. For fourball/match (individuell ball) forblir `round_hole`
uendret, én rad per DELTAKER per hull, akkurat som i dag.

**Konsekvens for statistikk (ADR-033 Beslutning B):** de detaljerte
per-hull-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
ingen entydig mening for en DELT ball (hvem sitt slag var det?) — for
delt-ball-formater lagres derfor KUN selve slagtallet på siderraden,
ingen av de andre detalj-feltene (samme "et fast sett felt, ikke fri
logg"-prinsipp, bare at feltsettet er tynnere for denne raden).

### Beslutning D — Skins: konfigurerbar netto/brutto OG rullerer/deles, ny motorfunksjon

Ingen eksisterende motorstøtte for skins i det hele tatt (ikke et
turnering-format). Bruker valgte at DEN SOM SETTER OPP runden skal
kunne velge BEGGE akser uavhengig — ikke ett fastlåst standardvalg:
- `round.skins_scoring`: `'net'` (HCP-slag trekkes fra, WHS-vanlig) |
  `'gross'`.
- `round.skins_tie_handling`: `'carry'` (uavgjort hulls "skin" ruller
  til neste hull, vinneren der tar hele potten) | `'split'` (uavgjort
  hulls skin deles likt mellom de tied spillerne i stedet).

Ny, ren, testbar motorfunksjon `compute_skins(scores_by_hole,
tie_handling)` i `handicap_engine.py` — tar allerede netto-ELLER-brutto-
avgjorte per-hull-scorer (kalleren bestemmer hvilket FØR kall, motoren
vet ikke selv om input er netto eller brutto), returnerer
`{participant_id: antall skins vunnet}` (float, siden 'split' kan gi
brøkdeler). Ingen sider trengs for skins — rent individuelt, som
slagspill.

### Beslutning E — HCP-telling per format

- **Slagspill, fourball, skins** (individuell ball — hver spiller har
  en FULL, ekte brutto-score per hull): teller normalt mot faktisk HCP,
  samme pipeline som i dag (Score Differential fra AGS). Bekreftet
  eksplisitt av bruker for skins — "krever fullt utspilte hull (ingen
  konsedering som i match) -- samme grunnlagsproblem som gjorde match
  upålitelig gjelder ikke her."
- **Match** (uendret fra ADR-038): fortsatt "spør, anbefal
  ekskludering" — Rule 3.3-problemet (konsederte hull) gjelder fortsatt
  uendret.
- **Foursome/greensome/scramble** (delt ball — INGEN individuell score
  finnes å telle): `counts_for_handicap` forblir usann for disse
  deltakerne i disse rundene, alltid, uansett antall spilte hull.
  Bekreftet eksplisitt av bruker som riktig løsning — matcher for
  øvrig hvordan WHS i praksis også behandler delt-ball-formater (ikke
  indeks-byggende på samme måte som individuell/fourball-spill).

### Beslutning F — Scorekort-presentasjon (frontend, ikke bygget i denne runden)

Notert for senere: match/fourball/foursome/greensome/scramble trenger
en løpende matchstatus-visning (gjenbruk av `describe()`-språket fra
`MatchState`, samme som `session-scorecard.tsx` allerede viser for
turnering-matcher) i stedet for rå slagsummer. Skins trenger en
skins-tavle (skins vunnet per spiller + hvilke hull som fortsatt er
"i potten"). Presist UI-design ikke gjort i denne runden — kun
backend/motor/skjema.

### Konsekvens — hva som bygges i DENNE runden vs. senere

Bruker valgte å gå rett til migrasjon + motor (ikke bare dokumentere).
Bygget: skjema (migrasjon 031), `compute_skins`-motorfunksjon + tester,
og backend-API for sider/delt-ball-scoring/format-resultat. Frontend
(nye skjermer for sideoppsett og de nye scorekort-presentasjonene,
Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
(backend/motor FØR frontend).

---

## ADR-040: «Det store grepet» — sammenhengende rundeoppsett + delt
Score/Scorekort/Leaderboard-navigasjon

**Kontekst:** reist 2026-08-01/02, som en direkte oppfølging av flere
visuelle redesign-runder (login-skjerm og dashbord, begge inspirert av et
eksternt design-verktøy, "Stitch"). Brukeren pekte på to strukturelle
problemer som selve fargeredesignet ikke løser: (1) rundeoppsettet er i
dag spredt over ETT langt skjema (`new-round.tsx`) OG en helt annen skjerm
(`round-detail.tsx`, etter at runden allerede er opprettet, for sider/
Money Ball-rekkefølge), (2) Score/Scorekort/Leaderboard for en frittstående
runde deler ingen fast navigasjon — hver side bygger sin egen fulle
side-chrome, ulikt Stitchs skisse.

**Beslutning A — rundeoppsettet blir én sammenhengende veiviser, 5 steg:**
Bane & tid → Spilleform (inkl. HCP-innstillinger) → Spillere → Lag/
rekkefølge (kun to-sidede formater/Money Ball) → Deling. Erstatter dagens
ett-langt-skjema-pluss-en-helt-annen-skjerm-mønster.

**Beslutning B — HCP-prosent er ALLTID justerbar, Match-HCP er en egen
bryter:** brukeren presiserte eksplisitt at prosenten (ADR-014s
`strategy`) aldri skal være låst til formatets standardverdi, og at
to-sidede formater i tillegg skal kunne skru `use_matchplay_handicap`
av/på — differensial-fordeling mellom sidene (laveste settes til 0
mottatte, resten mottar differansen fordelt fra laveste stroke index) i
stedet for ren brutto når det ikke er noen forskjell. **Funnet ved
verifisering, ikke antatt:** begge deler fantes allerede FERDIGBYGD i
motoren (`match_play_strokes()` i `handicap_engine.py`, ADR-014s "fire
brytere" i `app/handicap.py`) — kun brukt av org-scopede turneringer
(`session.allowance_override`). Frittstående runder (`round`-tabellen)
manglet bare selve kolonnen; ingen ny regnelogikk.

**Beslutning C — "Spillere og runde" (inkl. flighter) blir værende på
rundeoppsettsiden**, flytter IKKE inn i den nye delte fane-raden — dette
er forvaltning, ikke visning. Score/Scorekort/Leaderboard trenger i
stedet en kompakt, alltid tilgjengelig vei tilbake til oppsettsiden
(sammen med "Fullfør runde"/"Slett runde") — plassering (toppmeny vs.
footer vs. noe annet) er bevisst overlatt til V0 å foreslå ut fra en
presist beskrevet kontekst, ikke forhåndsbestemt her.

**Beslutning D — Scorekort og Statistikk slås sammen til ÉN side,**
statistikk RETT UNDER scorekortet — den delte fane-raden blir dermed tre
faner (Score / Scorekort / Leaderboard), ikke fire. `/my-rounds/[id]/
stats` forsvinner som egen rute, innholdet flytter inn i `/my-rounds/
[id]/scorecard`.

**Beslutning E — reelt, tidligere udokumentert funn: spillere i
scorekortet var ALDRI gruppert lagvis.** Brukeren rapporterte det
konkret (en ekte fourball-runde der Erol+Kåre var ett lag, Ellen
Anette+Christer det andre, men vist interleaved i rekkefølgen de ble
lagt til). Bekreftet i koden på BEGGE steder spillere vises for et
to-sidet format: `ScorecardGrid` (`round-detail.tsx`) og
`MatchScorecardGrid` (`round-scorecard.tsx`) sorterte ingen av dem på
`round_side_id` — ren innsettingsrekkefølge. Fikset med en liten,
presis sortering (stabil sort på side, side A samlet FØR side B) i
begge, IKKE en ny funksjon. Om celle-nivå-fargelegging (hvem som vant
hullet) alene er nok til å skille lagene visuelt, eller om det trengs
en tydeligere lag-blokk-markering, er et åpent spørsmål overlatt til V0
(samme "spør V0"-mønster som Beslutning C).

**Bygget og verifisert denne runden (2026-08-02):**
- Ny migrasjon `051_round_allowance_override.sql` — `round.allowance_
  override jsonb`, rent additivt.
- `app/routers/rounds.py`: ny `_round_allowance_override()`-hjelpe-
  funksjon (henter/parser friskt fra `round`-raden, samme mønster som
  `matches.py`/`scoring.py` sin `session.allowance_override` — IKKE
  cachet i selve config-objektet), koblet inn i `_recompute_side_
  handicaps`/`_relative_strokes_for_round`. `RoundCreate`/`RoundUpdate`/
  `RoundOut` fikk `allowance_override`. PATCH gjenberegner `playing_
  handicap` for allerede tildelte sider ved endring (samme mønster som
  `tournaments.py` sin `_recompute_session_matches`).
- Scratch-verifisert presist: 100%→50%-prosent ga `playing_handicap`
  24/10 → 12/5 (nøyaktig som beregnet for hånd fra HCP 20,0/10,0 mot en
  slope/rating-satt egendefinert bane), `use_matchplay_handicap:false`
  ga rå 12/5 i stedet for differensial 7/0 (`match_play_strokes([12,5])`
  → laveste=5 til 0, differanse 7). Slagspill upåvirket (regresjon
  bekreftet).
- Lag-grupperingsfiksen (Beslutning E) browserverifisert mot en fersk
  scratch-fourball-runde som gjenskapte brukerens eget rapporterte
  scenario nøyaktig (fire spillere lagt til i interleaved rekkefølge
  A→Rødt, B→Blått, C→Rødt, D→Blått) — bekreftet visuelt i BÅDE
  `ScorecardGrid` (live) og `MatchScorecardGrid` (post-runde) at Rødt
  lag nå vises samlet, deretter Blått.
- Rullet ut mot ekte systemer 2026-08-02, bruker bekreftet eksplisitt:
  migrasjon 051 kjørt mot ekte `teecup_db` (kolonne bekreftet,
  `test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
  --build teecup_api teecup_frontend`. Begge containere boot-et rent,
  `/health`/`/dashboard` → 200, anonym `PATCH /rounds/{ukjent-id}` ga
  korrekt `401` (ikke en rå 404 — bekrefter ruten når FastAPI),
  `teeoff.no` upåvirket.

**Steg 3 (V0-prompt 1, rundeoppsett-veiviseren) ✅ BYGGET, GRUNDIG
VERIFISERT OG LIVE (2026-08-02), samme dag:** V0-prompten (Beslutning A/B)
skrevet og kjørt av bruker (zip 25), `frontend/components/new-round.tsx`
skrevet fullstendig om — V0s 5-stegs veiviser-skall flettet med ekte
logikk: offisiell/egen bane-søk (inkl. nearby-geolokasjon), ekte egen-
bane-opprettelse (portert fra tidligere versjon), ekte `/people/search`,
ny `allowance_override`-konstruksjon i steg 2 (samme mønster som
`CreateSessionCard`s `buildAllowanceOverride`), og en helt ny
innsendingsorkestrering — runden (+ deltakere + sider/lineup) finnes
FØRST når HELE veiviseren er fullført (runde → sider → medspillere med
`round_side_id` satt direkte på opprettelse → eierens egen side-
tildeling via PATCH → Money Ball-rekkefølge via PATCH → naviger til
runden), ulikt den gamle "opprett runde først, legg til spillere
etterpå"-flyten.
**Reelle feil funnet og rettet under integrering:** V0s egen `Tee`-type
manglet kjønnsfelt (ville vist ufiltrerte utslag for både eier og
gjester) — lagt til `genders: ApiGender[]`, filtrert riktig per kjent
kjønn. To TS-feil (prop-spredning som overskrev `course`/`ownGender`
tilbake til `null`, og en `"x"`-kjønn-mismatch i tee-filtreringen).
**Bevisst, ærlig begrensning:** en spiller funnet via `/people/search`
har ukjent kjønn før de faktisk er lagt til runden (samme begrensning
som `PersonMatch`-modellen alltid har hatt) — utslagslisten for en slik
spiller er derfor UFILTRERT i veiviseren, med en forklarende tekst;
server-validering gir en klar feil ved et faktisk uforenlig valg.
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
ruter listet), og to komplette nettleser-gjennomkjøringer mot en fersk
scratch-backend — én Fourball-runde (egen bane, gjest, to sider
opprettet og tildelt interleaved, 90 % HCP-prosent) og én Slagspill-
runde (rene standardvalg). Bekreftet direkte mot databasen at
`allowance_override` ble bygget nøyaktig riktig i begge tilfeller
(`{"strategy":{"type":"per_player","percentage":0.9},...}` for
Fourball — riktig `per_player`, ikke `combined`, siden fourball ikke er
en side-enhet — og `null` for Slagspill, ingen unødvendig payload),
sidene/tildelingene stemte, og HCP ble beregnet korrekt fra ekte
profildata (kryssjekket mot hånd-regnet Course Handicap for begge
testspillerne).
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
migrasjon (ren frontend), `docker compose up -d --build teecup_frontend`
(gjenskapte også `teecup_api` som vanlig bivirkning, ingen backend-kode
rørt). Begge containere boot-et rent, `/health`/`/dashboard`/
`/my-rounds/new` → 200, `teeoff.no` upåvirket.

**Steg 4-5 (V0-prompt 2, den delte Score/Scorekort/Leaderboard-fane-raden
+ integrering) ✅ BYGGET, GRUNDIG VERIFISERT OG LIVE (2026-08-02), samme
dag:** V0-prompten (Beslutning C/D) skrevet — bevisst SMALERE i omfang
enn prompt 1: kun den delte header/fane-raden, ikke en redesign av selve
innholdet på Scorekort-siden (den sammenslåingen er ren sammenstabling av
to allerede ferdige komponenter, løst direkte i kode uten V0). Kjørt av
bruker (zip 26) — leverte `components/round-header.tsx` (kontekstblokk
med tilbakeknapp/bane/tee/dato/tid/Matchspill-merke, tre-fanet
segmentert kontroll, og en "Administrer"-knapp som åpner en bunnsheet-
dialog med lenke til "Spillere og runde" + "Fullfør runde" + to-stegs
"Slett runde") — tatt inn UENDRET (ren presentasjon, ingen logikk å
bytte ut).
**Ekte datalag bygget rundt den:** ny `components/round-page-shell.tsx`
— henter kun de feltene headeren trenger via `/rounds/{id}`, bygger
`tabHrefs` til de tre ekte rutene, og implementerer `onFinishRound`/
`onDeleteRound` som egne, selvstendige handlinger (samme
`POST .../complete`/`DELETE ...`-endepunkter som `round-detail.tsx` sin
opprinnelige logikk, men uavhengig av den siden komponenten også må
fungere fra Scorekort- og Leaderboard-siden).
**Wiret inn i alle tre destinasjonene:** Score (`round-detail.tsx`sin
gamle egne header fjernet, erstattet med `RoundPageShell` — den
INTERNE "Score"/"Spillere og runde"-fanevekslingen beholdt UENDRET som
et bevisst lavrisiko-valg, nås nå i tillegg via headerens
"Administrer"-dialog med en ny `?tab=manage`-URL-parameter lest ved
mount), Scorekort (`round-scorecard.tsx` + `round-stats.tsx` slått
sammen til ÉN side via en ny `embedded`-prop på begge, statistikk RETT
UNDER scorekortet som spec-et i Beslutning D — gammel `/stats`-rute er
nå en ren redirect dit), Leaderboard.
**Reelle feil funnet og rettet under integrering:** to `<main>`-
landemerker på samme side etter sammenslåingen av Scorekort+Statistikk
(ugyldig HTML/tilgjengelighet) — rettet med en `ContentTag`-switch
(`div` når embedded, `main` ellers) i `round-stats.tsx`. Fjernet nå
overflødige kryss-lenker mellom de to sammenslåtte sidene, kollapset
"Se scorekort"/"Se full rundestatistikk" til én "Se scorekort og
statistikk"-knapp i `round-detail.tsx` sitt fullført-banner.
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
ruter listet), full nettleser-gjennomkjøring gjennom hele "Administrer"-
flyten — headeren konsistent på tvers av alle tre fanene, dialogens
lenke til "Spillere og runde" bekreftet å faktisk åpne riktig intern
fane via `?tab=manage`, ekte "Fullfør runde" (bekreftet `completed_at`
satt via et direkte API-kall etterpå) og ekte to-stegs "Slett runde"
(bekreftet navigerte korrekt til tom-tilstand på `/my-rounds`
etterpå). Traff en Turbopack-flakighet i selve scratch-dev-serveren
underveis (stuck spinner, ingen nettverkskall i det hele tatt) — løst
med en frisk containerstart, bekreftet IKKE en kodefeil (samme
kjente ustabilitetsklasse som tidligere dokumentert i dette
prosjektet, ikke noe nytt).
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
migrasjon (ren frontend), `docker compose up -d --build
teecup_frontend` (gjenskapte også `teecup_api` som vanlig bivirkning,
ingen backend-kode rørt). Begge containere boot-et rent,
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.

**ADR-040 («det store grepet») er dermed HELT FERDIG, alle 5 steg,
backend + frontend, live:**
1. ~~Backend: `allowance_override`-migrasjon + kobling~~ ✅
2. ~~Kode-fiks: lag-sortering~~ ✅
3. ~~V0-prompt 1: den samlede rundeoppsett-veiviseren~~ ✅
4. ~~V0-prompt 2: den delte Score/Scorekort/Leaderboard-fane-raden~~ ✅
5. ~~Integrering + browserverifisering + utrulling~~ ✅

Bevisst utenfor omfang, egen fremtidig vurdering om ønskelig: den
INTERNE "Score"/"Spillere og runde"-fanevekslingen på selve Score-siden
ble bevisst beholdt (ikke fjernet/erstattet av `?tab=manage`-mekanismen
alene) som et lavrisiko-valg — kan forenkles videre senere om ønsket,
ikke noe brukeren har bedt om.

---

## ADR-041: Konkurranseklasser ("Damer fra 44, Herrer fra 50+")

**Kontekst:** Brukeren spurte om det var mulig å sette opp runder i en
turnering slik at f.eks. damer spiller fra utslag 44 mens herrer spiller
fra 50+. Undersøkelse viste at DELER allerede virket (hvert
`match_participant`/`round_participant` har alltid hatt sitt eget
`tee_id`, uavhengig av de andre, med server-side validering av at
utslaget har rating for spillerens kjønn, ADR-029) — men det var et
manuelt valg per spiller hver gang, ingen gjenbrukbar "klasse"-
gruppering. Ingen "klasse"/kategori-konsept for konkurranse fantes fra
før noe sted i skjemaet (kun den urelaterte venne-kategoriseringen,
ADR-036).

**Beslutning A — Klasser er fritt navngitte, ikke en fast taksonomi.**
Organisator oppretter klasser med eget navn (f.eks. "Damer", "Herrer A",
"Junior") og et VALGFRITT standardutslag — ikke en kjønns- eller
alders-spesifikk datamodell. Bekreftet eksplisitt med bruker
(`AskUserQuestion`) framfor en fast kjønn+alder-kombinasjon: dekker
kjønn/alder/HCP eller annet uten at systemet må forstå forskjellen,
samme fleksibilitets-prinsipp som `friend_categorization` (ADR-036).

**Beslutning B — Delt tabell, ikke duplisert per turneringstype.** Ny
`tournament_class`-tabell (migrasjon 053) FK'et til `tournament`, ikke
en utvidelse av selve `tournament`-raden — samme sidetabell-mønster som
`tournament_sponsor`. Siden BÅDE lagturneringer (ADR-011) og
individuelle turneringer (ADR-037) peker til samme delte `tournament`-
tabell (`format_type`-diskriminator), dekker ÉN tabell begge uten
duplisering. Klassetilhørighet lever på `team_roster.class_id`
(lagturneringer) og `tournament_participant.class_id` (individuelle
turneringer) — begge nullable, `ON DELETE SET NULL` (samme prinsipp som
`tournament_round_bbb_hole`s FK-er, migrasjon 044): sletter man en
klasse, forsvinner ikke roster-/deltaker-radene, de mister bare
merkelappen.

**Beslutning C — Standardutslag er en frontend-bekvemmelighet, ikke en
ny backend-mekanisme.** `tee_id` er ALLEREDE required og satt per
deltaker (ikke per økt/runde) ved både `POST .../matches/{id}/
participants` og `POST .../rounds/{id}/participants` — klassens
standardutslag forhåndsvelger bare dette feltet i UI-et (fortsatt fritt
overstyrbart), backend-kontrakten er uendret. Samme mønster som
`new-round.tsx`s eksisterende kjønnsfilter/-default for personlige
runder. Forhåndsvelgingen sjekker eksplisitt at utslaget faktisk finnes
på DEN aktuelle øktens/rundens bane før det brukes — en klasses
standardutslag kan tilhøre en annen bane enn den som er i bruk akkurat
da.

**Beslutning D — Egen resultatliste KUN i individuelle turneringer,
aldri i lagturneringer.** Den mest substansielle avklaringen
(`AskUserQuestion`, med begrunnelse presentert først): i lagturneringer
er poeng knyttet til hele KAMPER (lag mot lag, `match.points_side_a/b`),
ikke enkeltspillere — å dele opp dette per klasse gir ikke naturlig
mening, og brukeren bekreftet eksplisitt at klasse der KUN skal foreslå
utslag. Det finnes fra før et smalt, PER-ØKT individuelt leaderboard for
singles/fourball-slagspill (`fetch_individual_leaderboard`,
`tournaments.py`) — bevisst IKKE utvidet eller gjenbrukt av denne runden,
det er en annen, allerede eksisterende funksjon med annet formål
(sesjonsscoped, format-begrenset). I individuelle turneringer (flatt
felt, ADR-037) er splitting derimot en naturlig match: `individual_
leaderboard`-endepunktet fikk `class_id`/`class_name` lagt til i
SELECT+respons, men selve summerings-/sorteringslogikken er UENDRET —
frontend grupperer den allerede korrekt sorterte listen visuelt (stabil
gruppering bevarer riktig rangering per klasse), ingen ny motorlogikk i
`handicap_engine.py` trengtes (Copenhagen/BBB var allerede scope-
agnostiske på deltaker-mengde).

**Konsekvens:** `053_tournament_classes.sql`. Ny klasse-CRUD i
`tournaments.py` (delt, siden `tournament`-tabellen er delt). `Roster
EntryUpdate` (tidligere KUN `is_captain: bool` påkrevd) lagt om til
`exclude_unset`-PATCH-semantikk. Ny `PATCH .../participants/{id}` i
`individual_tournaments.py` (fantes ikke før). Frontend: "Klasser"-kort
i BÅDE `tournament-detail.tsx` og `individual-tournament-detail.tsx`,
utslag-forhåndsutfylling i `session-blind-draw.tsx`s `AddSlotForm` og
`individual-tournament-detail.tsx`s `AssignRoundParticipantControl`,
klasse-gruppert `LeaderboardTab` (individuelle turneringer). Se
CHANGELOG.md 2026-08-04 for full bygge-/verifiseringsdetalj (inkl. tall-
for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.

---

## ADR-042: Delt, plattform-omfattende bane-mal-bibliotek (`personal_course`)

**Kontekst:** Brukeren oppdaget at turneringsmodulens "opprett manuell
bane" i praksis var ubrukelig (kun et navnefelt, ingen vei til hull/
utslag), og ba om at BÅDE turneringsmodulen og single-runde-modulen skal
tilby "bruk en eksisterende bane (TeeOff eller andres custom-bane) som
mal" ved manuell baneoppretting. Et oppfølgingsspørsmål presiserte at
disse custom-banene "bør lagres, og de må være offentlige" — synlige og
gjenbrukbare av andre.

**Beslutning A — Gjenbruk `personal_course`, ikke en ny tabell.**
`personal_course` (020_personal_rounds.sql, opprinnelig bygget for
frittstående runder) er, til tross for navnet og til tross for at ingen
tidligere runde eksplisitt utnyttet det, ALLEREDE en global,
plattform-omfattende katalog — `GET /personal-courses` søker på tvers av
ALLE brukeres baner uten eier- eller organisasjonsfiltrering, siden
tabellen (bevisst, fra migrasjon 020) aldri har vært RLS-/org-scopet.
Fremfor å bygge en ny `community_course`-tabell (vurdert og forkastet)
gjenbrukes denne eksisterende, allerede-globale tabellen direkte som det
delte mal-biblioteket — eneste tilføyelse er én kolonne,
`forked_from_id` (migrasjon 054, selvreferende FK, `ON DELETE SET NULL`)
for proveniens/attribusjon. Dette er også den ENESTE farbare veien for
at single-runde-modulen (som ikke har noe organisasjons-begrep i det
hele tatt) kan dele samme bibliotek som org-turneringsmodulen.

**Beslutning B — "Offentlig" betyr hele plattformen, ikke bare egen
organisasjon.** Bekreftet eksplisitt med bruker (`AskUserQuestion`) etter
at jeg flagget spenningen: skal en publisert custom-bane være synlig for
ALLE organisasjoner + alle frittstående brukere, eller kun innad i én
organisasjon? Svaret var plattform-omfattende — organisasjonsgrensen
gjelder domenedata (turneringer, spillere, resultater), ikke dette
delte, lavsensitive bane-referansebiblioteket. En org-`course`-rad
(brukt til faktisk spill i en turnering) forblir like fullt org-scopet
og RLS-beskyttet som før — publisering til `personal_course` skjer som
en SEPARAT, samtidig innsetting i samme transaksjon (`courses.py`), ikke
en endring av `course`-tabellens egen skoping.

**Beslutning C — Eierskap håndheves i applikasjonslaget, ikke RLS.**
Siden `personal_course` bevisst aldri har vært org-scopet, finnes det
ingen `app.current_org`-kontekst å håndheve mot. De nye
endre-/dupliser-/slette-endepunktene (`rounds.py`) sjekker eksplisitt
`created_by_user_id == innlogget bruker` i handler-koden — samme
`plain_connection()`-uten-RLS-mønster som resten av `personal_course`/
`round`-familien allerede bruker (ADR-033 Beslutning A).

**Beslutning D — Rediger forker automatisk hvis du ikke eier raden;
Dupliser er en egen, eksplisitt handling.** `PATCH /personal-courses/{id}`:
eier den innloggede brukeren raden, oppdateres den i sted; eier
brukeren IKKE raden, opprettes automatisk en NY rad (eid av innlogget
bruker, `forked_from_id` satt til originalen) — originalen selv røres
aldri. Ett endepunkt dekker begge casene uten at frontend selv må
forgrene på eierskap. `POST .../duplicate` er en SEPARAT, eksplisitt
handling tilgjengelig uansett eierskap på kilden — dekker f.eks. å lage
en variant av DIN EGEN bane uten å miste originalen, noe fork-ved-
redigering alene ikke gjør (den trigges kun når raden IKKE er din).
Bekreftet eksplisitt med bruker at begge mekanismene skulle beholdes
side om side.

**Beslutning E — Mal-bruk er en engangs-kopi, ingen vedvarende kobling.**
Når en org-`course` opprettes med en `personal_course` (eller en
TeeOff-bane) som utgangspunkt, kopieres dataene inn i den nye raden ved
opprettelsestidspunktet — ingen fremmednøkkel eller synk-mekanisme
knytter dem sammen etterpå. Samme filosofi som offisiell TeeOff-import
allerede etablerte (ADR-019): en mal er et utgangspunkt å redigere fritt
fra, ikke en levende referanse. Konsekvens: sletting av en
`personal_course`-mal påvirker ALDRI en org-`course`-rad som tidligere
ble opprettet fra den.

**Konsekvens/bevisst utenfor omfang:** ingen moderasjon/vetting av
offentlige baner (tillitsbasert, matcher appens øvrige
lukkede-plattform-antagelser); ingen privat/kun-min-org-synlighet per
bane (alt publisert via denne veien er plattform-offentlig, ingen
per-rad-innstilling bygget). Se CHANGELOG.md 2026-08-04 for full
bygge-/verifiseringsdetalj (34/34 håndregnede API-sjekker, ekte
nettleser-verifisering på tvers av to brukere/to organisasjoner).
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.

---

## ADR-043: Order of Merit — sesong-sammenlagt rangering [OOM]

**Kontekst:** Brukeren ba om en vurdering av GolfBox sin "Order of
Merit"-funksjon (opplastet PDF) og om noe var verdt å adoptere. Svaret
var "ja, delvis": skillet mellom HVA som telles per turnering
(`result_type`) og HVORDAN det summeres over en sesong
(`aggregation_mode`) er et genuint godt, gjenbrukbart mønster —
GolfBox sin lag-kobling (strengmatching på lagnavn på tvers av
turneringer) ble eksplisitt flagget som noe å IKKE kopiere, siden
TeeCup allerede har ekte `player_id`-identitet på tvers av hele
organisasjonen.

**Beslutning A — Kun individuelle turneringer kan lenkes.** En OOM
(`order_of_merit`, migrasjon 055) lenker KUN `tournament.format_type =
'individual'`-turneringer (ADR-037) — ikke org-lagturneringer (ADR-011),
av samme grunn som konkurranseklassenes leaderboard-splitting (ADR-041):
poeng i en lagturnering er knyttet til hele kamper, ikke enkeltspillere.

**Beslutning B — To uavhengige akser, fem resultattyper.** `result_type`
(poeng-etter-plassering/Stableford-sum/brutto/netto/pengeliste) utleder
HVA som telles fra en lenket turnerings EGEN, allerede beregnede
leaderboard (gjenbruker `_compute_individual_standings`, faktorisert ut
av `individual_tournaments.py` sin `individual_leaderboard` 2026-08-04 —
ingen duplisering av posisjon-/til-par-beregningen).
`aggregation_mode` (sum/snitt/eclectic) avgjør HVORDAN dette summeres
over flere turneringer, uavhengig av `result_type`. Poeng/pengeliste
trenger en per-lenke konfigurasjon (`order_of_merit_link.points_table`/
`money_payout_table`, JSONB) siden ulike turneringer i samme OOM kan gi
ulik poengverdi for samme plassering.

**Beslutning C — "Lag" i en OOM er et sesong-par satt opp DIREKTE i
OOM-en, ikke hentet fra noen turnering.** Avklart eksplisitt med bruker
etter at jeg flagget en strukturell floke (individuelle turneringer har
per definisjon ingen lag, ADR-037): `order_of_merit_team`/
`_team_member` (migrasjon 055) er en REN gruppering av spillere oppå
deres allerede beregnede individuelle OOM-resultater — ingen
strengmatching, ingen ny turnering-struktur. Bevisst IKKE bygget i denne
runden (se FEATURE_BACKLOG.md).

**Beslutning D — `kind` og `result_type` låses etter opprettelse.**
Matcher GolfBox sin egen tilsvarende låsing, strukket til også å dekke
`result_type` her: å endre den i etterkant ville gjort eksisterende
lenkers poeng-/pengetabell-konfig meningsløs uten en egen
migreringssti. `PATCH`-endepunktet eksponerer bevisst ikke disse to
feltene i det hele tatt (ikke bare app-lags-validering) — opprett en ny
OOM i stedet.

**Beslutning E — "Regn ut ved lesing", ingen cache-tabell.** Samme
filosofi som Nassau/High-low-high/`individual_leaderboard` selv —
OOM-leaderboardet beregnes helt på nytt ved hvert `GET`-kall (henter
alle lenkede turneringers standings, utleder bidrag per spiller, filtrerer
på aldersgrense/minimum-resultater, aggregerer med
`order_of_merit_aggregate`, rangerer). Ingen ytelsesbekymring i praksis
gitt forventet skala (en sesong har typisk noen få til noen titalls
lenkede turneringer).

**Bevisst utenfor omfang v1 (se FEATURE_BACKLOG.md for full liste):**
Eclectic-aggregering (per-hull "drømmerunde" på tvers av lenkede
turneringer — krever egen datainnsamlingslogikk, ikke bare en ny
aggregeringsmodus); Lag-OOM sin faktiske leaderboard-beregning (CRUD-
skjema finnes fra migrasjon 055, men `GET .../leaderboard` avviser
eksplisitt `kind='team'` med en tydelig feilmelding inntil videre).

**Motor** (`handicap_engine.py`): `order_of_merit_points_for_position`
(uavgjort deler SAMME poengverdi ved sin felles plassering, ikke
gjennomsnitt) og `order_of_merit_aggregate` (sum/snitt, valgfri
behold-N-beste — "best" er alltid størst-er-bedre, kalleren negerer
brutto/netto-verdier før kall). 9 nye enhetstester, alle grønne.

**Frontend:** to nye sider (`/organizations/[id]/order-of-merit` liste
+ opprett, `.../[oomId]` detalj: lenkede turneringer → innstillinger →
resultatliste, i den rekkefølgen). Håndkodet (ikke V0 — ingen
V0-credits tilgjengelig denne runden), bevisst bygget til samme
visuelle presisjon som appens V0-eksporter (samme tokens/kort-mønster
som `org-members.tsx`, samme gull-ledertrøye-mønster som
`stroke-play-leaderboard.tsx`). Ny inngangslenke fra `org-members.tsx`.

**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-/frontend-container): 63/63
håndregnede API-sjekker (fem resultattyper, uavgjort-håndtering,
behold-N-beste, minimum-resultater, aldersgrense, låst `kind`/
`result_type`), RLS-isolasjon eksplisitt testet på de fire nye
tabellene (kryss-org-lesing gir 0 rader, kryss-org-skriving blokkeres av
`WITH CHECK`), `test_isolation.sql` 12/12 uendret, ekte
nettleser-gjennomgang (liste, opprettelse, lenke turnering inkl.
poeng-tabell-skjemaet, innstillinger, gull-ledertrøye, ikke-kvalifisert-
seksjon). Full detalj i CHANGELOG.md 2026-08-04.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.

---

## ADR-044: Kommentarer/bilder på frittstående runder + samlet feed

Reist av brukeren 2026-08-06: samme "Banter Board"-mulighet (bilde+tekst)
som org-turneringer allerede har (ADR-025), men for en enkelt, frittstående
runde — pluss en sentral feed-side som samler dette på tvers av runder.
Underveis avdekket research at rundevisibilitet (ADR-036 fase 2 —
`round.visibility_mode`/`round_visible_category`) allerede var bygget og
live siden 2026-07-28 (en eldre, aldri oppdatert CHANGELOG-seksjon sa
fortsatt "ikke bygget") — denne ADR-en gjenbruker den infrastrukturen
uendret, den handler kun om selve kommentarene og feed-aggregeringen.

**Beslutning A — Skriverett: kan-se-kan-poste.** Alle som kan SE en runde
(eier, lenket medspiller, `public`-synlig, eller riktig-kategorisert
`friends`-venn — samme `_can_view_round` som ADR-036) kan også POSTE et
bilde/kommentar til den. Samme "kan se = kan bidra"-modell som org-feeden,
IKKE begrenset til eier+medspillere. Avklart eksplisitt med bruker
(AskUserQuestion) — overstyrer et tidligere, snevrere "eier+medspillere"-
utkast notert i FEATURE_BACKLOG.md 2026-08-06.

**Beslutning B — Moderering: forfatter ELLER rundeeier.** Samme
"forfatter-eller-admin"-modell som org-feedens `delete_feed_message`
(ADR-025 Beslutning C) — rundens eier er her analogen til org-admin. Ulikt
lag-chattens strengere "kun forfatter, ingen unntak" (den er ekte privat,
en runde-kommentarseksjon åpen for tredjeparter trenger en reell
moderasjonsvei).

**Beslutning C — Egen `round_message`-tabell (migrasjon 058), ikke
gjenbruk av org sin `message`-tabell.** `round` har verken
`organization_id` eller RLS (ADR-033 Beslutning A) — en delt tabell ville
krevd enten en kunstig organisasjon rundt hver frittstående runde eller et
RLS-unntak. Samme feltform (forfatter-snapshot, valgfri tekst, valgfri
bilde-nøkkel) og samme MinIO+AVIF-pipeline (`app/storage.py`, ADR-018)
gjenbrukt uendret, egen `prefix="round_messages"`. Autorisasjon håndheves
utelukkende i app-laget (`app/routers/round_messages.py`,
`plain_connection()`), ikke database-policy — samme mønster som
`round`/`round_participant`.

**Beslutning D — Sanntid: ingen ny WebSocket-kanal.** Gjenbruker den
eksisterende `broadcast_round_update`/`/ws/rounds/{id}/live`-kringkastingen
(allerede trigget ved hver hull-endring) fremfor et nytt, payload-bærende
kanal-mønster (slik org-feeden bruker). `round-detail.tsx` sin eksisterende
WS-lytter fikk ett nytt tick-signal (`messagesRefreshTick`) som
`RoundMessages` reagerer på med en vanlig REST-refetch — ingen egen socket
for kommentarer. Bekreftet fungerende i ekte nettleser (se
CHANGELOG.md 2026-08-06) — MERK at dette KUN virker gjennom Caddy sin
dedikerte `handle /ws/* { reverse_proxy teecup_api:8000 }`-rute
(`/opt/teeoff/deploy/Caddyfile`), IKKE gjennom Next.js sitt eget
rewrite-lag (som ikke pålitelig proxyer WebSocket-oppgraderinger i
standalone-modus) — samme forutsetning som all annen runde-/turnering-
sanntid i appen.

**Beslutning E — Ny, dedikert `/my-feed`-side, IKKE en dashbord-seksjon.**
Avklart eksplisitt med bruker. Aggregerer på tvers av egne runder (uansett
`visibility_mode`) + venners synlige runder, modellert på
`list_friends_on_course` (`GET /friends/on-course`) sin reverserte retning
(viewer → venners synlige runder), men uten "spiller nå/siste 24t"-
filteret — en all-time, paginert (keyset, `?before=`) liste av
`round_message`-rader. Nås fra dashbordets "Venner på banen"-seksjon
(`SeeAllLink`) og en ny lenke i `/more`.

**KRITISK, funnet under full-stack-verifisering: rute-/rewrite-kollisjon,
samme klasse som `/rounds`→`/my-rounds` og `/friends`→`/my-friends`
(dokumentert i `frontend/next.config.mjs`).** Frontend-siden ble først lagt
på `/feed` — nøyaktig samme streng som det flate API-endepunktet `GET
/feed`. Uten en eksplisitt rewrite-regel vant Next.js sin egen side-rute
over ethvert forsøk på server-side proxy, så `fetch("/feed")` fra klienten
fikk appens egen HTML tilbake i stedet for JSON (stille feilet
JSON-parsing). Rettet ved (1) å flytte frontend-siden til `/my-feed`
(samme navnemønster som de tre presedensene over) OG (2) legge til en
manglende, eksplisitt `{ source: "/feed", destination: ... }`-regel i
`next.config.mjs` (det flate mønsteret, uten `:path*`, siden `/feed` ikke
har understier) — retting nummer to var nødvendig uavhengig av
sideflyttingen, siden regelen rett og slett manglet helt fra start.

**Scratch-verifisert grundig** (isolert scratch-DB/rolle/MinIO/API-/
frontend-container, PLUSS en minimal scratch-Caddy satt opp spesifikt for
å faktisk bevise WS-refetchen — ikke bare anta at den ville virke i
produksjon): 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen —
eier poster/sletter egen, medspiller poster, en annen ikke-eier kan IKKE
slette andres, eier KAN moderere andres, fremmed uten innsyn avvist på
GET+POST, `friends`-synlighet riktig inkludert/ekskludert per kategori,
feed-aggregering korrekt per bruker, paginering uten hopp/duplikat, anonym
`/feed`-tilgang korrekt 401). Ekte bildeopplasting bekreftet konvertert til
AVIF og lagret riktig. Ekte nettleser: kommentar+bilde postet og vist,
sletting fungerte, `/my-feed` viste riktig aggregert sett (egne + andres
offentlige runder), WS-refetch bekreftet fungerende GJENNOM Caddy (en
kommentar postet i én "økt" dukket opp automatisk i en åpen fane uten
interaksjon). Ekte typesjekket produksjonsbuild av frontend kjørt og
bekreftet (fanget rute-kollisjonen over ved kjøretid, ikke ved bygging —
typesjekk alene fanger ikke denne klassen feil). `test_isolation.sql`
uendret 12/12. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-/Caddy-
containere/images, V0-eksport-zip-er) ryddet opp fullstendig etter bruk.

**Frontend bygget via V0** (brukerens eksplisitte, stående arbeidsfordeling
— Claude skriver V0-prompten, bruker limer inn selv, Claude integrerer
eksporten): to prompter (kommentarseksjon + feed-side), begge levert med
eksakte data-kontrakter/design-tokens fra `DESIGN_SYSTEM.md` forhåndsutfylt
i prompten. Begge eksportene (zip 32/33) diffet ordrett mot hverandre og
mot ingenting-fra-før (nye filer) før integrering — ingen utilsiktet drift.
To små, bevisste integrasjonsjusteringer utover ren copy-inn: fjernet en
ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me` (samme
mønster som `round-detail.tsx`) i stedet for å kreve `currentUserId` som
ekstern prop, siden alle andre sider i appen er tynne wrappere som selv
henter egen brukeridentitet.

**Bevisst utenfor omfang:** egen payload-bærende WS-kanal for kommentarer,
redigering av innlegg (kun slett, samme som org-feeden), liker/reaksjoner,
varsler ved ny kommentar, sanntid på selve `/my-feed`-siden, inline
slett-knapp på `/my-feed` (kun fra runde-siden).

---

## ADR-045: Tilskuer-visning (`/watch/[id]`) — tre faner + "Spillere og runde", full paritet med eiersiden

Reist av brukeren 2026-08-06 (video-illustrasjon av bugen): en tilskuer som
klikker seg inn på en venns pågående runde via "Venner på banen" endte på
`/my-rounds/{id}` (eier-/medspiller-only, `_get_accessible_round_or_404`)
i stedet for `/watch/{id}` (den faktiske tredjepartsvisningen, ADR-036
fase 2) — rettet samme dag som en liten, isolert lenke-bug (se CHANGELOG
2026-08-06, punkt 26). I samme runde ba brukeren om at `/watch/[id]`
(fram til da ÉN enkelt side, kun kompakt status + leaderboard/matchstatus)
skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard) og
vise "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
flight-gruppering), uten eier-kun-handlingene (Rediger/Fullfør/Slett
runde, +Medspiller).

**Beslutning A — Full formatparitet via delt komponent + `publicMode`-
bryter, IKKE nye, forenklede tilskuer-komponenter.** `round-scorecard.tsx`
(`RoundScorecard`) og `round-leaderboard.tsx` (`RoundLeaderboard`) sin
faktiske rutenett-/tavle-rendring var ALLEREDE ren, skrivefri visning —
redigering skjer et helt annet sted (Score-fanens `ScoringWizard` i
`round-detail.tsx`). Det eneste som hindret gjenbruk for tilskuere var at
alle interne data-hentinger (flere hooks: `useFormatResult`,
`useFlightTeamStandings`, `useLeaderboardData`, pluss selve
`RoundScorecard`/`RoundLeaderboard` sine egne fetch-effekter) hardkodet
`/rounds/*`-prefikset og en autentisert WebSocket. Løsning: en ny,
valgfri `publicMode`-prop (default `false`, INGEN endring i eiersidens
oppførsel) som bytter alle interne URL-er til `/public/rounds/*` og
WS-kanalen til `/ws/public/rounds/{id}/live`. Dette gir ekte full
formatparitet (alle formater, ikke et utvalg) for null ekstra
vedlikeholdshold -- samme kode, to datakilder -- fremfor å bygge og
vedlikeholde en parallell, forenklet visning. Bekreftet eksplisitt med
bruker (AskUserQuestion) at full detalj var ønsket, ikke en forenklet
fellesvisning.

**Beslutning B — Ingen gjenbruk av `RoundHeader`/`RoundPageShell` for
selve fane-navigasjonen.** `RoundPageShell` gjør sitt eget autentiserte
`fetch('/rounds/{id}')`-kall og hardkoder `/my-rounds/{id}`-hrefs.
`RoundHeader` er i og for seg props-styrt, men "Administrer"-tannhjulet
render ALLTID og åpner `ManageRoundDialog` (Rediger/Fullfør/Slett-runde)
uansett om handlere er gitt inn — ingen eksisterende prop slår av selve
admin-seksjonen. Bygget i stedet en egen, liten `WatchTabs`-komponent
(`frontend/components/watch-tabs.tsx`) uten noen admin-vei i det hele
tatt -- ingen risiko for at en eier-handling vises ved et uhell, siden
koden rett og slett ikke inneholder den.

**Beslutning C — Ny, egen "Spillere og runde"-komponent
(`watch-players.tsx`), IKKE et forsøk på å gjøre `round-detail.tsx` sin
lokale `PlayerList`-funksjon gjenbrukbar via `canManage=false`.**
`PlayerList` er ikke eksportert, tett koblet til eier-autentiserte
handlere, og selv `canManage`-proppen dekker ikke alt konsekvent i dag
("Fullfør runde"-knappen der er kun gated på `!completed`, ikke
`isOwnerViewer` -- en eksisterende, urelatert kvalitetsbrist, ikke
arvet inn her). Den nye komponenten inneholder ingen +Medspiller-/
Rediger-/Fullfør-/Slett-kode i det hele tatt. Viser deltakerkort
(navn, "Eier"-merke -- bevisst INGEN "Deg"-merke, siden
`PublicRoundParticipantOut` ikke eksponerer `user_id` og det uansett
ikke gir mening i tilskuer-rammen), Utslag/HCP/Tildelte slag (allerede
offentlig eksponert data), "Så langt"-progresjon (fra
`/public/rounds/{id}/leaderboard`), og flight-gruppering.

**Beslutning D — Nytt offentlig endepunkt `GET /public/rounds/{id}/
flight-group`.** Speiler den eksisterende autentiserte `/rounds/{id}/
flight-group`, men med en ny `_viewable_flight_rows`-variant av
`_accessible_flight_rows` som bruker `_can_view_round` (eier/medspiller/
`public`/riktig-kategorisert `friends`-venn) per SØSKEN-flight uavhengig
-- en flight-gruppe kan ha søsken med ulik `visibility_mode`, og en
tilskuer skal kun se de søsknene de faktisk har innsyn i, akkurat som
selve anker-runden. Avklart eksplisitt med bruker (AskUserQuestion) at
flight-gruppering skulle bygges nå, ikke utsettes.

**Lukket, relatert gap:** den gamle `watch-round.tsx` sin lokale
`TWO_SIDED_FORMATS`-liste dekket kun 8 av 17 formater (manglet
`copenhagen/bbb/flag/shamble/money_ball/scramble_solo/scramble_solo_
match`, som falt til "Ingen live-visning tilgjengelig ennå"). Løst
automatisk av Beslutning A (samme `RoundLeaderboard`-komponent som
eiersiden, som allerede dekker alle formater) -- ikke en egen,
ekstra oppgave.

**Verifisert grundig, isolert scratch-miljø** (DB/rolle/MinIO/API-/
frontend-container, pluss en minimal scratch-Caddy for ekte WS-testing,
samme mønster som ADR-044): 6/6 håndregnede sjekker for det nye
flight-group-endepunktet (anonym ser kun offentlige søsken-flighter,
en venn med riktig kategori ser i tillegg `friends`-synlige, eieren ser
alle, en fremmed uten vennskap ser kun offentlige, en privat anker-runde
avvises helt for anonym). `tsc --noEmit` rent på hele frontend-prosjektet
etter refaktoren. Ekte nettleser: **regresjon FØRST** -- eiersidens
`/my-rounds/{id}/scorecard` og `/leaderboard` bekreftet uendret (samme
tall, samme layout) FØR noe nytt ble testet. Deretter tilskuer-sidene:
alle tre faner, "Spillere og runde" uten eier-knapper, ETT allerede-
dekket format (`stroke`) OG ETT tidligere udekket format (`flag`,
Flaggturnering) begge bekreftet å rendre korrekt gjennom
`/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte,
ikke bare i teorien. En privat rundes tilskuer-sider (alle tre) bekreftet
korrekt avvist for en anonym leser (egen isolert nettleser-kontekst,
ikke bare et API-kall) med riktig feilmelding og "Tilbake til
dashbordet"-lenke. Scratch-miljøet ryddet opp fullstendig etter bruk.

**Bevisst utenfor omfang:** enhver endring i selve eiersidens
`PlayerList`/`ManageRoundDialog`-logikk (den nevnte "Fullfør runde"-
gating-kvaliteten er en egen, urelatert observasjon).

---

## ADR-046: Emoji-reaksjoner + trådede kommentarer på innlegg

Reist av brukeren 2026-08-06, som del av samme melding som "nyeste først,
over alt": "man skal kunne like (eller bruke andre emojier) og kommentere
på innlegg." Avklart eksplisitt med bruker (AskUserQuestion, tre
spørsmål): (1) "innlegg" = BÅDE de eksisterende meldingene (rundekommentar/
lag-chat-melding/oppslagstavle-post) OG dashbord-feedens oppføringer, (2)
kommentarer skal være TRÅDET (svar på svar, flere nivåer), (3) reaksjoner
følger samme omfang som kommentarer.

**Nøkkelfunn fra research:** dashbord-feeden (`/my-feed`, ADR-044
Beslutning E) er IKKE en egen entitetstype ("runde fullført"-kort) — hver
feed-oppføring ER ganske enkelt én `round_message`-rad, aggregert på tvers
av runder. Å bygge reaksjoner/kommentarer på `round_message` dekker derfor
AUTOMATISK både rundens egen kommentarseksjon og feeden — ingen egen
tredje entitetstype trengtes. De reelle "innleggs"-typene var dermed kun
to underliggende tabeller: `round_message` (ADR-044, INGEN
`organization_id`/RLS) og `message` (ADR-025, `organization_id` NOT NULL
+ RLS, diskriminert på `scope` for lag-chat/oppslagstavle).

**Beslutning A — To parallelle tabellpar, IKKE én polymorf tabell.**
Migrasjon 059: `round_message_reaction`/`round_message_comment` (ingen
RLS, speiler `round_message` selv) og `message_reaction`/`message_comment`
(RLS'et via `app_current_org()`, speiler `message`). Samme presedens som
ADR-044 Beslutning C — en delt tabell på tvers av RLS/ikke-RLS-grensen
ville krevd enten en kunstig organisasjon eller et RLS-unntak, og
CLAUDE.md sin invariant ("organization_id på alle domenetabeller,
håndhevet av RLS") krever egen RLS-håndtering for org-siden uansett.

**Beslutning B — Reaksjoner: fast kuratert sett, én per bruker per
innlegg.** Seks emojier (👍 ❤️ 😂 😮 😢 🙏), validert i app-laget (400 ved
ukjent emoji) — IKKE et fritt emoji-utvalg. Bytte av emoji ERSTATTER
(UPSERT via `ON CONFLICT (post_id, user_id) DO UPDATE`), stables ikke —
matcher "like (eller bruke andre emojier)"-fraseringen (velg ÉN).

**Beslutning C — Kommentarer: flat lagring, kun tekst, kaskade-sletting.**
`parent_comment_id` selv-referanse, lagret FLATT — frontend bygger
tre-strukturen fra en kronologisk (eldst-først) liste (motsatt av resten
av appens "nyeste først"-strømmer, bevisst: en samtaletråd leses
top-til-bunn, ulikt en oppdateringsstrøm). Ingen bilde i selve
kommentaren (innlegget har allerede sitt eget). Sletting av en kommentar
med svar under KASKADE-SLETTER hele undertråden (FK `ON DELETE CASCADE`)
— ikke myk-slett-med-plassholder, samme "kun slett, aldri rediger"-
filosofi som `round_message`/`message` selv. Frontend viser en eksplisitt
advarsel ("Sletter du denne, forsvinner også svarene under.") før
bekreftet sletting av en kommentar med svar.

**Beslutning D — Autorisasjon speiler eksisterende regler per
innleggstype, ingen ny modell.** Hvem kan reagere/kommentere = hvem kan
POSTE selve innlegget (rundekommentar: `_can_view_round`/"kan se = kan
bidra"; lag-chat: rostret på laget; oppslagstavle: `_may_post_to_feed`).
Hvem kan slette en kommentar = samme regel som innleggstypen allerede
bruker for å slette selve innlegget (rundekommentar: forfatter ELLER
rundeeier; lag-chat: KUN forfatter, ingen unntak; oppslagstavle: forfatter
ELLER org-admin via `is_org_admin`). Egen reaksjon fjernes alltid fritt av
den som satte den.

**Beslutning E — Ingen ny WebSocket-payload-kanal.** Samme minimalt-
fotavtrykk-valg som ADR-044 Beslutning D: klienten refetcher reaksjons-/
kommentaroppsummeringen kun etter EGEN handling, ingen live-push til andre
samtidige seere i v1. **Viktig rettelse funnet under nettleserverifisering:**
de nye kommentar-endepunktene i `round_messages.py` kalte først
`broadcast_round_update()` (kopiert inn ved en inkonsekvens mot egen
plan) — dette trigget rundens EKSISTERENDE "noe endret seg"-WS-tick, som
`RoundMessages` reagerer på med en full refetch av HELE meldingslisten,
og med det kollapset enhver allerede-utvidet kommentartråd ANDRE STEDER
på siden ved hver eneste kommentarhandling. Fjernet fra de nye
kommentar-endepunktene (reaksjons-endepunktene hadde aldri kallet) —
oppdaget og rettet FØR utrulling, takket være ekte nettleserverifisering
i steget rett etterpå (ikke bare et API-testskript).

**Backend:** `app/routers/round_messages.py` fikk
`PUT/DELETE /rounds/{id}/messages/{id}/reaction` og
`GET/POST /rounds/{id}/messages/{id}/comments` +
`DELETE .../comments/{id}`, pluss `reactions`/`comment_count`-felt lagt
til på `RoundMessageOut` OG `FeedEntryOut` (grupperte batch-spørringer,
ingen N+1). `app/routers/messaging.py` fikk samme sett ×2 (lag-chat +
oppslagstavle), importerer `ALLOWED_REACTION_EMOJIS`/`ReactionSummary`/
`ReactionIn`/`CommentIn` fra `round_messages.py` for å unngå duplisert
typedefinisjon (bevisst IKKE en delt SQL-hjelpemodul på tvers av filene,
siden `plain_connection()` vs. `org_connection()` uansett gjør
spørringene ulike nok at abstraksjon over filgrensen ville vært kunstig).

**Frontend:** ny delt komponent `post-engagement.tsx` — reaksjonsrad
(kuratert sett, egen valgt fremhevet grønt) + trådet kommentarfelt (lazy-
lastet ved utvidelse, "Svar"-knapp per kommentar med innrykk per nivå,
kaskade-advarsel ved sletting), bygget via ÉN Claude-skrevet V0-prompt
(standard arbeidsfordeling) og integrert hånd-kodet i FIRE eksisterende
steder: `round-messages.tsx`, `feed.tsx` (`FeedCard`), `team-chat.tsx`,
`public-tournament.tsx` (`TournamentFeed`). To bevisste
integrasjonsjusteringer utover ren copy-inn: (1) byttet V0-eksportens
egendefinerte inline SVG-ikoner til `lucide-react` (`MessageSquare`/
`Reply`/`Trash2`) for å følge DESIGN_SYSTEM.md sin "ingen annen ikonpakke
blandet inn"-regel, (2) `feed.tsx` sin `FeedCard` var opprinnelig ÉN stor
`<Link>` som pakket inn HELE kortet — `PostEngagement` måtte flyttes UT av
selve lenken (til en søskenposisjon under) for å unngå ugyldig nøstet
interaktivt innhold og utilsiktet navigering ved klikk på en
reaksjonsknapp.

**Bevisst forenklet, ikke en bug:** `public-tournament.tsx` sin
`TournamentFeed` setter `canModerate={false}` alltid — den ekte "forfatter
ELLER org-admin"-regelen håndheves korrekt av BACKEND uansett (bekreftet i
autorisasjonsmatrisen under), men frontend mangler foreløpig et signal for
"er denne innloggede brukeren org-admin i akkurat denne organisasjonen" å
style et ekstra slett-ikon med for andres kommentarer. En org-admin som
vil moderere andres kommentar kan det (API-et tillater det), men ser ikke
en slett-knapp for det i UI-et ennå — en ren UI-fullstendighets-luke, ikke
et sikkerhetshull. Kan lukkes senere ved å tilføye ett felt (f.eks. i
tournament-info-oppslaget) uten skjemaendring.

**Scratch-verifisert grundig** (isolert scratch-DB/rolle fra
`teecup_db`-mal, egen API-container): full autorisasjonsmatrise for alle
tre innleggstyper (reager/bytt-reaksjon-som-upsert/fjern-reaksjon,
kommentér på toppnivå, svar på en kommentar, slett en kommentar med svar
under og bekreft kaskade, riktig avvisning per type sin faktiske
post-/slette-regel — lag-chat kun forfatter, oppslagstavle forfatter-
eller-org-admin via ekte org-medlemskap, rundekommentar forfatter-eller-
rundeeier). RLS-isolasjon for `message_reaction` bekreftet eksplisitt
(annen orgs `app.current_org` ser 0 rader). `/feed`-aggregeringen bekreftet
å vise samme reaksjons-/kommentardata som selve rundesiden (samme
`round_message.id` gjenbrukt). `test_isolation.sql` 12/12 uendret.
Ekte typesjekket produksjonsbuild av frontend kjørt og bekreftet (fanget
INGEN nye feil, ren build). **Ekte nettleserverifisering** (egen scratch-
Caddy for sesjon/WS): reagert, byttet reaksjon (upsert bekreftet visuelt),
postet trådet samtale (rot + svar, riktig innrykk), slettet en kommentar
med svar og bekreftet kaskaden fjernet begge fra UI-et, bekreftet
`feed.tsx` sin `PostEngagement` IKKE trigger navigering ved reaksjonsklikk
(Link-nøsting-fiksen virker), bekreftet `team-chat.tsx` og
`public-tournament.tsx` sin Oppslagstavle begge rendrer komponenten
korrekt uten konsollfeil. Fant og rettet WS-tick-regresjonen (Beslutning
E) i dette steget. Scratch-miljøet (DB/rolle/API-/frontend-/Caddy-
containere/images) ryddet opp fullstendig etter bruk.

**Bevisst utenfor omfang:** push-varsler ved ny kommentar/reaksjon,
sanntids-oppdatering hos ANDRE samtidige seere, fritt emoji-utvalg,
redigering av kommentarer, myk-slett-med-plassholder.

---

## ADR-047: Leaderboard — lukk gapet mot en konkurrentapp-referanse, uten å bygge om fra bunnen

Brukeren viste en video av GolfGameBook sitt leaderboard (trykk-for-å-
utvide rad → fullt hull-for-hull-scorekort inline + sosiale handlinger
per spiller) og ba om noe minst like bra i TeeCup — eksplisitt **IKKE**
en kopi av selve utseendet.

**Nøkkelfunn fra research, som endret omfanget vesentlig fra "bygg dette
fra bunnen":** det meste av kjernemekanikken referansen viser var
ALLEREDE bygget og live i `round-leaderboard.tsx`, fra en tidligere runde
(FEATURE_BACKLOG.md "Leaderboard for runder og turneringer", 2026-07-26)
som selv var inspirert av samme konkurrentapp — og allerede DA bevisst
designet med egen form+farge-språk (sirkel/firkant, grønn/oransje) i
stedet for å kopiere referansens fargebruk. `IndividualBoard` (dekker
`stroke/stableford/skins/flag/bbb/copenhagen`) hadde allerede utvidbare
rader (`IndividualRow`) med et fullt hull-for-hull-rutenett
(`HoleByHole`). Det som faktisk manglet: (1) ingen sosial/kommentar-
tilgang noe sted i leaderboardet, (2) ingen kompakt oppsummeringslinje
under rutenettet, (3) `TwoSidedBoard`/`HighLowBoard` (to-sidede formater)
hadde INGEN utvidelse i det hele tatt — kun en statisk hull-strip, (4)
`TeamFlightBoard` hadde en flat, ikke-utvidbar råscore-liste.

**Avklart med bruker (AskUserQuestion, to spørsmål) FØR bygging:**
1. **Sosial-kobling:** `post-engagement.tsx` (ADR-046) reagerer/
   kommenterer på ETT spesifikt `round_message`-innlegg, ikke runden som
   helhet — det finnes intet "innlegg per deltaker" i skjemaet (og
   GolfGameBooks egen "Kommentar til spillefeeden"-knapp åpner uansett
   samme delte feed uansett hvilken rad man trykker på). **Svar: delt
   tråd, lenket fra hver rad** — ingen ny per-deltaker-entitet, ingen ny
   migrasjon, ingen backend-endring i det hele tatt denne runden.
2. **To-sidede formater:** siden disse kun har 2 "sider" (ikke en
   spillerliste), gir "én rad per spiller"-mønsteret ikke mening.
   **Svar: ÉN utvidbar detaljseksjon for hele kampen**, ikke et
   per-rad-mønster.

**Bygget, alt i `round-leaderboard.tsx` (+ to eksisterende filer fikk en
anker-id):**
- **`HoleByHole`** (IndividualBoard) fikk en kompakt oppsummeringslinje
  (par-sum/totalscore/plassering, egen ordlyd — IKKE referansens) + en
  "💬 Kommentarer"-tekstlenke.
- **`TeamFlightBoard`** sin flate råscore-liste konvertert til samme
  utvidbare mønster via en ny `TeamMemberRow` (gjenbruker `HoleByHole`
  uendret, uten rangeringskolonnen — et lags medlemmer rangeres ikke seg
  imellom der).
- **`TwoSidedBoard`/`HighLowBoard`** fikk en ny, delt
  `MatchDetailSection` — ÉN "Vis hull-for-hull med score"-knapp som,
  lazy, monterer den nå eksporterte `MatchScorecardGrid`
  (`round-scorecard.tsx`, samme ADR-045-presedens: eksporter en
  allerede-godkjent, modul-privat komponent uendret, ren
  synlighetsendring) + samme kommentar-lenke.
- **Type-utvidelse, ikke ny data:** `ApiRoundHeader`/`ApiRoundParticipant`
  i round-leaderboard.tsx fikk flere felt lagt til (`tee_name_snapshot`,
  `guest_name`, `display_name`, `is_owner`, `playing_handicap`) — alle
  allerede returnert av samme `${apiPrefix}/{roundId}`-endepunkt
  (bekreftet mot `RoundOut`/`PublicRoundOut` i `app/routers/rounds.py`),
  bare ikke tidligere typet der. `guest_name` er valgfritt (fraværende i
  `publicMode`, siden `PublicRoundParticipantOut` ikke har feltet) — en
  ny `toGridRound()`-adapter defaulter det til `null` før
  `MatchScorecardGrid` mates, trygt siden komponenten aldri leser feltet.
- **Anker-scroll:** `round-detail.tsx`/`watch-round.tsx` sin
  kommentarseksjon fikk `id="kommentarer"`, PLUSS en liten
  `useEffect(() => { if (round && hash === "#kommentarer")
  scrollIntoView() }, [round])` i begge — nettleserens EGEN anker-scroll
  (som skjer FØR async data er lastet) rakk ikke frem til et element som
  ennå ikke fantes i DOM-en; funnet og rettet under nettleserverifisering.

**Ingen ny V0-runde, ingen backend-endring.** Alt er gjenbruk/eksport av
allerede-eksisterende, allerede godkjente komponenter/mønstre til nye
steder — "liten justering av eksisterende komponenter med allerede
etablerte mønstre"-unntaket fra den stående V0-arbeidsfordelingen.

**Bevisst forenklet, ikke en bug:** ingen kommentar-ANTALL-merke på selve
leaderboard-lenken (unngår et ekstra API-kall for kun et tall på en
lenke).

**Scratch-verifisert** (isolert DB fra `teecup_db`-mal, egen API-/
frontend-/Caddy-container): `tsc --noEmit` rent, ekte produksjonsbuild
kjørt og bekreftet ren. Ekte nettleser: **regresjon FØRST** —
`IndividualBoard`s eksisterende utvidelse bekreftet identisk for
slagspill-formatet (samme tall, samme rad-innhold) FØR noe nytt ble
testet. Deretter: ny oppsummeringslinje + kommentar-lenke bekreftet på
slagspill (inkl. anker-scroll faktisk fungerende, ikke bare landet på
riktig side), ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
bekreftet på et fourball-format med ekte 4-spiller-data (riktig
hull-for-hull-score, riktig løpende matchstatus "1 UP"/"AS"/"Dormie 1"),
samme fourball-format bekreftet identisk i `publicMode` (anonym
tilskuer-kontekst, egen isolert nettleser-kontekst — kommentar-lenken
riktig pekende til `/watch/{id}#kommentarer` i stedet for
`/my-rounds/...`). Ingen konsollfeil utover forventet 401 på
`/auth/me` for den anonyme leseren (allerede tolerert i koden).

**Ikke direkte nettleser-testet: `TeamFlightBoard` (shamble/money_ball)
og `HighLowBoard` (high_low_high)** — ingen eksisterende testdata for
disse formatene i scratch-DB-malen, og å håndkonstruere gyldige
runde+deltaker+hull-fixturer for dem fra bunnen ble vurdert som for
tidkrevende/feilutsatt for denne verifiseringsrunden. Vurdert lav risiko
likevel: `TeamFlightBoard`s nye `TeamMemberRow` gjenbruker `HoleByHole`
**uendret** (samme funksjon som allerede ble browser-testet via
`IndividualBoard`), og `HighLowBoard`s nye seksjon bruker **nøyaktig
samme** `MatchDetailSection`/`toGridRound`/`MatchScorecardGrid`-kobling
som allerede ble browser-testet via `TwoSidedBoard` — risikoflaten
(prop-tredding, type-adapter, komponent-montering) er identisk, ikke en
egen, utestet kodevei. Anbefales likevel spot-sjekket i ekte nettleser
neste gang en runde med disse formatene finnes eller opprettes.

**Merk, funnet under research, IKKE rettet i denne runden:** en
skrivefeil i eksisterende kode — `useLeaderboardData`s returnerte
variabel heter `viewerParticipantId` men inneholder faktisk den
INNLOGGEDE BRUKERENS `user_id` (fra `/auth/me`), ikke en
deltaker-id. Kun en forvirrende variabelnavngiving, ikke en funksjonell
feil (verdien er korrekt for det den faktisk brukes til alle steder,
inkludert det nye `MatchScorecardGrid`-kallet, som nettopp forventer
bruker-id under navnet `viewerId`) — la stå urørt, utenfor omfanget av
denne runden.

**Bevisst utenfor omfang:** `RoundLeaderboardMini` (topp-3-widgeten,
skal forbli et raskt overblikk), `ScrambleSoloResultView` (eget format,
lite utbredt), skudd-nivå-detalj (fairway/putts/GIR) utover det
`HoleByHole` allerede viser (den fulle scorekort-siden dekker det),
kommentar-antall-merke på leaderboard-lenken, et separat ett-trykks
"lik hele runden", "privat melding" (ingen DM-funksjon finnes).

---

## ADR-048: Slag-for-slag GPS-avstandsmåling

Reist av brukeren 2026-08-06: "Måle lenge på slag ... Fra der man er
ELLER fra et valgt punkt på et satellittfoto, til der man står ved siden
av ballen. Man skal også kunne si hvilken kølle man slo med. Dette kan
deles i feeden, eller beholdes i eget grensesnitt." Avklart eksplisitt med
bruker (AskUserQuestion, tre spørsmål, 2026-08-07): (1) v1 skal dekke
BÅDE individuelle runder OG lagformater (fourball/scramble/foursome m.fl.,
der hull henger av `round_side_id`), ikke bare individuell, (2)
delingsteksten skal være auto-generert MEN redigerbar, pluss et utsnitt
av satellittfotoet med slaget tegnet inn, (3) måling skal være tilgjengelig
både som en knapp i scoreførings-veiviseren OG som en alltid-synlig
merkelapp på hull-kortet (retroaktiv måling).

**Beslutning A — Kartleverandør: Mapbox GL JS, ikke Leaflet+Esri.** Esris
gratis satellittlag (den vanlige gratis Leaflet-kilden) forbyr eksplisitt
kommersiell bruk uten en separat, betalt ArcGIS-lisens — diskvalifiserende
gitt TeeCups kommersielle retning. Mapbox: 50 000 gratis kartlastninger/
mnd, ingen kortbinding for å starte, ~$5/1000 utover, eksplisitt tillatt
for kommersiell bruk.

**Beslutning B — Kostnadskontroll, tre tiltak.** (1) ETT kart-instans per
måle-økt (montert når "velg punkt på kart" åpnes, aldri remontert per
tap/pan — Mapbox fakturerer per initialisering, ikke per interaksjon).
(2) Kartet lastes KUN ved eksplisitt "velg punkt på kart" — velger
spilleren "min posisjon nå" for BEGGE punkt, initialiseres Mapbox GL JS
aldri (må verifiseres eksplisitt via nettverksfane under scratch-testing,
ikke bare visuelt, se verifiseringsseksjonen for ADR-048 i CHANGELOG.md
når den skrives). (3) Selve avstandsberegningen er ren klient-side
Haversine (`frontend/lib/geo.ts`), aldri et Mapbox-API-kall.

**Beslutning C — Ny `round_shot`-tabell (migrasjon 060), IKKE en
gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging som
hull-statistikk-DATAMODELL.** Dette er en separat, valgfri måle-funksjon;
`round_hole` sine faste felt (`club_off_tee`, `putts` osv.) er uberørt.
`round_shot` kjenner kun `round_hole_id` — IKKE `round_participant_id`/
`round_side_id` direkte — og arver dermed eierskap (individuell ELLER
lagformat) TRANSITIVT via `round_hole` sin eksisterende
`round_participant_id`/`round_side_id`-XOR (migrasjon 031). Dette dekker
begge hull-typer fra v1 uten egen XOR-logikk i den nye tabellen, via
speilede API-endepunkter
(`/participants/{id}/holes/{n}/shots` og `/sides/{id}/holes/{n}/shots`,
samme dobbelte mønster som `RoundHoleOut`/`RoundSideHoleOut` allerede
bruker for selve hull-scoringen). Samme non-RLS `plain_connection()`-
mønster som resten av det frittstående-runde-subsystemet (ADR-033
Beslutning A). `shot_number` er eksplisitt og hull-tolerant (ikke `ORDER
BY captured_at`) — unngår at GPS-tidsstempel-drift stille endrer
rekkefølgen; sletting midt i sekvensen etterlater et gap, ingen
omnummerering. Kun `UPDATE (shared_round_message_id)` er grantet (ett
smalt, eksplisitt unntak fra "slett og opprett på nytt, aldri rediger"-
prinsippet, se Beslutning E) — ellers ingen UPDATE, samme filosofi som
`round_message`.

**Beslutning D — Koordinater: rene `double precision` lat/lng-par, ikke
PostGIS.** Første funksjon i prosjektet som lagrer koordinater i det hele
tatt (60 migrasjoner uten presedens frem til nå). Volumet (maks noen
titalls rader per runde) og operasjonssettet (kun punkt-til-punkt
storsirkel-avstand, regnet klient-side) rettferdiggjør ikke en ny, tung
geo-avhengighet.

**Beslutning E — Deling gjenbruker `round_message` (ADR-044) uendret, via
en lenke.** Et nytt `POST /rounds/{id}/shots/{id}/share`-endepunkt
genererer satellitt-utsnittet SERVER-SIDE (Mapbox Static Images API, egen
HEMMELIG `TEECUP_MAPBOX_SECRET_TOKEN` — atskilt fra frontendens
OFFENTLIGE, URL-restriktere `NEXT_PUBLIC_MAPBOX_TOKEN` som Mapbox GL JS
bruker i nettleseren), laster bildet opp via eksisterende
`storage.upload_image()`, og setter INN en vanlig `round_message`-rad —
ingen ny feed-mekanisme. `shared_round_message_id` er en LENKE (ikke et
bool-flagg) slik at "delt" alltid reflekterer om meldingen faktisk
finnes (`ON DELETE SET NULL` hvis meldingen slettes separat i feeden).
Rekkefølgen er bevisst: slaget opprettes ALLTID først (uten deling);
deling er et frivillig, påfølgende steg — unngår at en feilet
slag-opprettelse kan etterlate en foreldreløs feed-melding. Mapbox-kallet
degraderer grasiøst til ren tekst uten satellittbilde ved feil/manglende
token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
delingen.

**Tillegg 2026-08-08 — sluttpunktet (ballen) kan nå OGSÅ velges på kart,
ikke bare GPS.** Brukeren, etter å ha brukt funksjonen i praksis: "Jeg må
også kunne velge på kartet hvor ballen ligger. Dessuten: Jeg vil gjerne se
kartet mens jeg går frem til ballen. Slagpunktet må være en del av det jeg
ser." Dette opphever den opprinnelige "sluttpunkt: alltid GPS, aldri
kart"-delen av flyt-beskrivelsen over (resten av ADR-048 står uendret) —
historikken beholdes over, ikke slettet, per CLAUDE.md-regelen om at en
endret beslutning får et tillegg, ikke en retusjert original.

- **end-steget** tilbyr nå samme valg som start-steget: "Jeg er ved ballen
  nå" (GPS, uendret oppførsel) vs. "Vis kart mens jeg går" (nytt,
  `MapPointPicker` med et `referencePoint`). Migrasjon `061` la til
  `round_shot.end_method` (samme `gps`/`map_tap`-CHECK-mønster som
  `start_method`, `DEFAULT 'gps'` siden ALLE slag før dette tillegget
  faktisk ble målt med GPS for sluttpunktet).
- **`MapPointPicker` generalisert** til å ta et valgfritt `referencePoint`
  (kun brukt på ball-steget): når satt, vises utslagspunktet som en fast,
  ikke-flyttbar oransje markør ("Utslag"-merkelapp), og selve
  punkt-markøren som plasseres ved tap er grønn i stedet for oransje —
  samme fargekonvensjon som allerede fantes i delings-bildet
  (`pin-s-a+ff5a1f` / `pin-s-b+2f7a3f`), nå ført konsekvent gjennom
  UI-et også. Løser "slagpunktet må være en del av det jeg ser" konkret:
  kartet henter brukerens live GPS-posisjon OG kjenner utslagspunktet, og
  kjører `map.fitBounds([nåværende posisjon, utslagspunkt])` slik at begge
  garantert er i bildet med det samme (ikke bare sentrert på det ene) —
  faller tilbake til å sentrere på utslagspunktet alene (zoom 17) hvis GPS
  feiler. Dekker samtidig "jeg vil se kartet mens jeg går fram til
  ballen": brukeren kan la kartet stå åpent (satellittbildet, med
  utslagsmarkøren som fast referansepunkt) mens de fysisk beveger seg, og
  trykke der ballen faktisk ligger når de er fremme — i stedet for å måtte
  vente på et GPS-fix. Fortsatt kun ETT kart-instans (Beslutning B
  uendret): samme "mountes når steget åpnes, aldri på nytt per tap/pan"-
  prinsipp gjelder nå BEGGE punkt-steg, ikke bare start.
- Scratch-verifisert (2026-08-08): migrasjon + CHECK-constraint
  (ugyldig `end_method` avvist), API-nivå (`end_method` lagres/returneres
  korrekt for begge verdier, default `gps` når feltet utelates), og full
  nettleser-gjennomgang via Chrome DevTools MCP (GPS-fabrikkert
  utslagspunkt → "Vis kart mens jeg går" → referansemarkør + fitBounds
  bekreftet visuelt → tap plasserte grønn ballmarkør → lagret slag hadde
  `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
"eier ELLER lenket medspiller kan føre for hele flighten"-regel som
`update_hole`/side-PATCH allerede bruker). `_resolve_round_message_author_name`
flyttet fra `round_messages.py` til `rounds.py` (som allerede var den
importerte, ikke-importerende parten av de to filene) for å unngå en
sirkulær import da `share_shot` trengte å gjenbruke den uendret.

**Frontend:** `frontend/lib/geo.ts` (ren Haversine-funksjon, ingen
avhengigheter). Eksisterende kølle-plukker-mønster i `ScoringWizard`
(round-detail.tsx) trukket ut til en delt `ClubPicker`-komponent (ren
refaktor, bekreftet typesjekk uendret) slik at den nye måle-flyten kan
gjenbruke nøyaktig samme pill-knapp-interaksjon. `mapbox-gl` lagt til
(pakken har egne typedefinisjoner -- den separate `@types/mapbox-gl`-
pakken er en utdatert stub, bevisst IKKE installert), `NEXT_PUBLIC_MAPBOX_TOKEN` tredd gjennom
`frontend/Dockerfile` (build-tid, IKKE runtime — `NEXT_PUBLIC_`-variabler
bakes inn i klient-bundlen ved `next build`, samme fallgruve-mønster som
`TEECUP_API_ORIGIN` allerede dokumenterer i Dockerfile-kommentarene, men
speilet KUN i builder-steget her siden bruken er ren klient-side) og
`docker-compose.yml` sitt `teecup_frontend.build.args`. Selve
`ShotMeasurementSheet`-komponenten (alle steg + kart-plassholder) går via
én Claude-skrevet V0-prompt (standard arbeidsfordeling) — ekte Mapbox GL
JS-integrasjon i kart-steget kobles på hånd-kodet ETTER V0-eksporten, ikke
av V0 selv (V0-prompten ber eksplisitt om en visuell plassholder, ikke et
ekte kartbibliotek, for å unngå at V0 genererer en selvstendig
kart-komponent som remonterer per interaksjon og dermed bryter
Beslutning B).

**Bevisst utenfor omfang:** redigering av et allerede logget slag (slett
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.

---

## ADR-052: Full clubhouse-overtagelse (designrunden) — 2026-08-10

Brukeren ba om "designrunden" og valgte, via `AskUserQuestion`, det
bredeste av tre foreslåtte omfang: **"Full overtagelse, alt på én
gang"** -- resten av appen (unntatt ny-runde-veiviserens egen `nr-*`-
palett, se ADR-048) skulle over på "clubhouse"-paletten
(ADR-047-oppfølgingen, hittil kun 7 filer: innlogging, dashbord,
bunnnav, velkommen) i én samlet runde, fremfor den tidligere planen om
gradvis retemaing skjerm for skjerm når de likevel røres.

**Beslutning A — CSS-variabel-remap i `globals.css`, IKKE per-fil
className-omskriving.** Utforsking (Explore-agent) bekreftet to
avgjørende fakta: (1) `components/ui/*` (button/card/badge/input osv.)
og samtlige ~90 gjenværende "Forest Green"-filer er RENE
token-konsumenter -- et grep etter hardkodede `bg-white`/`text-gray-*`/
`slate-*` utenfor de 7 clubhouse-filene ga null treff. (2) Clubhouse-
paletten var allerede definert som egne CSS-variabler
(`--tee-strong`/`--clubhouse-*`) additivt i samme fil. Konsekvens:
`:root`s shadcn-tokens (`--background`, `--card`, `--primary` osv.)
ble redefinert til å KOPIERE clubhouse sine verdier direkte, i stedet
for å endre className i 90 filer. `bg-primary`/`bg-card`/
`text-muted-foreground` osv. beholder navnene sine over hele appen --
det som endret seg er hvilken farge de peker på. Ingen av de ~90
filene ble rørt.

`--destructive`, `--brand-orange(-foreground)`, `--info(-foreground)`,
`--gold(-foreground)`, `--chart-1..6` er BEVISST uendret -- de er
distinkte semantiske signalfarger (feil/fare, sekundær-CTA, nøytral
info, personlig rekord, statistikk-skala), ikke del av
"nøytral/primær"-identiteten paletten styrer. `--brand-orange`
(opprinnelig `#ff5722`) er dessuten allerede nesten identisk med
clubhouse sin `--cup` (`#ff5427`) -- samme merkevarefamilie (ADR-009/
016), ingen synlig dissonans.

**Kontrast-korreksjon funnet FØR utrulling, ikke etterpå:** dagens
`--primary-foreground` var nesten-svart (riktig for den gamle LYSE
grønne primærfargen). Clubhouse sin `--tee-strong` (`#2f6b1e`) er en
MØRK, mettet grønn, brukt i eksisterende kode med `text-white`
(`velkommen.tsx`). `--primary-foreground` ble derfor satt til hvit ved
remap -- uten denne rettelsen ville teksten på hver primærknapp i hele
appen blitt ulesbar mørk-på-mørk umiddelbart ved deploy.

**Beslutning B — mørk variant designet av V0 på bestilling, ikke
oppfunnet av Claude.** Clubhouse hadde ingen mørk-modus-variant (kun
"fast lys-modus" på de 7 opprinnelige sidene). Bruker fikk valget
(`AskUserQuestion`) mellom "fjern mørk modus, hele appen blir fast
lys" og "lag et prompt til V0 om å designe en mørk variant" -- valgte
sistnevnte. Claude-forfattet V0-prompt ga V0 de ti låste lyse
clubhouse-verdiene som fasit-referanse og ba om et rent fargesvar
(ingen kode/komponenter) for et mørk-modus-motstykke, med eksplisitt
WCAG AA-kontrastkrav. V0 svarte (limt inn av bruker som en full
prosjekt-zip, `globals.css`-diffen inneholdt kun den nye `.dark`-
blokken) med: `--tee: #a3d165`, `--tee-strong: #3c8226`,
`--cup: #ff6a3d`, `--cup-strong: #ff8355`, `--clubhouse-bg: #131a0f`,
`--clubhouse-card: #1d2616`, `--clubhouse-ink: #eef3e6`,
`--clubhouse-muted: #a7b598`, `--clubhouse-border: #34402a`,
`--clubhouse-field: #26301d`. Claude verifiserte V0s egne oppgitte
kontrasttall med en uavhengig WCAG-beregning (ikke bare tillit til V0s
kommentar) -- alle stemte eksakt: ink/bg 15.7:1, muted/bg 8.2:1,
muted/card 7.2:1, hvit/tee-strong 4.76:1 (så vidt over AA-grensen på
4.5:1, verifisert med vilje siden det var det trangeste tallet).

**Beslutning C — ingen className-endring trengtes i de 7 opprinnelige
clubhouse-filene for mørk-modus-støtte.** Disse bruker eksplisitte
`bg-clubhouse-bg`/`text-tee-strong`-klasser (ikke de generiske
`bg-background`/`text-primary`-navnene). Opprinnelig plan (se
plan-fil) antok disse måtte skrives om for å følge mørk modus. Viste
seg unødvendig: siden Tailwinds `@theme inline` genererer disse
klassene som ren `var(--tee-strong)`-referanse, og en `.dark`-blokk
(+ tilsvarende `@media (prefers-color-scheme: dark)`-blokk) for de RÅ
`--tee`/`--cup`/`--clubhouse-*`-variablene ble lagt til samtidig (av
symmetri-/fullstendighetshensyn, se Beslutning A), arver disse 7
filene mørk modus automatisk via vanlig CSS-variabel-cascade -- helt
uavhengig av om de bruker `bg-clubhouse-bg` eller `bg-background` som
klassenavn. Ingen kode i disse 7 filene endret.

App-en har INGEN manuell mørk/lys-bryter (`app/layout.tsx` har ingen
`ThemeProvider`/`next-themes`, kun `@media (prefers-color-scheme:
dark)`) -- mørk modus er og forblir rent OS-styrt.

**Liten tilleggsrettelse i samme runde:** `app/layout.tsx`s
`themeColor`-metadata (nettleser-/statuslinje-tint, brukt av PWA-
installasjon) pekte fortsatt på de gamle fargene (`#8BC24A`/
`#1c261d`) -- oppdatert til de nye bakgrunnsfargene (`#f3f6ec`/
`#131a0f`), samme "rett opportunistisk opp når den likevel røres"-
prinsipp som tilgjengelighetsregelen i CLAUDE.md.

**Scratch-verifisert** (full stack: scratch-DB med alle 61
migrasjoner, `teecup_app_dr1`-rolle, hyphenert scratch-MinIO, scratch
API- og frontend-container, ekte seedet data via de faktiske API-
endepunktene -- personlig bane, runde, fem hullscore, IKKE rå SQL mot
domenetabellene). `tsc --noEmit` rent, ekte produksjonsbuild av
frontend rent (samme build som ville kjørt i `docker compose build`).
Browserverifisert i ekte nettleser (Chrome DevTools MCP,
`emulate colorScheme`) i BÅDE lys og mørk modus på: `/logg-inn` (alle
tilstander, uendret utseende), `/velkommen` (låst-profil-siden),
`/dashboard` (tom + med ekte seedet rundekort), `/my-rounds/[id]`
(scoreførings-hovedskjermen, appens største fil, 6726 linjer --
tidligere Forest Green, nå automatisk clubhouse via remap),
scorekort-tabellen (tett datagrid), `/account` (skjematung side).
Alle konsistente, god kontrast, ingen konsollfeil utover en harmløs
PWA-installasjons-infomelding. `/my-rounds/new` (ny-runde-veiviseren)
bekreftet visuelt UENDRET -- ingen smitte fra remappen inn i
`nr-*`-paletten. `BottomNav`s bevisste palett-nøytrale "flytende hvit
flate" (se CHANGELOG punkt 40) forblir hvit i mørk modus også -- dette
er et eksisterende, bevisst designvalg fra V0, ikke en regresjon fra
denne runden. `teecup_db`s ACL bekreftet uendret før/etter, alle
scratch-ressurser (DB, rolle, MinIO-volum, container-images) ryddet
opp fullstendig.

**Bevisst utenfor omfang:** dette er en FARGE-/identitetsovertagelse,
ikke en layout-/komponent-redesign av de ~90 filene -- de beholder sin
eksisterende struktur, kun ny fargeidentitet arvet automatisk. Egen,
fremtidig runde om noen av disse skjermene også trenger et
layoutløft, ikke besluttet eller påbegynt her.

---

## ADR-053: Ny-runde-veiviseren retemaet til clubhouse — 2026-08-10

Rett etter ADR-052 ba brukeren om at ny-runde-veiviseren (`nr-*`-
paletten, bevisst holdt utenfor ADR-052 som en tredje, uavhengig V0-
utforsking) også skulle få "det nye designet".

**Samme remap-prinsipp som ADR-052, samme fil.** De 16 `--nr-*`-
variablene i `globals.css` er den ENESTE kilden komponentene i
`components/ny-runde/*` leser (via rå `var(--nr-x)`-arbitrary-syntaks,
ikke `@theme inline`/Tailwind-klassenavn som resten av appen) -- et
grep bekreftet null hardkodet hex i alle 11 filer. Kun disse 16
variablenes VERDIER ble endret; ingen av de 11 filene rørt.

**Hvilke av de 16 som ble byttet til clubhouse, og hvilke som beholdt
sin opprinnelige, distinkte hue:** `--nr-bg/-surface/-surface-2/-ink/
-muted/-border/-accent/-accent-ink` byttet til de tilsvarende
clubhouse-verdiene (samme kilde som ADR-052, lys OG mørk). `--nr-danger
(-soft)`/`--nr-ok(-soft)` beholdt sin opprinnelige røde/grønne hue
UENDRET i lys modus -- samme prinsipp som `--destructive` i ADR-052 (en
distinkt semantisk signalfarge, ikke del av identiteten paletten
styrer). Disse fire fikk likevel NYE mørke varianter (`--nr-danger:
#ca655d`, `--nr-danger-soft: #382516`, `--nr-ok: #6da985`, `--nr-ok-
soft: #1d371f`) siden `.nr` aldri hadde noen mørk-modus-støtte i det
hele tatt før nå -- de opprinnelige lyse "soft"-bakgrunnene (pastellrosa/
-grønn ment for en hvit flate) ville sett ødelagt ut mot en mørk
overflate, så disse ble tilpasset mørk bakgrunn med samme
alpha-blande-teknikk som under.

**Fire variabler uten direkte clubhouse-kilde, avledet (ikke
oppfunnet fritt):** `--nr-faint`, `--nr-border-strong`, `--nr-accent-
soft`, `--nr-accent-ring` fantes ikke i clubhouse sitt sett på ti. Disse
ble alfa-blandet fra clubhouse sine allerede godkjente farger (f.eks.
`--nr-accent-soft` = clubhouse-bg blandet 14% med `tee`, samme visuelle
rolle som `bg-tee/10`-mønsteret allerede brukt andre steder i
clubhouse-filene). `--nr-faint` sin kontrast ble eksplisitt kalibrert
til å ALDRI havne under originalens egen kontrast (2.61:1 lys / mot
`--nr-bg`) -- endte på 3.28:1 lys / 4.64:1 mørk, komfortabelt over
originalen, ikke bare en tilfeldig fargeblanding.

**Scratch-verifisert** (eget scratch-miljø, samme fulle oppsett som
ADR-052 -- ny scratch-DB/rolle/MinIO/API/frontend-container).
`tsc --noEmit` rent. Browserverifisert i ekte nettleser i BÅDE lys og
mørk modus: bane-valg-steget, "Egen bane"-søket (øver `--nr-faint` i en
tom-tilstand-boks), og det tette hull-for-hull par/stroke-indeks-
skjemaet (18-raders tabell med dropdowns -- samme datatett-grid-test
som scorekortet i ADR-052). Alle konsistente, god kontrast. Ingen nye
konsollfeil (de tre "issue"-varslene som vises der -- manglende
`autocomplete`/`id`-attributter -- er uendret fra FØR denne runden,
altså forhåndseksisterende i selve V0-eksportens markup, ikke noe denne
CSS-only-endringen rørte eller kan ha forårsaket). Scratch-ressurser
ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.

**Bevisst utenfor omfang:** samme som ADR-052 -- ren fargeovertagelse,
ingen layout-/strukturendring i veiviserens 11 filer.

---

## ADR-054: Kartretning ved slagmåling — bearing fra eget forrige slag, ikke green-koordinater — 2026-08-10

Brukeren spurte: ved lengdemåling av slag, kan kartet vises slik at "man
går oppover" (opp på skjermen = fremover på hullet) UTEN å registrere
green-koordinater (som verken TeeCup eller teeoff har noe sted, bekreftet
tidligere denne sesjonen)? Svaret var ja, og brukeren ba om at det bygges.

**Beslutning A — bearing regnet fra spillerens EGET forrige slag på
hullet, ikke fra en lagret hull-linje.** `round_shot` (ADR-048) lagrer
allerede start-/sluttkoordinater per slag, sortert på `shot_number`.
Ny `bearingDegrees(a, b)` i `frontend/lib/geo.ts` (standard forward-
azimuth-formel, ren funksjon, ingen Mapbox-avhengighet -- samme mønster
som `haversineMeters`) regner ut retningsvinkelen til FORRIGE slag på
hullet (siste element i den allerede hentede `shots`-listen i
`round-detail.tsx` sin `ShotMeasurementEntry`, som GET-endepunktet
allerede returnerer `ORDER BY shot_number`). Denne vinkelen sendes ned
som `initialBearing`-prop gjennom `ShotMeasurementSheet` til BEGGE
`MapPointPicker`-instansene (start-steget OG ball-steget), og settes som
`bearing` i selve Mapbox-konstruktøren.

**Beslutning B — statisk, ikke sanntids-rotasjon.** Bearingen settes ÉN
gang ved kart-initialisering, aldri oppdatert løpende mens brukeren går.
En kontinuerlig rotasjon etter live GPS-heading (`coords.heading` fra
`watchPosition`, som allerede kjører på ball-steget for sanntids-avstand)
ble vurdert og avvist -- GPS-heading er notorisk ustøyende ved lav
gangfart, og en urolig/hakkete kartrotasjon mens brukeren går ville vært
verre enn ingen rotasjon. Samme begrunnelse som ADR-048s eksisterende
"ett kart-instans, aldri re-initialisert"-prinsipp.

**Beslutning C -- fallback-rekkefølge for det FØRSTE slaget på et hull**
(ingen forrige slag å regne bearing fra ennå): (1) ett engangs-forsøk på
`coords.heading` fra det samme posisjonsoppslaget kartet uansett gjør for
sentrering (kun populert når enheten beveger seg, ofte `null` for et
enkeltstående oppslag -- akseptert, ikke en feiltilstand), (2) nord (0)
som endelig fallback. Ingen ny geolocation-spørring lagt til utover det
som allerede fantes.

Ingen migrasjon, ingen backend-endring -- rent klientside, tre filer
(`lib/geo.ts`, `components/shot/map-point-picker.tsx`,
`components/shot/shot-measurement-sheet.tsx`) pluss beregningen i
`components/round-detail.tsx` sin `ShotMeasurementEntry` (som allerede
har `shots`-listen fra sin eksisterende `useEffect`-henting).

**Verifisert i to lag:** (1) `bearingDegrees()` unit-verifisert
frittstående (Node, fire kjente himmelretninger fra ett origo-punkt --
nord/øst/sør/vest ga 0.0/90.0/180.0/270.0 eksakt). (2) Ende-til-ende i
ekte nettleser (eget scratch-miljø, samme fulle DB/rolle/MinIO/API/
frontend-oppsett som tidligere runder denne økten): seedet ett slag rett
ØST (bearing 90°) på hull 1 via de ekte API-endepunktene, midlertidig
konsollogg i `initMap()` bekreftet kartet fikk `bearing≈89.996`
(avrundingsdifferanse fra at 0.01°-lengdegrad-delta ikke er eksakt øst
på en kule -- korrekt) da måle-arket ble åpnet på hull 1. Hull 2 (ingen
tidligere slag) ga `bearing=0` (nord-fallback), som forventet. Debug-
loggen fjernet igjen etter verifisering, `tsc --noEmit` rent. Scratch-
ressurser ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.

**Bevisst utenfor omfang:** sanntids-rotasjon under gange (se Beslutning
B), og en egen "nullstill til nord"-knapp i kart-UI-et (ikke bedt om,
lav verdi når bearingen uansett er statisk og sjelden feil).

---

## ADR-055: Nytt app-ikon (ball/pokal/tee), full-bleed, korrigert etter reell tegnefeil — 2026-08-10

Brukeren: dagens app-ikon var "fremdeles et gammelt utkast" til tross for
at et tidligere punkt (CHANGELOG, 2026-08-06) hevdet ikonene var "beskåret
fra den ekte TeeCup-logoen". Visuell inspeksjon av `icons/icon-512.png`,
`icon-maskable-512.png` og `apple-icon.png` (åpnet direkte i nettleser)
bekreftet brukerens vurdering: riktig motiv (golfball/pokal/tee), men en
grov beskjæring med enorm hvit padding rundt en liten sentrert grafikk --
nesten ulesbart ved favikon-størrelse (32×32), og for lite innhold i
sentrum til å tåle Androids sirkel-/dråpe-maskering.

**Beslutning A -- ny komposisjon via V0, samme motiv.** Claude skrev en
V0-prompt som IKKE ba om et nytt motiv, kun en ny KOMPOSISJON av det
eksisterende (bruker la ved den ekte kilde-SVG-en, `TeeCup-logo.svg`,
i samme V0-melding): full-bleed bakgrunn kant til kant (ingen
gjennomsiktig/hvit "luft"), motivet skalert til å fylle et sentrert
"safe zone"-område (~70-75%) som tåler både iOS- og Android-maskering,
ingen egen avrunding tegnet inn (plattformen masker selv), lesbart ned
til 32×32. V0 leverte ett master-SVG (1024×1024) med en mørkegrønn
full-bleed bakgrunn (`#14351c`) og motivet nestet i et sentrert
undervindu.

**Reelt funn under verifisering, IKKE en V0-feiltolkning.** Første
V0-svar hadde to synlige "hakk" skåret inn i pokal-kroppen ved
skulder-/hank-overgangen. Claude sammenlignet først mot feil kildefil
(en annen, rasterbasert `TeeCup-logo.svg` funnet i `Temp-uploads/` fra
tidligere i sesjonen) og konkluderte feilaktig at V0 måtte ha "tegnet på
nytt etter øyemål". Bruker delte deretter den FAKTISKE kilde-SVG-en
(`TeeCup-logo-kun.svg`, rene Inkscape-vektorbaner) direkte i chatten.
Rendret stort på hvit bakgrunn viste denne at hakkene faktisk stammer fra
en ekte, pre-eksisterende unøyaktighet i selve kildekunsten: to
overlappende oransje former (`path20`, fyll `#fd5524`, og `path21`, fyll
`#fe5d2c`) dekker ikke hverandre helt i to punkter. Usynlig på hvit
bakgrunn (de to oransjetonene ligner hverandre), men synlig som et hakk
på en sterkt kontrasterende mørk bakgrunn, siden bakgrunnsfargen lyser
gjennom gapet. V0s opprinnelige "tatt verbatim, ikke tegnet på nytt"
-påstand var altså korrekt -- Claudes første mistanke var feil.

**Beslutning B -- fikset ved å utvide, ikke omtegne.** Claude ga V0 en
presis, kildehenvisende rettemelding (identifiserte de to konkrete
path-fyllfargene og hvorfor gapet oppstår). V0s fiks: la til en synlig
`stroke` i samme farge som fyllet (`stroke="#fd5524" stroke-width="0.6"`)
på `path20`, som tetter gapet ved å utvide den underliggende formens
synlige kant, i stedet for å redigere selve bezier-kurvene. To små
håndtak-innvendige "glans"-former (opprinnelig nær-hvite, ville vist som
lyse flekker i håndtak-hullene mot mørk bakgrunn) fikk samtidig fyll
byttet til bakgrunnsfargen (`#14351c`) -- korrekt, siden de fungerer som
utsparinger, ikke skyggelegging, i denne fargekonteksten.

**Claude verifiserte fiksen visuelt** (stor rendring, samme
sammenligningsteknikk som avdekket problemet) før integrering --
hakkene bekreftet borte, ingen nye artefakter.

**Integrering:** ett 1024×1024 master-SVG er eneste kilde. Claude
rasterte selv alle nødvendige størrelser via ekte nettleser-rendring
(Chrome DevTools MCP, `devicePixelRatio=1` for eksakte piksler, bekreftet
med `file`-kommandoen etterpå) -- IKKE V0-genererte PNG-er, for å unngå
enhver eksport-unøyaktighet: `icons/icon-192.png`, `icons/icon-512.png`,
`icons/icon-maskable-512.png` (samme kilde som icon-512 -- V0s
komposisjon var allerede safe-zone-riktig, ingen egen beskjæring
trengtes), `apple-icon.png` (180×180), `icon-light-32x32.png` og
`icon-dark-32x32.png` (samme bilde for begge -- det nye ikonet har sin
egen faste mørkegrønne bakgrunn og trenger ikke lenger to
tema-varianter, ulikt en eventuell fremtidig gjennomsiktig/hvit
favikon-variant). `public/icon.svg` (SVG-favikon-fallback) erstattet med
master-SVG-et selv.

`app/manifest.ts` sin `theme_color`/`background_color` sto fortsatt på
de gamle Forest Green-fargene (`#8BC24A`/`#ffffff`) -- oppdatert til
clubhouse (ADR-052): `theme_color: "#2f6b1e"` (primær merkevarefarge,
Android UI-tinting), `background_color: "#f3f6ec"` (lys bakgrunn,
PWA-splashskjerm).

**Scratch-verifisert:** `tsc --noEmit` rent. Egen frontend-only
scratch-container (ingen DB/API-endring i denne runden) -- ekte
produksjonsbuild, `GET /manifest.webmanifest` bekreftet nye farger,
`icon.svg`/`icons/icon-192.png`/`icon-light-32x32.png` bekreftet 200 OG
visuelt korrekte i ekte nettleser. Scratch-ressurser ryddet opp
fullstendig.

**Bevisst utenfor omfang:** `teecup-wordmark.svg` (det fulle
ikon+tekst-ordmerket, brukt andre steder) er IKKE endret -- kun de rene
app-ikon-filene.

---

## ADR-056: Reell WHS-svakhet + reell rundevisnings-bug — funnet via ekstern kodegjennomgang, 2026-08-10

Brukeren delte en ekstern vurdering av appen (parafrasert): to "harde
kjerner" -- WHS-beregningen og live-scoring/samtidighet -- er der en
vibe-kodet app typisk svikter, og fortjener ekte enhetstester mot kjente
fasitcaser, ikke selvbekreftende tester. To parallelle Explore-agenter
gransket hver sin kjerne. WHS-agentens funn ga direkte opphav til
Beslutning A under. Samtidighets-agentens funn (last-write-wins er et
BEVISST, allerede dokumentert valg i ADR-028 -- ikke en overraskelse)
ga IKKE grunnlag for umiddelbar koding, kun en åpen designbeslutning
brukeren må ta først (se "Åpent, ikke besluttet" nederst).

**Beslutning A -- WHS Rule 3.1b sitt >54/4+-slag-unntak implementert og
fasit-testet.** Agentens revisjon fant at `handicap_engine.py` sin NDB-
cap (`max_hole_score_for_handicap`) manglet et reelt, publisert WHS-
unntak: ved banehandicap over 54 OG 4 eller flere mottatte slag på ett
hull, er maks hullscore par+5 (IKKE den vanlige par+2+mottatte slag).
Claude verifiserte dette FØR koding direkte mot kildePDF-en (`WHS_Rules_
of_Handicapping_2024.pdf`, side 37, `pdftotext`-søk, ikke agentens
gjenfortelling): "Where a Course Handicap is calculated at more than 54
and a player receives 4 or more strokes on a hole, the maximum hole
score is par + 5 for handicap purposes."

Lagt til som et nytt, valgfritt `course_handicap`-parameter (standard
`None`) på `max_hole_score_for_handicap` og `adjusted_gross_score` --
bakoverkompatibelt (uendret oppførsel for ethvert kall som ikke sender
det). To produksjonskallsteder i `rounds.py` (`update_hole` sin
"plukket opp"-gren, og rundefullførings-differensial-beregningen)
oppdatert til å sende med den allerede tilgjengelige
`course_handicap_snapshot`.

Fire nye, presist begrunnede tester lagt til i `test_handicap_engine.py`
(grensetilfellene eksplisitt: eksakt 54 -- IKKE "mer enn 54", eksakt 3
slag -- IKKE "4 eller flere", samt end-til-ende via
`adjusted_gross_score`). 117/117 tester grønne (opp fra 115). Scratch-
verifisert også via ekte API-kall (deltaker med banehandicap 63, hull
med 4 mottatte slag, "plukket opp"-endepunktet returnerte 9 som
forventet, ikke det gamle 10).

**Beslutning B -- `round.tee_name_snapshot` synkes nå med eierens eget
utslagsbytte.** Egen, urelatert bug rapportert av bruker samme økt (se
skjermdump): rundens toppheader (delt på tvers av Score/Scorekort/
Leaderboard) viste et gammelt utslag ("50") selv etter at brukeren
hadde endret utslaget til "55" -- bekreftet i `round_participant` (begge
rader riktig "55") vs. `round.tee_name_snapshot` (fortsatt "50"). Root
cause: `PATCH /rounds/{round_id}/participants/{participant_id}`
(`update_participant`, brukt ved enkelt-deltaker-utslagsbytte) skrev
KUN til `round_participant`, aldri til `round` -- ulikt den separate
hele-bane-bytte-grenen i `update_round`, som alltid har oppdatert begge
sammen. De to skrive-stiene var ikke holdt i synk.

Fiks: når denne PATCH-en endrer `tee_name` for EIERENS EGEN
deltaker-rad (`current["user_id"] == current["round_owner_user_id"]`,
ikke en medspillers/gjests -- ulike deltakere kan bevisst spille ulike
utslag, se `ParticipantCreate.tee_name`), oppdateres
`round.tee_name_snapshot` til samme verdi i samme kall -- speiler
nøyaktig hvordan `create_round`/`_create_participant` allerede holder
disse to i synk ved selve opprettelsen.

Scratch-verifisert: eierens eget utslagsbytte oppdaterte
`round.tee_name_snapshot` korrekt; et påfølgende utslagsbytte for en
GJEST lot `round.tee_name_snapshot` stå urørt (bekrefter at kun
eierens egen rad trigger synken, som tiltenkt).

**Ikke rettet i denne runden (venter på egen brukerbekreftelse, se
CHANGELOG):** den konkrete, allerede-live runden brukeren viste
skjermdump av (`round.tee_name_snapshot` fortsatt "50" i ekte
`teecup_db` for akkurat den runden) -- kodefiksen over hindrer nye
tilfeller, men retter ikke historisk feil data. Krever en engangs
`UPDATE`-setning mot ekte `teecup_db`, vist og bekreftet separat per
CLAUDE.md.

**Besluttet samme økt:** samtidighets-/konflikt-halvparten av den
eksterne vurderingen krevde en designbeslutning før noe kunne bygges --
bruker fikk valget mellom "enkel versjonssjekk + tydelig feilmelding",
"feltvis i stedet for radvis overskriving", og "ikke nå" via
`AskUserQuestion`, valgte det anbefalte første alternativet. Bygget
samme økt, se ADR-057.

---

## ADR-057: Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10

Direkte oppfølging av ADR-056: bruker valgte "enkel versjonssjekk +
tydelig feilmelding" for `update_hole`/`update_side_hole` sin
last-write-wins-oppførsel (ADR-028, bevisst den gangen, men brukeren
ønsket det endret nå).

**Beslutning A -- én ny `version`-kolonne på `round_hole` (migrasjon
062), dekker begge eierskapstyper.** `round_hole` er allerede delt
mellom deltaker- og side-eide hull (XOR-constraint) -- én kolonne holder
for begge `update_hole`/`update_side_hole`. `DEFAULT 1`, økes med 1 for
hver skrivning. Klienten sender `expected_version` tilbake (valgfritt --
`None`/utelatt hopper over sjekken, samme bakoverkompatible mønster som
resten av appens valgfrie felt).

**Beslutning B -- atomisk sjekk i selve UPDATE-en, ikke en separat
"les så skriv".** `WHERE ... AND ($N::int IS NULL OR version = $N::int)`
i samme setning som selve skrivningen -- ingen TOCTOU-vindu mellom sjekk
og skrivning. Et `None`-resultat skilles fra "hullet finnes ikke" via
enten en allerede-utført eksistenssjekk lenger opp i funksjonen
(`update_hole`, som uansett må slå opp par/stroke-index før en
"plukket opp"-beregning) eller en egen fallback-eksistenssjekk
(`update_side_hole`, som ikke hadde noen forhåndssjekk fra før) -- aldri
tvetydig hvilken feil som returneres.

**Beslutning C -- offline-køen kjeder versjonen fremover innad i én
flush-runde.** Reelt funn under design (før noe ble bygget, ikke en bug
funnet i ettertid): uten dette ville en spillers EGNE påfølgende
offline-redigeringer av samme hull avvist HVERANDRE som falske
konflikter, siden lokal state (og dermed enqueue-tidspunktets
`expected_version`) aldri oppdateres mellom to sekvensielle
avspillinger i samme batch. `flushQueue` (`offline-queue.ts`) holder nå
et lite `Map<url, siste kjente versjon>` gjennom hele flush-runden og
overstyrer `expected_version` på påfølgende oppføringer til samme URL
med den nyeste kjente verdien.

**Reelt UX-hull funnet UNDER scratch-verifisering, rettet før
utrulling.** Første implementasjon gjenbrukte komponentens
eksisterende `error`-tilstand for konfliktmeldingen. Viste seg (kun ved
ekte to-enhets-test i nettleser, ikke synlig fra kode-lesing alene) at
`error` er en FATAL, hele-siden-erstattende tilstand (brukt for f.eks.
"runden finnes ikke") -- en forbigående hull-konflikt tok dermed over
HELE skjermen og tvang brukeren til å navigere bort, i stedet for en
liten varsel. Rettet med en egen, dismissbar `conflictNotice`-tilstand
(banner øverst i siden, "Skjul"-knapp, resten av UI-et forblir fullt
brukbart) -- gjenbrukt også for den eksisterende (fra før denne runden)
kø-synk-feilmeldingen, som hadde nøyaktig samme fullskjerm-problem.

**Scratch-verifisert i tre lag, alle i ekte nettleser/API, ingen
enhetstester på TS-siden (ingen testinfrastruktur for det i
prosjektet ennå):**
1. Backend, `update_hole` OG `update_side_hole` hver for seg (ekte
   API-kall): enhet A skriver først (lykkes, versjon 1→2), enhet B med
   samme (nå utdaterte) `expected_version` avvist med 409 og eksakt
   forventet feiltekst, A sin verdi bekreftet fortsatt lagret (ikke B
   sin avviste), B henter ny versjon og lykkes på nytt forsøk. Bekreftet
   at PATCH uten `expected_version` fortsatt fungerer (bakoverkompatibelt).
   `update_side_hole` i tillegg bekreftet at et ikke-eksisterende hull
   fortsatt gir 404, ikke feilaktig 409.
2. To ISOLERTE nettleser-kontekster (`isolatedContext`, egne
   informasjonskapsel-rom -- ekte to-enheter-simulering, ikke bare to
   faner som deler økt) som samme flight-medlem: enhet B med en allerede
   åpen, utdatert veiviser fikk 409 ved innsending, veiviseren viste
   automatisk den ferske (A sin) verdien i stedet for B sitt avviste
   forsøk, banneret vist og dismissbart, RESTEN AV SIDEN forble
   fullt brukbar (dette avdekket UX-hullet over).
3. Offline-kø-kjeding: ekte nettverks-emulering (DevTools "Offline"),
   to påfølgende redigeringer av SAMME hull mens frakoblet, tilbake på
   nett -- begge synkroniserte korrekt (endte på siste verdi), INGEN
   falsk konflikt seg imellom, "venter på synk"-indikatoren forsvant helt.

`tsc --noEmit` og `py_compile` rene gjennom hele runden. Scratch-
ressurser ryddet opp fullstendig hver gang, `teecup_db`s ACL bekreftet
uendret.

**Bevisst utenfor omfang:** ORG-TURNERINGENES match-scoring
(`session-scorecard.tsx`, `/orgs/{id}/matches/{id}/hole-scores`) er et
HELT separat system (annen tabell, annen router) med trolig samme
last-write-wins-egenskap -- IKKE undersøkt eller endret denne runden,
siden verken den opprinnelige eksterne vurderingen eller brukerens
oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det
trengs der også.

---

## ADR-058: Automatisert backend-testinfrastruktur — RLS, versjonssjekk, aldersgrense — 2026-08-10

Direkte oppfølging av investor-statusrapporten (kodebasert revisjon,
samme dag): "nesten ingen automatisert testdekning utenfor HCP-motoren"
pekt ut som det klart største enkeltfunnet. Bruker ba om å ta tak i
akkurat dette, med to avklarte avgrensninger: (1) usikkert om Forgejo
Actions faktisk har en registrert runner for dette repoet på
forgejo.jegvil.no — bekreftet KUN at instansen har `has_actions: true`
for repoet (anonymt API-kall), ikke at en runner faktisk plukker opp
jobber. CI-workflow-fil er derfor BEVISST utenfor denne runden — egen,
senere runde, verifisert empirisk ved faktisk push. (2) Frontend-
testoppsett (Vitest/Playwright) valgt bort denne runden — kun backend.

**Beslutning A — ekte integrasjonstester mot en automatisert scratch-
database, ikke mot mocks.** Samme prinsipp som all manuell scratch-
verifisering i prosjektet til nå (se CLAUDE.md), men automatisert i
`scripts/run_backend_tests.sh`: oppretter en midlertidig database +
en midlertidig rolle i den SAMME `teeoff_db`-Postgres-containeren,
kjører ALLE 62 migrasjonene i rekkefølge, kjører testene, dropper
database + rolle igjen. Rører aldri ekte `teecup_db` — bekreftet
eksplisitt etter hver kjøring (ACL uendret, ingen gjenglemte
scratch-ressurser).

Migrasjon 002 hardkoder BÅDE rollenavnet `teecup_app` (cluster-globalt
navn, delt med den ekte rollen) OG databasenavnet `teecup_db` i én
GRANT-setning. Begge patches med `sed` (ordgrense-presist, unngår å
treffe `teecup_app_password`/`teecup_app_exists`-variabelnavnene i
samme fil) til scratch-spesifikke navn før migrasjonen kjøres — samme
sed-patch-mønster som er brukt manuelt i alle tidligere scratch-runder
i prosjektet, nå skriptet.

**Beslutning B — testene kaller de FAKTISKE router-/auth-funksjonene
direkte (`app.routers.rounds.update_hole`, `app.auth.get_authorized_org`,
`app.routers.auth.update_profile`), ikke en gjenimplementering av
logikken i SQL.** FastAPI sin `Depends()`-injeksjon trengs ikke når
funksjonen kalles direkte i Python — de resolverte verdiene (en
`CurrentUser`, en allerede-verifisert `organization_id`) sendes rett
inn som vanlige argumenter. Dette betyr testene kjører gjennom SAMME
kodesti som produksjon (samme SQL, samme feilhåndtering, samme
`app_error`) — IKKE en parallell test-bare implementasjon som kunne
drevet fra virkeligheten uten å bli fanget opp.

Testene kjører i en dedikert `Dockerfile.test`-container (pytest +
pytest-asyncio, IKKE del av prod-imaget) tilkoblet samme
`teeoff_default`-Docker-nettverk som `teecup_api` selv bruker i
produksjon — nødvendig fordi Postgres-containeren ikke har noen
host-publisert port i dette miljøet, kun cluster-intern DNS
(`teeoff_db:5432`, nøyaktig slik den ekte appen kobler til).
`lifespan`/MinIO-oppstart (`app.main`) er BEVISST unngått — testene
importerer routerne direkte i stedet for å boote hele ASGI-appen, så
ingen ekte MinIO-bucket berøres av denne runden.

**Beslutning C — tre dekningsområder, valgt etter risiko, ikke
fullstendighet.** 17 tester totalt:
- `tests/test_rls_isolation.py` (4 tester) — automatiserer
  `test_isolation.sql`s manuelle sjekker: cross-org SELECT lekker
  aldri rader (selv uten eksplisitt WHERE i spørringen), cross-org
  UPDATE påvirker 0 rader, INGEN org-kontekst satt gir TOMT resultat
  (ikke "alt" — regresjonsvern for migrasjon 005s NULL-guard-fiks),
  og `get_authorized_org` avviser et faktisk ikke-medlem FØR noen
  org-scopet spørring i det hele tatt kjøres.
- `tests/test_concurrency_version_check.py` (6 tester) — ADR-057s
  versjonssjekk, nå automatisert: vellykket skrivning inkrementerer
  versjon, en utdatert `expected_version` gir 409 STALE_VERSION,
  `expected_version=None` hopper bevisst over sjekken
  (bakoverkompatibilitet), et ikke-eksisterende hull gir 404 (ikke
  409) — alt dekket for BÅDE `update_hole` (deltaker-eide hull) og
  `update_side_hole` (side-eide hull, egen 404-vs-409-logikk lagt til
  i samme runde som versjonskolonnen).
- `tests/test_auth_age_gate.py` (5 tester) — 13-årsgrensen på selve
  dagsgrensen (eksakt 13 år i dag tillatt, én dag for tidlig avvist),
  godt under grensen avvist, andre feltoppdateringer uten fødselsdato
  utløser ikke sjekken, og en avvist oppdatering skriver INGENTING
  til databasen (ingen delvis skrivning før feilen kastes).

**Verifisert:** alle 17 nye tester + de eksisterende 117 HCP-testene
grønne. Ekte `teecup_db` sitt rolleoppsett (`teecup_app`:
`NOSUPERUSER`/`NOBYPASSRLS`) og radantall i sentrale tabeller bekreftet
uendret etter kjøring. Ingen deploy denne runden — dette er ren
testinfrastruktur, ingen produksjonskode endret.

**Bevisst utenfor omfang, egne fremtidige runder:** frontend-tester, og
dekning av flere router-funksjoner enn de tre høyest-risiko-områdene
over (f.eks. øvrige `rounds.py`-endepunkter,
`individual_tournaments.py`, org-turnering-match-scoring sin
`scoring.py` — sistnevnte spesielt interessant siden ADR-057
dokumenterte AT den mangler samtidighetsvern, men det er foreløpig
udekket av en test som beviser det empirisk). CI-workflow-filen ble
løst SAMME dag — se ADR-059.

---

## ADR-059: Selvhostet Forgejo Actions-runner — 2026-08-10

Direkte oppfølging av ADR-058: `.forgejo/workflows/backend-tests.yml`
ble lagt til og pushet som en EMPIRISK test av om
forgejo.jegvil.no faktisk hadde en aktiv runner for dette repoet
(instansen svarte `has_actions: true` på et anonymt API-kall, men det
beviste ikke at noe faktisk plukker opp jobber). Resultat: jobben la
seg i kø som "Waiting" og ble stående uendret i over et minutt — ingen
runner fantes. Bruker ba om at det rettes.

**Beslutning A — selvhostet `act_runner` PÅ DENNE serveren, ikke en
ekstern/skyhostet runner.** `run_backend_tests.sh` er avhengig av
`docker exec teeoff_db` og `teeoff_default`-nettverket, som kun
eksisterer på denne verten. En runner et annet sted ville uansett
ikke kunnet kjøre testene slik de er skrevet i dag.

**Beslutning B — Docker-utenfor-Docker (DooD) via
`docker_host: automount` i runner-konfigen, ikke Docker-i-Docker.**
Jobb-containeren får HOST-ens `docker.sock` bind-montert inn (samme
mønster som runneren selv bruker for å starte jobb-containere i
utgangspunktet), slik at `scripts/run_backend_tests.sh` sine egne
`docker build`/`docker run`/`docker exec`-kall oppretter ekte
søsken-containere på host-nivå — ikke en nøstet, isolert
docker-daemon som ikke ville sett `teeoff_db` i det hele tatt.
Jobb-image: `catthehacker/ubuntu:act-latest` (de facto standardimage
for act/act_runner — har docker-CLI, git, bash forhåndsinstallert,
unngår `apt-get install` i hvert eneste kjør).

**Beslutning C — registrerings-tokenet ble ALDRI limt inn i chatten.**
Bruker hentet det selv fra Forgejo sitt UI (repo → Settings → Actions
→ Runners) og la det i en midlertidig fil med `chmod 600` på serveren;
Claude leste filen direkte via shell, registrerte runneren
(`forgejo-runner register --no-interactive`), og makulerte
tokenfilen umiddelbart etterpå (`shred -u`). Selve
runner-legitimasjonen som oppsto (`.forgejo-runner/.runner` — en ekte,
langlevd hemmelighet, ikke det korte engangs-registreringstokenet) er
gitignored, `chmod 600`, aldri committet — samme disiplin som
`.env`.

**Beslutning D — kjøres via `docker-compose.yml`, ikke en løs
`docker run`.** Startet først manuelt for å bevise at det fungerte
(ekte push → "Waiting" → runner registrert → jobb plukket opp →
"Success", verifisert i Forgejo sitt UI), deretter flyttet inn som en
egen `teecup-forgejo-runner`-tjeneste i `docker-compose.yml` for
samme drift-disiplin som resten av stacken (`restart: unless-stopped`,
dokumentert, ikke en engangs-kommando ingen husker senere).
`group_add: "112"` (docker-gruppens GID på DENNE verten) er
nødvendig fordi runner-imagets prosess kjører som ikke-root — uten
den kan den ikke lese den mountede sokkelen selv om den er tilgjengelig.
Vertsspesifikk verdi, sjekk på nytt om dette noensinne flyttes.

**Sikkerhetsnotat, bevisst akseptert:** tilgang til `docker.sock`
tilsvarer root-ekvivalent kontroll over VERTEN (enhver som kan
opprette en container kan mounte hva som helst fra filsystemet).
Dette er en reell heving av angrepsflaten sammenlignet med resten av
stacken, som ingen andre containere her har. Akseptert fordi (a) det
er en direkte konsekvens av arkitekturen `run_backend_tests.sh` allerede
hadde (samme prinsipp, bare automatisert fremfor kjørt manuelt fra en
already-priviligert shell), og (b) runneren kjører KUN denne ene
repoens CI, på en enkeltpersons egen server — ikke en delt/flerbruker-
instans.

**Verifisert:** ekte push → `.forgejo/workflows/backend-tests.yml`
kjørte, viste "Success" i Forgejo sitt UI (grønn hake, samme run som
sto "Waiting" rett før runneren startet). `teecup_db`s rolleoppsett og
fravær av gjenglemte scratch-databaser bekreftet uendret etter kjøring.

---

## ADR-060: Optimistisk versjonssjekk for org-turneringers match-scoring — 2026-08-10

Fortsettelse av robusthetslinjen fra ADR-057: bruker ba eksplisitt om å
tette det siste kjente, dokumenterte hullet — org-turneringenes
match-scoring (`hole_score`/`match_hole_result`,
`app/routers/scoring.py`, `session-scorecard.tsx`) hadde INGEN
samtidighetsvern, ren `INSERT ... ON CONFLICT DO UPDATE` siste-skriving-
vinner, dokumentert som bevisst utenfor omfang i både ADR-057 og
ADR-058.

**Beslutning A — samme `version`-kolonne-mønster, men UPSERT i stedet
for UPDATE.** Migrasjon 063 legger `version integer NOT NULL DEFAULT 1`
til BÅDE `hole_score` og `match_hole_result` (to separate tabeller, to
separate scoring-modi — `stroke` vs. `hole_result` — se
`session.scoring_mode`). Ulikt round_hole (som alltid har en
forhåndseksisterende rad å oppdatere) skriver disse to endepunktene
via `INSERT ... ON CONFLICT DO UPDATE`, siden FØRSTE registrering av et
hull ikke har noen eksisterende rad. Postgres sin
`DO UPDATE SET ... WHERE <betingelse>` løser dette elegant: betingelsen
sjekkes KUN på selve UPDATE-grenen (konflikt-tilfellet) og ignoreres
fullstendig på ren INSERT — en helt ny rad tar alltid INSERT-veien og
bryr seg aldri om `expected_version`, uansett hva klienten sendte.
Konsekvens: `row is None` etter denne UPSERT-en betyr ALLTID en ekte
versjonskonflikt, ALDRI "finnes ikke" — INSERT-grenen dekker
"finnes ikke"-tilfellet transparent. Enklere enn round_hole/
`update_side_hole`, som trengte en egen eksistenssjekk for å skille de
to.

**Beslutning B — offline-kø-kjedingen (`offline-queue.ts`) måtte
generaliseres, ikke bare gjenbrukes.** Reelt funn UNDER design, før
noe ble bygget: ADR-057s versjonskjeding nøklet på `url` alene, riktig
for round_hole (én URL per hull). Men `/hole-scores`/`/hole-results`
bruker SAMME URL for ALLE hull i en match — uten en mer presis nøkkel
ville versjonen returnert fra hull 3 sin skriving blitt brukt som
"forventet versjon" for en påfølgende, urelatert skriving til hull 7 i
samme flush-runde, verre enn ingen kjeding i det hele tatt. Løst med et
nytt, valgfritt `resourceKey`-felt på `QueueEntry`
(default = `url`, så round_hole sin eksisterende bruk er 100 %
bakoverkompatibel, uendret oppførsel) — `session-scorecard.tsx` sender
nå en egen nøkkel per hull+side+enhet (`strokeKey`/`resultKey`).

**Beslutning C — autorisasjon i testene, ikke i produksjonskoden.**
`submit_hole_score`/`submit_hole_result` krever at brukeren enten ER en
faktisk match-deltaker (via `player`/`team_roster`/`match_participant`-
kjeden) ELLER org-eier/admin (`is_org_admin`-fallback,
`team_authz.py`). Testene bruker bevisst org-eier-fallbacken for de
fleste tilfellene (langt billigere fixture-oppsett), men bygger den
FULLE deltaker-kjeden for individuell-ball-testen spesifikt, siden
`match_participant_id`-oppslaget i `submit_hole_score` er uavhengig av
org-admin-status og krever en ekte rad.

**Verifisert:** 6 nye pytest-integrasjonstester
(`tests/test_scoring_concurrency.py`) — delt-ball OG individuell-ball-
grenen i `submit_hole_score` hver for seg, `submit_hole_result`, alle
med vellykket skrivning/versjonsøkning, 409 STALE_VERSION ved konflikt,
og bekreftet at en avvist skrivning faktisk IKKE ble lagret. Alle 23
backend-tester (17 fra før + 6 nye) grønne i samme kjøring via CI-
runneren fra ADR-059. `tsc --noEmit` rent.

**Bevisst utenfor omfang:** ingen browser-basert to-enhets-verifisering
denne runden (ulikt ADR-057) — pytest-dekningen ble vurdert som
tilstrekkelig gitt at testene kaller de faktiske router-funksjonene
direkte, og frontend-siden (`session-scorecard.tsx`) følger nøyaktig
samme, allerede browser-verifiserte mønster som `round-detail.tsx` fikk
i forrige runde.

**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt — migrasjon 063
kjørt mot ekte `teecup_db`, `teecup_api`/`teecup_frontend` bygget/
restartet rent, ingen konsollfeil. CI plukket opp pushen automatisk og
kjørte grønt (samme runner som ADR-059).

---

## ADR-061: Frontend-testinfrastruktur (Vitest) — 2026-08-10

Direkte fortsettelse av ADR-058/060, som begge eksplisitt navnga
frontend-tester som bevisst utenfor omfang. Bruker ba om å ta tak i det
som neste steg i robusthetslinjen, nå som backend-testriggen og CI-
runneren stod klare.

**Beslutning A — Vitest, ren logikk-testing (`lib/**`), ingen
komponenttester ennå.** Node-miljø, ingen jsdom/@testing-library i
denne runden — det finnes ingen komponenttester å kjøre ennå, og
`node`-miljø er raskere/enklere når det ikke trengs. Tre moduler valgt
etter samme risikobaserte prinsipp som backend-rundene:
- `lib/geo.ts` (`haversineMeters`/`bearingDegrees`) — ekte GPS-
  avstandsmåling (ADR-048), fasit-forankret mot kjente geodetiske
  verdier (Oslo–Bergen luftlinje, 1 breddegrad ved ekvator ≈ 111,2 km),
  ikke bare selv-konsistente tall.
- `lib/offline-queue.ts` — regresjonsvern for `resourceKey`-fiksen fra
  ADR-060 selv: egen test beviser eksplisitt at to ulike ressurser som
  deler samme URL IKKE lenger blander sammen versjonsnummer, og at
  round_hole sin eksisterende bruk (ingen `resourceKey` satt) fortsatt
  faller tilbake til url-basert nøkling uendret. `fake-indexeddb` gir en
  ekte IndexedDB-implementasjon i test-miljøet — ikke en mocket
  tilnærming til køen.
- `lib/ny-runde/formats.ts` — fullstendighets-/konsistenssjekker på
  tvers av format-listene (`FORMAT_TO_API`, `FORMAT_MAP`, `API_TO_FORMAT`,
  `TWO_SIDED` m.fl.), direkte motivert av et tidligere kjent
  ømtåelig-punkt (separate `play_format`-gatingsett i flere
  frontend-filer, lett å glemme å oppdatere ett sted når et nytt
  format legges til). Testene fanger spesifikt opp det TypeScript IKKE
  gjør automatisk: `FORMAT_MAP` bygges med en `as`-cast som omgår
  fullstendighetssjekk, og en vanlig array som `FORMATS` har ingen
  kompilator-garanti for å dekke alle `FormatId`-verdier.

**Beslutning B — pnpm, ikke npm.** Prosjektet bruker pnpm
(`pnpm-lock.yaml`, Dockerfilens `corepack enable && pnpm install`) —
et første forsøk med `npm install` feilet med en intern npm/arborist-
krasj nettopp FORDI `node_modules` allerede var strukturert som et
pnpm-lagret (`.pnpm/`-innhold), ikke fordi noe var korrupt. Ingen skade
skjedd (bekreftet med `pnpm install --frozen-lockfile`, som validerte
hele treet på nytt), men en påminnelse om å sjekke lockfile-typen FØR
man antar npm er riktig verktøy.

**Reelt funn, rettet i samme runde:** `frontend/pnpm-workspace.yaml` var
ignorert i `frontend/.gitignore` (arvet fra den opprinnelige V0-sandbox-
malen, der fila kun inneholdt V0-interne sandbox-detaljer). Filen fikk
nå reelt, deploy-relevant innhold (`allowBuilds: sharp: true`, fra
`pnpm approve-builds` — uten den feiler `pnpm install` i et rent
miljø/CI med `[ERR_PNPM_IGNORED_BUILDS]`). Ignore-regelen fjernet,
fila sporet i git — ellers ville CI feilet på akkurat denne
installasjonen hver eneste gang, usynlig lokalt siden node_modules
allerede var godkjent på denne serveren fra før.

**Beslutning C — egen, lettvekts CI-workflow, ikke gjenbruk av backend-
sin.** `.forgejo/workflows/frontend-tests.yml` trigger kun på
`frontend/lib/**`/`package.json`/lockfile-endringer, bruker
`actions/setup-node@v4` (Node 22, matcher Dockerfilen) + `corepack
enable` + `pnpm install --frozen-lockfile` + `pnpm test` — INGEN
Docker-utenfor-Docker, ingen `docker.sock`-tilgang nødvendig, siden
denne suiten ikke rører database eller containere i det hele tatt.
Enklere og tryggere blast-radius enn backend-workflowen.

**Verifisert:** 30 tester, 3 filer, alle grønne lokalt
(`./node_modules/.bin/vitest run`, og via `pnpm test` etter
`allowBuilds`-fiksen). Ingen produksjonskode endret -- ren
testinfrastruktur.

**Bevisst utenfor omfang:** komponenttester (jsdom/@testing-library),
og en tilsvarende fullstendighetssjekk PÅ TVERS AV `round-detail.tsx`/
`scorecard.tsx`/`leaderboard.tsx` sine egne, separate
`play_format`-gatingsett (kjent ømtålelig punkt, men disse er store
komponentfiler som ville krevd en helt annen testtilnærming enn ren
lib-import — egen, fremtidig vurdering).

---

## ADR-062: Åtte brukerrapporterte UX-funn — "Ny runde" og score-registrering — 2026-08-11

Bruker sendte åtte konkrete, skjermbilde-dokumenterte problemer fra
faktisk bruk (dashbord, ny-runde-veiviseren, score-registrering, feed).
Delt i to spor etter samme prinsipp som tidligere runder: reelle
logikkfeil rettet direkte, reelt interaksjonsdesign sendt via V0.

**Direkte rettet, ingen V0 (4 av 8):**
1. **`round-card.tsx`: "Hull"-cellen viste alltid PLANLAGT antall hull
   (`holes_planned`), aldri antall FAKTISK spilt** — en runde markert
   "Fullført" etter f.eks. 9 av 18 hull viste fortsatt "18". Data fantes
   allerede (`my_holes_played` sendes for alle runder, uansett status);
   selve "X/Y hull spilt"-indikatoren var bare feilaktig gatet til
   `status === "active"`. Rettet til `played < round.holes` (viser
   avviket uansett status, skjuler seg selv når spilt == planlagt).
2. **Putt-avstand-knappenes etiketter** (`round-detail.tsx`) —
   fem av seks brukte "&lt;Xm"-mønster, den sjette ("8m+") brøt mønsteret
   med et "+"-suffiks og ble dermed lett oversett. Etikettene endret til
   konsekvent "X-Ym"-format (kun `label`, ikke `value`/`PuttBucket`-
   kontrakten — ingen backend-endring). Bekreftet via git-historikk at
   dette var Claude-forfattet, ikke V0 (brukeren spurte eksplisitt).
3. **"Se feed" på dashbordet** — for anonymt/uspesifikt. Endret til "Se
   venneaktivitet", knyttet direkte til seksjonens egen kontekst
   ("Venner på banen").
4. **@-tagging av medspillere i feeden** — vurdert, IKKE bygget denne
   runden (egen, betydelig funksjon: datamodell + søk + rendering).
   Anbefalt regel hvis/når den bygges: treff begrenset til faktiske
   relasjoner (medspillere/venner, ikke hele brukerbasen — personvern),
   og en ekte søkbar nedtrekksliste ved "@" fremfor fritekst-tolkning
   (upålitelig med flere like navn).

**Sendt via V0, mottatt som "tee-cup (9).zip", diffet mot live-treet FØR
noe ble tatt inn (samme rutine som alltid — kun `delivery/`-mappen var
den faktiske leveransen, resten var V0s egen sandbox-scaffolding, ikke
tatt inn):**

5. **Ny-runde steg 2: "Antall hull" og "Avanserte handicap-
   innstillinger" flyttet FØR det store 18-knappers formatrutenettet**
   (var sist, lett oversett bak noe man må skrolle forbi). Ren
   rekkefølge-endring, ingen ny funksjonalitet.
6. **Score-registreringens "detaljer"-steg delt i to synlige seksjoner**
   ("Retning" / "Detaljer per hull") med overskrift+divider, og en ny
   `ScrollFade`-hjelpekomponent (bunn-fade + nedoverpil, ResizeObserver-
   drevet, skjuler seg selv ved bunn) som løser at brukere ikke visste
   de måtte skrolle for å finne Chip/Bunker/Straffeslag/Anywayslag.
7. **Anywayslag endret fra tall-rutenett til +/−-stepper** — samme
   inndatamønster som Chip/Bunker/Straffeslag nå (alle fire er
   konseptuelt samme type data, brøt tidligere mønster uten grunn).
8. **Netto par-markør på Slag-knappene**: en liten oransje prikk (med
   kontrastring, synlig i BÅDE valgt og uvalgt tilstand) på knappen som
   tilsvarer spillerens personlige netto par (rå par + `strokesReceived`,
   allerede tilgjengelig i komponenten), pluss en tekstlig
   "Ditt netto par (N)"-forklaring over selve rutenettet (tilgjengelighet
   — ikke avhengig av fargesyn alene). `aria-label` utvidet tilsvarende.
9. **Putter-velgeren fikk egen visuell identitet** (oransje venstre-
   aksentkant + flagg-ikon på tittelraden) for å skille den fra Slag-
   velgeren, som ellers er nøyaktig samme rutenett-komponent. Selve
   tallknappenes etablerte grønne "valgt"-stil er UENDRET.

**Verifisering:** `tsc --noEmit` rent på alle endringer. Full scratch-
stack bygget (scratch-DB med alle 63 migrasjoner, egen scratch-MinIO,
scratch-API- og scratch-frontend-container, ekte produksjonsbuild av
frontend — ikke bare `tsc`). Ekte bruker opprettet og logget inn via
magic-link-bypass (`DEV_LOG_MAGIC_LINKS`), egen bane opprettet via ekte
API-kall (`POST /personal-courses`) med HCP 24 satt på spilleren for å
garantere `strokes_received > 0` på hull 1 — nødvendig for faktisk å se
netto-par-markøren i bruk, ikke bare anta at koden er riktig.
Browserverifisert (Chrome DevTools MCP, mobil viewport 390×844) i BÅDE
lys og mørk modus: ny-runde steg 2 sin nye rekkefølge, seksjonsdeling og
scroll-hint (bekreftet `opacity-100`→`opacity-0` ved faktisk scroll til
bunn, ikke bare visuell antagelse), netto-par-prikk (både alene og
samtidig med "valgt"-tilstand), Putter-aksent, de nye "X-Ym"-putt-
avstand-etikettene. Ingen konsollfeil. Scratch-miljøet ryddet opp
fullstendig (containere, images, database, rolle), `teecup_db`s
rolleoppsett bekreftet uendret.

**Oppfølging 2026-08-11 — punkt 5 (ChoiceRow) IKKE løst, brukeren
rapporterte tilbake:** "Avstand første putt" sine seks alternativer
viste seg fortsatt som 5 knapper på én rad + 1 alene, ikke ryddig 3+3
som antatt. Root cause: `ChoiceRow` (den delte komponenten, IKKE
`NumberPicker`) brukte `flex flex-wrap` + `flex-1` per knapp, som pakker
så mange knapper som teksten tillater per rad i stedet for et fast
antall — tilfeldigvis akkurat 5+1 for disse seks korte etikettene.
Rettet til `grid grid-cols-3` (samme mønster som `NumberPicker` allerede
brukte). Alle FIRE andre `ChoiceRow`-bruk i filen (Kjønn,
Statistikk-nivå × 2) har nøyaktig 3 valg fra før — visuelt uendret for
dem, kun 6-alternativs-tilfellet endrer seg. Browserverifisert lys+mørk.
Committet (`17e4793`), deployet 2026-08-11 sammen med tagging-runden
(se ADR-063).

**Oppfølging 2026-08-11 — punkt 6 (ScrollFade) hadde TO reelle feil,
brukeren rapporterte via skjermbilde av Retning-steget:** "Den
sprettende ned-pilen fungerer ikke. Den ligger OPPÅ en annen nedpil."

1. `ResizeObserver` observerte scroll-BEHOLDEREN (`el`), ikke
   innholdet. Beholderens boks-størrelse er fast (`flex-1`, bundet av
   veiviserens layout) og endrer seg ALDRI når man bytter steg —
   observeren fyrte derfor aldri når et steg med reelt overflow ble
   vist, og `hasMore` ble stående på sin opprinnelige (ofte `false`)
   verdi. Hintet virket rett og slett ikke der det trengtes, nøyaktig
   det brukeren rapporterte. Rettet ved å observere en egen
   `contentRef`-div rundt `children` i stedet for beholderen selv.
2. Fade-/piloverlayet er absolutt posisjonert over de siste 64px av
   scroll-området uten noen garanti om at ekte innhold ikke havner
   der. På Retning-steget (med fullt kølle-utvalg satt på testbrukeren
   for å reprodusere brukerens eksakte rutenett) landet nettopp
   "Kort"-knappens eget `ArrowDown`-ikon i den sonen — to piler oppå
   hverandre, akkurat som beskrevet. Rettet med en usynlig 64px-buffer
   (`SCROLL_FADE_HEIGHT_PX`) etter innholdet; `hasMore`-utregningen
   trekker fra samme høyde så bufferen selv aldri gir et falskt
   positivt hint på kort innhold.

Browserverifisert (samme reproduksjon: full kølle-liste for å tvinge
frem overflow): hintet vises korrekt (`opacity: 1`) når steget faktisk
overflower, forsvinner korrekt (`opacity: 0`) ved reell bunn, "Kort"
fullt synlig og klar av overlay-sonen ved skrolling, lys+mørk, ingen
konsollfeil. `tsc --noEmit` rent, 45/45 vitest grønt. Committet
(`ee91d03`).

---

## ADR-063: @-tagging av medspillere/venner i runde-feeden — 2026-08-11

Reist av bruker som en av åtte UX-funn (ADR-062, punkt 7), bevisst
utsatt der og bakt inn i FEATURE_BACKLOG.md som egen sak. Bruker ba om å
sette den i gang som egen runde.

**Beslutning A — kun FAKTISKE relasjoner er taggbare, aldri fritekstsøk
i hele brukerbasen** (personvernbegrunnelsen fra FEATURE_BACKLOG-notatet
fulgt direkte). `_taggable_candidates()` (ny hjelpefunksjon,
`round_messages.py`) returnerer UNION av rundens lenkede deltakere og
avsenderens egne venner (`friendship`, status='accepted', begge
retninger) — ALDRI et generelt personsøk. Håndhevet BÅDE i det nye
søkeendepunktet (`GET /rounds/{id}/taggable-people`) OG server-side ved
innsending (klientens forslag stoles aldri blindt på).

**Beslutning B — offset-basert tag-span, ikke innebygd tekst-syntaks.**
`round_message_tag`/`round_message_comment_tag` (migrasjon 064) lagrer
`start_index`/`end_index` inn i `body`-teksten SLIK DEN BLE SKREVET, ikke
en `@[navn](id)`-markdown-lignende syntaks i selve teksten. Trygt fordi
verken `round_message` eller `round_message_comment` NOENSINNE kan
redigeres i etterkant (kun slettes, se 058/059) — offsettene kan derfor
aldri gå ut av synk med teksten. Den viste teksten forblir alltid
NØYAKTIG det avsenderen skrev; kun lenkemålet (bruker-ID) løses opp mot
NÅVÆRENDE navn ved lesing, ikke frosset — en tag skal peke på personen,
ikke et navn-øyeblikksbilde (bevisst forskjell fra `author_display_name`,
som ER frosset).

**Beslutning C — ugyldige tags forkastes stille, hele innlegget avvises
aldri.** En tag mot noen som ikke lenger er taggbar (f.eks. sluttet å
være medspiller mellom autocomplete og innsending), eller med en offset
utenfor tekstens lengde, filtreres bare bort (`_validate_and_prepare_tags`)
— resten av innlegget/kommentaren postes uendret. Unngår at en
kapp-løpstilstand på klientsiden blokkerer en ellers gyldig melding.

**Beslutning D — varsling gjenbruker eksisterende `type="round"`,
ingen ny notification-kategori.** `notification.type` er begrenset til
fire faste verdier (`friend`/`tournament`/`round`/`result`, migrasjon
026/033, koblet til brukerens egne e-post-preferanser per kategori) — en
ny femte kategori ville krevd UI-endring i varslingsinnstillingene også,
utenfor denne rundens omfang. `"{fullt navn} tagget deg i et innlegg."`
sendes til hver gyldig tagget person (aldri til seg selv), samme
`create_notification()`-vei (in-app + push + evt. e-post) som alt annet
rundevarsel.

**To parallelle tabeller, ikke én delt polymorf** (`round_message_tag`/
`round_message_comment_tag`) — samme konvensjon som
`round_message_reaction` vs. `message_reaction` (migrasjon 059).

**Verifisert:** 8 nye pytest-tester (`tests/test_message_tags.py`) —
kandidatlisten inkluderer venn og rundedeltaker, ekskluderer fremmede og
seg selv, søkefiltrering, gyldig tag på både innlegg og kommentar (med
varsel bekreftet skrevet til `notification`-tabellen), ugyldig tag
(fremmed / utenfor tekstlengde / seg selv) forkastes stille UTEN å
avvise selve meldingen. Alle 31 backend-tester (23 fra før + 8 nye)
grønne i samme kjøring. `teecup_db` bekreftet uendret.

**Frontend — oppdatering samme dag:** opprinnelig sendt som eget
V0-prompt (se over), men bruker gikk tom for V0-credits og ba Claude
implementere promptet selv i stedet. `lib/mentions.ts` (ren offset-/
diff-logikk, 15 enhetstester) + `components/mention-input.tsx`
(`MentionTextarea`-autocomplete + `TaggedText`-rendring), koblet inn i
`round-messages.tsx` (innlegg), `post-engagement.tsx` (kommentarer —
delt av flere meldingssystemer, `roundId`/`tags` gjort valgfrie for å
ikke bryte de org-scopede callerne) og `feed.tsx` (aggregert
`/my-feed`-visning — der backendens `GET /feed` manglet `tags` ble
også lagt til her, oppdaget under dette arbeidet). Scratch-verifisert
i nettleser (autocomplete, tagging i innlegg og kommentar, rendring som
lenke både i rundevisning og i feed, varsel bekreftet, personvern-
scoping bekreftet i UI også, lys+mørk). `tsc --noEmit` rent, 45/45
vitest grønt. Se CHANGELOG.md.

Org-turneringenes tilsvarende "Banter Board" (`message`/
`message_comment`) fikk IKKE samme funksjon — helt separat system,
ikke nevnt i den opprinnelige forespørselen, fortsatt bevisst utenfor
omfang.

---

## ADR-064: GolfAPI.io som tredje banekilde — avstand til mål (rangefinder) — 2026-08-12

Brukeren fikk et GolfAPI.io-token (20 API-kall) og ba om avstandsmåling
til grønn (front/midt/bak) og hindringer — notert som en kjent, ikke-
designet ambisjon siden 2026-07-22 (se FEATURE_BACKLOG.md), atskilt fra
den allerede byggede slag-for-slag GPS-avstandsmålingen (ADR-048, som
måler AD HOC-punkter brukeren selv velger, ikke faste banepunkter).
Startbane: **Tjøme Golfklubb** — bekreftet av bruker at den IKKE finnes
i TeeOff, som gjør dette til det generelle "bane utenfor TeeOffs
dekning"-tilfellet FEATURE_BACKLOG.md (tillegg 2026-08-03) allerede
pekte ut som naturlig neste steg, ikke et spesialtilfelle.

**Live validert FØR noe ble bygget** (ekte GolfAPI-kall, ikke antatt fra
research alene): søk (`GET /clubs?name=Tjøme`), fullt scorekort
(`GET /courses/{id}` — par/SI per hull, 4 tees med per-hull-lengde i
meter, course rating/slope inkl. front9/back9), og koordinater
(`GET /coordinates/{id}` — 167 punkter for Tjøme: grønn front/midt/bak
per hull, bunkere, vann, avstandsmarkører, tee-punkter). Kostnad
bekreftet: søk 0,1 kall, full henting (course + coordinates) 2 kall —
matcher brukerens egen "20 kall ≈ 10 baner"-anslag.

**Beslutning A — ett delt, globalt cache-lag (`golfapi_course`/
`_hole`/`_tee`/`_coordinate`, migrasjon 065), ALDRI live oppslag.**
Motsatt av teeoffs policy (fritt, offentlig API, live oppslag er
kostnadsfritt) — GolfAPI-kall koster ekte, sterkt begrenset budsjett.
GolfAPIs avtale tillater eksplisitt permanent caching ("no need to call
the API to fetch the same course multiple times"), så cachen hentes KUN
én gang NOENSINNE per fysisk bane, uansett hvor mange organisasjoner/
brukere som senere kobler til den samme banen — håndhevet av én sentral
chokepoint-funksjon, `golfapi_cache.get_or_fetch_golfapi_course()`, som
ALL import-kode må gå via (aldri et direkte `golfapi_client`-kall fra et
endepunkt). Speiler ADR-019s "import ved eksplisitt valg, ikke live
oppslag"-filosofi, men med en enda strengere begrunnelse (penger, ikke
bare transaksjonssikkerhet/oppetid).

**Beslutning B — kopier inn i BEGGE eksisterende banemodeller, ikke en
tredje.** Bekreftet med bruker (AskUserQuestion): GolfAPI-importerte
baner skal kunne brukes i BÅDE org-turneringer OG frittstående runder.
- `course` (org-scopet): `course_source`-enum utvidet med
  `'international'`; `external_course_ref` lagrer GolfAPIs `courseID`
  direkte (samme kolonne teeoff allerede bruker til
  `facility_slug:course_id`). Nye endepunkt `international-search`/
  `international-import` i `courses.py`, speiler `official-search`/
  `official-import` (ADR-019) strukturelt og validerings-messig
  ("fail loudly" på ufullstendige data, aldri en delvis import).
- `personal_course` (global, ADR-042): ny nullable
  `external_golfapi_course_id`-kolonne + delvis unik indeks (samme
  mønster som `course`s migrasjon 010). Speilende endepunkter i
  `rounds.py`. `round.course_source` trenger INGEN endring — en
  GolfAPI-importert bane blir en helt vanlig `personal_course`-rad
  (`course_source='custom'`), kun med proveniens-kolonnen satt.
- Begge kopier-inn-stegene leser FRA det samme delte cache-laget —
  importeres Tjøme via en turnering OG senere via en frittstående runde,
  koster det GolfAPI 0 ekstra kall andre gang, uansett rekkefølge.

**Beslutning C — koordinatene kopieres ALDRI inn per import.** Ulikt
par/rating (som MÅ fryses per import for reproduserbare HCP-resultater,
ADR-007/ADR-019s prinsipp), er koordinatene ikke en del av HCP-
beregningen i det hele tatt. Rangefinder-oppslag (nytt endepunkt
`GET /rounds/{id}/holes/{n}/target-points`) leser derfor alltid DIREKTE
fra den delte `golfapi_course_coordinate`-cachen via banens lagrede
GolfAPI-courseID — én fysisk bane har ett koordinatsett, uansett hvor
mange ganger banen er importert til ulike organisasjoner/brukere.
Endepunktet returnerer RÅ punkter (lat/lng), ALDRI en ferdigregnet
avstand — en forhåndsberegnet avstand ville vært utdatert i det
øyeblikket spilleren beveger seg. Klienten Haversine-regner selv
(`frontend/lib/geo.ts`, samme rene funksjon som ADR-048 allerede bygget)
mot spillerens EGEN, ferske GPS-posisjon.

**Beslutning D — v1-visning: ren tall-/tekstvisning, intet kart.**
Bekreftet med bruker (AskUserQuestion) — "142 m front / 151 m midt /
163 m bak" pluss evt. nærmeste hindring, ingen Mapbox-kartlasting i det
hele tatt. Null ekstra driftskostnad utover selve GolfAPI-importen,
matcher Claude sin egen tidligere loggførte anbefaling
(FEATURE_BACKLOG.md, 2026-08-06) og ADR-048s etablerte kostnadskontroll-
filosofi (kart lastes kun når det er strengt nødvendig). Brukeren
foreslo selv en fremtidig **freemium-idé** (gratis = tall, betalt =
interaktivt satellittkart/"Plays Like"-vind-/høydejustering/flyover,
etter mønster fra etablerte konkurrenter som Hole19/18Birdies) —
BEVISST NOTERT, IKKE BYGGET: appen har ingen betalingsinfrastruktur i
dag, og v1-beslutningen (tall, ikke kart) er uansett riktig uavhengig av
om et fremtidig betalt lag legges til senere.

**Beslutning E — datamodell for koordinatene speiler GolfAPIs egen POI-
struktur, men normalisert til lesbar tekst.** `poi_type` (grønn/bunker/
vann/trær/avstandsmarkør/dogleg/vei/tee-punkt), `location`
(front/midt/bak — IKKE navngitt "green_location": Tjømes bunkere hadde
også front/bak-punkter, feltet er generisk for ethvert POI, ikke kun
grønn) og `side_fairway` (venstre/senter/høyre) er tekst+CHECK, ikke
egne enum-typer — samme "kan utvides uten `ALTER TYPE`"-begrunnelse som
`session.format` allerede bruker (001).

**Budsjettdisiplin:** import er ALLTID en eksplisitt, synlig
brukerhandling (et "Hent bane fra GolfAPI"-søk + valg), ALDRI
automatisk/implisitt — med kun ~15-16 kall igjen etter denne rundens
utvikling+verifisering (2,3 til research/validering + 2 til det siste
bevisste, ekte E2E-kallet), er dette en reell begrensning appen må
respektere fremover, ikke en teoretisk bekymring.

**Bevisst utenfor denne runden:** rangefinder-VISNINGEN i selve
scorekortet (`round-detail.tsx` sin hull-header har allerede reservert
plass ved GIR-merket siden 2026-07-25) er ikke koblet inn ennå — venter
på en Claude-skrevet V0-prompt (samme arbeidsdeling som ADR-048s
`ShotMeasurementSheet`), ikke bygget håndkodet. Org-siden fikk full
import-støtte (`international-search`/`-import`), men INGEN tilsvarende
rangefinder-visning i turneringens scoreførings-UI ennå — ingen
reservert plass finnes der i dag, egen, senere vurdering når/hvis
etterspurt. GolfAPI som generell fallback for ALLE norske søk (ikke
bare når TeeOff mangler banen) er en bevisst IKKE-endring — Tjøme er
eksempelet som utløste dette, ikke en politikkendring for Norge
generelt.

**Verifisert:** migrasjon 065 kjørt mot en automatisert scratch-database
(`scripts/run_backend_tests.sh`-mønsteret) — alle 31 eksisterende
backend-tester fortsatt grønne (ingen regresjon). Egen scratch-runde
(fixture-basert, monkeypatchet `golfapi_client` med de FAKTISKE Tjøme-
svarene fra research-fasen, for å unngå å bruke flere av de knappe
API-kallene under iterasjon) bekreftet: cache-chokepoktet henter GolfAPI
nøyaktig én gang og ALDRI på nytt ved gjentatte kall (idempotens), 18
hull + 4 tees + 167 koordinater cachet korrekt, org-import oppretter en
`course`-rad med `source='international'` + riktig `hole`/`tee_rating`-
antall (18 hull, 8 tee_rating-rader — 4 tees × 2 kjønn), duplikat-import
avvist av unik indeks i BEGGE modeller (`course_org_external_ref_unique`
fra migrasjon 010, ny `personal_course_golfapi_ref_unique`), og
target-points-oppslaget returnerer korrekte front/midt/bak-punkter for
et gitt hull. **Ett bevisst, siste ekte kall** mot GolfAPI (course +
coordinates, 2 kall) bekreftet at hele kjeden — `golfapi_client.py`s
faktiske HTTP-kall inkludert, ikke bare cache-logikken — fungerer mot
den virkelige tjenesten, ikke bare mot fixtures. `teecup_db` bekreftet
uendret gjennom hele verifiseringen (`\l`-sjekk før/etter, scratch-
database+rolle droppet). Frontend: `tsc --noEmit` rent, 45/45 vitest.
**Tillegg samme dag — rangefinder-visningen koblet inn og rullet ut.**
Brukeren kjørte V0-prompten (zip 10), resultatet
(`components/target-distance.tsx`) matchet spesifikasjonen nøyaktig.
Claude koblet den inn via en ny `components/hole-target-distance.tsx`
(GPS/fetch/Haversine-logikken, samme `watchPosition`-mønster som
ADR-048), montert i `ScoringWizard` sin hull-header
(`round-detail.tsx`). Fant og fikset en reell driftsfeil under
utrullingen: `TEECUP_GOLFAPI_TOKEN` var satt i `.env`, men
`docker-compose.yml` sin `teecup_api`-tjeneste videreførte den aldri
til containeren. Full-stack scratch-verifisert (ekte innlogging, ekte
import via det virkelige endepunktet -- cache-treff, 0 nye API-kall --
ekte runde, nettleserverifisert lys+mørk med geolocation-emulering:
front/midt/bak-avstander og nærmeste hindring rendret korrekt, ingen
konsollfeil). **Rullet ut 2026-08-12**, bruker bekreftet. Migrasjon 065
kjørt mot ekte `teecup_db`, `docker compose build && up -d` kjørt (to
ganger denne dagen -- brukeren stanset bevisst mellom første og andre
gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full
verifiseringsdetalj.

**Tillegg 2026-08-13 — den primære "Ny runde"-veiviseren manglet
GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.** Brukeren
spurte hvor, nøyaktig, funksjonen kunne sees — det viste seg at
2026-08-12-rundens søk-UI kun var koblet inn i "endre bane"-skjemaet
(`round-detail.tsx`), ikke i den faktiske `/my-rounds/new`-veiviseren
(`components/ny-runde/`, en helt separat komponenttre). Lagt til der.
Under verifisering av DENNE tilkoblingen (ikke forrige runde, som kun
reelt HTTP-testet POST-import, aldri GET-søket) ble en ekte 500-feil
funnet: `GET /personal-courses/{personal_course_id}` (eksisterende,
generisk) var registrert FØR den nye, mer spesifikke
`GET /personal-courses/international-search` — FastAPI matcher ruter i
registreringsrekkefølge, så den generiske ruten fanget grådig opp
"international-search" som en ugyldig UUID. Lærdom: en ny literal-path-
rute som deler prefiks med en eksisterende parameterisert rute MÅ
registreres FØR den, ikke etter — nå kommentert direkte i koden
(`rounds.py`) for å unngå gjentakelse. Org-siden (`courses.py`) hadde
aldri dette problemet (ingen bar `/courses/{course_id}`-rute der).
Full scratch-verifisert på nytt (ekte klikk-gjennom i selve "Ny
runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle
treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg)
for full detalj.

**Tillegg 2026-08-13 — to nye baner (Larvik/Seasidebanen, Nesbyen/
Nesfjellet) + en reell GolfAPI-datakvalitetsbug funnet under importen.**
Nesbyen har `hasGPS=0` hos GolfAPI (ingen koordinater), så koordinatene
ble registrert MANUELT av bruker (124 punkter, feltbefart) og satt inn
direkte i den samme delte `golfapi_course_coordinate`-cachen som
Beslutning C beskriver — skjemaet er kilde-agnostisk, rangefinderen
fungerer identisk uansett om et punkt kom fra API-et eller ble hånd-
registrert. To nye `poi_type`-verdier lagt til (migrasjon 067): `rock`
(fjellknaus — regnes som hinder) og `layup` (til fairway — kun
informativt, ikke et hinder).

Under selve importen krasjet koden to ganger på et reelt GolfAPI-funn:
tjenesten returnerer noen ganger en TOM STRENG (`""`) i stedet for
`null`/fraværende felt for `courseRatingMen`/`slopeMen`/
`courseRatingWomen`/`slopeWomen` — ikke bare for kvinner, som ADR-019s
tilsvarende teeoff-antakelse forutsatte, Nesbyen manglet `slopeMen`
også. Fikset på tre nivåer: (1) `golfapi_cache.py` normaliserer nå
`""` → `None` for alle fire ratingfelt FØR insert, (2)
`golfapi_course_tee.course_rating_men`/`slope_men` gjort nullable
(migrasjon 068 — var feilaktig `NOT NULL`), (3) begge import-
endepunktene (`courses.py`s `international-import`, `rounds.py`s
personal-course-ekvivalent) bygger nå rating per utslag defensivt —
hopper over et utslag HELT (ingen `tee`/`personal_course_tee`-rad) hvis
verken herre- eller dame-rating er brukbar, og avviser HELE importen
med `EXTERNAL_DATA_INCOMPLETE` FØR transaksjonen starter hvis
bokstavelig talt intet utslag har noen brukbar rating (fail-fast,
samme ADR-019 Beslutning C-filosofi). Nesbyens 6 utslag hadde faktisk
INGEN rating i det hele tatt hos GolfAPI (ikke bare tomme strenger på
enkeltfelt) — brukeren oppga alle 6 utslags course rating/slope
(herre+dame, ett utslag uten damerating) manuelt fra klubbens eget
scorekort, satt inn direkte i `golfapi_course_tee`-cachen før import.

**Verifisert:** `python3 -m py_compile` rent på alle tre endrede
Python-filer, full `./scripts/run_backend_tests.sh` (31/31, migrasjon
068 bekreftet anvendbar). Selve importen kjørt direkte mot ekte
`teecup_db` via et engangsskript i `teecup_api`-containeren (speiler
`import_international_personal_course`s kopier-inn-logikk nøyaktig) —
etterpå bekreftet med en read-only spørring: Larvik (18 hull, 6 utslag,
11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6 utslag, 11
tee-ratinger, 124 koordinater), begge eid av Erol Haagenrud, `golfapi_
course.has_gps`/`num_coordinates` korrigert for Nesbyen. **Rullet ut
2026-08-13** — migrasjon 067+068 kjørt mot ekte `teecup_db`,
`teecup_api` bygget/omstartet, ren oppstartslogg.

Disse må avklares før eller under de relevante fasene:

1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
   `JWT_SECRET`. TeeCup bør bruke separate, uavhengige secrets. *(Sikkerhet)*
2. **Cache over flere prosesser:** In-memory `dict`-cache på `app.state` deles ikke
   mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
   isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
4. **Scramble-grensesnitt — ✅ HELT FERDIG 2026-07-29.** Arkitekturen skal
   ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
   **Utvidet 2026-07-25, DELVIS AVKLART OG BYGGET 2026-07-28:** brukeren
   ba om statistikk over hvor mange utslag hver spiller har hatt (dvs.
   hvor mange ganger spillerens drive ble valgt). Bygget for FRITTSTÅENDE
   RUNDER (ADR-039 sitt delt-ball-format, `round_hole.selected_
   participant_id`, migrasjon 036) — se FEATURE_BACKLOG.md ("Scramble:
   statistikk over utslag brukt per spiller") for full detalj.
   **Fullført 2026-07-29:** samme funksjon bygget for org-scopede
   turnering-scramble/-greensome også (`hole_score.selected_
   participant_id`, migrasjon 039) — samme mønster, se CHANGELOG.md
   2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
   ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
   dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
   databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
   på at f.eks. et foursome ikke får per-spiller-scorer.
6. **Individuelle turneringer, flerrunde-turneringer og Order of Merit**
   (reist 2026-07-26). **Grunnstruktur for de to første AVKLART samme
   dag, se ADR-037:** ny, parallell org-scopet datamodell (IKKE en
   utvidelse av ADR-033s `round`-tabeller), samme `tournament`-tabell
   med ny `format_type`-diskriminator, flerrunde via ny
   `tournament_round`-tabell, rå slag lagres + poeng caches per format
   (samme mønster som match-play). **Fortsatt åpent:** de fem konkrete
   formatenes egne poengregler (Københavner m.fl.), og Order of Merit
   (sesong-sammenlagt på tvers av flere turneringer) — bekreftet som et
   beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
   skrevet — ADR-037 er ren struktur-beslutning.
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
   par-lag-konkurranse, reist 2026-07-28) — **AVKLART, BYGGET (backend +
   frontend) OG LIVE SAMME DAG, se ADR-039.** `round.play_format` styrer
   nå faktisk både HCP-beregning (via en portert versjon av turnering-
   motoren) og et `format-result`-lese-endepunkt for scorekort-
   presentasjon, med tilhørende UI for sideoppsett/skins-konfig/
   matchstatus-visning — se FEATURE_BACKLOG.md/CLAUDE.md for full detalj
   og etterfølgende samme-dags oppfølgingsrunder. "Flere flighter i én
   frittstående runde" (egen seksjon i FEATURE_BACKLOG.md) forblir et
   adskilt, ikke-relatert spørsmål (grupperer RUNDER, ikke deltakere
   innad i én runde).

---

## ADR-065: Per-deltaker-fullføring av frittstående runder + scorekort på e-post — 2026-08-13

Brukerens konkrete scenario: fire spillere sammen i én runde, to går 18
hull, én går 9, én må avslutte etter 14. Med dagens modell
(`round.completed_at`, rundeomfattende) kan runden ikke fullføres for
de ferdige spillerne uten samtidig å låse scoreføring for de som
fortsatt spiller (`readOnly = completed` var alt-eller-ingenting) — og
ingen kunne få scorekortet sitt på e-post før HELE runden var ferdig.

Bekreftet med bruker (AskUserQuestion) og ett tillegg i etterkant:
1. **Rundens egen fullføring forblir MANUELL** — eieren trykker
   fortsatt "Fullfør runde" til slutt (HCP-beregning/varsler skjer
   fortsatt der, uendret). Per-deltaker-fullføring er KUN en nagging-
   brems + trigger for e-post, IKKE en auto-cascade til hele rundens
   fullføring.
2. **Scorekort på e-post sendes UMIDDELBART** når en spiller selv blir
   ferdig, ikke samlet ved rundefullføring — mest nyttig nettopp for
   spilleren som går av tidlig.
3. **Tillegg:** må også kunne sende scorekort til en enkelt spiller i
   ETTERTID, lenge etter at runden allerede er fullført (glemt/tapt
   e-post, ønske om å sende på nytt) — et eget on-demand-endepunkt,
   ikke bundet til selve fullførings-transisjonen.

**Bevisst utenfor omfang:** delt-ball-formater (foursome/scramble/
greensome, som fullfører per SIDE via `round_side`, ikke per deltaker)
— brukerens eksempel er individuell slagspill. Strukturelt symmetrisk
follow-up når/hvis etterspurt.

**Datamodell (migrasjon 066):** `round_participant.completed_at
timestamptz`, ingen CHECK mot `round.completed_at` — applikasjonslaget
garanterer rekkefølgen (deltaker-fullføring skjer alltid FØR/uavhengig
av rundens egen, aldri etter at runden er låst).

**Tilgang — `completed` er et FLIGHT-STYRT felt, ikke eier-/selv-
only.** Speiler `update_hole`s eksisterende begrunnelse ("en lenket
medspiller kan registrere score for HELE flighten") — enhver med
tilgang til runden (eier ELLER lenket medspiller) kan avslutte/
gjenåpne EN HVILKEN SOM HELST deltaker, akkurat som de allerede kan
føre score for en hvilken som helst deltaker. Ny
`_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}`-konstant i
`rounds.py` hopper eksplisitt over både eier-only-sjekken og
selv-only-sjekken KUN for dette feltet — uendret for alle andre felt i
samme kall. Avvist med `ALREADY_COMPLETED` (409) hvis `round.
completed_at` allerede er satt — ingen åpning/lukking av
enkeltdeltakere etter at hele runden er låst.

**E-post — én delt funksjon, to mottakertyper.**
`_send_participant_round_summary(conn, round_id, participant_id,
round_label) -> bool` erstatter den gamle gjeste-only
`_send_guest_round_summaries` — slår nå opp BÅDE gjest (`guest_email`)
OG lenket konto (`app_user.email` via LEFT JOIN), returnerer om en
adresse fantes. Kun den FØRSTE `null → true`-transisjonen av
`completed_at` trigger e-post (idempotent — re-sending `completed:
true` når allerede sann, eller `completed: false`, sender aldri på
nytt automatisk). `complete_round` har en fallback-løkke: enhver
deltaker som fortsatt er `completed_at IS NULL` når hele runden
fullføres, får det satt nå OG e-post sendt der også — dekker det
vanlige tilfellet (ingen trykket "avslutt" underveis) uten
dobbel-sending for de som allerede var individuelt avsluttet.
`app/email.py`s `send_round_summary_email` fikk `is_linked_account`/
`round_id`-parametre: lenkede mottakere får en direkte
`/my-rounds/{round_id}`-lenke (ingen magic-link, de har allerede
konto) og hilsen med `app_user.first_name` (navneformat-regelen i
CLAUDE.md), gjester beholder den opprinnelige magic-link-CTA-en.

**Nytt on-demand-endepunkt** (tillegg 3 over): `POST /rounds/{round_
id}/participants/{participant_id}/send-scorecard` — fungerer UANSETT
fullføringsstatus, også lenge etter at runden er fullført. Samme
flight-styrte tilgang som `completed`-feltet, men bevisst INGEN
`ALREADY_COMPLETED`-sperre (leser og sender kun, muterer aldri
databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) hvis
deltakeren ikke har noen registrert adresse.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller
frittstående slagspill-runde (blanding lenket bruker + gjest med
e-post), ulikt antall hull spilt, avsluttet to deltakere manuelt via
PATCH — bekreftet e-post trigges umiddelbart med riktig mottaker-
variant (gjest vs. lenket, ulik lenke/hilsen), `ALREADY_COMPLETED` ved
forsøk etter rundefullføring, `complete_round` sender KUN til
ikke-allerede-avsluttede deltakere, en lenket medspiller (ikke eier)
kan avslutte en ANNEN deltaker (flight-styrt tilgang bekreftet), angre
(`completed: false`) fungerer før rundens egen fullføring, og det nye
on-demand-endepunktet fungerer også lenge etter rundefullføring.
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.

**Tillegg samme dag — frontend-UI bygget og rullet ut.** `Player.
completed` lagt til (avledet fra `ApiParticipant.completed`), delt
`ParticipantCompletionActions`-komponent gjenbrukt i BÅDE
`PlayerHoleCards` (Score-fanen) og `PlayerList` ("Spillere og
runde"-fanen) -- "Avslutt for [navn]" (skjult når runden selv er
fullført, siden backend uansett avviser da), "Ferdig ✓ (N hull)" +
"Angre" (kun når ikke rundefullført), "Send scorekort" (alltid synlig,
uansett fullføringsstatus -- dekker "send til enkeltspiller i etterkant,
lenge etter fullføring"-tillegget). `allHolesEnteredForEveryone` og
hurtig-hopp-stripens "registrert"-sjekk teller nå en `completed`-spiller
som ferdig uavhengig av faktisk hull-dekning. `advanceWizardPlayer`
hopper over fullførte spillere i samlebånds-kjeden. Et ikke-spilt hull
for en fullført spiller viser nå "Ferdig" i stedet for "Registrer" +
den stiplede pluss-boksen.

**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte nettleser-
verifisering (ekte innlogging inkl. reell TOTP-2FA-kode generert fra
kontoens lagrede secret, ekte runde opprettet på Tjøme -- cachet bane,
0 nye GolfAPI-kall -- med to deltakere): "Avslutt for X"/"Send
scorekort" synlig på begge kort i begge faner, avslutning av gjesten
trigget e-post umiddelbart (ingen feil i `teecup_api`-loggen, SMTP
konfigurert), UI oppdaterte seg til "Ferdig ✓"-merke + "Angre"-lenke +
"Ferdig"-tekst i score-kolonnen, "Send scorekort" for eieren (lenket
konto, andre kodesti enn gjesten) ga "Sendt ✓"-tilbakemelding, "Angre"
tilbakestilte korrekt og var persistent etter fane-bytte. Bekreftet
visuelt lys+mørk. Ingen konsollfeil gjennom hele forløpet. Testrunden
slettet etter verifisering (`DELETE /rounds/{id}`, bekreftet fjernet fra
`teecup_db`). **Rullet ut 2026-08-13** -- ren frontend-endring (ingen ny
migrasjon), `docker compose build teecup_frontend && up -d
teecup_frontend`.

**Tillegg samme dag — "fullt scorekort" i e-posten, pluss dato/klokkeslett/
spilletid i hilsenen.** Bruker, rett etter forrige tillegg: e-posten burde
sende DET FULLE scorekortet ("all statistikk for runden"), ikke bare
slag/netto -- og hilsen-linjen burde vise dato, klokkeslett og hvor lang
tid runden tok, med eksempel: "Her er scorekortet ditt fra Tjøme
Golfklubb – Bane, i dag klokken 08:40 (du spilte 18 hull på 3t 42min,
sammen med 3 andre):". Et FØRSTE forsøk bygde en enkel vertikal
hull-per-rad-tabell (kolonner Hcp/Par/Slag/Netto/Poeng/Fw/Putt/GIR/
Innspill/Chip/Bunker/Straff, én rad per hull) -- bruker så testresultatet
og ba i stedet om at e-posten skal SE UT SOM selve Scorekort-siden i
appen (sendte skjermbilde av `round-scorecard.tsx`), ikke bare inneholde
de samme tallene i et annet oppsett. Hele HTML-tabellen ble derfor bygget
om fra bunnen -- se "faktisk skipet design" under, den vertikale
varianten eksisterer ikke lenger i koden.

**Faktisk skipet design (HTML-delen av `send_round_summary_email`,
email.py):** hull som KOLONNER (ikke rader), i to 9-hulls-blokker
("Ut"/"Inn", eller "Tot" for en 9-hullsrunde) -- porterer
`ScoreBlock`-komponenten i frontend/components/round-scorecard.tsx
rad-for-rad til ren HTML/inline-CSS: mørk hull-nummer-header, en grønn
Ut/Inn-oppsummeringskolonne, fargede score-merker (sirkel/firkant,
fylt/omrisset etter eagle/birdie/par/bogey/double -- samme
`_classify()`-terskel som frontend sin `classify()`), og glyffer for
Fw/Innspill (◎ traff, ↖/↗/↑/↓/←/→ retning, farget grønt/oransje) og GIR
(✓). Rad-etikettene ble kortet ned samme dag etter et oppfølgingsbilde
av selve e-posten (`Score`/`Netto`/`Poeng` → `Scr`/`Net`/`Pnt` -- tok for
mye bredde i en allerede trang 9-kolonners tabell). Under blokkene: fire
"total tiles" (Par/Score/Til par/Poeng), identisk med `TotalTile`-raden
nederst på selve Scorekort-siden. Radene graderer seg fortsatt -- en rad
(Netto/Poeng/Fw/Putt/GIR/Innspill/Chip/Bnk/Str/Any) tas kun med når
MINST ett hull faktisk har data for den, samme show*-mønster som
round-scorecard.tsx. Tekst-fallbacken (for klienter uten HTML-støtte)
beholdt sin egen, enklere hull-per-linje-form (samme feltsett, men kan
ikke meningsfullt gjengi et 2D-rutenett i ren tekst) -- ett bevisst,
akseptert avvik mellom de to variantene, resten av innholdet er identisk.
`RoundSummaryHole` (email.py) utvidet med `stroke_index`/`picked_up`/
`points` (Stableford, portert fra frontend sin `stablefordPoints`)/
`putts`/`tee_shot_result`/`approach_result`/`chip_count`/
`bunker_shot_count`/`penalty_strokes`/`anyway_strokes`.

**To reelle bugs funnet UNDER ende-til-ende-verifisering mot en ekte,
tidligere fullført runde (ikke bare isolert forhåndsvisning):**
1. `round.played_at` er KUN en `date`-kolonne -- ingen klokkeslett lagret
   i det hele tatt. "Utslagstid"-feltet i ny-runde-veiviseren fanges opp
   av wizard-state, men sendes faktisk ALDRI til backend
   (`wizard-context.tsx` sender kun `played_at: s.date`) -- en egen,
   allerede eksisterende feil, ikke rettet her (FEATURE_BACKLOG.md-verdig
   funn, utenfor denne rundens omfang). Løsning: brukte `round.
   started_at` (timestamptz, satt når runden faktisk startes) som
   klokkeslett i stedet, med graceful fallback (kun dato, ingen
   klokkeslett-ledd) når `started_at` mangler. Nytt `start_hole`-
   parameter styrer Ut/Inn-rekkefølgen etter SPILLEREKKEFØLGEN (samme
   sirkulære `holeOrder`-logikk som round-scorecard.tsx), ikke rå
   hole_number.
2. Samme root cause forplantet seg til spilletid-utregningen:
   `duration_start` falt opprinnelig tilbake til `round.played_at` (en
   `date`) når `started_at` manglet -- ville krasjet med
   `TypeError` ved subtraksjon mot en ekte `datetime`. Fjernet fallbacken
   helt (ingen varighet vises hvis runden aldri ble "startet", i stedet
   for å blande typer). Spilletiden bruker `round_participant.
   completed_at` (DENNE spillerens egen sluttid) minus `round.
   started_at`, med fallback til `round.completed_at` for historiske
   runder fullført FØR migrasjon 066 (ingen individuell completed_at satt
   den gang) -- verifisert direkte mot en ekte, gammel runde (started_at
   06:30 UTC, completed_at 10:46 UTC → riktig utledet 4t16min).

**Verifisert:** `python3 -m py_compile` rent, 31/31 backend-tester ved
hver iterasjon. Isolert forhåndsvisning (monkeypatchet `_send_sync` for å
fange opp HTML-en uten å faktisk sende, 18 hull med data for ALLE
graderte felt inkl. eagle/dobbel bogey/plukket opp, fairway/GIR/chip/
bunker/straff/innspill i alle retninger) nettleser-rendret og
skjermbilde-sammenlignet direkte mot brukerens eget skjermbilde av
Scorekort-siden -- bekreftet visuelt samsvar (samme blokk-oppsett,
farger, glyffer, tiles). **Ekte ende-til-ende-kall** (`POST .../
send-scorecard`) mot en reell, tidligere fullført runde i produksjon,
gjentatt etter hver av de to bug-fiksene og etter etikett-forkortelsen
-- endte med 204 og ren `teecup_api`-logg. **Rullet ut 2026-08-14**
(flere delrunder samme kveld/natt) -- ren backend-endring (ingen ny
migrasjon), `docker compose build teecup_api && up -d teecup_api` hver
gang.

---

## ADR-066: Flaggturnering -- GPS-flaggplanting + runde 2+-scoreføring ("Del A") — 2026-08-14

Bruker ba om en tredelt utvidelse av Flaggturnering: (A) spilleren
"planter flagget" via GPS der slagbudsjettet tar slutt, (B)
turneringsledelsen kan slå av/på en kartoversikt over ALLE plantede
flagg (satellittfoto), synlig for spillere og tilskuere, (C) nytt format
Eclectic (brutto/netto/Stableford, beste resultat per hull på tvers av
en turnerings runder). **Kun Del A er bygget i denne runden** -- Del
B/C er fullt spesifisert i en godkjent plan, men ingen kode skrevet
ennå.

Bekreftet med bruker (AskUserQuestion, to runder):
1. Flagg-GPS bygges for BEGGE hjem -- frittstående runder OG
   org-individuelle turneringer (Flaggturnering finnes i begge, selv om
   nærmeste presedens -- ADR-048 slag-for-slag-GPS -- kun fantes for
   frittstående runder).
2. **Full runde 2-scoreføring**, ikke bare en visuell markering --
   spilleren som fullfører 18 hull MED slag igjen fortsetter reelt inn i
   "runde 2" på samme play_order.
3. **Selvkorrigerende, interaktiv flyt** (brukerens eget forslag,
   foretrukket fremfor mitt opprinnelige forslag om en passiv
   påminnelse): spilleren trykker "Plant flagget" når som helst; appen
   spør "Hullet du ut på hull N?" (N = første hull uten registrert score
   i spillerekkefølgen). JA → flagget var prematurt, spilleren må føre
   inn scoren for hull N og trykke "Plant flagget" på nytt fra neste
   utslag (ingen innsending skjer). NEI → dette ER flagg-punktet, GPS
   fanges, og appen spør om spilleren landet på green (i så fall:
   avstand i meter+cm, som direkte påvirker resultatlisten -- jo nærmere
   koppen, jo lengre har spilleren kommet blant andre som gikk tom på
   samme hull). **Backend validerer UANSETT server-side** mot faktisk
   registrert scoredata ("stol aldri blindt på klienten") -- klientflyten
   er en UX-guide, ikke autoritativ.

**Datamodell -- to nye, HELT ISOLERTE tabellpar** (migrasjon 069
frittstående, 070 org, se filenes egne headerkommentarer for full
kolonneliste): `round_participant_flag_plant`/`tournament_round_
participant_flag_plant` (posisjon der spilleren gikk tom for slag, UNIQUE
på deltaker-id -- re-planting er slett+opprett-på-nytt, samme prinsipp
som `round_shot`) og `round_hole_flag_overflow`/`tournament_round_hole_
flag_overflow` (scoreføring for runde 2+). **Bevisst IKKE en `lap`-
kolonne på selve `round_hole`/`tournament_round_hole`** -- de tabellene
er delt av samtlige ni eksisterende formater og lest av et stort antall
spørringer (scorekort/statistikk/HCP-differensial) som alle antar ≤18
rader per deltaker; en delt utvidelse hadde krevd en revisjon av HVER
eksisterende spørring for å unngå at runde-2-rader lekker inn uventet
andre steder. En helt egen tabell holder all ny risiko innenfor selve
flagg-funksjonen. Org-tabellene er RLS-beskyttet, samme
`org_isolation`-mønster som migrasjon 040; frittstående-tabellene har
ingen RLS (samme `plain_connection()`-presedens som resten av
frittstående-rundesystemet).

**Motor:** ny, ren `flag_lap_and_hole(holes_completed, play_order) ->
(lap, hole_number)`-hjelpefunksjon i `handicap_engine.py`, rett etter
eksisterende `flag_result()`/`FlagResult`. Selve `flag_result()` viste
seg IKKE å trenge endring -- kalleren konkatenerer bare lap 1s fulle
gross_strokes-sekvens med lap 2s (kun hvis lap 1 var fullført) til én
flat liste før kallet; `holes_completed` blir naturlig en telling som
kan overstige 18. 5 nye tester i `test_handicap_engine.py` (budsjett
brukt opp midt i lap 2, budsjett akkurat nok til begge laps uten
plantbart punkt, lap/hull-oversettelse ved ikke-standard start_hole,
m.fl.) -- full suite 122/122.

**API (speilende ruter i `rounds.py`/`individual_tournaments.py`):**
`POST/GET/DELETE .../flag-plant`, `GET/PUT .../flag-overflow/{lap}/
holes/{hole_number}`. Server beregner FAKTISK "hull i gang" fra
eksisterende scoredata (`_flag_current_position`/`_flag_current_
position_org`) og avviser (`VALIDATION_FAILED`, 400) ethvert
plant-/overflow-forsøk som ikke stemmer -- selv om frontend allerede
guider brukeren dit. **To ulike autorisasjonsmodeller bevisst
beholdt, ikke slått sammen:** frittstående runder er flight-styrt
(eier ELLER lenket medspiller kan handle for enhver deltaker, samme som
`update_hole`); org-turneringer er selv-only (`user_is_own_tournament_
participant`, samme som den filens `update_hole`).

**Frontend -- ny UI-overflate, bygget via V0 (standingregel i
CLAUDE.md/minne).** Skrev en detaljert V0-prompt for `FlagPlantSheet`
(GPS auto-fanget → "Hullet du ut?" Ja/Nei → evt. "Landet du på green?"
+ meter/cm → bekreftelse), sendt til bruker. Bygde parallelt en tydelig
MERKET midlertidig håndkodet versjon (samme props-kontrakt) for å kunne
verifisere hele backend+integrasjonen uten å vente. **V0-eksporten (zip
11) kom tilbake samme dag** og erstattet den midlertidige versjonen i
BÅDE `round-detail.tsx` (`FlagPlantSheet`) og
`individual-tournament-detail.tsx` (`FlagPlantSheetOrg`, egen kopi per
filens konvensjon -- fikk i tillegg `expectedLap`-prop lagt til
kallstedet, som den midlertidige org-varianten manglet). Selvstendig
GPS-håndtering (ingen kartkomponent -- det er "Del B"), server-avvisning
vist som vedvarende inline-feil (ikke en forsvinnende toast).

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (46/46, inkl. 15 nye flagg-plant-
tester i to nye testfiler). Egen scratch-database + scratch
`teecup_api`-container (port 18000) + lokal `next dev` (port 13000):
ende-til-ende-flyt for BEGGE hjem, inkl. server-side avvisning av et
for-tidlig plantingsforsøk og full runde 2-scoreføring, geolocation
emulert via Chrome DevTools MCP (måtte omgå at `emulate()` kun setter
koordinater, ikke selve Permissions API-tilstanden -- løst med
`navigate_page({type:"reload", initScript: ...})` som overstyrer
`navigator.geolocation` direkte). Lys+mørk bekreftet, lap 2 sin
`(runde {lap})`-visuelle markering bekreftet live. Etter V0-swap:
`tsc --noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet
ned igjen etterpå -- ingen av dette har rørt ekte `teecup_db`.

**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte `teecup_db`
som `teeoff_admin` (`teecup_app` mangler bevisst `CREATE`-rett på schema
public, se ADR-002-begrunnelsen i `002_roles_and_grants.sql` -- migrasjoner
kjøres alltid av admin-rollen, aldri av runtime-rollen), etter eksplisitt
bekreftelse fra bruker. Alle fire tabeller verifisert direkte i skjemaet
etterpå (kolonner/constraints/RLS-policy som forventet), deretter
`docker compose build teecup_api teecup_frontend && up -d` -- begge
containere startet rent, ingen feil i logg. Del B (kartoversikt +
synlighetsbryter) og Del C (Eclectic) gjenstår, egne
scratch-verifiserings-/bekreftelsesrunder per CLAUDE.md/plan-disiplinen.

---

## ADR-067: Flaggturnering -- kartoversikt over plantede flagg + synlighetsbryter ("Del B") — 2026-08-14

Andre del av den tredelte Flaggturnering-utvidelsen (se ADR-066 for Del A
og full kontekst). Turneringsledelsen (frittstående: eier: org:
ethvert medlem) kan slå av/på en satellittkartoversikt som viser ALLE
deltakeres plantede flagg samtidig -- standard AV, samme "skjult til
avslørt"-filosofi som `visibility_mode`/blind draw. En spiller ser
ALLTID sitt eget flagg uansett bryter-status; det er kun ANDRES flagg
bryteren styrer.

**Avklart med bruker underveis (AskUserQuestion):** org-individuelle
turneringer har INGEN offentlig tilskuer-side i det hele tatt ennå
(`/t/[id]/live` er bygget utelukkende for lagturneringer -- team_a/
team_b/matcher, ingen `format_type`-gren for individuelt). Å bygge en
slik side fra bunnen for å kunne vise kartet der også ville vært en HELT
EGEN, mye større oppgave enn selve kartfunksjonen. Bruker valgte
(anbefalt alternativ) å AVGRENSE Del B sin org-side til org-medlemmer/
deltakere (samme tilgangsnivå som det eksisterende `flag-result`-
endepunktet) -- INGEN ny offentlig spectator-infrastruktur bygget her.
Frittstående runder FÅR full tilskuerstøtte, siden `/watch/[id]` og
`/public/rounds/*`-mønsteret allerede finnes og fungerer likt innlogget
som anonymt.

**Datamodell (migrasjon 071):** to enkle boolean-kolonner, `round.
flag_map_visible` og `tournament.flag_map_visible`, begge `NOT NULL
DEFAULT false`. Ingen nye tabeller -- gjenbruker flagg-plant-tabellene
fra migrasjon 069/070 (ADR-066) direkte, kun ETT nytt felt per hjem for
selve synlighets-bryteren.

**API:** ingen nye skrive-endepunkter for selve bryteren -- kun nye
felt på de allerede eksisterende PATCH-modellene (`RoundUpdate.
flag_map_visible`, eier-only via `_get_owned_round_or_404`;
`TournamentUpdate.flag_map_visible`, ethvert org-medlem via
`get_authorized_org` -- begge gjenbruker sine filers eksisterende PATCH-
handlere UENDRET, siden begge allerede var generiske "kun de feltene som
faktisk sendes" whitelist-drevne). Nytt LESE-endepunkt per hjem: `GET
/rounds/{id}/flag-map` (delt `_build_flag_map()`-hjelper, gjenbrukt av
BÅDE den autentiserte ruten og `GET /public/rounds/{id}/flag-map` for
tilskuere -- samme datauttrekk, ulik tilgangssjekk via
`_get_accessible_round_or_404` vs. `_get_viewable_round_or_404`) og `GET
/orgs/{id}/tournaments/{id}/rounds/{id}/flag-map` (kun autentisert,
`get_authorized_org`, se avgrensningen over). Responsen har alltid
`visible_to_all` + `flags`-listen -- ALLE deltakeres flagg når bryteren
er PÅ, ELLERS kun requesterens eget (0 eller 1 rad).

**Frontend -- nok en ny UI-overflate via V0.** Skrev en detaljert prompt
for `flag-map-overview.tsx` (FØRSTE flerpunkts-Mapbox-kart i appen --
alt annet Mapbox-innhold viser ett punkt om gangen), sendt til bruker.
Bygde parallelt en tydelig MERKET midlertidig håndkodet versjon (samme
props-kontrakt: `flags: FlagMapEntry[]`, `visibleToAll: boolean`, ren
UI uten egne nettverkskall) for å kunne verifisere hele funksjonen uten
å vente. Wired inn via tre tynne, selvstendige "seksjon"-komponenter
(egen fetch + `next/dynamic(..., {ssr:false})`-lasting av selve kartet,
samme mønster som `NassauPanel`): `FlagMapSection`
(`round-detail.tsx`, Administrer-fanen), `FlagMapSectionOrg`
(`individual-tournament-detail.tsx`, Score-fanen) og `WatchFlagMap`
(`watch-round.tsx`, tilskuer-siden -- viser ingenting i det hele tatt
når bryteren er av OG tilskueren ikke har noe eget flagg, i stedet for
en tom/forvirrende boks). Kartet skiller lap 1 fra lap 2+ med BÅDE farge
OG en tekst-badge ("R{lap}") på selve markøren, aldri farge alene
(fargeblindhet-hensynet i CLAUDE.md sin tilgjengelighetsregel), pluss en
alltid synlig tekst-legende.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (50/50, 4 nye flag-map-tester i de
samme to testfilene som ADR-066 -- "kun eget flagg vises når bryteren er
av"/"alle flagg vises når den er på", begge hjem). `tsc --noEmit` rent +
45/45 vitest etter frontend-endringene. Egen scratch-database + scratch
`teecup_api`-container (port 18000, kodendringer live-mountet inn i
containeren for å unngå gjenoppbygging ved hver iterasjon) + lokal `next
dev` (port 13000): sådde to komplette scenarioer direkte via
`tests/conftest.py` sine hjelpefunksjoner (ikke gjenimplementert) --
frittstående runde og org-turnering, hver med to spillere og ett flagg
plantet hver (én lap 1, én lap 2 med `on_green`+avstand). Bekreftet i
ekte nettleser: bryteren av → kun eget flagg + "Kun ditt eget flagg er
synlig"-banner; bryteren på → begge flagg, korrekt lap 2-merking, popup
med spillernavn/hull/runde/avstand ved trykk; samme for org-siden;
tilskuer-siden (`/watch/[id]`) viser begge flagg når bryteren er på,
uten å kreve innlogging. Lys+mørk bekreftet. Mapbox sin URL-restrikterte
offentlige token krevde samme `Referer`-header-omgåelse via Chrome
DevTools MCP som tidligere kartverifiseringer i denne loggen (se punkt
51 i CHANGELOG.md). Scratch-stacken (database, rolle, container, MinIO-
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.

**Rullet ut 2026-08-14** -- migrasjon 071 kjørt mot ekte `teecup_db`
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker. Begge nye
kolonner verifisert direkte i skjemaet. `docker compose build teecup_api
teecup_frontend && up -d` -- begge containere startet rent. Del C
(Eclectic) gjenstår.

---

## ADR-068: Eclectic-format for org-individuelle turneringer ("Del C") — 2026-08-14

Tredje og siste del av den tredelte utvidelsen (se ADR-066/067 for Del
A/B -- Del C er ikke Flaggturnering-relatert i seg selv, men bedt om i
samme runde). Eclectic er en "drømmerunde": spillerens BESTE resultat
per hullnummer på tvers av ALLE turneringens runder, summert til én
total. Tre varianter (brutto/netto/Stableford), samme mønster som
`stroke_gross`/`stroke_net`/`stableford` sin eksisterende oppsplitting.

**Fail-loudly samme-bane-krav:** Eclectic krever at ALLE turneringens
runder spilles på samme bane -- ellers betyr ikke "hull 5" det samme på
tvers av rundene (ulik par/stroke index/fysisk hull), og "beste resultat
per hull" blir meningsløst å sammenligne. Håndhevet ved rundeopprettelse
(`create_round`, individual_tournaments.py): avviser med
`VALIDATION_FAILED` hvis turneringens `scoring_method` starter med
`eclectic_` og den nye rundens `course_id` avviker fra en allerede
eksisterende runde. Samme ADR-019-filosofi som resten av appen (avvis
tydelig, ikke stille feil).

**Datamodell (migrasjon 072):** INGEN nye tabeller/kolonner -- kun tre
nye verdier i `tournament_scoring_method_check` (samme drop+recreate-
mønster som 042/044/046). Eclectic gjenbruker eksisterende
`tournament_round_hole` (per-hull bruttoslag, allerede lagret av alle
formater) og `tournament_round_participant.course_handicap` (allerede
beregnet for alle scoring_method-verdier) -- alt regnes ut VED LESING,
samme "regn ut ved lesing"-filosofi som resten av individuelle
turneringers leaderboard (Beslutning C).

**Motor:** ny, ren `eclectic_best_per_hole(values_by_hole, result_type)`
i `handicap_engine.py` -- for hvert hullnummer, plukker BESTE verdi
(lavest for brutto/netto, høyest for Stableford) blant alle registrerte
runde-verdier for det hullet, uavhengig av hvilken runde den kom fra.
Sporer også HVILKEN runde (via en kaller-tildelt `round_index`) hvert
plukk kom fra -- brukt til frontend sin "beste-kilde"-visning. Hull uten
noen registrering utelates (ikke tellet som 0). 5 nye tester, inkl. ett
scenario der beste resultat for ulike hull kommer fra ULIKE runder
(beviser at plukkingen faktisk skjer per hull, ikke "velg beste hele
runde") -- full motor-suite 127/127.

**API-integrasjon -- BEVISST avvik fra opprinnelig plan.** Planen
foreslo opprinnelig et helt nytt leseendepunkt for Eclectic. Etter å ha
studert `_compute_individual_standings` (den delte funksjonen bak
`individual_leaderboard`, allerede brukt av `stroke_gross`/`stroke_net`/
`stableford`/`copenhagen`/`bingo_bango_bongo`) viste det seg klart
renere å legge Eclectic til som EN NY GREN i den samme funksjonen --
`LeaderboardEntry` fikk `eclectic_total`/`eclectic_holes`-felt (alltid
`None`/tom for andre scoring_method-verdier), og en ny
`_attach_eclectic_totals()`-hjelper populerer dem. INGEN nytt
endepunkt -- frontend bruker det samme `GET .../individual-leaderboard`
den allerede kaller for alle andre formater. 5 nye pytest-tester
(gross/net/stableford-utregning, samme-bane-avvisning, bekreftet at
IKKE-eclectic-turneringer fortsatt tillater ulike baner) -- full
backend-suite 55/55.

**Frontend (individual-tournament-detail.tsx, håndkodet -- filens egen
uttalte konvensjon, se filens header-kommentar).** `SCORING_METHOD_
LABELS` fikk de tre nye verdiene. `LeaderboardTab` sin flate liste
(samme gren som Københavner/BBB bruker, ikke Augusta-resultattavlen som
er forbeholdt de tre opprinnelige slagspill-variantene) fikk en
utvidbar "vis hull-for-hull"-rad per deltaker -- ny `EclecticHoleTable`-
komponent viser hull/par/verdi/"Fra runde N" for nøyaktig de hullene som
faktisk teller, beviser visuelt for spilleren at totalen er satt sammen
på tvers av runder.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (55/55). `.venv/bin/python
test_handicap_engine.py` (127/127, standalone-selvsjekk uten pytest).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (port 18001, live-mountet kode) + lokal `next
dev` (port 13001): sådde én turnering med to runder på SAMME bane, én
deltaker med hull 1 = 4/7 slag og hull 2 = 6/3 slag på tvers av de to
rundene -- forhåndsberegnet forventet total (4+3=7) stemte nøyaktig med
det viste leaderboardet, og "vis hull-for-hull" viste riktig kilde-runde
per hull. Bekreftet i nettleser: samme-bane-avvisningen ga en synlig,
tydelig feilmelding ved forsøk på å opprette en runde på en annen bane.
Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned etterpå --
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.

**Samme økt, tre små ikke-relaterte UI-rettelser** (brukerrapportert via
skjermbilder, rettet opportunistisk før Del C): avstandsindikatoren
(`target-distance.tsx`) brukte "grønn" og "Midt" i stedet for de riktige
golf-uttrykkene "green"/"senter" -- rettet, samt fjernet "Oppdateres
live"-badgen (og det nå ubrukte `isLive`-sporet i
`hole-target-distance.tsx`) helt, etter brukerønske. "Ny runde"-
veiviserens "Antall hull"-bryter (`Segmented`-primitiven i
`components/ny-runde/primitives.tsx`) hadde en avvikende valgt-stil
(hvit/skygge) sammenlignet med resten av samme skjerm sine knappegrupper
(`ChoiceCard`/`ToggleButton`, grønn aksent-stil) -- rettet til samme
`border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]`-mønster,
gjelder alle `Segmented`-instanser appen-vidt (delt primitiv).

**Rullet ut 2026-08-14** -- migrasjon 072 kjørt mot ekte `teecup_db`
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
`docker compose build teecup_api teecup_frontend && up -d` -- begge
containere startet rent (ruller også ut de tre UI-rettelsene). Dette
var siste del av den tredelte Flaggturnering-utvidelsen -- Del A/B/C
alle bygget OG rullet ut.

---

## ADR-069: Offline-utvidelse -- kommentar-/bildeposting i rundefeeden (ADR-028-tillegg) — 2026-08-14

Foranledning: bruker spurte om installasjonsbanneret sin påstand
"virker delvis uten nett" faktisk stemte. Undersøkelse bekreftet at den
GJORDE det (ekte IndexedDB-skrivekø for hull-scoreføring, service
worker-cache for allerede besøkte sider) -- men bruker ønsket å gjøre
"delvis" til mer fullstendig. Presentert tre konkrete utvidelsesretninger
(AskUserQuestion); bruker valgte "Utvid offline-skriving til mer enn
scoreføring (kommentarer/bilder)" -- IKKE de to andre alternativene
(proaktiv full-runde-precaching ved åpning, eller et generelt app-skall
for en aldri-besøkt kaldstart).

**Datamodell (migrasjon 073) -- idempotens, IKKE en ny funksjon i seg
selv.** Hull-score-køen (ADR-028) er trygg å gjenta fordi PATCH +
`expected_version` konvergerer uansett hvor mange ganger den sendes.
`POST /rounds/{id}/messages` er IKKE naturlig idempotent -- hvert kall
oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
samme kommentar to ganger. Løsning: `round_message` fikk en nullbar
`client_message_id uuid`-kolonne + en delvis unik indeks
`(round_id, client_message_id) WHERE client_message_id IS NOT NULL`.
Klienten genererer denne (`crypto.randomUUID()`) FØR første forsøk
(både det direkte online-forsøket og et evt. køet gjenforsøk sender
samme id) -- et gjentatt kall med samme id returnerer den allerede
opprettede meldingen (`post_round_message`, round_messages.py) i stedet
for å opprette en duplikat. 3 nye backend-tester (repetert id → samme
melding, ulike id-er → separate meldinger, manglende id → uendret
oppførsel for bakoverkompatible kallere) -- full backend-suite 58/58.

**offline-queue.ts utvidet til multipart, ikke bare JSON.** Køen
(opprinnelig kun `Content-Type: application/json`) fikk et nytt
`isMultipart`-flagg på `QueueEntry`. Når satt, er `body` et FLATT
objekt av string/Blob-felt i stedet for en JSON-serialiserbar verdi --
IndexedDB sin structured-clone-algoritme lagrer `Blob`/`File`-verdier
NATIVT, ingen egen blob-lagringsstruktur trengs. `flushQueue` bygger da
`FormData` i stedet for å JSON-serialisere. Uendret oppførsel for
eksisterende (hull-score-)oppføringer uten flagget.

**Frontend (round-messages.tsx) -- SAMME mønster som round-detail.tsx
sin hull-score-kø, men et EGET, isolert kø-navnerom.** `matchId:
\`${roundId}:messages\`` (ikke bare `roundId`) -- forhindrer at denne
komponentens kø noensinne blandes med round-detail.tsx sin egen
hull-score-kø i samme IndexedDB-database, selv om begge er montert på
samme side samtidig. Egne `isOnline`/`pendingCount`/`syncingMessages`-
tilstander, egne online/offline-lyttere, egen mount-tids-sjekk for
gjenværende kø fra en tidligere økt -- fullstendig frikoblet fra
round-detail.tsx sin tilsvarende logikk. Et lokalt "optimistisk kort"
(egen `PendingRoundMessage`-type, ALDRI blandet inn i den ekte
`messages`-listen) vises øverst med "Venter på synk" + klokke-ikon
inntil en vellykket synk trigger en full `load()` som erstatter det med
serverens fasit (fanger bl.a. opp tagger forkastet server-side).
Bilde-forhåndsvisningen bruker sin EGEN, dedikerte `URL.createObjectURL`
(ikke composerens `imagePreview`, som revokes med det samme av
`clearSelectedImage()`).

**Bevisst UTENFOR omfang:** kommentar-på-kommentar (`round_message_
comment`, `PostEngagement`-tråden) fikk IKKE samme idempotens/kø-støtte
-- kun rundefeedens hovedinnlegg (tekst+bilde), som var det brukeren
faktisk pekte på. Å gjenopprette et optimistisk kort etter en FULL
nettleser-lukking+gjenåpning mens offline er bevisst IKKE forsøkt
(bilde-forhåndsvisningens object-URL overlever uansett ikke en reload)
-- en køet skriving flushes likevel korrekt automatisk når nettet er
tilbake, den vises bare ikke optimistisk i mellomtiden. Denne
avgrensningen er testet eksplisitt (se under) og fungerer som forventet.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (58/58, migrasjon 073 inkludert).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (port 18002, live-mountet kode) + lokal `next
dev` (port 13002), ekte nettverk-frakoblet-emulering (Chrome DevTools
MCP sin `networkConditions: "Offline"`, IKKE bare CDP-nivå -- bekreftet
at appens egen `navigator.onLine`-avledede tilstand faktisk reagerte):
(1) tekst-only kommentar postet offline → optimistisk kort med
"Venter på synk" → satt online igjen → automatisk synk, banner
forsvant, EKTE melding med reaksjoner/slett-knapp dukket opp, bekreftet
NØYAKTIG 1 rad i databasen (ingen duplikat); (2) samme med bilde
(ekte opplastet PNG via Chrome DevTools sin `upload_file`) -- optimistisk
bilde-forhåndsvisning vist offline, ekte AVIF-konvertert MinIO-bilde
etter synk; (3) reload-mens-offline-scenario -- postet en tredje
kommentar offline, lastet siden PÅ NYTT mens fortsatt offline (simulerer
at appen lukkes/gjenåpnes), satt online igjen -- den køede skrivingen
fra FØR reload ble automatisk funnet og synket ved mount, bekreftet
NØYAKTIG 3 rader totalt (ingen duplikat på tvers av reload). Lys+mørk
bekreftet. Scratch-stacken (database, rolle, container, MinIO-
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.

**Rullet ut 2026-08-14** -- migrasjon 073 kjørt mot ekte `teecup_db`
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
`docker compose build teecup_api teecup_frontend && up -d` -- begge
containere startet rent.

---

## ADR-070: Utvidet spillerskjema i "Deltakere" -- for-/etternavn, betalt, kommentar + bla i eksisterende (2026-08-14)

Foranledning: bruker viste to skjermbilder av "Deltakere"-flyten i en
org-individuell turnering (`individual-tournament-detail.tsx`). Søkefeltet
tilbød KUN "Opprett ny spiller: «Erik»" -- ingen måte å velge en allerede
eksisterende spiller i org-poolen. Brukeren ba samtidig om at midlertidige/
nyopprettede spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato,
E-postadresse, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp,
Sjekkboks for betalt, Kommentar.

**Rotårsak for "kan ikke velge eksisterende": ikke en datalastings-bug.**
`AddParticipantControl` sin `matches`-liste ble KUN utledet når søkefeltet
hadde en ikke-tom, substring-matchende query -- det fantes ingen måte å
bla i poolen uten allerede å kjenne et treffende navn. Fikset ved å legge
til et `available`-memo (`pool` minus allerede lagt-til deltakere) som
`matches` faller tilbake til når søket er tomt, i stedet for å returnere
`[]`. `MAX_VISIBLE_MATCHES` (8, opp fra hardkodet 6) begrenser visningen.

**7 av 11 ønskede felt fantes allerede i `player`-skjemaet** (migrasjon
007: `birth_date`, `email`, `club_member_number`, `club`, `country`,
`handicap_index`, `gender`) -- bare ikke eksponert i DENNE UI-flyten.
`first_name`, `last_name`, `paid`, `comment` manglet reelt -- migrasjon
074 legger dem til på `player`, med samme for-/etternavn-splitt-mønster
som migrasjon 052 (`round_participant.guest_first_name/guest_last_name`):
`display_name` beholdes UENDRET som det faktisk viste navnet alle andre
steder i appen leser (leaderboards, matcher, rostring) -- `first_name`/
`last_name` er en NY, valgfri kilde ved siden av, ikke en erstatning.
Backfill for eksisterende spillere bruker samme "første ord = fornavn,
resten = etternavn"-heuristikk som migrasjon 052 sin backfill.

**Bevisst avvik fra det etablerte "backend synker display_name"-mønsteret:**
migrasjon 052/015 lot backend beregne/synke et avledet visningsnavn fra
for-/etternavn ved skriving. Her lot jeg i stedet FRONTEND beregne
`display_name = \`${firstName} ${lastName}\`.trim()` før sending, som et
helt normalt felt sammen med de nye `first_name`/`last_name`-feltene --
for å unngå å røre `create_player`/`update_player` sin eksisterende
kontrakt (generisk, whitelist-drevet PATCH via Pydantic sin
`exclude_unset`, brukt av mengder av eksisterende kallere). `paid`/
`comment` er ren registreringsadministrasjon, ingen kobling til noe
eksisterende.

**Frontend:** `AddParticipantControl` fikk et progressivt avslørt
skjema -- Fornavn/Etternavn/Kjønn/Hcp alltid synlig, resten bak en
"+ Flere detaljer"-knapp (Fødselsdato, E-post, Hjemmeklubb,
Medlemsnummer, Land, Betalt-avkrysning, Kommentar). `splitName()`-
hjelpefunksjon forhåndsutfyller Fornavn/Etternavn fra søkefeltets
frittekst når "Opprett ny spiller"-knappen trykkes (samme heuristikk som
backfillen).

**Fant og fikset underveis (selvfunnet under nettleserverifisering, ikke
brukerrapportert):** tom-tilstand-teksten når `available.length === 0`
sa "Ingen eksisterende spillere i organisasjonen ennå" uansett årsak --
misvisende når poolen faktisk HAR spillere, de er bare allerede lagt til
denne turneringen. Splittet i to riktige meldinger (`pool.length === 0`
vs. alle allerede lagt til), pluss en tredje ny melding for "søkte, null
substring-treff blant eksisterende" (viste tidligere ingenting).

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (live-mountet kode) + lokal `next dev`: opprettet
en eksisterende spiller i poolen ("Kari Nordmann") og bekreftet den nå
dukker opp direkte ved åpning av "Legg til deltaker" med tomt søkefelt
(fikset bug), opprettet en ny spiller med alle 11 felt utfylt via det
utvidede skjemaet og bekreftet samtlige lagret korrekt i databasen.
Lys+mørk bekreftet, inkl. det utvidede skjemaet med avkrysningsboks.

**Verifiseringsfallgruve, ikke en app-bug:** Chrome DevTools MCP sin
`fill`-verktøy satte visuelt riktig verdi på et natively multi-segment
`<input type="date">`, men trigget ikke Reacts `onChange` pålitelig --
`birth_date` kom tilbake `null` fra API-et til tross for at feltet viste
riktig dato rett før innsending. Bekreftet ved å gjenta med
tastatur-drevet `press_key` inn i dato-feltets spinbuttons i stedet --
lagret korrekt. Ingen kodeendring nødvendig; kun automatiserings-
verktøyets begrensning med native dato-inputs, ikke en reell
applikasjonsbug.

**Tillegg samme dag:** bruker ba om samme fiks i lag-turneringers
roster-tillegg (`tournament-detail.tsx`, `AddPlayerControl`) etter å ha
fått påpekt den strukturelt identiske begrensningen der. Samme
"available"-fallback-mønster (utledet av `matches`, ikke en egen
tilstand) lagt til -- spillere allerede rostret på det ANDRE laget
forblir i listen, nedgradert/deaktivert med "allerede på {lag}", kun
spillere på DETTE laget ekskluderes fra `matches`. Tom-tilstand-teksten
splittet på samme måte (pool tomt / alle allerede lagt til / søk uten
treff). Ingen migrasjon -- ren frontend-endring. `tsc --noEmit` rent +
45/45 vitest. Egen scratch-database + scratch `teecup_api`-container
(port 18004, live-mountet kode) + lokal `next dev` (port 13004): to
lag, tre poolspillere (én allerede rostret på det andre laget) --
bekreftet at kontrollen viser alle tre direkte ved tomt søk (riktig
nedgradert for den som er opptatt), søk smalner riktig inn, og
"Legg til"-flyten for en eksisterende spiller fungerer uendret. Lys+mørk
bekreftet. Scratch-stacken revet ned -- ekte `teecup_db`/`teecup_api`/
`teecup_frontend` urørt (ingen migrasjon å rulle ut for dette tillegget).

**Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db`
som `teeoff_admin` (4 eksisterende spillere backfillet), etter
eksplisitt bekreftelse fra bruker. `docker compose build teecup_api
teecup_frontend && up -d` -- begge containere startet rent.

---

## ADR-071: Full statistikkdybde i org-turneringers hull-scoring, paritet med frittstående runder ("Steg 1" av spillerens per-hull-historikk) (2026-08-15)

Foranledning: bruker ba om å kunne se full statistikk basert på ALLE
tidligere ganger en spiller har spilt et gitt hull. Undersøkelse avdekket
at `tournament_round_hole` (org-turneringer) aldri har hatt noe utover
`gross_strokes` siden den ble opprettet (migrasjon 040) -- ingen putts/
utslag-/innspillretning/bunker-/straffeslag-detalj, og ingen UI for det i
`individual-tournament-detail.tsx` sin `HoleGrid`. Historikk-funksjonen
kan derfor kun bli "full" for turneringssiden hvis den datadybden bygges
FØRST. Bekreftet med bruker (AskUserQuestion): begge datakilder skal
telle med, med full dybde i begge -- valgte det tyngste alternativet
fremfor "kun frittstående" eller "turnering kun med slagtall".

**Migrasjon 075**: `tournament_round_hole` fikk nøyaktig samme nye felt
som `round_hole` allerede har (`putts, club_off_tee, tee_shot_result,
approach_result, chip_count, bunker_shot_count, penalty_strokes,
anyway_strokes, first_putt_distance_bucket`), pluss `version` (ny
optimistisk-lås-kolonne for denne tabellen -- endepunktet var til nå rent
siste-skriver-vinner, ulikt `round_hole` sitt ADR-057/migrasjon-062-
mønster). `tournament_participant` fikk `stat_level` (samme tre verdier
som `round_participant.stat_level`) -- lagt PÅ TOURNAMENT-NIVÅ (ikke per
runde), fordi en deltaker normalt spiller flere runder i samme turnering
og ikke skal måtte velge nivå på nytt hver gang. Trygg default
(`strokes_only`) -- ingen eksisterende turnering endrer oppførsel før
noen eksplisitt hever nivået.

**Backend** (`individual_tournaments.py`): `HoleUpdate`/`RoundHoleOut`
utvidet feltnavn-for-feltnavn etter `rounds.py` sin ekvivalent.
`update_hole` sin `INSERT ... ON CONFLICT DO UPDATE` fikk en
`WHERE (expected_version IS NULL OR tournament_round_hole.version =
expected_version)`-betingelse på DO UPDATE-grenen -- MERK at dette er en
reell forskjell fra `round_hole` sitt mønster: der finnes raden alltid
fra rundestart (ren `UPDATE`), her opprettes raden først ved FØRSTE
score, så INSERT-grenen må være ubetinget (en fersk innsending skal
aldri kunne 409-blokkeres av en `expected_version` som ikke gir mening
ennå). `TournamentParticipantCreate/Update/Out` + `_TOURNAMENT_
PARTICIPANT_COLUMNS` fikk `stat_level` som ren whitelist-tilføyelse
(samme mønster som migrasjon 074 sin `paid`/`comment`, ingen
handler-logikk-endring). `RoundParticipantOut` fikk `stat_level` speilet
inn (join via `tournament_participant`) slik at frontend vet hvilken
UI-vei å ta per deltaker.

**Frontend**: `NumberPicker`/`ChoiceRow`/`DirectionCross`/`Stepper`/
`WizardSection` (+ `golfTermForScore`-hjelperen) trukket UT av
`round-detail.tsx` til en ny delt fil `hole-stat-inputs.tsx` -- ren
mekanisk utrekking (ingen atferdsendring for `ScoringWizard`, bekreftet
med full `tsc`/vitest etterpå), nødvendig fordi begge scoringsflytene nå
trenger identiske trykkbaserte inputs (ALDRI dropdowns i selve
hull-registreringen -- egen, bevisst UI-regel for denne typen felt,
IKKE en generell "ingen select-elementer i appen"-regel: `class_id`/nye
`stat_level`-velgerne i organisatorens deltakerliste er vanlige
`<select>`, samme presedens som eksisterende klasse-velger der).

Nytt `HoleStatsSheet` i `individual-tournament-detail.tsx` -- ETT
skjermbilde (IKKE en flerstegs-veiviser som `ScoringWizard`, siden
`HoleGrid` allerede har valgt ETT hull for ÉN allerede valgt deltaker,
ikke en hel spillerekkefølge å bla gjennom). `HoleGrid` sin egen
`editingHole`/`draft`-tilstand (enkelt inline-tallfelt) er BEVISST
UENDRET for `stat_level="strokes_only"`-deltakere -- kun `stat_level !=
"strokes_only"` åpner det nye sheet-et. `stat_level` redigeres i
"Deltakere"-listen under "Oppsett" (ny `<select>` ved siden av den
eksisterende klasse-velgeren).

**Bevisst avgrenset:** `ownBagClubs` sendes tom (`[]`) til
`HoleStatsSheet` -- "din egen kølle-bag" krever å vite om scoreren ER
spilleren selv (`player.user_id` mot innlogget bruker), som
`individual-tournament-detail.tsx` ikke allerede henter noe sted.
`ClubPicker` fungerer fint uten (faller tilbake til fritekst) -- en
convenience-nicety utelatt, ikke en mangel.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (64/64, 6 nye tester i
`test_tournament_hole_stats.py`: versjonsøkning, 409 ved stale version,
`expected_version=None` omgår sjekken, FØRSTE skriving lykkes uansett
feil `expected_version` (INSERT-grenen ubetinget), full feltrundtur,
`stat_level`-default+eksplisitt-verdi ved opprettelse). `tsc --noEmit`
rent + 45/45 vitest. Egen scratch-database + scratch `teecup_api`
(port 18005, live-montert kode) + lokal `next dev` (port 13005): to
deltakere (`strokes_only` og `full`) i samme runde -- bekreftet
`HoleStatsSheet` åpner og lagrer alle felt korrekt for `full`-deltakeren
(inkl. gjenåpning med forhåndsutfylte verdier), OG at `strokes_only`-
deltakerens opprinnelige inline-tallfelt fungerer helt uendret (ingen
regresjon). Lys+mørk bekreftet, inkl. retning/detaljer-seksjonene.
Scratch-stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
`teecup_frontend` urørt.

**Neste steg (Steg 2, egen ADR):** selve historikk-aggregeringen på
tvers av alle spilte runder/turneringer -- denne ADR-en leverer kun
datadybden Steg 2 er avhengig av.

**Rullet ut 2026-08-15** -- migrasjon 075 kjørt mot ekte `teecup_db` som
`teeoff_admin`, etter eksplisitt bekreftelse fra bruker. `docker compose
build teecup_api teecup_frontend && up -d` -- begge containere startet
rent.

---

## ADR-072: Spillerens per-hull-historikk på tvers av alle runder/turneringer ("Steg 2") (2026-08-15)

Foranledning: se ADR-071 -- dette er selve historikk-funksjonen bruker ba
om, bygget oppå statistikkdybde-pariteten ADR-071 leverte. Bekreftet med
bruker (AskUserQuestion): custom/håndlagde baner ekskluderes helt (ingen
pålitelig felles identitet); ordinære (teeoff-/GolfAPI-importerte) baner
telles med fra BÅDE frittstående runder og org-turneringer, slått sammen
til én historikk når det faktisk er samme fysiske bane.

**Ny delt modul `app/hole_history.py`** (mønster: `app/blind_draw.py`/
`app/team_authz.py`, ikke db-fri som `handicap_engine.py` siden dette er
datainnhenting, ikke handicap-regler). Kjernen er bane-broen mellom to
helt forskjellige identitetssystemer:
- Teeoff: `round.teeoff_facility_slug`+`teeoff_course_id` (frittstående)
  `<->` `course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"`
  når `course.source='official'` (org-turnering) -- eksakt samme
  streng-format som `import_official_course()` i `courses.py` allerede
  bygger den med, verifisert mot kilden før bruk.
- GolfAPI: `personal_course.external_golfapi_course_id` `<->`
  `course.external_course_ref` når `course.source='international'` --
  begge lagrer samme rå GolfAPI-ID, uten prefiks.
- Custom (`round.course_source='custom'` med `personal_course.external_
  golfapi_course_id IS NULL`, eller `course.source='custom'`) gir
  bevisst `None` fra begge resolve-funksjonene -- kalleren skjuler da
  historikk-panelet stille, ingen feilmelding.

`fetch_personal_hole_history` går via `round_participant.user_id`
(samme presedens som `/rounds/stats/summary`). `fetch_tournament_hole_
history` må derimot håndtere at spilleren kan ha spilt turnering i
FLERE organisasjoner -- `tournament_round_hole`/`course`/`tournament_
participant` er alle org-scopet/RLS-beskyttet, så ett enkelt `org_
connection()`-kall kan aldri dekke alle. Løst med SAMME N+1-per-org-
mønster som `/auth/me` allerede bruker: `player_organizations_for_user()`
(migrasjon 015, en smal `SECURITY DEFINER`-bro som kun eksponerer en
org-ID-liste) gir org-listen trygt via `plain_connection()`, deretter ett
`org_connection(org_id)`-kall per org. Ingen superuser-/bypass-RLS-
snarvei (ufravikelig regel, CLAUDE.md).

GIR-/fairway-formlene speiler den ETABLERTE `score - putts <= par - 2`
(rounds.py/round-stats.tsx), IKKE skjema-kommentarens avvikende formel
(samme presisering som ADR-071). `summarize_hole_history()` er en ren
funksjon (ingen db) -- regner GIR%/fairway%/snitt-putter KUN over
instanser som faktisk har putts/utslagsretning registrert (en
`strokes_only`-historisk rad bidrar til snitt-slag, men trekkes ikke inn
i GIR%-nevneren), sorterer mest-nylig-først med udaterte turneringsrunder
(`tournament_round.scheduled_at` er nullbar) sist.

**To tynne endepunkt**, ett i hver router, begge kaller inn i samme
`hole_history_for_user()` og returnerer SAMME kombinerte historikk
uansett hvilken side spørringen kom fra:
- `GET /rounds/{id}/participants/{pid}/holes/{n}/history` (`rounds.py`)
- `GET /orgs/{org}/tournaments/{tid}/rounds/{rid}/participants/{pid}/holes/{n}/history`
  (`individual_tournaments.py`)

Historikken er for DELTAKEREN (`participant_id`), ikke nødvendigvis den
innloggede brukeren -- en lenket medspiller kan se en annens historikk
mens de fører score for flighten, samme tilgang som selve scoringen.
Gjester (`round_participant.user_id`/`player.user_id` er `NULL`) gir
`None` fra endepunktet -- samme "skjul panelet stille"-kontrakt som en
custom bane, ingen feil.

**Frontend**: nytt `HoleHistoryPanel` i den delte `hole-stat-inputs.tsx`
(egen unntak fra filens ellers rene "ingen datahenting"-regel, siden
begge scoringsflytene trenger nøyaktig samme henting/visning). Henter
selv (`GET .../history`), viser INGENTING når responsen er `null`
(laster ELLER ingen historikk -- ingen synlig forskjell, unngår et
flimrende spinner-perifert-panel). Ekspanderbar: lukket viser ett
sammendrag ("Spilt N ganger før (X frittstående, Y turnering) -- snitt A
slag (+/-par), GIR B%, C putter i snitt"), åpen viser hver enkeltinstans
(dato, kilde, resultat). Lagt til i `ScoringWizard` sitt strokes-steg
(`round-detail.tsx`) og i det nye `HoleStatsSheet` (`individual-
tournament-detail.tsx`, `ADR-071`) -- samme komponent, ulik URL.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i
`test_hole_history.py`: 6 rene enhetstester av `summarize_hole_history`
(GIR%/fairway%/snitt-eksklusjon, sortering), pluss integrasjonstester som
BEVISER selve bane-broen -- samme spiller, samme teeoff-bane spilt både
frittstående OG i en turnering i en ANNEN organisasjon enn spilleren
selv eier noe i, kombinert historikk viser begge; en custom personal_
course (ingen GolfAPI-kobling) resolve-r til `None`; en gjeste-
turneringsdeltaker (`player.user_id IS NULL`) gir `None`-historikk).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (port 18006, live-montert kode) + lokal `next
dev` (port 13006): samme spiller, samme teeoff-bane (facility/course-ID
matchende), én frittstående runde-hull + én turneringsrunde-hull i en
ANNEN org -- bekreftet at BEGGE kontekster (ScoringWizard OG
HoleStatsSheet) viser identisk kombinert historikk ("Spilt 2 ganger før
(1 frittstående, 1 turnering) -- snitt 4.5 slag (+0.5 til par), GIR 50%,
1.5 putter i snitt" -- håndregnet og bekreftet korrekt), ekspandert
instansliste viser begge kildene med riktig dato/kilde-merking (udatert
turneringsrunde viste korrekt "Ukjent dato"), og et upsilt hull viste
INGEN panel (stille skjult, ikke en feil). Lys+mørk bekreftet. Scratch-
stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
`teecup_frontend` urørt.

**Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt skjema.

**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent.

---

## ADR-073: Regresjon i "Ny runde"-veiviseren -- eierens statistikknivå (og utslag ved endring) ble ikke lagret (2026-08-15)

Foranledning: bruker rapporterte at de "til stadighet må gå tilbake å
sette utslagssted og statistikktype på nytt før runden begynner", og at
"det må ha blitt borte i prosessen, for det fungerte tidligere" --
vedla en skjermopptak-video (`Temp-uploads/Video_2026-08-15_070115.mp4`,
lest via `ffmpeg` hentet på farten som npm-pakke `ffmpeg-static`, siden
ingen video-verktøy fantes fra før og `apt`/`pip` krevde sudo).

**Videoen viste presist to symptomer for RUNDE-EIEREN** (frittstående
runde, `ny-runde/`-veiviseren): (1) endres utslag i "Bane & tid"-
bekreftelsesskjermen ETTER at banen først ble valgt, viser steg 3 sitt
spillerkort fortsatt det GAMLE utslaget -- (2) "Statistikk for deg
selv"-valget i steg 2 ("Spilleform") hadde INGEN effekt på den faktiske
innsendingen -- runden ble alltid opprettet med `strokes_only` ("Kun
slag") uansett hva som ble valgt der, bekreftet ved å åpne "Rediger" på
eget spillerkort i steg 3 rett før innsending.

**Rotårsak (`wizard-context.tsx`/`step1-course-time.tsx`/`step2-
format.tsx`):** eierens spillerobjekt i `state.players[]` har SINE EGNE
`teeId`/`statLevel`-felt, atskilt fra de øverste `state.teeId`/`state.
statLevel`-feltene steg 1/steg 2 faktisk redigerer. Disse ble tidligere
KUN synkronisert i ett øyeblikk -- da banen først velges (`pickCourse()`
i `step1-course-time.tsx`, som satte begge samtidig). Endres utslag
IGJEN i bekreftelsesskjermen (`Fields()`, linje 746, `onChange={(e) =>
patch({ teeId: ... })}`), oppdateres kun det øverste feltet -- for
utslag var dette rent KOSMETISK (selve `submit()` i `wizard-context.tsx`
leser `state.teeId` direkte for eieren ved selve rundeopprettelsen, se
linje 307/311), men for statistikknivå var det en REELL datafeil:
`submit()` sin `stat_level`-verdi for eieren leses fra `owner?.
statLevel` (spillerobjektet, linje 317) -- IKKE fra `state.statLevel`.
`owner.statLevel` settes KUN til `"score"` hardkodet i `loadMe()`
(linje 217) når `/auth/me` laster, og synkroniseres ALDRI fra `state.
statLevel` når brukeren faktisk gjør et valg i steg 2 -- "Statistikk for
deg selv"-seksjonen påvirket dermed KUN default-verdien for NYE
medspillere lagt til senere (`defaultStat`-proppen til `AddPlayerPanel`,
uendret/fortsatt riktig), aldri eierens eget valg.

**Ikke en ny mangel -- en bekreftet regresjon:** kommentaren over
`pickCourse()` sin manuelle engangs-synk siterte eksplisitt at den
"matcher eksakt" den opprinnelige (nå oppdelte) `new-round.tsx` sin
`chooseCourse()`-logikk for UTSLAG -- selve engang-synk-mønsteret er
altså gammelt og forsettlig replikert ved en tidligere refaktorering til
dagens `ny-runde/`-mappestruktur. Ingenting tilsvarende fantes noensinne
for STATISTIKKNIVÅ i denne koden -- det er uklart om en eldre
enkeltfil-versjon av veiviseren hadde `owner.statLevel` lest direkte fra
`state.statLevel` uten et eget spillerfelt (brukerens "det fungerte
tidligere" tyder på det), men konklusjonen er uansett den samme: i
DAGENS kode har "Statistikk for deg selv" vært virkningsløs for eieren
siden splittingen til separate steg-komponenter, sannsynligvis
usett fordi standardverdien (`strokes_only`) er nettopp det de fleste
runder uansett bruker.

**Fiks:** i stedet for punktvise manuelle synkroniseringer, en ENKELT
`useEffect` i `WizardProvider` (`wizard-context.tsx`) som holder
`players[owner].teeId`/`statLevel` KONTINUERLIG i synk med `state.teeId`/
`state.statLevel`, uansett hvor mange ganger disse endres eller fra
hvilket steg. Kjører kun en faktisk `patch()` når verdiene faktisk
avviker (unngår unødvendige re-renders/uendelig løkke). Den nå
overflødige manuelle engangs-synken i `pickCourse()` fjernet -- ett
sted for denne logikken fremfor to som lett driver fra hverandre igjen.
Endrer brukeren stat-nivået sitt EKSPLISITT i steg 3 sitt eget
spillerkort (i stedet for steg 2), respekteres det inntil de ev. går
TILBAKE til steg 2 og endrer det der igjen (samme presedens-rekkefølge
som "siste redigerte felt vinner", ingen ny brukerforvirring innført).

**Verifisert:** `tsc --noEmit` rent + 45/45 vitest (ingen eksisterende
test dekket denne regresjonen -- INGEN ny automatisert test lagt til
heller, siden `ny-runde/`-veiviseren ikke har noen eksisterende
test-infrastruktur i dette repoet; browserverifisering ansett
tilstrekkelig for en ren frontend-tilstandssynk-fiks). Egen
scratch-database + scratch `teecup_api`-container (port 18007,
live-montert kode) + lokal `next dev` (port 13007), MED en egen bane
opprettet med to utslag (for å kunne reprodusere "bytt utslag etter
førstevalg") -- gjenskapte videoens eksakte scenario (banevalg → bytt
utslag fra standard "32" til "50" i bekreftelsesskjermen → "All
statistikk" i steg 2 → steg 3 sitt spillerkort viste nå KORREKT "Utslag:
50" og "Alt" (uten regresjonens stale "32"/"Kun slag") → fullført runde
→ bekreftet direkte i databasen at BÅDE `round.tee_name_snapshot='50'`
OG `round_participant.stat_level='full'` faktisk ble lagret → åpnet
scoreregistrering for hull 1, bekreftet at veiviseren auto-hopper videre
til Putter-steget (kun mulig for `strokes_and_putts`/`full`, aldri for
`strokes_only`) -- beviser fiksen virker HELE VEIEN til faktisk lagret
data, ikke bare i visningen. Lys+mørk bekreftet for selve
"Ny runde"-veiviseren (steg 1-3). Scratch-stacken fullstendig revet ned
-- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.

**Ingen migrasjon** -- ren frontend-tilstandssynk-fiks.

**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent.

---

## ADR-074: Order of Merit -- lag-leaderboard + eclectic-total-bug fikset ("Steg 0+1" av gjenstår-listen) (2026-08-15)

Foranledning: bruker ba om å gå videre med de to store gjenstående OOM-
punktene fra ADR-043 (spiller-OOM, 2026-08-04): lag-OOM sin faktiske
leaderboard-beregning (skjema fantes, `GET .../leaderboard` avviste
eksplisitt for `kind='team'`), og eclectic-aggregering på tvers av
lenkede turneringer. Utvidet til tre punkter da FEATURE_BACKLOG.md sin
"gjenstår"-liste viste seg å ha et tredje, ikke opprinnelig nevnt punkt
(offentlig/delt visning) -- bekreftet med bruker at alle tre tas i denne
runden (se ADR-075/076 for de to andre).

**Bifunn tatt FØRST, uavhengig av resten:** `_contribution_for_entry`
(brukt av BÅDE eksisterende sum/snitt-OOM-er og den nye eclectic-
aggregeringen) leste alltid `entry.gross_total`/`net_total`/
`stableford_total` for en lenket turnering -- ALDRI `entry.
eclectic_total`, selv når den lenkede turneringens `scoring_method` var
`eclectic_gross`/`net`/`stableford` (ADR-068, bygget 2026-08-14, EFTER
OOM-en selv 2026-08-04 -- aldri revidert siden). Konsekvens: en klubb som
lenket en eclectic-scoret turnering inn i en stableford/brutto/netto-OOM
fikk stille FEIL tall -- spillerens rå slagsum på tvers av rundene (et
tall som ikke engang avgjorde DEN turneringens egen vinner), i stedet
for dens faktiske "drømmerunde"-resultat. Fikset: `_contribution_for_
entry` tar nå også imot turneringens `scoring_method` (allerede
tilgjengelig fra `_compute_individual_standings` sin returnerte
metadata, kun forkastet før) og leser `eclectic_total` når relevant.

**Lag-OOM sin leaderboard** (ADR-043 Beslutning C, aldri bygget):
- Ny CRUD (`order_of_merit_team`/`_team_member`, tabellene fantes
  allerede fra migrasjon 055 -- INGEN ny migrasjon i dette steget):
  `POST/GET/DELETE .../order-of-merits/{id}/teams`,
  `POST/DELETE .../teams/{id}/members`. Samme mønster som
  `order_of_merit_link`-CRUD-en (eksisterer-sjekk + `translate_db_
  errors()`). `player_id`-FK-en i skjemaet er IKKE org-scopet (migrasjon
  055-avgjørelse) -- egen eksplisitt eksistenssjekk lagt til i
  `add_order_of_merit_team_member` for å ikke stole blindt på klienten.
- Leaderboard-grenen for `kind='team'` erstatter den tidligere eksplisitte
  avvisningen: for hvert lag, regn ut HVERT medlems allerede-etablerte
  individuelle OOM-sluttresultat (samme `_player_eligibility`+
  `_aggregate_value`-par som spillernivået, faktorisert ut og delt), slå
  deretter disse medlemsverdiene sammen med SAMME `order_of_merit_
  aggregate`-kall -- gjenbrukt EN GANG TIL på lagnivå, med OOM-ens
  ALLEREDE LAGREDE `aggregation_mode`/`count_best_n`. **Bevisst
  designvalg, ingen ny konfig**: ETT lagret aggregeringsvalg styrer
  BEGGE nivåer -- `count_best_n=1` betyr dermed BÅDE "hver spillers
  beste 1 av N turneringer" OG "lagets beste 1 av M medlemmer" samtidig
  (bekreftet i test: `test_team_leaderboard_best_n_applies_at_both_
  levels`, ikke en overraskelse men en direkte konsekvens av
  gjenbruket). `aggregation_mode='eclectic'` er allerede sperret for
  `kind='team'` ved opprettelse (eksisterende sjekk, uendret) -- lagnivået
  trenger derfor aldri håndtere eclectic.
- `OrderOfMeritLeaderboardEntry` sitt `player_id`/`player_name`-par
  gjenbrukes UENDRET for lagnivået (bærer der lagets EGEN id/navn) --
  ingen ny modell, kalleren vet allerede OOM-ens `kind`.

**Frontend**: `order-of-merit-detail.tsx` sin `kind === "team"`-
placeholder ("Lag-resultatliste er ikke bygget ennå") fjernet helt --
`LeaderboardTable` var allerede generisk nok til å vise lag-resultater
uendret (leser kun `player_id`/`player_name`/`value`/`position`, ingen
spiller-spesifikk logikk å fjerne). Ny "Lag"-seksjon (kun for
`kind='team'`) mellom Innstillinger og Resultatliste -- samme liste+
skjema-mønster som Lenkede turneringer-seksjonen: opprett/slett lag,
legg til/fjern medlem fra org-ens spillerpool (enkelt `<select>`, samme
presedens som andre administrasjons-sammenhenger i appen -- IKKE i strid
med "ingen dropdowns"-regelen, som gjelder scoring-registrering
spesifikt, se ADR-071).

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (77/77 -- 5 nye tester i
`test_order_of_merit.py`: eclectic-total-bug-regresjon, lag-leaderboard
summerer medlemmers individuelle OOM-totaler korrekt (håndregnet: 162 +
198 = 360), `count_best_n` sin to-nivås-anvendelse bevist eksplisitt,
full lag-CRUD + `kind='player'`-guard). `tsc --noEmit` rent + 45/45
vitest. Egen scratch-database + scratch `teecup_api`-container (port
18008, live-montert kode) + lokal `next dev` (port 13008): opprettet en
ekte lag-OOM i nettleseren, lenket to turneringer, opprettet ett lag,
la til to medlemmer ETT OM GANGEN -- bekreftet leaderboardet oppdaterte
seg LIVE til riktig verdi ved hver endring (162 → 360 ved andre medlem,
tilbake til 162 ved fjerning), gull-fremhevet førsteplass-rad vist
korrekt. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.

**Ingen migrasjon** -- ren kode-endring, tabellene fantes fra migrasjon
055.

**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent.

---

## ADR-075: Order of Merit -- eclectic-aggregering på tvers av lenkede turneringer ("Steg 2" av gjenstår-listen) (2026-08-15)

Foranledning: andre av de tre gjenstående OOM-punktene fra ADR-043, se
ADR-074 sin innledning for hele bakgrunnen/bekreftelsen. Eclectic-
aggregering ("sesong-drømmerunde": beste resultat PER HULL på tvers av
ALLE lenkede turneringer, kun `result_type IN ('stableford','gross',
'net')`) var forberedt i skjemaet (`aggregation_mode='eclectic'` fantes
som verdi) men eksplisitt avvist i `order_of_merit_leaderboard` ("ikke
støttet ennå").

**Samme-bane-håndheving** (app-laget, ingen migrasjon): eclectic-
aggregering på tvers av turneringer er meningsløs hvis turneringene ikke
er spilt på samme bane (ulike hull-oppsett/par/stroke index). Håndhevet
på TO steder, samme "fail loudly"-filosofi som ADR-068/ADR-019
Beslutning C:
- `link_tournament_to_order_of_merit`: avviser lenking av en turnering
  hvis OOM-en allerede er `aggregation_mode='eclectic'` OG den nye
  turneringens bane avviker fra en allerede lenket turnerings bane.
- `update_order_of_merit`: avviser å endre `aggregation_mode` TIL
  `'eclectic'` hvis turneringer med ulik bane allerede er lenket.

Ny `_validate_same_course_for_eclectic()` + `_course_id_for_tournament()`
(samme `LIMIT 1`-antakelse på `tournament_round.course_id` som
`_attach_eclectic_totals` allerede gjorde -- én bane per turnering,
udiskutert forutsetning i hele kodebasen).

**Datainnsamling** (ny `_compute_eclectic_oom_values`, gjenbruker
`eclectic_best_per_hole()` fra `handicap_engine.py` HELT UENDRET --
motoren viste seg, som antatt i planen, kildeuavhengig nok til å
fungere identisk for "flere runder i én turnering" (ADR-068) og "flere
turneringer i én OOM" (dette steget), kun datainnsamlingen foran den er
forskjellig): tilpasset variant av `_attach_eclectic_totals` sin
`tournament_round_hole`-spørring, filtrert på `tr.tournament_id =
ANY($linked_ids)` i stedet for én turnering. Nøkkelforskjell fra
innad-i-turnering-varianten: keyed på `player_id` (ikke
`tournament_participant_id`, som er ulik per turnering for samme
spiller), og kilde-indeksen er sammensatt av (turnering-id, runde-
sekvens) siden `sequence` kun er unik INNENFOR én turnering. Returnerer
også `tournament_count_by_player` -- antall DISTINKTE turneringer som
faktisk bidro minst ett hull til spillerens sluttsum, brukt som
`results_available` for `min_results_required`-sjekken (rå `len(values)`
ville alltid vært 1 i eclectic-modus, meningsløst som eligibility-mål).

`_player_eligibility` sin signatur endret fra å ta `raw_values: list`
til å ta `results_available: int` direkte -- frikobler "hvor mange
resultater talte" fra "de faktiske verdiene", nødvendig fordi eclectic-
modusens tall ikke lenger kommer fra `len(raw_values)`.

**Leaderboard**: `order_of_merit_leaderboard` sin tidligere eksplisitte
avvisning for `aggregation_mode == 'eclectic'` erstattet med faktisk
beregning -- kun for `kind='player'` (kind='team' + eclectic er allerede
sperret ved opprettelse, uendret fra ADR-043).

**Frontend**: "Eclectic"-valget lagt til begge steder
`aggregation_mode` settes, gated identisk til (og speilende) server-
sidens regel (`kind==='player' && result_type IN
('stableford','gross','net')`) slik at UI-et aldri tilbyr et valg
serveren uansett ville avvist:
- `order-of-merit-list.tsx` (opprettelsesskjema): ny `eclecticAllowed()`-
  helper, tredje `SegmentButton` vist betinget, PLUSS auto-reset til
  `'sum'` hvis brukeren endrer `kind`/`resultType` ETTER å ha valgt
  Eclectic slik at den ikke lenger er gyldig (kind/resultType er fritt
  redigerbare før opprettelse, i motsetning til detail-siden under).
- `order-of-merit-detail.tsx` (innstillinger, eksisterende OOM): samme
  `eclecticAllowed()`-mønster, men INGEN reset-logikk trengs -- `kind`/
  `result_type` er låst etter opprettelse her (vist eksplisitt i UI-en,
  se ADR-074), så gatingen er ren visning av et fast faktum. Hjelpetekst
  lagt til under Eclectic-valget som forklarer samme-bane-kravet.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (80/80 -- 3 nye tester i
`test_order_of_merit.py`: cross-tournament eclectic velger faktisk
beste-per-hull på TVERS av turneringer, ikke bare beste turnering eller
snitt (hånd-utregnet: par 1-9 fra turnering A + par 10-18 fra turnering
B = 72, `results_available == 2`), avvisning ved lenking av turnering på
annen bane, avvisning ved modus-bytte til eclectic med allerede ulike
baner lenket). `tsc --noEmit` rent + 45/45 vitest.

Egen scratch-database + scratch `teecup_api`-container (port 18102,
live-montert kode, `TEECUP_DEV_LOG_MAGIC_LINKS=true` for å logge inn
uten SMTP) + lokal `next dev` (port 13102): seedet to turneringer på
samme bane (par hull 1-9/bogey 10-18 i A, omvendt i B) + én tredje
turnering på en ANNEN bane, opprettet en eclectic-OOM, lenket A+B --
leaderboard viste 72 (bekreftet også direkte mot API før nettleser-
verifisering). Innstillinger-seksjonen viste "Eclectic" korrekt valgt
med hjelpeteksten synlig. Forsøk på å lenke turnering C (annen bane) ga
en tydelig, lesbar feilmelding i UI-et: "Eclectic-aggregering krever at
alle lenkede turneringer spilles på samme bane." Opprettelsesskjemaets
gating verifisert direkte i nettleseren: Eclectic-knappen dukker opp for
`Spiller`+`Stableford-sum`/`Bruttoscore-sum`/`Nettoscore-sum`, forsvinner
og aggregering nullstilles automatisk til `Sum` når resultattype endres
til `Poeng etter plassering` ELLER type endres til `Lag`. Lys+mørk
bekreftet (skjermbilde av Innstillinger-seksjonen i mørk modus). Scratch-
stacken fullstendig revet ned (database, rolle, container, MinIO-bøtte,
`next dev`) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.

**Ingen migrasjon** -- ren kode-endring, `aggregation_mode='eclectic'`
fantes som gyldig verdi fra migrasjon 055.

**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent.

---

## ADR-076: Order of Merit -- offentlig/delt visning ("Steg 3" av gjenstår-listen, sluttfører ADR-043) (2026-08-15)

Foranledning: siste av de tre gjenstående OOM-punktene fra ADR-043/074/
075, se ADR-074 sin innledning for hele bakgrunnen/bekreftelsen.
`order_of_merit.public_visible` fantes som redigerbart felt helt fra
migrasjon 055 (2026-08-04), men ingen uautentisert side leste det --
feltet stod ubrukt i praksis frem til nå.

**Migrasjon 076** (ny SECURITY DEFINER-bro, fjerde i rekken etter
`public_org_by_slug` 009/`public_tournament_by_code` 011/`link_player_
by_email` 008): `public_order_of_merit_by_id(p_id uuid) RETURNS uuid`
(organization_id). Samme NULL-for-begge-tilfeller-mønster som de tre
andre broene -- returnerer NULL for BÅDE "finnes ikke" OG "finnes, men
`public_visible=false`", slik at et 404-svar aldri røper at en gitt
id faktisk eksisterer (anti-enumerering). `GRANT EXECUTE ... TO
teecup_app`.

**Backend**: ny `public_router` (prefix `/public/order-of-merits`) i
`order_of_merit.py` selv -- IKKE lagt til `registration.py` (som
rommer resten av `/public/...`-endepunktene) fordi den gjenbruker
`order_of_merit_leaderboard()` direkte i stedet for å duplisere hele
beregningen for en uautentisert variant. Samme triks som testsuiten
allerede brukte: kaller router-funksjonen med `organization_id` som et
vanlig Python-argument, utenom FastAPI sin `Depends`-injeksjon (gyldig
fordi `get_authorized_org` kun er en `Depends`-default, ikke en
signatur-begrensning). `GET /public/order-of-merits/{oom_id}` slår opp
org via broen, henter OOM-metadata (`name`/`kind`/`result_type`/
`aggregation_mode`) fra `org_connection`, kaller leaderboard-funksjonen,
returnerer alt samlet. Registrert i `main.py` som
`order_of_merit.public_router`.

**Frontend**: ny `app/order-of-merit/[id]/page.tsx` (server component,
`generateMetadata` mot den nye public-ruten -- OG-tags/tittel satt til
OOM-ens faktiske navn) + `components/public-order-of-merit.tsx`
(`"use client"`, modellert direkte på `public-club.tsx` sitt hente-/
laste-/feil-tilstand-mønster -- samme banner-stil, samme loading-
spinner/feil-tilstand). `LeaderboardTable` inni denne komponenten er en
BEVISST DUPLIKAT av `order-of-merit-detail.tsx` sin (gull-fremhevet
førsteplass, "ikke kvalifisert"-seksjon) -- i tråd med hvordan list/
detail-komponentene i dette området allerede holder egne lokale kopier
av samme visuelle mønster, ikke en delt komponent.

"Del offentlig lenke"-knapp lagt til i `order-of-merit-detail.tsx` sine
Innstillinger, rett under "Offentlig synlig"-bryteren -- samme kopier-
til-utklippstavle-mønster som `JoinCodeChip` i `individual-tournament-
detail.tsx`/`tournament-detail.tsx` (klikk kopierer, viser "Lenke
kopiert" i 1,5 sekund). Gated på `oom.public_visible` (den FAKTISK
LAGREDE verdien, ikke bryterens lokale, kanskje-ulagrede tilstand) --
viser i stedet en hjelpetekst ("Lenken blir aktiv når du lagrer.") hvis
brukeren har slått på bryteren men ikke lagret ennå, slik at knappen
aldri tilbyr en lenke som faktisk ville gitt 404.

**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (83/83 -- migrasjon 076 kjørt som del
av den vanlige scratch-migreringsløypen, 3 nye tester: 404 for
ikke-eksisterende id, 404 for en PRIVAT OOM -- eksplisitt bevist samme
statuskode som ikke-eksisterende, altså reell anti-enumerering, ikke
bare en påstand -- og 200 med korrekt navn/kind/result_type/leaderboard
for en offentlig OOM). `tsc --noEmit` rent + 45/45 vitest.

Egen scratch-database (migrasjonene kjørt fra bunnen av, inkl. den nye
076) + scratch `teecup_api` (port 18103, live-montert kode) + lokal
`next dev` (port 13103): seedet en offentlig OOM og en privat OOM i
samme org. Offentlig side nådd i en EGEN, isolert nettleser-kontekst
UTEN innlogget økt/session-cookie (reelt inkognito-scenario, ikke bare
"ingen linke til innlogging i UI-et") -- viste navn, type/resultattype,
og korrekt resultatliste (72/90 for to spillere, håndregnet).
`generateMetadata` bekreftet virket server-side (fanetittel "Offentlig
sesong-OOM | TeeCup" satt FØR klient-JS kjørte). Privat OOM i samme
inkognito-kontekst ga identisk "Order of Merit finnes ikke"-feilside som
en helt oppdiktet id -- ingen synlig forskjell. "Del offentlig lenke"
verifisert i den autentiserte innstillinger-seksjonen: knapp med riktig
url-konstruksjon bekreftet (`window.location.origin +
/order-of-merit/{id}`, samme url faktisk besøkt og fungerte i
inkognito-konteksten over). Slo av "Offentlig synlig" og lagret --
knappen forsvant fra UI-et OG endepunktet ga 404 umiddelbart etterpå,
bekreftet direkte mot API-et. Lys+mørk bekreftet. Scratch-stacken
fullstendig revet ned (database, rolle, container, MinIO-bøtte, begge
`next dev`-instanser) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend`
urørt.

**Migrasjon 076 vist og bekreftet av bruker FØR kjøring mot ekte
`teecup_db`** (kun additiv -- én ny funksjon, ingen skjemaendring,
ingen datamigrering).

**Rullet ut 2026-08-15** -- migrasjon 076 kjørt mot ekte `teecup_db`
etter eksplisitt bekreftelse fra bruker (`CREATE FUNCTION`/`GRANT`,
verifisert med `\df` + et direkte NULL-for-ukjent-id-kall FØR kode-
utrullingen), deretter `docker compose build teecup_api teecup_frontend
&& up -d`. Begge containere startet rent. Etter utrulling: `GET
/public/order-of-merits/<ukjent-id>` bekreftet 404 og `GET
/order-of-merit/<ukjent-id>` bekreftet 200 (feil-tilstanden i selve
siden) direkte mot `teecup.golf`.

Med dette er alle tre punktene fra ADR-043 sin opprinnelige
"gjenstår"-liste fullført: lag-OOM (ADR-074), eclectic på tvers av
turneringer (ADR-075), offentlig visning (dette dokumentet).

---

## ADR-077: Tilbake-navigering til en tidligere spiller i scoringsveiviseren ("Steg 1" av tre forbedringer i score-registreringen) (2026-08-16)

Foranledning: bruker observerte at når man fører score for flere spillere
etter hverandre i samlebånd-flyten (`ScoringWizard`, frittstående runder)
og har trykket "Neste" videre forbi en spiller, er det umulig å rette opp
en feilregistrering hos den spilleren INNE i selve veiviser-flyten — man
må fullføre veiviseren helt til slutten, tilbake på Score-fanen lete opp
riktig spillers kort for det aktuelle hullet, og åpne det på nytt. Første
av tre planlagte forbedringer (se `~/.claude/plans/joyful-wishing-crab.md`
for hele planen) — kun denne er round-only: org-turneringer har ingen
tilsvarende kjede-flyt (`HoleGrid` velger ett hull+én spiller om gangen),
og ADR-065 (per-deltaker-fullføring) er eksplisitt bygget kun for
frittstående runder.

**Løsning**: kontekst-raden i `ScoringWizard` (alle spillerne, aktiv
fremhevet) var BEVISST ikke-interaktiv ved bygging (2026-07-26-kommentaren:
"bytte spiller midt i et steg ville latt uferdige felt henge løst") —
denne antakelsen viste seg foreldet: `updateWizardStat` har siden lagret
hvert felt for seg MED EN GANG (PATCH per felt, ikke batch-lagring), så
det er trygt å bytte spiller når som helst uten datatap. Raden er nå
interaktiv: hver spiller-chip er en knapp, trykk på en ANNEN spiller enn
den aktive (inkludert allerede "Ferdig"-markerte -- bekreftet at backend
ikke har noen hard sperre mot å redigere en fullført spillers data, kun
runde-nivå `readOnly` blokkerer) bytter veiviseren dit via en ny
`switchWizardPlayer()`-funksjon i `RoundDetail` (samme mål-oppløsning som
`openPlayerOrTeamSideEntry`, inkl. scramble-solo-lag-siden-avviket, men
uten å røre `currentHole`). Steget nullstilles automatisk til "Slag" for
den nye spilleren via den allerede eksisterende `useEffect(() =>
setStepIndex(0), [player.id])`. "Ferdig"-markerte spillere vises med en
tekst-etikett ("· Ferdig") i raden, ikke bare farge (tilgjengelighetsregel
-- aldri fargekoding alene).

Ingen ny fil, ingen V0 (gjør et eksisterende, bevisst
ikke-interaktivt element interaktivt -- ingen ny visuell flate).

**Verifisert:** `tsc --noEmit` rent + 45/45 vitest. Egen scratch-database
+ scratch `teecup_api` (port 18104, live-montert kode,
`TEECUP_DEV_LOG_MAGIC_LINKS=true`) + lokal `next dev` (port 13104): runde
med tre spillere. Registrerte Par for spiller 1, byttet midt i steget til
spiller 2 (Bogey) via kontekst-raden -- spiller 1 sin allerede lagrede
score forble urørt. Byttet tilbake til spiller 1 FRA MIDT I spiller 2 sin
flyt, rettet scoren til Birdie -- lagret korrekt, spiller 2 sin Bogey
urørt. Trykket "Avslutt for Erol Scratch" (ADR-065), åpnet spiller 2 sin
veiviser på nytt, bekreftet at spiller 1 nå vises som "Erol Scratch ·
Ferdig" i kontekst-raden OG fortsatt er trykkbar -- åpnet ham, viste
korrekt lagret Birdie (ikke nullstilt), ingen sperre. Lys+mørk bekreftet.
Scratch-stacken fullstendig revet ned (database, rolle, container,
MinIO-bøtte, `next dev`) -- ekte `teecup_db`/`teecup_api`/
`teecup_frontend` urørt.

**Ingen migrasjon** -- ren frontend-endring.

**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent.

---

## ADR-078: Chip/Bunker/Straffeslag/Anywayslag som egen skjerm ("Steg 2" av tre forbedringer i score-registreringen) (2026-08-16)

Foranledning: bruker observerte at Chip/Bunker/Straffeslag/Anywayslag ble
vist klemt inn under retningsvalget for utslag/innspill i begge
scoringsflytene -- ikke godt nok, burde være en egen skjerm. Bekreftet
(AskUserQuestion) at dette skal gjelde BEGGE flyter -- frittstående runder
OG org-turneringer -- "opplevelsen for brukeren skal være lik uansett".

**Strukturell splitt, begge flyter:**
- Frittstående runder (`ScoringWizard`, `round-detail.tsx`): `WizardStep`
  utvidet fra `"strokes" | "putts" | "puttDistance" | "details"` til
  `"strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"` --
  "details" splittet i to egne steg. GIR-auto-inferensen (2026-07-30)
  flyttet fra å trigge på `step !== "details"` til `step !== "direction"`.
- Org-turneringer (`HoleStatsSheet`, `individual-tournament-detail.tsx`):
  bygget om fra ÉN lang scrollbar skjerm til en LITEN intern steg-flyt
  (samme stegrekkefølge som over), med samme auto-hopp og
  GIR-auto-inferens som runde-siden. **Bevisst avgrensning**: lagringen
  er UENDRET -- fortsatt ÉN batched `save()`-kall ved siste steg (nå
  trigget fra "Lagre"-knappen i stedet for en alltid-synlig knapp), IKKE
  konvertert til runde-sidens PATCH-per-felt-modell. De to flytene får nå
  samme STRUKTUR/interaksjon, ikke delt kode eller delt lagrings-
  mekanikk -- unngår å risikere en fungerende, annerledes arkitektur som
  ikke var en del av forespørselen.

**Visuell polering (V0-designet)**: siden det gjenstående "Detaljer per
hull"-steget nå har en hel skjerm for seg selv, ble den tidligere
1-kolonne-stabelen (mye tomrom under fire små tellere) erstattet med et
FAST 2x2-rutenett av teller-FLISER -- egne kort med etikett + stort
sentrert tall + -/+ nederst. Interaksjonskontrakten er uendret (minus
disabled på 0, pluss disabled på `max`, alle trykkflater >=44px), kun det
visuelle uttrykket er nytt. `Stepper`-komponenten (delt av begge flyter
via `hole-stat-inputs.tsx`) oppdatert i selve komponenten -- automatisk
identisk i begge flyter uten duplisert arbeid. `grid-cols-2` (ALDRI
`grid-cols-3`) fordi seksjonen er fast `max-w-sm` (384px): to fliser gir
~184px hver, nok til flisens to 44px-knapper side om side.

V0-prompt skrevet av Claude (se `feedback_frontend_via_v0`), kjørt av
bruker, eksport (`tee-cup (12).zip`) integrert -- KUN den relevante
`Stepper`-omdesignet + grid-endringen ble portert inn (eksporten var en
full prosjekt-snapshot fra en delt V0-arbeidsflate, inkludert en eldre,
FØR-splitt kopi av hele `round-detail.tsx` -- ingen fullstendig
filerstatning, kun det faktisk endrede uttrykket for Stepper/rutenett
overført til den ekte, allerede steg-splittede filen). Zip slettet fra
`Temp-uploads/` etter integrering.

**Verifisert:** `tsc --noEmit` rent + 45/45 vitest (begge ganger --
strukturell splitt, og etter V0-integrering). Egen scratch-database +
scratch `teecup_api` + lokal `next dev`, to runder (port 18105/13105 for
strukturell verifisering, port 18106/13106 for V0-integrering): begge
flyter testet ende-til-ende gjennom ALLE steg (strokes → putts →
puttDistance → direction → holeDetails), GIR-auto-inferens bekreftet
fortsatt virker (trigget riktig på "direction"-steget), lagring bekreftet
i begge flyter (round-siden PATCH-per-felt, turnering-siden batched
"Lagre"), gjenåpning av et allerede utfylt hull viste korrekt lagrede
verdier. 2x2-rutenettet med teller-flisene bekreftet identisk i begge
flyter, interaksjon (øke/redusere, disabled ved 0/max) bekreftet
fungerende. Lys+mørk bekreftet i begge runder. Scratch-stackene
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/`teecup_frontend`
urørt.

**Ingen migrasjon** -- ren frontend-endring.

**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent.

---

## ADR-079: Personlig hull-historikk ut av slagvinduet + full historikk med grafer ("Steg 3" av tre forbedringer i score-registreringen) (2026-08-16)

Foranledning: bruker observerte at personlig hull-historikk ble vist INNI
selve slagvinduet/-arket (forstyrret registreringen), og at klikk kun ga
en kort oppsummering -- ikke full historikk. Ba om at den flyttes til
"vinduet før, f.eks. før 'Avslutt før...'", og at klikk skal vise FULL
historikk inkl. grafer. Bekreftet (AskUserQuestion) at "grafer" betyr det
ETABLERTE stolpe-mønsteret appen allerede bruker (`CategoryBar`/
`DeviationBar` i `round-stats.tsx`), ikke et nytt graf-bibliotek -- og at
begge scoringsflyter skal dekkes.

**Ingen backend-endring** -- `app/hole_history.py` sin `HoleHistoryOut`
(bygget i ADR-072) returnerte allerede alt som trengs (aggregert
oppsummering + full per-instans-liste); rent frontend-arbeid.

**Flytting, begge flyter:**
- Frittstående runder: `HoleHistoryPanel` fjernet fra `ScoringWizard` sitt
  `"strokes"`-steg, lagt til i `PlayerHoleCards` sitt spillerkort (RETT
  FØR `ParticipantCompletionActions`, altså nøyaktig "vinduet før 'Avslutt
  for...'" brukeren pekte på).
- Org-turneringer: fjernet fra `HoleStatsSheet` sitt `"strokes"`-steg,
  lagt til som et NYTT FØRSTE steg (`"overview"`) i steg-flyten ADR-078
  bygde -- ingen ekstern "kort-liste"-skjerm finnes der slik runde-siden
  har (`HoleGrid` er et tett rutenett). For å unngå en ekstra obligatorisk
  trykk-runde for HVER hull-registrering (18 hull × N spillere) hopper
  dette steget automatisk videre av seg selv når spilleren ikke har noen
  historikk å vise -- kun synlig når det faktisk er noe der, verifisert
  eksplisitt (hull uten historikk hoppet rett til "Slag", hull MED
  historikk viste steget og ventet på trykk).

**Klikk-oppførsel endret**: `HoleHistoryPanel` ekspanderte tidligere en
enkel liste inline ved klikk -- åpner nå i stedet den nye fulle
historikk-skjermen (`onOpenFull`-prop). Chevron endret fra ned (ekspander)
til høyre (naviger videre), samme visuelle konvensjon som resten av appen
bruker for "gå til en annen skjerm" vs. "utvid her".

**Ny delt komponent `components/hole-history-detail.tsx`
(`HoleHistoryDetail`)** -- fullskjerm (`fixed inset-0 z-50`, samme
konvensjon som `ScoringWizard`/`HoleStatsSheet`), samme `historyUrl`-
parametermønster som panelet selv brukte. Viser: fire stat-fliser (ganger
spilt/snitt slag/beste/dårligste), snitt-til-par som en fremhevet stor
DeviationBar-lignende tall-boks, GIR%/fairway%, en SCORE-FORDELING som
fargede `CategoryBar`-stolper (EKSAKT samme 6-kategori-inndeling og
`--chart-1..6`-fargelogikk som `round-stats.tsx`, egen lokal kopi -- samme
"ikke delt kode på tvers av filer"-mønster som resten av dette området),
og den detaljerte per-instans-listen under (dato/kilde/score/putter).

**Bevisst avvik fra planen: INGEN V0-runde for denne skjermen.** Planen
forutså en V0-prompt for denne, men i motsetning til ADR-078 sin
holeDetails-skjerm (som IKKE hadde noe visuelt forbilde) ba brukeren
EKSPLISITT om å gjenbruke et NAVNGITT, allerede eksisterende mønster
(`CategoryBar`/`DeviationBar`) -- selve designbeslutningen var dermed
allerede tatt og bekreftet av brukeren, ikke noe som gjensto å utforske
via V0. Bygget direkte, med samme presisjon som om mønsteret var hentet
fra en V0-eksport.

**Verifisert:** `tsc --noEmit` rent + 45/45 vitest. Egen scratch-database
+ scratch `teecup_api` (port 18107) + lokal `next dev` (port 13107): to
frittstående runder på SAMME bane-nøkkel (hull 1 scoret Par i den ene,
Birdie i den andre) + en org-turnering på en bane med MATCHENDE
`external_course_ref`-bro (`app/hole_history.py` sin teeoff-bro-streng),
for å bevise kombinert historikk på tvers av BEGGE kontekster i
`"overview"`-steget. Bekreftet: historikk-panelet vises korrekt plassert
FØR "Avslutt for {navn}" i `PlayerHoleCards` (runde-siden), IKKE lenger
synlig inni selve slagvinduet. Klikk åpnet full historikk-skjerm med
korrekt aggregerte tall (snitt 3,5 slag, −0,50 til par, GIR 100%) OG en
korrekt score-fordelingsgraf (Birdie 50%/Par 50%, riktige farger). Samme
skjerm bekreftet identisk fra org-turnering-siden sitt `"overview"`-steg,
med kombinert historikk fra begge runde-typer. Hull UTEN historikk
bekreftet hoppet automatisk forbi `"overview"`-steget til "Slag" uten
synlig flimmer. Lys+mørk bekreftet på hele historikk-skjermen (fliser,
stolpediagram, liste). Scratch-stacken fullstendig revet ned -- ekte
`teecup_db`/`teecup_api`/`teecup_frontend` urørt.

Med dette er alle tre forbedringene i score-registreringen brukeren ba om
etter Order of Merit fullført: tilbake-navigering (ADR-077), egen skjerm
for hull-detaljer (ADR-078), personlig historikk relokert + full historikk
med grafer (dette dokumentet).

**Ingen migrasjon** -- ren frontend-endring.

**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent.

---

## ADR-080: Kontosammenslåing, selvbetjent ("Del 2" av flere e-postadresser, ADR-032) (2026-08-17)

Foranledning: "flere e-postadresser" (ADR-032, migrasjon 016/017) løste å
legge til en FRI, ukrevd adresse på egen konto ("Del 1"). Når adressen
som legges til ALLEREDE eies av en annen, eksisterende konto (samme
person har endt opp med to separate TeeCup-kontoer, hver med egen
historikk), avviste `request_secondary_email` dette med en 409 og
teksten "Ekte konto-sammenslåing støttes ikke ennå." Denne runden bygger
"Del 2". Bekreftet med bruker (AskUserQuestion): (1) selvbetjening --
spilleren gjør det selv, ikke et superadmin-verktøy, (2) bygg hele
sammenslåingen i én runde, ikke faseinndelt (forhåndsvisning + faktisk
utførelse samtidig).

**Modell: "keeper" (initiativtaker, aktiv økt, kontoen som OVERLEVER) vs.
"taper" (målet, kontoen som slås inn og SLETTES).** Keeperens økt beviser
keeper-eierskap; en lenke sendt til taperens e-post beviser taper-
eierskap -- samme prinsipp som e-postbytte (ADR-032 Beslutning B): lenken
sendes til MÅLET, ikke til avsenderen, fordi det er MÅL-eierskapet som
må bevises.

**Gjenbrukt, ikke oppfunnet på nytt:** samme token-mønster som
`magic_link_token`/`email_change_token`/`secondary_email_token` --
`token_hash` (SHA-256 via `_hash_secret()`) + `expires_at` +
`consumed_at`, konsumert atomisk via `UPDATE ... WHERE consumed_at IS
NULL AND expires_at > now() RETURNING ...`. Ny tabell `account_merge_
token` (migrasjon 077): `initiator_user_id` (keeper), `target_user_id`
(taper, låst på forespørsel-tidspunktet), `CHECK (initiator_user_id <>
target_user_id)`.

**E-post → bruker-oppslag faktorert ut** til `resolve_user_id_by_email()`
(`app/auth.py`) -- sjekker primær adresse (`app_user.email`) så sekundær
(`user_secondary_email`). Erstatter identisk, duplisert logikk i
`verify_magic_link` og `login_with_password` (ren refaktorering, ingen
atferdsendring på de to eksisterende innloggingsveiene) og gjenbrukes av
merge-forespørsel-endepunktet.

**RLS-grensen avgjør transaksjonsstrukturen -- verifisert direkte mot
skjemaet, ikke antatt.** To grupper tabeller:
- IKKE RLS-beskyttet (`plain_connection()`, kan holdes i ÉN global
  transaksjon): `app_user`, `organization_membership` (bevisst UTEN RLS,
  se `001_initial_schema.sql` linje 364-366 -- "håndteres av auth-laget"),
  `user_secondary_email`, `friendship`, `friend_categorization`,
  `notification`, `user_notification_email_pref`, `push_subscription`,
  `handicap_history`, `personal_course`, `round`, `round_participant`,
  `round_message*` (inkl. `round_message_reaction`), `round_shot`,
  `round_participant_flag_plant`.
- RLS-beskyttet (`org_isolation`-policy, `organization_id =
  app.current_org`, KREVER `org_connection(org_id)` per org): `player`,
  `team_roster`, `tournament_registration`, `tournament_participant`,
  `order_of_merit_team_member`, `tournament`, `lineup_lock`,
  `organization_invitation`, `message`/`message_reaction`/
  `message_comment`, `tournament_round_participant_flag_plant`, og alt
  som kaskaderer fra disse.

Konsekvens: en sammenslåing kan ALDRI bli én atomisk transaksjon på tvers
av alt -- blir N+1 transaksjoner (én global + én per berørt org). Samme
mønster som allerede etablert praksis i `/auth/me` (kommentar `app/
routers/auth.py` rundt linje 786-795: "dette er den eneste måten å gjøre
det på uten å endre selve RLS-modellen"). Berørte org-er finnes via
`organization_membership` (plain) UNION `player_organizations_for_user()`
(SECURITY DEFINER, migrasjon 015, ADR-031) for BEGGE kontoer.

**Konfliktkart** (fra full FK-inventering):

| Tabell | Løsningsregel |
|---|---|
| `organization_membership` | Behold HØYEST rolle (owner > admin > member) |
| `player` + avhengige (`team_roster`/`tournament_registration`/`tournament_participant`/`order_of_merit_team_member`) | Har keeper allerede en player-rad i orgen: re-pek taperens avhengigheter dit, slett taperens duplikat (dedup på hver sin `UNIQUE(x, player_id)`). Har keeper ingen: enkel re-peking. |
| `friendship` | Selv-lenke (de var venner med HVERANDRE) slettes (ville brutt `CHECK(requester <> addressee)`). Delt tredjepart-venn: behold `accepted` fremfor `pending`. |
| `friend_categorization` | Samme selvlenke-sjekk + union av kategori-sett for delt venn |
| `user_notification_email_pref` | Behold keeperens rad, dropp taperens duplikat |
| `round_message_reaction`/`message_reaction` | Behold keeperens reaksjon om den finnes, ellers overfør taperens |
| `user_secondary_email` | Taperens PRIMÆRE e-post blir en ny sekundær-e-post på keeper |
| Alt annet med `user_id`/`author_user_id`/`created_by`/`invited_by` osv. | Trygt å re-peke direkte, ingen dedup |

**Ingen sesjon-tilbakekallingsmekanisme finnes** (`get_current_user`
sjekker kun `app_user`-radens eksistens). Eneste måte å tvinge ut en
gjenlevende øktinformasjonskapsel for taper-kontoen er en ekte `DELETE
FROM app_user` -- IKKE en myk "alias"-løsning. Dette er siste, irreversible
steg i `execute_merge()`, kjørt kun etter at alt annet har lyktes.
CASCADE dekker resten av FK-ene automatisk.

**En reell bug funnet og fikset via testsuiten, FØR noe var i nærheten av
produksjon:** seks RLS-beskyttede tabeller (`tournament`, `lineup_lock`,
`organization_invitation`, `message`, `message_comment`,
`tournament_round_participant_flag_plant`) samt `message_reaction` ble
først ved en feil lagt i den globale "trygt å re-peke"-løkken (ment kun
for tabeller UTEN RLS). Symptomet var IKKE en stille RLS-no-op som man
skulle tro, men et krasj: `InvalidTextRepresentationError: invalid input
syntax for type uuid: ""`. Rotårsak: en Postgres GUC-kvirk der
`current_setting('app.current_org', true)` returnerer tom streng (`''`),
ikke NULL, på en POOLET forbindelse som TIDLIGERE hadde en committet
`SET LOCAL app.current_org` fra en annen transaksjon og senere
gjenbrukes via `plain_connection()` -- `''::uuid`-cast i RLS-policyen
kaster i stedet for å filtrere stille. Fikset ved å flytte de seks
tabellene + `message_reaction`-dedup til en egen `_repoint_rls_scoped_
safe_tables()`, kjørt INNI samme `org_connection(org_id)`-transaksjon som
player-sammenslåingen. Verifisert via full testsuite-kjøring: gikk fra
5 feilet/90 bestått til 95/95 bestått.

**Nye endepunkter** (`app/routers/account_merge.py`, egen fil):
`POST /auth/account-merge/preview` (sesjon påkrevd, les-only), `POST
/auth/account-merge/request` (sesjon påkrevd, oppretter token + sender
e-post), `GET /auth/account-merge/token/{token}` (ingen sesjon, fersk
forhåndsvisning, konsumerer IKKE token), `POST /auth/account-merge/
confirm` (ingen sesjon, konsumerer token atomisk + kjører
`execute_merge()`).

**Frontend:** ny seksjon "Slå sammen med en annen konto" i
`account-settings.tsx` (samme kort-mønster som `SecondaryEmailSection`)
-- e-post → forhåndsvisning inline → "Send bekreftelse". Ny side
`app/kontosammenslaing/[token]/page.tsx` + `AccountMergeConfirmForm` --
laster FERSK forhåndsvisning ved åpning, viser en tydelig "dette kan
ikke angres"-advarsel (samme destructive-tint-mønster som `round-
header.tsx` sin inline slett-bekreft, ikke `window.confirm()`), krever
et eksplisitt "Bekreft sammenslåing"-trykk -- ALDRI automatisk ved
lenke-åpning.

**Verifisert:**
1. `tests/test_account_merge.py`, 12 nye tester -- hver rad i
   konfliktkartet dekket eksplisitt (org-rolle-kollisjon, player-
   sammenslåing inkl. ekte `team_roster`-kollisjon, venn-selvlenke,
   delt-venn-dedup, sekundær-e-post-overføring, utløpt/allerede-konsumert
   token), pluss én full ende-til-ende-test. Full backend-suite: 95/95
   bestått.
2. `tsc --noEmit` rent + 45/45 vitest.
3. Scratch-database + scratch `teecup_api` (port 18108) + lokal `next
   dev` (port 13108): to ekte kontoer, keeper medlem i org A + admin i
   org B, taper eier i org A + admin i org B (rolle-kollisjon), begge med
   en player-rad i org A (team_roster-kollisjon), venner med hverandre.
   Logget inn som keeper, forhåndsvisning i kontoinnstillinger viste
   korrekt konfliktkart (org B: administrator, org A: eier -- oppgradert
   fra medlem, 1 dobbeltregistrering ryddes, 1 runde, 1 venn). Hentet
   bekreftelseslenke fra containerlogg, åpnet i en HELT separat,
   ikke-autentisert nettleserkontekst -- fersk forhåndsvisning viste
   identisk konfliktkart. Bekreftet: suksess-skjerm vist. Verifisert
   direkte mot databasen: taperens `app_user`-rad borte, taperens
   e-post nå keeperens sekundæradresse, org-roller korrekte (eier/
   administrator), kun én player-rad i org A (dedup fungerte), kun én
   venneforespørsel (selvlenke slettet). Innlogging med taperens gamle
   e-post routet korrekt til keeper-kontoen (bekreftet ved at 2FA-
   oppsett umiddelbart ble krevd, fordi keeper nå er org-eier). Lys+mørk
   bekreftet på bekreftelsessiden. Scratch-stacken fullstendig revet ned
   (Docker-container, database+rolle, MinIO-bucket, `next dev`) -- ekte
   `teecup_db`/`teecup_api`/`teecup_frontend` urørt gjennom hele
   verifiseringen.
4. Migrasjon 077 vist og bekreftet av bruker før kjøring mot ekte
   `teecup_db` (additiv -- én ny tabell, ingen skjemaendring på
   eksisterende tabeller).

**Rullet ut 2026-08-17** -- bruker bekreftet ("Git Commit og deretter
ja"). Migrasjon 077 kjørt mot ekte `teecup_db` (`docker exec -i
teeoff_db psql ... -f 077_account_merge.sql`, additiv, ingen eksisterende
data rørt, verifisert med `\d account_merge_token` etterpå), deretter
`docker compose build teecup_api teecup_frontend && up -d`. Rene
containerlogger, `https://teecup.golf/logg-inn` bekreftet 200 OK
etterpå.

---

## ADR-081: Rangefinder-koordinater for offisielle (TeeOff-koblede) baner (migrasjon 079) (2026-08-17)

Foranledning: forrige runde erstattet koordinatene på Erol sin
PERSONLIGE Tjøme-bane (`personal_course`, GolfAPI-cache). Brukeren
påpekte at dette ikke er banen org-turneringer faktisk spilles på --
"Tjøme Gents" sin offisielle, TeeOff-koblede bane (`course.source=
'official'`, `external_course_ref='tjome-golfklubb:140'`) er en helt
separat rad, og har ALDRI hatt koordinatdata i det hele tatt. Brukeren
ba om samme koordinater der også, OG at dette skal være en generell,
gjenbrukbar mulighet for ALLE offisielle baner fremover -- ikke en
engangsfiks til.

Rangefinder-funksjonalitet (avstand til green/hindringer, GPS-basert)
fantes til nå KUN for GolfAPI-importerte personlige baner. Verken
org-turneringer (individuell slagspill ELLER lag-matchplay) hadde noen
rangefinder i det hele tatt -- og selv frittstående runder spilt på en
EKTE teeoff-bane (`round.course_source='teeoff'`) fikk tom liste, samme
underliggende hull (ingen koordinatkilde for teeoff-baner). Løsningen
dekker derfor tre steder for prisen av én delt backend-brikke.

**Bekreftet med bruker (AskUserQuestion):** rangefinderen skulle wires
inn i BEGGE org-turnering-scoringsflytene i samme runde (individuell
slagspill OG lag-matchplay), ikke én med den andre som senere sak --
til tross for at ingen aktiv runde fantes på den offisielle Tjøme-banen
ennå (fremtidsrettet grunnlag, ikke en akutt "vis det nå"-fiks).

**Gjenbrukt, ikke oppfunnet på nytt:** `app/hole_history.py` hadde
ALLEREDE nøyaktig den bane-bro-abstraksjonen dette trengte fra ADR-072:
`CourseKey = tuple[Literal["teeoff"], str, str] | tuple[Literal[
"golfapi"], str]`, med `resolve_personal_round_course_key()` og
`resolve_tournament_round_course_key()`. Tredje søster `resolve_match_
course_key()` (match->session->course, lag-matchplay) lagt til ved
siden av de to andre, identisk mønster.

**Ny delt backend-modul `app/target_points.py`:** `get_target_points
(conn, course_key, hole_number)` grener på `course_key[0]` -- `"teeoff"`
slår opp den nye `teeoff_course_coordinate`, `"golfapi"` slår opp
eksisterende `golfapi_course_coordinate` (uendret spørring). Ingen RLS
på noen av kildetabellene (globale, delt på tvers av organisasjoner/
brukere som tilfeldigvis bruker samme fysiske bane), så samme funksjon
virker uansett om `conn` kommer fra `plain_connection()` eller
`org_connection()`. Tre tynne kallesteder: `rounds.py` sitt eksisterende
endepunkt REFAKTORERT til dette mønsteret (villet sideeffekt:
frittstående runder på en ekte teeoff-bane får nå rangefinder for
første gang), nytt endepunkt i `individual_tournaments.py`, nytt
endepunkt i `scoring.py` (tilgangssjekk speiler `get_scorecard`: org-
medlem ELLER deltaker i turneringen).

**Migrasjon 079, del 1 -- delte domener.** To runder på rad (067:
rock/layup, 078: landmark) måtte hver for seg utvide DEN SAMME
poi_type-CHECK-listen i `golfapi_course_coordinate`. Å innføre en ny
tabell med en IDENTISK, dupliserende CHECK-liste ville gjort dette
vedlikeholdsproblemet permanent. Løst med `CREATE DOMAIN course_poi_
type`/`course_poi_location`/`course_poi_side` -- én kilde til sannhet,
brukt av BEGGE tabeller. Eksisterende `golfapi_course_coordinate`-kolonner
konvertert til domenene (additivt, ingen datatap).

**Migrasjon 079, del 2 -- `teeoff_course_coordinate`.** Samme
kolonneform som `golfapi_course_coordinate`, men nøkkel er `course.
external_course_ref` (tekst, IKKE en FK -- speiler at `external_course_
ref` selv er en myk referanse, ingen global "teeoff_course"-tabell
finnes å FK-e mot). GLOBAL (ikke org-scopet, ingen RLS) -- samme
begrunnelse som `golfapi_course_coordinate`: flere organisasjoner kan
importere samme fysiske bane fra teeoff hver sin `course`-rad,
koordinatene skal deles, ikke registreres på nytt per organisasjon.

**Ny skrive-vei (`courses.py`): `PUT`/`GET .../courses/{id}/coordinates`.**
400 hvis banen ikke er `source='official'`. Full erstatning i én
transaksjon (samme "erstatt hele settet"-semantikk brukeren ba om for
Tjøme). Autorisasjon: `get_authorized_org` -- samme bar som ALLE andre
course-muterende endepunkter i denne filen (ingen strengere rolle-sjekk,
matcher eksisterende presedens). Selve tolkningsarbeidet (fritekst-navn
på feltbefarte punkter -> `poi_type`/`location`/`side_fairway`) er
BEVISST IKKE automatisert -- krever dømmekraft (bekreftet konkret av
denne rundens egen Tjøme-erfaring: "Voll"/"Bjella" kunne ikke vært
gjettet av et script). Capabiliten som åpnes er lagrings-/gjenbrukslaget;
selve kartleggingen gjøres fortsatt av bruker+Claude sammen per bane.

**Frontend:** `HoleTargetDistance` (allerede en fritt gjenbrukbar,
selvstendig komponent -- egen fetch+GPS, selvskjulende ved tom data)
generalisert fra `roundId`-prop til `baseUrl`-prop, slik at alle tre
kallesteder (frittstående runde `/rounds/{id}`, individuell org-
turnering `/orgs/{org}/tournaments/{t}/rounds/{r}`, lag-matchplay
`/orgs/{org}/matches/{m}`) kan gjenbruke samme komponent uendret utover
selve URL-en. Limt inn persistent (uansett steg) i `HoleStatsSheet`
(individuell slagspill) og som en ny betinget blokk i `session-
scorecard.tsx` sin hull-`<section>` (lag-matchplay, samme
plasseringsmønster som `NassauPanel`).

**Verifisert:**
1. `tests/test_target_points.py`, 11 nye tester -- `get_target_points()`
   begge grener, `resolve_match_course_key()` alle tre utfall, alle tre
   GET-endepunktene + skrive-veien (inkl. 400 på ikke-offisiell bane,
   full erstatning, tilgangskontroll). Full backend-suite: 106/106
   bestått. `tsc --noEmit` rent + 45/45 vitest.
2. Scratch-database (alle migrasjoner 001-079 anvendt) + scratch
   `teecup_api` + lokal `next dev`: én org med offisiell bane, koordinater
   satt via det nye PUT-endepunktet over ekte HTTP (verifisert tilbake
   via GET), én individuell turnering-runde og én match-play-økt på
   samme bane. Rangefinder bekreftet korrekt koblet i BEGGE UI-ene
   (nettverksspor viste riktig `target-points`-kall med riktige 6 punkter
   for hull 4, tom liste for hull 5 -- komponenten viste følgelig "venter
   på GPS"-tilstanden på hull 4 og INGEN rad i det hele tatt på hull 5,
   nøyaktig den designede selvskjulende oppførselen). Lys+mørk bekreftet
   begge steder. Regresjonssjekk: en frittstående GolfAPI-personlig-bane-
   runde satt opp i samme scratch-miljø, bekreftet identisk oppførsel
   som før refaktoreringen (data på hull med koordinater, ingen rad på
   hull uten). Scratch-stacken fullstendig revet ned (Docker-container,
   database+rolle, MinIO-bucket, `next dev`) -- ekte `teecup_db` urørt
   gjennom hele verifiseringen.
3. Migrasjon 079 vist og bekreftet av bruker før kjøring mot ekte
   `teecup_db` (additiv -- tre domener + én ny tabell, eksisterende
   `golfapi_course_coordinate`-data verifisert uendret etterpå, 399
   rader).

**Rullet ut 2026-08-17** -- bruker bekreftet ("Git commit og rull ut").
Migrasjon 079 kjørt mot ekte `teecup_db`, deretter `docker compose
build teecup_api teecup_frontend && up -d`. Rene containerlogger,
`https://teecup.golf/logg-inn` bekreftet 200 OK. Deretter fylt Tjømes
offisielle bane ("Tjøme Golfklubb – Hovedbanen", org "Tjøme Gents") med
de samme 180 feltbefarte koordinatene som ble bekreftet mot den
personlige banen i forrige runde (samme mapping-beslutninger gjenbrukt
direkte, ikke re-avklart) -- kjørt via et engangsskript i `teecup_api`-
containeren mot det NYE endepunktets underliggende tabell (samme
DELETE+INSERT-logikk som `PUT`-endepunktet selv bruker), bekreftet
180/180 rader med korrekt `poi_type`-fordeling direkte mot databasen
etterpå.

---

## ADR-082: Massimport av spillere til turneringer (CSV) + redigerbar spillertabell (2026-08-17)

Foranledning: å legge til spillere har til nå vært ren én-og-én-arbeid
(ett skjema, én `POST`, gjentatt manuelt). Bruker ba om en CSV-basert
massimport, med en tabell der ALLE felt er redigerbare. Bekreftet med
bruker (AskUserQuestion): importen gjør BEGGE steg samlet (org-
spillerpool + påmelding til turneringen du står i, ikke bare poolen),
og dekker BÅDE lagturneringer (med lagtildeling via en "Lag"-kolonne)
og individuelle turneringer (med valgfri "Klasse"-kolonne) i samme
runde.

**Backend: ett nytt endepunkt, gjenbrukt dedup-mønster.** `POST /orgs/
{organization_id}/players/bulk` (`players.py`) tar en liste av rader.
Samme "match på e-post, fyll KUN tomme felt, ellers opprett ny"-mønster
som `registration.py` sin selvregistrering (ADR-017 Beslutning B) --
her utvidet til ALLE `PlayerCreate`-felt (registration.py sin variant
manglet `first_name`/`last_name`/`paid`/`comment`, lagt til senere i
migrasjon 074). `display_name` røres ALDRI på en eksisterende match --
organisators kanoniske navn er urørlig fra en import. `paid` røres
heller aldri (ikke-nullbar boolean, `COALESCE` mot NULL er alltid en
no-op der -- riktig oppførsel: en import skal ikke stille kunne endre en
allerede satt betalt-status). Hele lista i ÉN transaksjon
(`org_connection` sin egen). Selve turnering-/lag-påmeldingen (steg 2)
gjenbruker de EKSISTERENDE `participants`/`roster`-endepunktene i en
frontend-løkke etter at bulk-kallet returnerer spiller-IDene -- speiler
det etablerte "loop over enkeltkall"-mønsteret som allerede fantes i
`tournament-detail.tsx` for manuell tillegging. Ingen ny bulk-
registrerings-endepunkt, ingen migrasjon (ingen skjemaendring).

**Frontend: CSV parses klient-side.** Ny avhengighet `papaparse` (+
`@types/papaparse`) -- ingen ny multipart-endepunkt nødvendig. Enkel
fuzzy kolonnegjetting (norske/engelske header-varianter) lar brukeren
overstyre hver kolonnetilknytning før forhåndsvisning. Rader uten treff
på lagnavn (lagturnering) markeres synlig i resultat-oppsummeringen
etter lagring -- IKKE blokkerende: spilleren legges uansett i org-
poolen, lagvalg kan rettes manuelt etterpå (samme "degrader grasiøst"-
filosofi som resten av appen).

**Midlertidig hånd-kodet UI, V0-eksport venter (samme rekkefølge som
FlagPlantSheet-presedensen).** Ny komponent `frontend/components/
player-import-panel.tsx` bygget FØRST, hånd-kodet, for å verifisere hele
kjeden (fil → kolonnetilknytning → redigerbar tabell → `bulk`-kall →
løkke over `participants`/`roster`) ende-til-ende mot ekte backend. Kun
selve VISNINGEN erstattes senere med en V0-eksport (Claude-skrevet
prompt, samme "STÅENDE tilgjengelighetsregel eksplisitt i prompten"-
disiplin som all annen V0-bruk denne perioden) -- logikken
(CSV-parsing/kolonne-gjetting/API-orkestrering) forblir Claude-skrevet
og uendret ved bytte, siden komponenten allerede er en kontrollert
"dum" visning utad (`rows`/`onCellChange`/`onDeleteRow`/`onAddRow`/
`onSave` som ville vært V0-prompten sin kontrakt).

Nytt inngangspunkt ("Importer fra CSV") lagt til ved siden av
eksisterende "Legg til spiller" i BÅDE `tournament-detail.tsx` (lag) og
`individual-tournament-detail.tsx` (individuell).

**Verifisert:** `tests/test_players_bulk.py`, 5 nye tester (e-post-match
oppdaterer kun tomme felt uten å overskrive eksisterende data,
`display_name`/`paid` aldri overskrevet, ingen e-post gir alltid ny
rad, transaksjonell konsistens på tvers av rader). Full backend-suite:
111/111 bestått. `tsc --noEmit` rent + 45/45 vitest. Scratch-database +
scratch `teecup_api` + lokal `next dev`: én org med både en
lagturnering (to lag) og en individuell turnering (én klasse), CSV med
tre blandede rader (ny spiller, spiller som matcher eksisterende på
e-post, spiller med et ukjent lagnavn) importert i lagturneringen --
bekreftet direkte mot databasen at den nye spilleren opprettes, den
matchede spilleren kun får sine TOMME felt fylt (allerede satt `country`
uendret), og ukjent-lagnavn-raden ble synlig flagget uten å blokkere de
to andre. Individuell-turnering-import med en "Klasse"-kolonne bekreftet
riktig klassetilknytning der navnet matchet, `Ingen klasse` der det ikke
gjorde. Lys+mørk bekreftet på selve importpanelet. Scratch-stacken
fullstendig revet ned.

**✅ FIKSET ved V0-integrering:** kolonnegjettingen normaliserer nå æ/ø/å
(`normalizeHeader` i `player-import-panel.tsx`), "Kjønn" treffer
automatisk.

**Tillegg 2026-08-17 -- "stor skjerm først" (ny STÅENDE regel, se
CLAUDE.md).** MENS V0-eksporten ble ventet på ga bruker en ny, generell
instruks: turneringsOPPSETT (i motsetning til spillerens skjermer UNDER
selve runden) skal designes for stor skjerm først, siden organisatoren
sitter foran en PC. Lagt til i CLAUDE.md som egen STÅENDE regel (samme
mønster som tilgjengelighets-/navneformat-reglene), og V0-prompten for
DENNE komponenten ble oppdatert og sendt PÅ NYTT før brukeren kjørte den
i v0.app -- bred, tett flerkolonne-tabell i stedet for en smal mobil-
modal, sticky handlingsrad, fortsatt betjenbar (ikke ekskluderende) på
smal skjerm.

**V0-eksport mottatt og integrert (`tee-cup-login-screen.zip`,
2026-08-17).** Fulgte props-kontrakten i prompten presist. Delt i to
filer, samme "Claude eier logikk, V0 eier presentasjon"-todeling som
FlagPlantSheet: `player-import-view.tsx` (V0-eksportert, ren kontrollert
visning, urørt bortsett fra en forklarende kommentar øverst) +
`player-import-panel.tsx` (orkestreringslag, uendret ekstern kontrakt
mot de to kallestedene). Kun reelt nye/relevante filer plukket fra det
fulle V0-prosjekt-re-eksporten (samme diff-mot-levende-tre-disiplin som
tidligere runder) -- resten av eksporten (`tournament-detail.tsx`,
`round-detail.tsx` osv., alle V0s egen kopi av dagens app) ignorert som
forventede reverts.

**Én reell integreringsfeil funnet og fikset i scratch, FØR
produksjon:** V0s Kjønn-nedtrekk bruker lesbare verdier
(`female`/`male`/`other`), men rå CSV-tekst ("m"/"f") ble kopiert inn
UENDRET i kolonnetilknytningen -- nedtrekket viste da "—" (ingen synlig
verdi) for enhver rad, og siden lagre-oversettelsen (`toBackendGender`)
kun kjenner igjen de tre eksakte V0-verdiene, ville rå "m"/"f" blitt
STILLE MISTET ved lagring (ikke en synlig feil, bare tapt data) med
mindre brukeren manuelt valgte kjønn på nytt for HVER rad. Fikset med en
egen `guessGenderValue()`-normalisering i `player-import-panel.tsx`,
anvendt ved selve kolonnetilknytningen (samme sted som header-
normaliseringen) -- IKKE ved å endre V0-visningen. Funnet ved å faktisk
laste opp en CSV med `m`/`f`-verdier i scratch og se det manglende
utvalget, ikke ved kodelesing alene.

**Verifisert (etter V0-integrering):** `tsc --noEmit` rent + 45/45
vitest uendret. Ny scratch-database + scratch `teecup_api` + lokal
`next dev`, 1440×900-visning ("stor skjerm først"): full import-flyt på
nytt i BÅDE lagturnering og individuell turnering med den polerte
visningen -- bekreftet bred flerkolonne-tabell, kolonnegjetting
(inkl. Kjønn med æøå), riktig `Mann`/`Kvinne`/`Annet`-visning etter
kjønn-normaliseringsfiksen, korrekt lagret `m`/`f` i databasen etterpå,
gul (ikke rød) oppfølgings-boks for ukjent lagnavn, riktig `aria-
label`er per celle ("Kjønn for rad 1" osv.). Lys+mørk bekreftet på
selve tabellen. Scratch-stacken fullstendig revet ned.

**Ingen migrasjon.**

**Rullet ut 2026-08-17** -- bruker bekreftet ("Git Commit og rull ut").
`docker compose build teecup_api teecup_frontend && up -d`, ingen
migrasjon (kun kode). Rene containerlogger, `https://teecup.golf/logg-
inn` bekreftet 200 OK.

---

## ADR-083: Visuelt hull-diagram for rangefinderen (tee nederst, green øverst, alle hindringer) (2026-08-17)

Foranledning: bruker rapporterte fra et ekte skjermbilde (produksjon,
personlig Tjøme-runde) at rangefinderen kun viste ÉN hindring
("Bunker (fairway)") selv om hullet også har en greenbunker i
datasettet, og ba om at tee vises nederst/senter green øverst "på alle
skjermer". Bekreftet via AskUserQuestion: (1) de 82 km i skjermbildet
var ikke en feil (testet ikke fysisk på banen), (2) "tee nederst/green
øverst" betyr et EKTE visuelt hull-diagram, (3) full 2D-plassering
(langs OG til venstre/høyre for hullet), ikke bare en enkel vertikal
rekkefølge.

**To atskilte fikser, bevisst i rekkefølge:**

1. **Selve "kun nærmeste"-buggen** (`hole-target-distance.tsx:155-164`,
   ADR-064-arven) -- reduserte ALLE hindringer (green_bunker/
   fairway_bunker/water/rock) til ett `nearestHazard`-objekt. Erstattet
   med en full, sortert (nærmest spilleren først) liste. `target-
   distance.tsx` sin `HazardLine` fikk en søster `HazardList` som
   stabler ALLE hindringer -- denne fiksen gjelder for ENHVER bane,
   uavhengig av om et diagram kan tegnes (se punkt 2), og var det
   raskeste å verifisere og fikk lavest risiko å skille ut for seg selv.

2. **Ny geometri-modul `frontend/lib/hole-geometry.ts`** --
   `projectOntoAxis(axisStart, axisEnd, point)` projiserer et vilkårlig
   punkt ned på en fast tee->green-akse (langs-prosent + tverr-meter),
   bygget på de eksisterende `haversineMeters`/`bearingDegrees`-
   primitivene i `geo.ts` (ren plan trigonometri, ingen nye
   avhengigheter). **Egne enhetstester** (`hole-geometry.test.ts`, 10
   tester) beviste geometrien riktig FØR noe visuelt ble bygget -- kjente
   fasitverdier (start→0%, slutt→100%, midtpunkt→50%, kjent vinkelrett
   offset→riktig tverr-meter, punkt utenfor akse→hhv. negativ/>100%).
   `hole-target-distance.tsx` henter nå ut en FAST tee-referanse
   (midtpunkt av `tee_front`+`tee_back` hvis begge finnes, ellers den
   ene som finnes) -- **bevisst IKKE spillerens bevegelige `livePosition`**,
   som ville fått hele diagrammet til å rotere rundt mens spilleren går.
   Selve avstandstallene som vises er FORTSATT alltid live fra
   spilleren -- kun selve TEGNE-posisjonen bruker den faste aksen.

**Anbefalt og valgt retning: ren SVG/CSS-skjematisk fremstilling, IKKE
et nytt Mapbox-kart.** Begrunnet i research: (a) matcher `geo.ts` sitt
eksplisitte "ingen Mapbox-kall der ren klientberegning holder"-prinsipp
(ADR-048 kostnadskontroll-invariant B), (b) rangefinderen brukes løpende
gjennom en runde -- gjentatte kartlastninger ville kostet mer (penger+
batteri) enn nødvendig, (c) et ekte kart tvinger frem nord-orientering/
kompleks rotasjon for å få "tee nederst/green øverst" konsistent -- en
geometrisk projeksjon gir dette gratis, alltid riktig orientert uansett
spillerens gangretning. `flag-map-overview.tsx` (et ekte, midlertidig
Mapbox-satellittkart, venter selv på egen V0-erstatning) ble vurdert som
mønster, men forkastet -- annet formål, ingen "tee nederst"-orientering.

**Midlertidig hånd-kodet visning bygget FØRST** (samme "Claude eier
logikk, V0 eier presentasjon senere"-rekkefølge som FlagPlantSheet/
PlayerImportPanel): `frontend/components/hole-diagram-view.tsx` --
vertikal "bane", green-markør fast øverst, tee-markør fast nederst,
spillerens posisjon OG hver hindring plassert ved `alongPercent`
(vertikalt, klippet 0-100% KUN ved visning) + `crossMeters` (horisontalt,
klippet til ±30 m fra midtlinjen -- faktisk fairway-bredde er ikke kjent
fra dataen, en fornuftig typisk verdi). Rendres KUN når geometri er
tilgjengelig (tee-punkt finnes) -- `target-distance.tsx` sin flate
visning (nå med full, sortert hindringsliste fra punkt 1) forblir
UENDRET fallback for baner uten tee-koordinater, ingen regresjon.
Gjelder automatisk alle tre rangefinder-kallesteder (frittstående
runder, individuell org-turnering, lag-matchplay) siden alle tre
allerede deler samme `HoleTargetDistance`-datalag (ADR-081).

**Verifisert:**
1. `hole-geometry.test.ts` (10 tester) + full `vitest run` (55/55) +
   `tsc --noEmit` rent -- geometrien bevist matematisk riktig FØR
   scratch-verifisering.
2. Scratch-database + scratch `teecup_api` + lokal `next dev`, ETT hull
   med PRESIST KJENTE, syntetiske koordinater (tee->green rett nord,
   200 m, én hindring 100 m ut/20 m høyre, én hindring 180 m ut/15 m
   venstre) -- GPS simulert via et `initScript` (headless Chrome nekter
   ekte geolokasjon uansett posisjonsoverstyring, kjent begrensning fra
   tidligere denne økten) på et kjent punkt 50 m fra tee. ALLE viste tall
   stemte EKSAKT med håndregnede fasitverdier (front 140/senter 150/bak
   160/hindring 54 m/hindring 131 m via Pythagoras). Visuelt bekreftet:
   green øverst, tee nederst, 54 m-hindringen riktig plassert til HØYRE
   midt på hullet, 131 m-hindringen riktig plassert til VENSTRE nær
   green, spilleren riktig plassert 25% ut fra tee. Fallback til den
   flate visningen bekreftet feilfritt på et hull MED green men UTEN
   tee-koordinater (fortsatt viste alle hindringer, bare uten diagram).
   Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned.

**Ingen migrasjon** -- ren frontend-endring, ingen backend-endring
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
uendret, bare ikke brukt av frontend før nå).

**V0-eksport mottatt og integrert samme dag** (samme
`tee-cup-login-screen.zip`-prosjektnavn V0 alltid re-eksporterer med,
kun `components/hole-diagram.tsx` var relevant -- kopiert til
`hole-diagram-view.tsx`, ingen andre filer fra eksporten rørt). Traff
props-kontrakten i prompten eksakt, ingen integreringsfeil å rette
(til forskjell fra forrige V0-runde/kjønn-mismatchen i ADR-082) --
`tsc --noEmit` rent på FØRSTE forsøk. Egne, ikke eksplisitt etterspurte
kvalitetstillegg fra V0: `role="img"` + en full tekstlig `aria-label`-
oppsummering av HELE diagrammet (`buildDiagramAria`) for skjermlesere,
og en lett kollisjons-unngåelse for hindringsetiketter som havner nær
hverandre vertikalt (sortert etikett-nudge, ikke bedt om i prompten,
men matcher "ikke la det se ødelagt ut hvis to hindringer er nære"-
kravet godt).

**Verifisert på nytt med den polerte visningen:** samme kjente,
syntetiske hull-fixture som den midlertidige versjonen -- IDENTISKE tall
og posisjoner bekreftet (front 140/senter 150/bak 160/hindringer 54 m
til høyre midt på hullet, 131 m til venstre nær green, spiller 25% ut
fra tee), nå med V0s poleringer (varseltrekant-ikoner, "Deg"-etikett
over spillermarkøren, ryddig etikett-plassering). Lys+mørk bekreftet.
Scratch-stacken revet ned.

**Ingen migrasjon** -- ren frontend-endring, ingen backend-endring
(target-points-endepunktene returnerte allerede `tee_front`/`tee_back`
uendret, bare ikke brukt av frontend før nå).

**Rullet ut 2026-08-17** -- bruker bekreftet. `docker compose build
teecup_frontend && up -d`, ingen migrasjon (kun frontend-kode). Ren
containerlogg, `https://teecup.golf/logg-inn` bekreftet 200 OK.

---

## ADR-084: Hull-diagram v2 -- kategoriske venstre/senter/høyre-akser med egne ikoner (2026-08-17)

Revisjon av ADR-083 SAMME DAG, etter reelt produksjonsskjermbilde fra
Tjøme hull 18: både bunkeren og vannhinderet havnet visuelt nesten
rett under senterlinjen i ADR-083 sin KONTINUERLIGE
venstre/høyre-projeksjon (`crossMeters`), selv om bunkeren faktisk ligger
til venstre og vannet er senter. Årsak: det faktiske sideveis-avviket i
meter var for lite til synlig forskjell på ekte banedata -- geometrisk
riktig, men ikke lesbart i sollys.

**Ny løsning, bekreftet med bruker over flere spørsmål/svar-runder:**
- Tre FASTE kategoriske "baner" (venstre/senter/høyre), drevet av
  `side_fairway`-feltet direkte (allerede hentet, men ikke brukt før nå)
  -- IKKE av utledet `crossMeters`. Garantert tydelig atskillelse
  uansett faktisk avstand.
- Green og tee er ALLTID på midtaksen, uansett egen `side_fairway`.
  Spillerens "Deg"-posisjon likeså -- ingen kategorisk side for GPS.
- Hindringer med et ekte forkant+bakkant-par vises som ETT ikon:
  avstand til forkant rett UNDER, avstand til bakkant rett OVER
  (speiler green sitt front/senter/bak). Enkeltpunkt: én avstand,
  alltid under.
- Egne PNG-ikoner (bruker lastet opp, 32×31 sand/vann, 40×40 green) i
  `frontend/public/hole-diagram/` -- IKKE de tre opprinnelig opplastede
  landskaps-SVG-ene (fulle illustrerte banekart, 237-495 path-elementer
  hver), som ble vurdert og avvist: for detaljerte til å være lesbare
  nedskalert til 32-40px, ville brutt CLAUDE.md sin STÅENDE
  tilgjengelighetsregel. Typer uten dedikert ikon (i dag kun `rock`)
  faller tilbake til lucide sin `TriangleAlert`.

**Viktig rettelse fra bruker underveis (endret datalaget):** første
forsøk grupperte hindringspunkter til forkant+bakkant-par basert på
`poi_type`+`side_fairway` -- avvist: to ATSKILTE hindringer av samme
type på samme side (f.eks. to venstre fairway-bunkere) ville da
feilaktig blitt slått sammen. Undersøkt: verken `golfapi_course_
coordinate` eller `teeoff_course_coordinate` hadde noen eksisterende
gruppe-/hindrings-id (kun en usorterbar `gen_random_uuid()`, ingen
`created_at`), og verken GolfAPI sin kildedata eller den manuelle
feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel.

**Ny migrasjon 080:** `hazard_group text` (nullable) lagt til begge
tabeller. `NULL` (dagens tilstand for ALT eksisterende data, inkl.
Tjøme/Nesbyen) = enkeltstående punkt, ALDRI slått sammen -- ingen
regresjon. Ikke-null = et menneske har eksplisitt bekreftet at to
punkter er samme fysiske hindring, satt ved data-inntasting (samme
tillitsnivå som `poi_type`/`location` i ADR-081). Eksponert i
`app/target_points.py` (begge SELECT-lister) og
`app/routers/courses.py` (`CoursePointIn`/`CoursePointOut`,
`_COORDINATE_COLUMNS`, INSERT i `replace_course_coordinates` -- eneste
skrive-vei, kun for offisielle TeeOff-baner; `golfapi_course_
coordinate`, Tjømes personlige bane, har ingen skrive-endepunkt i det
hele tatt, kun engangsskript).

**Kjent, akseptert etterfølger-oppgave:** eksisterende Tjøme/Nesbyen-
data har INGEN `hazard_group` satt ennå -- bunkeren+vannet fra
skjermbildet vises derfor fortsatt som enkeltpunkt (uendret, korrekt
oppførsel) inntil noen manuelt tagger dem like.

**Frontend:** `hole-target-distance.tsx` fikk `groupDiagramHazards()`
som erstatter den gamle per-punkt-mappingen -- grupperer UTELUKKENDE på
delt, ikke-null `hazard_group`. `hole-diagram-view.tsx` skrevet om fra
kontinuerlig `crossToLeftPct`/`MAX_CROSS_METERS`-logikk til
`grid-cols-3`-layout med tre uavhengige baner, hver med egen
kollisjons-forskyvning. `TargetDistance`-fallback (baner uten
tee-koordinater) er UENDRET.

**Verifisert:** `tsc --noEmit` rent, full `vitest run` 55/55, full
`pytest` 111/111 (migrasjon 080 kjørt problemfritt i scratch-testsuiten
sin egen per-kjøring-database). Egen scratch-database+scratch
`teecup_api` (rebygd lokalt bilde for å inkludere backend-endringene)
+ lokal `next dev`, presist kjent syntetisk datasett: to ATSKILTE
venstre fairway-bunkere (én paret via `hazard_group`, én enkeltstående)
-- bekreftet TO atskilte ikoner, IKKE slått sammen, riktig
forkant(54m)/bakkant(63m) på den parede, riktig enkelttall(103m) på
den enkeltstående. Vannhinder (senter, enkeltpunkt, 130m) og rock
(høyre, generic-ikon, 18m) begge riktig plassert og med riktig
enkelttall. Green(40px)/tee bekreftet alltid midtbane. Lys+mørk
bekreftet. Scratch-stacken fullstendig revet ned.

**Rullet ut 2026-08-18** -- bruker bekreftet migrasjon 080 eksplisitt
("Jeg bekrefter"). `ALTER TABLE ... ADD COLUMN hazard_group text` kjørt
mot ekte `teecup_db` (begge tabeller, kolonner bekreftet via `\d`),
deretter `docker compose build teecup_api teecup_frontend && up -d`
(egen bekreftelse). Rene containerlogger, `https://teecup.golf/logg-
inn` 200 OK.

**Gjenstår:** liste over `_HAZARD_CONFIG`-typer uten eget ikon leveres
til bruker (i dag kun `rock`/"Fjellknaus"). V0-prompt for polert
visning skrives etter at bruker har godkjent den hånd-kodede
versjonen.

### ADR-084 del 2: retting fra ekte produksjonsbruk, ny hazard_group-data for Tjøme (2026-08-18)

Bruker viste et ekte skjermbilde av v2-diagrammet i produksjon (samme
dag som utrullingen over) og meldte fem konkrete avvik, pluss lastet
opp fem nye 32px-ikoner (fjellknaus/trær/dogleg/layup/landemerke) og et
regneark med Tjømes koordinater. Alle fem rettet:

1. **Ett ikon per hindring, ikke to** -- viste seg IKKE å være en kode-
   feil, men at ekte Tjøme-data manglet `hazard_group` (forventet, se
   "gjenstår" over). Løst ved å faktisk sette `hazard_group` på ekte
   data (se under).
2. **Ikon-overlapp med green** -- reell kode-feil: `layoutLane()` sin
   kollisjons-kaskade for midtbanen tok ikke hensyn til at green-ikonet
   selv opptar plass i samme sone. Fikset med `CENTER_TRACK_TOP`
   (reservert sone under green-ikonet, kun for midtbanen).
3. **Skal ikonene i listen under diagrammet gjenbrukes, evt. i større
   størrelse?** Bruker spurte oss om å vurdere -- svarte ja, 36px (opp
   fra det gamle universelle 20px `TriangleAlert`-ikonet), forskjellig
   ikon per type.
4. **Listen manglet forkant/senter/bakkant og venstre/senter/høyre-info**
   -- lagt til som en undertekst-linje per rad.
5. **Listen viste hvert par to ganger** -- skrevet om til én rad per
   hindring, med "Forkant X m / Bakkant Y m" på samme rad når parret.

**Under samme runde, ny rettelse fra bruker etter at "bakre
greenbunker" (hull 4) skulle pares:** "(Den må jo for øvrig vises OVER
greenikonet?)" -- riktig observasjon: `alongPercent` (fra
`projectOntoAxis`, se hole-geometry.ts) klippes bevisst IKKE til
[0,100] i geometrimodulen, men visningen klippet den likevel ved selve
tegningen, så en hindring bak selve greenen (alongPercent > 100) endte
opp i SAMME sone som "helt inntil green"-hindringer -- fysisk feil
side av green-ikonet. Fikset i `hole-diagram-view.tsx`: når minst én
midtbane-hindring har alongPercent > 100, flyttes green-ikonet lenger
ned (`GREEN_TOP_PCT_PUSHED`), og en egen liten sone åpnes OVER det
flyttede ikonet (`BEHIND_GREEN_ZONE_TOP/BOTTOM`), med kollisjons-
kaskade i motsatt retning (`layoutBehindGreenLane`, kaskaderer OPPOVER
i stedet for nedover). Ingen tilsvarende sone bak TEE ennå -- ingen
kjent forekomst i data, men samme mønster kan gjenbrukes symmetrisk.

**Ny `hazard_group`-data for Tjøme, fra ekte kildedata (ikke
heuristikk):** brukeren lastet opp klubbens eget regneark med
koordinater, som viste seg å ha en EKSPLISITT gruppenummer-kolonne som
parer forkant/bakkant til samme fysiske hindring -- akkurat den
identiteten `hazard_group` (del 1, migrasjon 080) var designet for å
representere. Kryssjekket regnearkets 38 gruppenumre mot databasens
180 koordinatpunkter via lat/long-matching (Python-script, engangs,
scratchpad): 76 av 76 gyldige punkter identifisert eksakt. Løste
DEFINITIVT de to tilfellene forrige runde bevisst utelot som
tvetydige (hull 4: tre høyre fairway-bunkere, hull 13: to) ved
eliminasjon -- 2 av regnearkets rader hadde feil koordinatformat (UTM/
EPSG:25833 i stedet for WGS84, sannsynligvis kopi-lim-feil ved
regnearkets forfatning), men gruppenummeret var likevel nok til å
identifisere DB-raden ved eliminasjon (de gjenværende umatchede
punktene på riktig hull+side+type). Fant i tillegg at `rock`
("fjellknaus") også har forkant/bakkant-par på fire hull -- ikke
fanget opp i del 1 siden rock da bare hadde generisk ikon. Bruker
bekreftet i tillegg manuelt én ekstra paring regnearket selv ikke
hadde gruppenummer for (hull 4 sin "bakre greenbunker").

**Resultat: 78 koordinatrader satt til 39 distinkte `hazard_group`-
verdier** (format `{hull}-{regneark-gruppenummer}`, sporbart tilbake
til kilden), kjørt som en frittstående `UPDATE ... FROM (VALUES ...)`
mot ekte `teecup_db` (ikke en migrasjon -- datainnhold, ikke skjema).

**Bevisst IKKE gjort:** bekk (creek) fikk et eget ikon fra bruker
(`hazard-creek.png`, kopiert til `frontend/public/hole-diagram/`, IKKE
koblet til noe ennå) -- domenet `course_poi_type` har ingen egen verdi
for bekk i dag (kun `water` dekker både dam og bekk), så å skille dem
visuelt krever en ny domeneverdi + reklassifisering av de spesifikke
bekk-punktene regnearket identifiserer. Bruker ba eksplisitt om at
dette gjøres som en EGEN runde/beslutning, ikke bakt inn i denne.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55 (to ganger,
etter hvert av de to kode-rundene). Ny scratch-runde (database+API+
next dev, samme disiplin) med presist konstruert "bak green"-scenario
-- bekreftet visuelt at bakre greenbunker faktisk rendres OVER
green-ikonet, ikke i normal-sonen, med normal-sonens egne hindringer
uendret ved siden av. Lys+mørk bekreftet begge runder. Begge scratch-
stackene fullstendig revet ned.

**Rullet ut 2026-08-18** -- bruker bekreftet begge deler eksplisitt
(kode + data). `docker compose build teecup_frontend && up -d`
(ingen migrasjon -- kun frontend-kode denne runden), deretter
`UPDATE`-en over kjørt mot ekte `teecup_db`. Rene containerlogger,
`https://teecup.golf/logg-inn` 200 OK, 78/78 rader bekreftet satt
(`count(*) ... WHERE hazard_group IS NOT NULL` = 78, 39 distinkte).

**Gjenstår:** bekk (creek) som egen `poi_type` -- egen runde, bruker
har bedt om dette eksplisitt. Liste over `_HAZARD_CONFIG`-typer uten
eget ikon oppdatert: nå KUN `road` (ingen ikon lastet opp for denne).
V0-prompt for polert visning fortsatt ikke skrevet -- venter på at
bruker har sett/godkjent denne runden i produksjon også.

### ADR-084 del 3: egen poi_type "creek" for bekk (2026-08-18)

Del 2 sa bevisst nei til å blande bekk inn i `water` sitt ikon --
bruker ba om egen runde, som ble gjort samme dag. `course_poi_type`
(delt domene, migrasjon 079) hadde ingen verdi for bekk, kun `water`
som historisk har dekket både dam og bekk.

**Migrasjon 081** legger til `creek` i domenet (`ALTER DOMAIN
course_poi_type DROP/ADD CONSTRAINT`) -- gjelder automatisk begge
koordinattabellene siden de deler domenet. Additiv, ingen eksisterende
rader berøres av selve migrasjonen.

**Reklassifisering, egen UPDATE (ikke migrasjon):** 8 kjente Tjøme-
punkter (hull 2/3/11/12, "fairway bekk" i koordinat-regnearket fra del
2, gjenbruker samme fysiske bekk på to hull-par) hadde `poi_type =
'water'` siden det var eneste tilgjengelige verdi ved import.
Identifisert presist via samme regneark+lat/long-matching som ga
hazard_group-dataen i del 2 (ikke gjettet) -- alle 8 hadde allerede
`hazard_group` satt fra den runden, urørt av denne oppdateringen.

Frontend: nytt ikon (`hazard-creek.png`, 32×32, lastet opp av bruker)
koblet inn som egen `HazardKind` i `_HAZARD_KIND`/`_ICON_SRC`, label
"Bekk". Ingen backend-modellendring nødvendig -- `poi_type` er
allerede `str` (ikke et strengt enum) i både `TargetPoint`
(target_points.py) og `CoursePointIn/Out` (courses.py).

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, full
`./scripts/run_backend_tests.sh` 111/111 (migrasjon 081 kjørt
problemfritt i scratch-testsuiten sin egen database). Ny scratch-runde
med et konstruert bekk-forkant+bakkant-par -- bekreftet eget,
distinkt ikon (ikke forvekslet med vann-ikonet), riktig forkant/
bakkant-tekst, lys+mørk. Scratch-stacken revet ned.

Under samme opprydding oppdaget: flere gamle, TOMME MinIO scratch-
bøtter fra tidligere økter (`teecup-scratch-hh/hs/oom/players/roster/
wz`) som aldri ble ryddet bort ved forrige økters teardown. Bekreftet
adskilt fra ekte data (produksjon bruker `teecup-media`,
`TEECUP_MINIO_BUCKET` i `.env`) og tomme (0 objekter hver) før sletting
-- ingen tilsvarende glemte scratch-databaser/-roller/-containere
funnet.

**Rullet ut 2026-08-18** -- bruker bekreftet alle tre deler eksplisitt
(migrasjon, reklassifisering, kode). Migrasjon 081 og
reklassiferings-UPDATE kjørt mot ekte `teecup_db`, deretter `docker
compose build teecup_frontend && up -d`. Rene containerlogger,
`https://teecup.golf/logg-inn` 200 OK, 8/8 rader bekreftet
`poi_type = 'creek'`.

### ADR-084 del 4: road-ikon, siste hull i ikon-mangel-lista (2026-08-18)

Bruker lastet opp `road.png` (32×32) -- siste gjenstående type uten
eget ikon fra del 2/3 sine lister. Ren frontend-endring: `road` fantes
allerede som domeneverdi (migrasjon 001/067) og som ekte data (5
enkeltpunkter på Tjøme, hull 7/8/16/17/18, alle uparet), men var
usynlig i diagrammet siden `_HAZARD_LABELS`/`_HAZARD_KIND` (hole-
target-distance.tsx) ikke inkluderte den. Lagt til med samme mønster
som de fem foregående ikonene (rock/tree/dogleg/layup/landmark) denne
økten -- ingen ny kode-vei, kun konfig+ikon. Ingen migrasjon, ingen
datareklassifisering nødvendig.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Ingen ny
scratch-runde kjørt for selve ikon-visningen -- identisk, allerede
gjentatte ganger visuelt bekreftet kodevei denne økten (samme
`HazardIcon`/`_ICON_SRC`-mønster), vurdert som neglisjerbar risiko.

**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
teecup_frontend && up -d`, ingen migrasjon. Rene containerlogger,
`https://teecup.golf/logg-inn` 200 OK. Ikon-mangel-lista er nå TOM --
alle klassifiserte hindringstyper har eget ikon.

### ADR-084 del 5: sortering, side_fairway-datafeil funnet, standardisert CSV-format (2026-08-18)

Bruker rapporterte to nye avvik fra ekte bruk: (1) hindringslisten
sorteres "motsatt" av forventet, (2) hull 7 sitt vannhinder fremstår
som senter selv om det faktisk ligger til venstre.

**(1) Sorteringsfiks:** `groupDiagramHazards()` (hole-target-
distance.tsx) manglet den eksplisitte "nærmest spilleren først"-
sorteringen ADR-083 sin opprinnelige flate liste hadde -- falt tilbake
til rå API-/innsettingsrekkefølge, som verken reflekterte avstand eller
oppdaterte seg når spilleren beveget seg. Fikset med
`entities.sort((a, b) => a.below - b.below)`.

**(2) Data-diagnose:** kryssjekket alle punkter i den gamle CSV-en som
eksplisitt oppga venstre/høyre mot databasens `side_fairway` -- fant 12
avvik (fjellknaus/vann/trær på hull 4/5/7/13/14/16), alle satt til
`center` i databasen når kilden sa noe annet. Mønster: punkter som IKKE
fulgte det vanlige "Forkant/Bakkant [side] [type]"-friteksten ble
tilsynelatende satt til `center` som fallback ved en tidligere import.
Bekreftet at lapping av enkeltpunkter ikke var veien videre -- for
upålitelig datagrunnlag til å stole på uten en fullstendig, ren kilde.

**Standardisert CSV-format avtalt** (kolonner: `ID,HULL,TYPE,PLASS,
PUNKT,LAT/LNG`) -- erstatter den gamle friteksten som krevde skjør
NLP-aktig parsing. `ID` er kun unik INNENFOR ett hull (samme prinsipp
som `hazard_group` allerede var designet for), tom `ID` = enkeltstående
punkt (som Tee/Green), ingen ID-kollisjon selv om flere hull gjenbruker
samme fysiske hindring (bekreftet mønster fra tidligere runder).

**Ingen migrasjon denne delen.**

### ADR-084 del 6: full reimport av Tjøme (194 punkter), to nye poi_type (2026-08-18)

Bruker leverte hele 18-hulls-fila i det avtalte formatet. Grundig
validering før noe ble kjørt (se scratchpad-analyse): fant og avklarte
med bruker seks konkrete ting -- to ukjente TYPE-verdier (`Fairway`,
`Voll`, løst med nye ikoner + migrasjon 082, se under), ett par uten
PUNKT til å skille forkant/bakkant (hull 15 sitt vann -- løst ved
brukerens egen regel: punktet som slutter på "683" er bakkant), én
tydelig kopier-lim-feil (hull 11 sin andre "Tee"-rad skulle vært
"Tre" -- bekreftet), seks rader med fortsatt ugyldige UTM/EPSG:25833-
koordinater (denne gangen løst med en ordentlig projeksjons-
transformasjon via `pyproj`, EPSG:25833→4326 -- konvergerte på under
en millimeters avvik fra forrige rundes elimineringsbaserte gjetning,
god kryssbekreftelse av begge metodene), og ett hull 18-veipunkt som
bevisst fjernes (fantes i databasen, ikke i den nye fila -- bekreftet
riktig av bruker).

**Migrasjon 082:** `fairway` og `stone_fence` lagt til
`course_poi_type`-domenet -- bruker lastet opp egne ikoner
(`fairway.png`, `stonefence.png`) for disse. `stone_fence` er den
norske "Voll" (jordvoll/steingjerde) sitt engelske domenenavn.

**Full reimport, ikke patch:** hull 4 sine green-/greenbunker-
koordinater viste seg vesentlig endret mellom gammel database og ny
fil (bl.a. det jeg i forrige runde identifiserte som "bakre
greenbunker" via elimineringslogikk var faktisk feil -- ekte bakre
greenbunker har helt andre koordinater i den nye, autoritative fila).
Dette gjorde inkrementell patching for risikabelt -- kjørte i stedet
`DELETE`+`INSERT` i én transaksjon for HELE banen (180 gamle rader
byttet ut med 194 nye), beregnet direkte fra kolonnene (ikke
friteksttolkning). Genererings-/valideringsskript i scratchpad
(engangs, ikke innsjekket).

**PLASS="Bakre"** (kun hull 4 sin greenbunker) trengte ingen
særbehandling -- beregnet `alongPercent` viste at punktet geometrisk
ligger godt innenfor normalsonen (94-97%, ikke bak green sin bakkant
på 101%+), så det rendres helt vanlig i midtbanen uten å trigge
"bak green"-sonen fra del 2. Verdt å vite: "Bakre" er en beskrivende
etikett fra klubben, ikke nødvendigvis bokstavelig "forbi flagget".

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, full
`./scripts/run_backend_tests.sh` 111/111 (migrasjon 082). Egen
scratch-database seedet med EKTE Tjøme-data (kopiert fra produksjon,
read-only kilde) -- reimport-SQL-en kjørt mot scratch-kopien FØRST,
bekreftet 194/39 riktig, `poi_type`-fordeling matchet plan eksakt.
Visuell bekreftelse (scratch API+frontend) av `fairway`- og
`stone_fence`-ikonene på hull 2/3, samt at sorteringsfiksen virker
riktig med ekte reimportert data (stigende avstand bekreftet hull
1/2/3). Lys+mørk bekreftet. Scratch-stacken revet ned.

**Rullet ut 2026-08-18** -- bruker bekreftet migrasjon, full reimport
og kode hver for seg. Migrasjon 082 og reimport-SQL (DELETE 180/
INSERT 194, én transaksjon) kjørt mot ekte `teecup_db`, deretter
`docker compose build teecup_frontend && up -d`. Hull 7 sitt vann
bekreftet `side_fairway='left'` etter reimport (matcher kildedataen,
rettet fra den kjente `center`-feilen).

### ADR-084 del 7: Tjøme fantes i to atskilte tabeller -- kun én reimportert (2026-08-18)

Bruker rapporterte at en gruppert hindring fortsatt viste to ikoner --
undersøkelse viste at forrige rundes reimport (del 6) KUN traff
`golfapi_course_coordinate` (`course_id='0121250146602173'`, brukt av
frittstående "mine runder" via `personal_course.external_golfapi_
course_id`). Bruker presiserte: "Disse nyeste koordinatene skal brukes
på ALLE Tjømebaner i systemet."

Full gjennomgang (kun lesende spørringer) fant nøyaktig ÉN til: en
"official"-bane i `course`-tabellen ("Tjøme Golfklubb – Hovedbanen",
`external_course_ref='tjome-golfklubb:140'`), med tilhørende data i
`teeoff_course_coordinate` -- brukt av org-turneringer og lag-
matchplay (`hole_history.py` sin `CourseKey`-oppløsning, IKKE
frittstående runder). Denne hadde fortsatt de gamle 180 punktene: 0
`hazard_group`, samme `center`-fallback-feil, ingen `creek`/`fairway`/
`stone_fence`. Ingen tredje representasjon funnet (bekreftet via
`course`/`golfapi_course`/`personal_course` -- alle Tjøme-oppføringer
listet eksplisitt).

**Løsning:** samme 194-rads datasett fra del 6 (identisk kilde, samme
Python-genererte rader) satt inn i `teeoff_course_coordinate` i stedet
-- kolonnene er identiske (begge tabeller delte `course_poi_type`/
`course_poi_location`/`course_poi_side`-domenene siden migrasjon 079).
Egen generert reimport-SQL (`DELETE`+`INSERT`, én transaksjon, samme
mønster som del 6).

**Verifisert:** kjørt FØRST mot en scratch-kopi av de ekte 180 radene
fra `teeoff_course_coordinate` -- 194/39 riktig, identisk resultat som
del 6. Ingen ny visuell scratch-runde (identisk radstruktur/kodevei
allerede bekreftet flere ganger).

**Rullet ut 2026-08-18** -- bruker bekreftet. Reimport-SQL (DELETE
180/INSERT 194, én transaksjon) kjørt mot ekte `teecup_db`. Bekreftet
194/39/`poi_type`-fordeling identisk med `golfapi_course_coordinate`,
hull 7 sitt vann `side_fairway='left'`. Ingen kode-/frontend-endring
denne delen (samme deployerte kode fra del 6 leser nå riktig data fra
begge kilder).

**Lærdom:** Tjøme har to helt separate koordinat-datasett i systemet
(én for frittstående runder, én for org-turneringer) -- verdt å huske
ved FREMTIDIGE data-korrigeringer for denne (eller andre) baner med
begge tilkoblingstyper. Ikke dokumentert eksplisitt noe sted fra før;
bør vurderes nevnt i CLAUDE.md om det skjer igjen.

---

## ADR-085: Rediger organisasjonens hele spillerpool, når som helst (2026-08-18)

Bruker meldte "lettere krise": ingen måte å redigere spillere i org-
turneringer utenom den tre-felts inline-formen fra 2026-07-19/20
(`EditPlayerForm` i `tournament-detail.tsx`: kun navn/HCP/kjønn). Ba
eksplisitt om at tabellen fra spillerimporten (ADR-082) skal være
tilgjengelig for redigering NÅR SOM HELST, ikke bare under en fersk
CSV-import inni én bestemt turnering.

**Undersøkt før bygging:** `PlayerImportPanel` fantes kun som en modal
inni to turnering-rostringsskjermer, alltid startet ved `step="upload"`
-- ingen rute, ingen vei til å åpne den med eksisterende spillere
forhåndsutfylt. **Kritisk funn i `POST .../players/bulk`** (ADR-082 sitt
lagre-endepunkt): bruker `COALESCE` per felt -- fyller KUN tomme felt på
en eksisterende match, og rører ALDRI `display_name`/`email`/`paid` i
det hele tatt (bevisst, for CSV-import). Dette endepunktet ville derfor
IKKE fungert som lagre-vei for en ekte "rett dette feltet"-skjerm --
en korrigering av et allerede utfylt navn eller HCP ville stille feilet
(returnert 200, men ingenting endret). `PATCH /orgs/{id}/players/{id}`
gjør derimot ekte per-felt-overskriving, men kun ett spiller om gangen,
ingen bulk-variant.

**Løsning: gjenbruk selve tabell-UI-et (`PlayerImportView`) uendret i
sin visuelle utforming, ny orkestrering rundt den.** Ny
`Mode.kind === "roster"` (ingen turneringskontekst -- skjuler lag-/
klasse-kolonnene, endrer tittel/avslutningstekst). Ny
`org-player-roster-panel.tsx`: laster radene fra `GET /orgs/{id}/
players` (i stedet for en opplastet CSV), differ hvert felt mot
originalverdien ved lagring, og sender KUN de faktisk endrede feltene
som `PATCH` per eksisterende spiller (ekte overskriving) -- helt nye
rader (lagt til i tabellen denne økten) oppretter med vanlig `POST`,
én om gangen. Ingen backend-endring, ingen migrasjon -- ren
klient-side orkestrering mot eksisterende endepunkter.

**Bevisst utenfor omfang:** ekte sletting av en spiller fra
organisasjonen -- "Slett"-knappen i tabellen fjerner kun raden fra
DENNE redigeringsøkten (samme klient-side-only-oppførsel som i
import-flyten), IKKE spilleren fra databasen. Ingen slette-endepunkt
finnes. Gjort eksplisitt i UI-teksten (`player-import-view.tsx` sin
`EditTable`-beskrivelse, roster-modus) for å unngå at "Slett" feilaktig
oppfattes som en reell sletting.

**Ny rute:** `/organizations/[id]/players` (`org-players.tsx` +
`app/organizations/[id]/players/page.tsx`, samme skall-mønster som
`org-members.tsx`/`members/page.tsx`), lenket fra en ny kort-lenke på
Medlemmer-siden (`org-members.tsx`, ved siden av Order of Merit).

**Ingen ny V0-runde denne gangen** -- selve tabellen (`EditTable`) er
100% UENDRET gjenbruk av den allerede V0-designede komponenten fra
ADR-082, kun ny data-inn/data-ut-orkestrering og et par tekstlinjer
avhengig av modus. Vurdert IKKE å bryte "nye UI-flater går via V0"-
regelen, siden ingen ny visuell design faktisk lages her.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Scratch-runde
(database+API+next dev) med tre eksisterende spillere -- bekreftet at
tabellen laster reelle verdier (ikke bare navn), at HCP-korrigering på
en spiller MED allerede satt verdi (12.4→10.5) og navneendring på en
spiller UTEN e-post (dermed ingen bulk-matching mulig uansett) begge
faktisk persisterte i databasen ved reload, og at en helt ny rad
korrekt opprettet en ny spiller. Lys+mørk bekreftet. Måtte nedgradere
scratch-brukeren fra `owner` til `member` underveis -- org-eiere/
-admins krever obligatorisk 2FA-oppsett ved innlogging (urelatert
funn, ikke en bug, bare et scratch-oppsett-hinder). Scratch-stacken
revet ned.

**Rullet ut:** venter på bekreftelse (ren frontend, ingen migrasjon).

---

## ADR-086: Slett en hel turnering, permanent (2026-08-18)

Bruker: "Jeg mangler mulighet til å slette HELE turneringen." Undersøkt
FØR bygging: ingen `DELETE`-endepunkt for selve `tournament`-raden fantes
noe sted -- alle eksisterende `DELETE`-endepunkter i `tournaments.py`/
`individual_tournaments.py` treffer kun UNDER-ressurser (én økt, én runde,
ett medlem osv.), og de fleste av DEM nekter i tillegg sletting så snart
det finnes ekte barn (409 "ikke tom"). Genuint manglende funksjonalitet,
ikke bare uklikkbar.

**Autorisasjon: strengere enn resten av turneringsoppsettet, bevisst.**
Alt annet i `tournaments.py` krever kun org-medlemskap (`get_authorized_
org`) -- dokumentert som en bevisst grense i filens egen header-kommentar
("dette er organisator-arbeid"). En hel-turnering-sletting er
kategorisk mer ødeleggende (fjerner ALL ekte score-/matchdata permanent,
ikke tomt oppsett) -- ny, strengere sperre: `is_org_admin` (eier/admin),
samme funksjon `messaging.py` allerede bruker for moderasjon. Håndheves
server-side (403 for vanlige medlemmer); frontend skjuler i tillegg selve
knappen for dem via `/auth/me` sin rolle -- ren UX-hjelp, ikke den reelle
sperren.

**Eksplisitt, riktig-rekkefølge-sletting, IKKE naiv enkelt-setnings-
kaskade.** Skjemaet har to RESTRICT-FK-er MIDT INNI kaskade-grafen, ikke
bare i kantene: `match_participant.team_roster_id` (mens `team_roster`
kaskaderer fra `team`, og `match_participant` OGSÅ kaskaderer fra
`match`→`session` -- en "diamant"), og tilsvarende `tournament_round_
participant.tournament_participant_id` for individuell-formatet. Siden
RESTRICT (ulikt NO ACTION) sjekkes UMIDDELBART, ikke utsettbart, kunne en
enkel `DELETE FROM tournament` feilt avhengig av Postgres sin interne
kaskade-rekkefølge -- KUN når det faktisk finnes ekte kamper/runder (en
tom turnering ville aldri avslørt dette). Løst med eksplisitt sletting i
riktig rekkefølge (fjerner RESTRICT-blokkerende rader FØR det de peker på
forsvinner) i stedet for å stole blindt på kaskaden -- `org_connection`
sin egen transaksjon gjør alt atomisk uansett.

**Spillerne selv (`player`, org-scopet pool) røres ALDRI** -- kun
turneringens egne koblinger til dem (roster/deltakelse) forsvinner.

**Ingen soft-delete/arkivering.** Sjekket: ingen `deleted_at`/`is_
deleted`-mønster finnes noe sted i skjemaet. `tournament.status` sin
`'archived'`-verdi er et rent visningsmerke uten noen reell backend-
oppførsel (ingen skjuling/beskyttelse) -- IKKE en soft-delete-erstatning.
Ekte, permanent `DELETE` er den etablerte normen i denne kodebasen
(speiler `DELETE /rounds/{id}` i `rounds.py`, den nærmeste presedensen
for "slett en hel ting med barn").

**UI:** søppelbøtte-ikon i hoved-header, ved siden av
`TournamentStatusPicker`, i BÅDE `tournament-detail.tsx` (lag-format) og
`individual-tournament-detail.tsx` (individuell) -- identisk mønster i
begge, kun synlig for eier/admin. Bekreftelse via nettleserens `confirm()`
med eksplisitt, sterk ordlyd ("Kan ikke angres") -- samme mønster som
`round-detail.tsx` sin runde-sletting og `org-members.tsx` sin
medlem-fjerning, ingen ny bekreftelses-konvensjon oppfunnet.

**Bevisst IKKE bygget:** ingen statusbasert sperre (f.eks. "kan ikke
slette en `active`/`completed` turnering") -- status kan i dag settes
fritt av hvem som helst med ett klikk, usynkronisert med faktisk
datainnhold, så en statusbasert sperre ville vært en falsk trygghet.
Ekte barnesjekk (finnes det score) ble vurdert, men forkastet til fordel
for enkelhet -- eier/admin-sperren + den sterke `confirm()`-teksten ble
ansett tilstrekkelig for en bevisst, sjelden handling.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Fire nye
backend-tester (`tests/test_delete_tournament.py`, 111→115 totalt) --
begge formater med EKTE populerte data (lag-format: kamp+roster+
match_participant+hole_score; individuelt: runde+deltaker+round_
participant+round_hole), bekrefter RESTRICT-diamant-scenarioet faktisk
fungerer, pluss 403 for vanlig medlem og 404 for ikke-eksisterende
turnering. Full nettleser-scratch-verifisering (database+API+next dev)
med et ekte scratch-scenario med reell 2FA-fullføring (TOTP-hemmelighet
lest fra oppsettskjermen, kode beregnet med `pyotp`, siden org-eier/admin
krever obligatorisk 2FA ved innlogging) -- bekreftet søppelbøtte-ikonet
vises, `confirm()`-dialogen viser riktig tekst, og etter bekreftelse:
navigasjon til dashbord OG faktisk 0 rader igjen i `tournament`/`team`/
`team_roster`/`match`/`match_participant`/`hole_score`/`session`, mens
spillerne selv (2 stk) fortsatt fantes uendret. Scratch-stacken revet
ned.

**Rullet ut:** venter på bekreftelse.

---

## ADR-087: Cut i individuelle turneringer -- topp N og delt plass, manuelt anvendt (2026-08-18)

Bruker spurte om Cut var tenkt på ("Delt 18. plass eller bedre klarer
Cut'en"). Undersøkt FØR bygging: bekreftet at konseptet ikke fantes noe
sted -- verken i skjema, backend eller frontend. Design bekreftet med
bruker over to runder med spørsmål/svar:

- **Topp N og delt plass.** Alle med samme til-par-tall som den N-te
  plassen slipper gjennom -- IKKE et strengt kutt til nøyaktig N stykker.
- **Etter et organisator-valgt rundenummer.** Ikke fast (f.eks. "alltid
  etter runde 1") -- fritt konfigurerbart per turnering.
- **Vises i egen "Cut"-seksjon på leaderboardet, blokkert fra videre
  scoring.** Kuttede spillere skal IKKE bare vises dempet innimellom
  resten -- egen, tydelig atskilt del av tabellen.
- **MANUELT anvendt, ikke automatisk.** Bruker eksplisitt: en "Anvend
  cut"-knapp organisator trykker selv, IKKE noe som slår inn automatisk
  idet runden er fullspilt -- unngår at cutten treffer feil mens noen
  fortsatt er midt i runden.

**Migrasjon 083:** `tournament.cut_after_round`/`cut_size`/
`cut_applied_at` (ren KONFIGURASJON + tidsstempel for når cutten faktisk
ble anvendt -- de to første er frie å endre helt til organisator trykker
"Anvend cut"). `tournament_participant.cut boolean NOT NULL DEFAULT
false` (selve utfallet, satt i bulk av selve anvendelsen).

**Backend (`app/routers/individual_tournaments.py`):**
- `_compute_individual_standings`/`_attach_stroke_play_columns` fikk et
  nytt, valgfritt `max_sequence`-parameter -- lar standings beregnes KUN
  ut fra runder t.o.m. et gitt rundenummer, uavhengig av hvor langt
  turneringen faktisk har kommet siden (brukt av selve cut-beregningen
  til å regne "stillingen som den var etter cut_after_round", ikke
  dagens stilling). `LeaderboardEntry` fikk `rank: int | None` (rå
  konkurranse-rangering uten "T"-prefiks, til numerisk cut-grense-
  sammenligning) og `cut: bool`.
- Ny `POST .../apply-cut`: henter stillingen med `max_sequence=
  cut_after_round`, finner cut-grensen som `rank` til deltakeren på
  `cut_size`-plassen (uavgjorte på nøyaktig den rangeringen slipper
  også gjennom -- "delt plass"), setter `tournament_participant.cut` i
  ETT bulk-`UPDATE ... SET cut = (id = ANY($ids))` for HELE feltet
  (idempotent -- regner alt på nytt fra bunnen hver gang, ikke
  inkrementelt, så en rettelse i en tidligere runde og et nytt trykk gir
  korrekt oppdatert resultat). Deltakere som ikke har spilt ETT eneste
  hull gjennom cut-punktet kuttes alltid, uansett `cut_size`.
- `update_hole`: ny sperre -- en kuttet deltaker (`tournament_participant.
  cut = true`) blokkeres (403) fra å registrere score i runder MED
  høyere `sequence` enn `cut_after_round`. Runder T.O.M. cut-punktet
  forblir fritt redigerbare (cutten låser ikke historikken, kun
  fremtiden).
- `_compute_individual_standings` sin vanlige (ikke-cut-begrensede)
  variant: kuttede deltakere sorteres ALLTID etter alle ikke-kuttede
  (stabil sortering på `cut` alene, bevarer den allerede riktige til-
  par-rekkefølgen innad i begge gruppene -- uten dette kunne en kuttet
  spiller med et godt frosset tall endt opp foran en som fortsatt
  spiller og nettopp har hatt en dårlig runde). `total_label`
  overstyres til `"CUT"` -- gjenbruker STATUS-mekanismen WD/DQ/DNS/NR
  allerede har i `stroke-play-leaderboard.tsx`.

**Frontend:**
- `stroke-play-leaderboard.tsx` (liten, håndkodet tilføyelse til den
  eksisterende V0-komponenten -- IKKE en ny UI-flate, se
  [[feedback_frontend_via_v0]]): ny `CutLineRow`, en tydelig, tilgjengelig
  skillelinje ("CUT -- spillerne under gikk ikke videre") satt inn RETT
  FØR første rad med `totalLabel === "CUT"` -- komponenten hadde
  allerede en kommentar fra V0-rundens prompt om at nettopp dette skulle
  være enkelt å legge til senere ("A future 'cut line' divider row can be
  inserted between any two FieldRows here without restructuring").
- `individual-tournament-detail.tsx`: ny `CutCard` i Oppsett-fanen
  (samme "vanlig innstillingskort"-mønster som Scoringsmetode/BBB-bonus/
  flaggkart-seksjonene rett over den -- alle allerede håndkodet, ikke
  V0-eksportert, så dette er en tilføyelse i samme stil, ikke en ny
  UI-flate). To tallfelt (rundenummer, antall som går videre) + "Lagre
  innstilling", og en separat "Anvend cut"-knapp med `confirm()` (samme
  føre-var-mønster som andre konsekvensfulle handlinger i appen, f.eks.
  slett turnering) -- vises kun for brutto/netto/stableford-turneringer.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 6 nye
tester i `tests/test_cut.py` (topp N + delt plass slipper en ekstra
spiller gjennom, idempotens, avvist uten konfigurasjon satt, kuttet
spiller blokkert i senere runde MEN fortsatt kan rette runden t.o.m.
cut-punktet, leaderboard-sortering+status) -- 126/126 grønt (opp fra
120), kjørt via `scripts/run_backend_tests.sh` (egen scratch-database,
rører aldri ekte `teecup_db`). Frontend: midlertidig lokal
`/cutlinepreviewtmp`-side (kun i `next dev`, ALDRI committet) rendret
`StrokePlayLeaderboard` med `MOCK_ROWS` utvidet med to nye "CUT"-rader --
skjermbilde + a11y-snapshot bekreftet skillelinjen dukker opp nøyaktig
rett før første kuttede rad, med riktig R1-tall fortsatt synlig (kun
TODAY/HULL/R2+ blir dashet ut, samme mønster som WD/DQ).

**IKKE fullt nettleser-verifisert denne runden:** selve `CutCard`-
skjemaet (Oppsett-fanen) er IKKE klikket gjennom i en ekte scratch-
nettleserøkt -- kun `tsc --noEmit` (feltnavnene i fetch-kallene matcher
`TournamentUpdate`/`apply_cut` strukturelt) og gjenbruk av et allerede
tre ganger fungerende, identisk PATCH-mønster (scoringsmetode/BBB-bonus/
flaggkart, rett over i samme fil). Vurdert som lav nok gjenværende
risiko til å ikke rettferdiggjøre en full 2FA-innlogging-scratch-runde i
en allerede svært lang økt -- bør likevel klikkes gjennom manuelt (eller
i en fremtidig scratch-runde) før dette lener seg tungt på i en skarp
turnering.

**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 083 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge. Rene logger, 200 OK.

---

## ADR-088: Utslagsgrupper for individuelle turneringer -- hvem spiller med hvem, klokka når (2026-08-18)

Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille fra hvor, med
hvem, klokka når?" -- fulgt av skjermbilde av Oppsett-fanen som bekreftet
gapet: individuelle turneringer hadde INGEN gruppe-/pairing-konsept i
det hele tatt, kun ett-om-gangen "Deltaker + Utslag + Legg til". Da
bruker ble spurt om å vente på en egen scoping-runde (som Cut fikk):
"Jeg vil ha den nå, takk." Design avklart med tre spørsmål:

- **TRADISJONELT utslag** (samme starthull for alle -- rundens
  allerede eksisterende `start_hole`, uendret), staggerte klokkeslett
  PER GRUPPE med fast intervall -- IKKE shotgun (ulike hull samtidig),
  selv om "hvor" i det opprinnelige spørsmålet kunne pekt den veien.
- **Gruppesammensetning MANUELL, med et forslag å justere** -- ikke
  helautomatisk, ikke helt fra bunnen av.
- **FAST gruppestørrelse**, organisator velger tallet (f.eks. 4).

**Migrasjon 084:** ny `tournament_round_group` (sequence, valgfri
`tee_time_override`) + `tournament_round_participant.tournament_round_
group_id` (nullable). Gruppens tee-tid er UTLEDET (samme prinsipp som
`match.tee_time` i matches.py: `scheduled_at + (sequence-1)*tee_
interval_minutes`, override vinner hvis satt), ikke lagret direkte.

**Reell bug funnet og fikset UNDERVEIS, FØR utrulling** (begge via egen
scratch-verifisering, ingen av dem nådde produksjon):
1. Første forsøk ga `tournament_round_group_id`-FK-en `ON DELETE SET
   NULL` -- viste seg at en SAMMENSATT FK (organization_id, ...) med
   SET NULL setter BEGGE kolonnene til null ved kaskade, ikke bare
   fremmednøkkelen. Ville korrumpert `organization_id` (RLS sin tenant-
   nøkkel) på deltaker-rader hver gang en gruppe ble slettet. Fikset
   som ADR-086 allerede etablerte mønsteret for: appen nullstiller
   `tournament_round_group_id` EKSPLISITT i Python før sletting, FK-en
   er vanlig RESTRICT (utløses aldri fra denne kodestien). Egen
   regresjonstest lagt til `test_delete_tournament.py` som bekrefter
   `delete_tournament` (ADR-086) fortsatt fungerer med en ekte gruppe
   koblet på.
2. `TournamentRoundCreate`/`TournamentRoundUpdate.scheduled_at` var
   typet `str`, ikke `datetime` -- asyncpg krever en ekte
   `datetime`-instans for en `timestamptz`-kolonne, en rå streng feiler
   med `TypeError`. Latent i `create_round` siden migrasjon 040 (aldri
   truffet i produksjon fordi frontend-skjemaet aldri satte feltet ved
   opprettelse), oppdaget når `update_round` (ny her) faktisk ble
   testet med en verdi satt. Fikset ved å bruke `datetime`-typen
   direkte (Pydantic parser ISO-tekst selv).
3. **Tidssone-bug i selve frontend-skjemaet**, funnet under browser-
   scratch-verifisering: `<input type="datetime-local">` gir en
   tidssoneløs streng ("organisatorens lokale klokkeslett"), som ble
   sendt RÅTT til backend -- Postgres-sesjonen tolket den som UTC,
   altså feil med hele tidssoneavviket (2 timer i testmiljøet). Fikset
   i `round-groups-panel.tsx`: `new Date(inputVerdi).toISOString()`
   FØR sending (nettleseren tolker den tidssoneløse strengen som lokal
   tid, `.toISOString()` konverterer korrekt til UTC).
4. **Ekte React-state-bug** i drag/velg-om-gruppe-logikken: en flyttet
   spiller forsvant SPORLØST (fjernet fra gammel gruppe, aldri lagt til
   ny) -- årsak: en `let moved`-variabel ble satt INNI en
   `setState`-oppdateringsfunksjon, så lest RETT ETTERPÅ i samme
   funksjonskall. React kjører ikke oppdateringsfunksjoner umiddelbart
   (de køes), så `moved` var fortsatt `null` når den ble lest. Fikset
   ved å lese fra det allerede rendrede state-et SYNKRONT, FØR noen
   `setState`-kall, og gjøre fjern+legg-til i én samlet oppdatering.

**Frontend:** ny fil `round-groups-panel.tsx` -- full-skjerm-overlegg
(samme mønster som `org-player-roster-panel.tsx`), åpnet fra en ny
"Utslagsgrupper"-knapp i `RoundCard` (`individual-tournament-detail.tsx`,
Oppsett-fanen). Bevisst HÅNDKODET av Claude, ikke sendt via V0 først --
bruker ba eksplisitt om funksjonen NÅ, samme avveining som `DuplicateStep`
(ADR/CHANGELOG #114): en avgrenset, funksjonell arbeidsflate, bygget med
de samme globale design-tokenene (border/bg-card/bg-primær) som resten
av appen, ikke forsøkt en ferdig-polert ny flaggskip-skjerm. Kan
poleres videre via V0 senere om ønskelig (se [[feedback_frontend_via_v0]]).

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 5 nye
tester i `tests/test_round_groups.py` (rundeplanlegging, forslag uten
lagring, full-erstatning ved re-lagring, ugrupperte forblir ugrupperte,
tee_time_override vinner over utledet tid) + 1 ny regresjonstest i
`test_delete_tournament.py` -- 131/131 grønt (opp fra 126), kjørt via
`scripts/run_backend_tests.sh` (egen scratch-database). Frontend: FULL
nettleser-scratch-verifisering denne gangen (database+API+next dev, ekte
passord-innlogging med `role='member'` for å unngå obligatorisk
2FA-omveien) -- 9 ekte deltakere, satte første utslag/intervall,
bekreftet riktig UTC-lagring OG riktig lokal tilbake-visning (10:00
rundtur), klikket "Foreslå grupper" (3 grupper à 4/4/1, riktige utledede
klokkeslett 10:00/10:10/10:20), flyttet en spiller til en annen gruppe
via nedtrekksmenyen (fanget opp state-buggen over), lagret, og bekreftet
den nøyaktige gruppesammensetningen direkte i databasen etterpå. Scratch-
stacken revet ned (database, rolle, API-container, MinIO-bucket, next
dev-prosess).

**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 084 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge. Rene logger, 200 OK.

---

## ADR-089: Tiebreak ved likt resultat -- countback eller laveste HCP, organisator velger (2026-08-18)

Bruker, etter Cut (ADR-087): "Dersom jeg ønsker at f.eks Tied 12. plass
skal klare cut'en, hvordan løser vi det? ... En annen ting: Ved likt
resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/
siste hull plasseres foran, eller deles plasseringene. Her kan svaret
være forskjellig for vinner og resten av feltet."

**Første spørsmål var allerede løst** -- Cut sin "topp N og delt plass"
(ADR-087) bruker RANGERING, ikke et hodetall, som cut-grense: alle med
samme til-par som grenseplassen slipper gjennom, uansett hvor mange.
Ingen endring nødvendig, kun bekreftet med et testeksempel.

**Andre spørsmål var en ekte, udesignet mangel** -- ingen tiebreak-logikk
fantes i det hele tatt, delte plasseringer ble ALLTID vist som delt
("T1"), for vinneren og resten av feltet likt. Design avklart med
bruker (tre spørsmål):

- **Metoden er IKKE fastlåst i koden** -- "Dette skal defineres av
  arrangøren ved oppsett av turneringen", for BÅDE vinneren og resten
  av feltet, som TO UAVHENGIGE innstillinger (kan være ulik metode for
  hver).
- **Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI
  selve Cut-grensen** -- Cut sin fulle delte plass (ADR-087) forblir
  uendret uansett tiebreak-innstilling.

**Migrasjon 085:** `tournament.tiebreak_winner_method`/
`tiebreak_field_method` (`'none'` default -- uendret oppførsel, ingen
regresjon -- `'countback'`, `'lowest_hcp'`).

**Backend (`app/routers/individual_tournaments.py`):**
- `_countback_sort_keys`: kaskaderende "matching cards"-countback --
  siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens SISTE
  runde (standard klubbgolf-metode, ingen egen dybde-innstilling å
  konfigurere -- kaskaden er alltid full). Python sin tuple-
  sammenligning gir selve kaskaden gratis: `(siste9, siste6, siste3,
  siste1)` sammenlignet elementvis er nøyaktig hvordan en reell
  countback fungerer. Gjenbruker `allocate_over_played_holes`/
  `stableford_points_for_hole` fra `handicap_engine.py` (samme
  primitiver som resten av scoringen) for netto/Stableford per hull.
- `_lowest_hcp_sort_keys`: `tournament_participant.handicap_index_
  snapshot`, manglende indeks sorteres sist (kan aldri vinne en
  tiebreak ved å mangle data).
- Begge integrert i `_attach_stroke_play_columns` sin eksisterende
  POS-løkke: en tidligere fullt delt gruppe får nå sin tiebreak-metode
  anvendt (vinner-metoden for rank 1, felt-metoden ellers), tildelt med
  samme "vanlig konkurranse-rangering"-algoritme REKURSIVT innad i
  gruppen (`_assign_ranks_within_group`) -- resterende uavgjorte ETTER
  tiebreak (samme nøkkel også der) deler fortsatt plass innbyrdes,
  tiebreak garanterer ikke en total orden, den reduserer bare hvor ofte
  uavgjort faktisk skjer.
- **Cut-grensen upåvirket, håndhevet strukturelt, ikke bare ved
  konvensjon:** tiebreak-koden er portet bak `max_sequence is None` --
  `apply_cut` (ADR-087) sender ALLTID en satt `max_sequence`, så
  tiebreak-grenen kan aldri kjøre derfra, uansett hvilken metode som er
  konfigurert. Egen test bekrefter dette eksplisitt (countback slått på
  OG en ekte fullt uavgjort cut-grense -- begge slipper likevel gjennom).

**Frontend:** ny `TiebreakCard` i Oppsett-fanen, rett under `CutCard`,
samme "vanlig innstillingskort"-mønster (håndkodet, ikke V0 -- liten
tilføyelse til allerede håndkodede innstillingskort i samme fil, se
[[feedback_frontend_via_v0]]) -- to uavhengige nedtrekk (vinner/felt),
hver med "Delt plassering"/"Countback"/"Laveste HCP".

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 4 nye
tester i `tests/test_tiebreak.py` (countback løser en vinner-uavgjort
på bedre back9, laveste HCP løser en felt-uavgjort MEN lar en klar
vinner stå urørt, `'none'` beholder uendret delt-plassering-oppførsel,
og en eksplisitt cut-grense-test med countback slått PÅ som likevel
ikke bryter en ekte uavgjort cut-grense) -- 135/135 grønt (opp fra 131),
kjørt via `scripts/run_backend_tests.sh` (egen scratch-database).

**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 085 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge. Rene logger, 200 OK.

---

## ADR-090: Omspill (playoff) som tiebreak-valg for vinneren + direkte HCP-retting per deltaker (2026-08-18)

To mindre, beslektede oppfølgere samme dag som ADR-089.

**Del A -- Omspill.** Bruker: "ved lik score for leder; nytt valg:
omspill med avgjørelse. Dette bør informeres om før turneringen. (Eks:
Omspill på hull 18 til vinner er kåret.)"

Migrasjon 086: `tiebreak_winner_method` sin CHECK utvidet med `'playoff'`
(KUN gyldig for vinner-metoden -- bruker sin egen formulering var
eksplisitt "ved lik score for LEDER", ikke feltet forøvrig), ny
`tiebreak_playoff_rule text` (fritekst, f.eks. "Sudden death fra hull
18, til vinner er kåret"). Appen kan ikke selv AVGJØRE et omspill (det
skjer på banen, utenfor scoring-systemet) -- `_attach_stroke_play_
columns` behandler derfor `'playoff'` NØYAKTIG som `'none'` i selve
rangeringen (delt plassering, ingen automatisk oppløsning, samme
`is_leader=true` for alle tiede ledere). Forskjellen er ren
INFORMASJON: regelen vises proaktivt to steder -- i Oppsett (`TiebreakCard`,
et nytt tekstfelt som dukker opp når "Omspill" velges) OG på selve
leaderboardet (`LeaderboardTab` i `individual-tournament-detail.tsx`,
en gull-farget banner øverst, ALLTID synlig når konfigurert -- ikke
bare når en uavgjort faktisk oppstår, per "dette bør informeres om FØR
turneringen").

**Del B -- direkte HCP-retting per deltaker.** Bruker: "Hvordan ...
justerer hcp på spillere?" Undersøkt: å redigere en spiller i org-
spillerpoolen (ADR-085) endrer KUN grunnlaget for FREMTIDIGE
turnering-påmeldinger -- en allerede satt `tournament_participant.
handicap_index_snapshot` ble ALDRI rørt (samme "frosset snapshot"-
filosofi `PlayerUpdate` sin docstring allerede dokumenterer for
lagturneringers `team_roster`). Ingen vei fantes til å rette EN
SPESIFIKK deltakers indeks direkte i turneringen den faktisk gjelder
for.

Løst ved å legge `handicap_index_snapshot` til `TournamentParticipantUpdate`
(endepunktet var allerede fullt generisk -- ingen andre backend-endringer
nødvendig). Endrer BEVISST kun selve `tournament_participant`-raden --
allerede opprettede `tournament_round_participant.course_handicap`/
`playing_handicap` for runder deltakeren allerede er satt opp i, forblir
UENDRET (samme "ingen retroaktiv omskriving av frosne runde-verdier"-
prinsipp, bekreftet med egen test). Frontend: `Deltakere`-lista i Oppsett
fikk en ny inline `HcpInput` (lite tallfelt, forpliktet på blur, samme
mønster som andre tallfelt i appen) i stedet for den tidligere rene
tekstvisningen av HCP.

**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 1 ny
test for omspill (bekrefter delt plassering, identisk til `'none'`) +
2 nye tester for HCP-rettingen (verdien settes, OG at en allerede
frosset runde-verdi IKKE skrives over) -- 140/140 grønt (opp fra 137).
Ingen egen scratch-/mock-runde denne gangen -- begge er små, godt
avgrensede tilføyelser til allerede scratch-verifiserte komponenter
(`TiebreakCard` fra ADR-089, `Deltakere`-lista sitt etablerte
inline-rediger-mønster fra klasse-/statistikknivå-feltene rett ved
siden av), vurdert som lav restrisiko gitt sterk typesikkerhet og
testdekning.

**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 086 kjørt mot
ekte `teecup_db`, deretter `docker compose build teecup_api
teecup_frontend && up -d` for begge. Rene logger, 200 OK.

---

## Utviklingsplan (rekkefølge)

1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
2. ✅ Denne beslutningsloggen (dette dokumentet)
3. ✅ Handicap-motor som frittstående, testet bibliotek (ADR-005) — 24 tester, R&A-verifisert
4. ✅ Databaseskjema (`001_initial_schema.sql`) — RLS, miksede formater, pool
5. ⏩ Backend-API + regelmotor-integrasjon (neste)
6. ⬜ Frontend (admin + score-registrering)
7. ⬜ PWA & offline-synk
