teecup/ARCHITECTURE_DECISIONS.md

159 KiB
Raw Blame History

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 (AB, AC, BC), 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.


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.


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 CLAUDE.md-status 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 CLAUDE.md-status 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 CLAUDE.md-status 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 CLAUDE.md-status 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 CLAUDE.md-status 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 CLAUDE.md-status 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 CLAUDE.md-status 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). gendertee_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(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 CLAUDE.md-status 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(), ny generalisert StatTileRow-komponent (2 ELLER 3 fliser -- StatTilePair var fast to) brukt for Bunkerslag/Straffeslag/(Anywayslag når tilgjengelig). Seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" (bredere navn, bruker nevnte at et fritekst-notatfelt trolig havner der senere). 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, 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_hole1 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 (<1m8m+) — 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.


Åpne spørsmål (ikke besluttet ennå)

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: Arkitekturen skal ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
  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.

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