teecup/ARCHITECTURE_DECISIONS.md
Erol Haagenrud 1bfac02944 ADR-084/CHANGELOG: hazard_group rullet ut mot ekte database
Migrasjon 080 kjørt mot ekte teecup_db og teecup_api/teecup_frontend
redeployet, begge med brukerens eksplisitte bekreftelse i samme økt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 05:06:07 +02:00

488 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.

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


ADR-012 — To scoring-moduser per økt

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

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

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


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

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

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

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


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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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


ADR-017 — Selvregistrering og utvidet spillerprofil

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

Migrasjon: 009_landing_pages_and_visibility.sql.


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

Migrasjon: 011_join_code_and_leading_side.sql.


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

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

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

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

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

Beslutning C — 2FA: brukeren velger metode selv, TOTP ELLER e-post- engangskode. app_user.two_factor_method (nullable, 'totp'/'email').

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


ADR-024: Walkover/konsesjon

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:

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

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


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

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

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

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

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

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

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


ADR-028: PWA — installasjon + offline scoreregistrering

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

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

Beslutning B — Omfang av selve offline-skrivingen: kun hole-scores/ hole-results, ingenting annet. De to eksisterende scoreregistrerings- endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke glemt:

  • Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett.
  • Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen ADR-006 faktisk siktet til.
  • Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter nett i v1, uendret.

Beslutning C — Køen lever i klientkoden (IndexedDB, lib/offline- queue.ts), IKKE i service workeren, og bruker window sitt online- event, IKKE Background Sync API. To bevisste forenklinger:

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

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

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

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

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

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

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

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

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


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

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

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

Beslutning A -- gender flyttes fra tee til tee_rating. Ett fysisk utslag (tee, identifisert kun ved navn) kan ha 0, 1 eller 2 kjønnsspesifikke ratinger. tee_rating sin unikhet endres fra (tee_id, scope) til (tee_id, scope, gender). 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 CHANGELOG.md for full byggerunde.


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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bevisst UTENFOR omfang i første byggerunde, til tross for at kilden nå finnes (for å holde v1 håndterbar — presist avgrenset, ikke bare utsatt i vage vendinger):

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

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

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

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

Frontend ERSTATTET med V0-designet versjon 2026-07-23, samme dag som den hånd-bygde frontend-en over ble rullet ut: brukeren påpekte (berettiget) at de tre nye skjermene var hånd-kodet av meg direkte i stedet for designet i V0 -- et avvik fra prosjektets etablerte mønster gjennom HELE resten av appen. Bekreftet med bruker at arbeidsmåten fortsatt skal være: JEG skriver V0-prompten, BRUKEREN kjører den i v0.app og sender koden tilbake, JEG integrerer (samme flyt som alltid, ikke endret). Skrev tre detaljerte prompter (liste/opprett/hull- registrering, inkl. eksplisitt tilgjengelighetskrav i hver) -- bruker lastet opp tre zip-eksporter (tee-cup-login-screen (10/11/12).zip). Samme "full re-eksport hver gang"-mønster som ALLE tidligere V0-runder: diffet mot levende tre FØR noe ble tatt inn -- kun fire filer var reelt nye/relevante (round-card.tsx, own-rounds.tsx, new-round.tsx, round-detail.tsx + tre app/rounds*/page.tsx-ruter), resten (login-form, dashboard, config, ui/*) var forventede full-reverts og ble IKKE tatt inn. Samme kjente V0-feil dukket opp igjen, hoppet bevisst over: app/clubs/[id]/page.tsx med feilnavngitt [id]-parameter (egentlig en slug) -- identisk feil som ble rettet i klubbside-runden under ADR-018, V0 gjenskaper den tydeligvis når prosjektet re-genereres. Mine tre hånd-bygde komponenter (personal-rounds.tsx, round-new.tsx, og min opprinnelige round-detail.tsx) er ERSTATTET, ikke supplert -- V0s presentasjon beholdt, datalaget skrevet om fra mock til ekte fetch (samme "V0 leverte mock, jeg kabler ekte data"-mønster som enhver annen skjerm i appen). Reelle tilpasninger utover ren om-kabling:

  1. V0s teeoff-søkemodell antok hele bane+utslag-lista lå ferdig i søkeresultatet -- det ekte API-et er et to-stegs oppslag (søk → facility-detalj). Løst med en resolving-tilstand i OfficialSearchStep (eneste strukturelle tillegg til V0s JSX).
  2. La til et tredje kjønnsvalg "Annet" i gjeste-skjemaets ChoiceRow (V0 hadde kun Mann/Kvinne) -- matcher appens ellers etablerte Herre/Dame/Annet-konvensjon (account-settings.tsx m.fl.), og API-ets GuestParticipantCreate.gender støtter allerede x.
  3. Beholdt merge-før-PATCH-sikringen fra forrige rundes kontraktsfunn (hull-PATCH skriver alle felt, ikke bare det endrede) -- V0 kjente naturligvis ikke til denne kontraktsdetaljen, lagt inn i updateStat() uendret i prinsipp fra min opprinnelige versjon.
  4. Fjernet V0s dev-only forhåndsvisningskontroller (ReviewToggle i own-rounds.tsx, den nederste "Forhåndsvis: Pågår/Fullført"-linjen i round-detail.tsx) -- samme opprydningsmønster som ALLE tidligere V0-runder i prosjektet. Ingen backend-endring i denne del-runden (samme API-kontrakt som allerede var scratch-verifisert). Verifisert: ekte typesjekket produksjonsbuild (docker build --target builder) kompilerte rent, alle ruter listet. Ingen ny interaktiv nettleser-test utført (intet slikt verktøy tilgjengelig i denne økten) -- kun kodegjennomgang + typesjekk, flagget eksplisitt til bruker. Rullet ut live 2026-07-23, bruker bekreftet eksplisitt: docker compose up -d --build teecup_frontend (gjenskapte også teecup_api som vanlig compose-bivirkning, ingen backend-kode rørt), begge containere boot-et rent, /health//dashboard//rounds//rounds/new → 200 over ekte https, teeoff.no upåvirket. De tre V0-zip-ene slettet fra prosjektroten etter fullført integrering.

Reell produksjonsbug funnet OG FIKSET 2026-07-23, rapportert av bruker med to skjermbilder (/rounds ga "Klarte ikke å hente rundene dine", /rounds/new sitt siste steg ga "Klarte ikke å opprette runden"): rot-årsak var en EKSAKT navnekollisjon mellom frontend-sidens toppnivå- prefiks (/rounds) og backend-APIets ressursprefiks (samme /rounds, app/routers/rounds.py) -- en verre variant av den allerede dokumenterte "afterFiles"-fellen fra ADR-016s medlemsside-hendelse, denne gangen rammende BEGGE retninger samtidig:

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

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

  • idempotent 404 + kryss-bruker-autorisasjon 403) pluss 8 sjekker for utslagstid/tidsbruk. test_isolation.sql 12/12. Ekte typesjekket produksjonsbuild kompilerte rent begge ganger. Rullet ut mot ekte systemer 2026-07-24, bruker bekreftet eksplisitt: migrasjon 023 kjørt mot ekte teecup_db (kolonne bekreftet, test_isolation.sql fortsatt 12/12), deretter docker compose up -d --build teecup_api teecup_frontend, begge containere boot-et rent, /health//dashboard//my-rounds → 200 over ekte https, teeoff.no upåvirket.

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

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

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

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

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

Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video, BYGGET OG LIVE samme dag (2026-07-25): brukeren lastet opp en 26 sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat. Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker- container, ikke installert på verten -- ryddet opp etterpå). Innhold identifisert: score-fordeling (par/bogey/dobbel bogey/verre), snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut + per-hulltype + kryss med fairway + score-splitt, bom-retning på green som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype

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

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

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

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

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

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

  • andel hull med minst ett anywayslag (samme "andel hull med..."-mønster som straffeslag-statistikken). Ny pctHolesWithAnyway-beregning i computeStats(). Den midlertidige StatTileRow-komponenten (bygget for den feilaktig sammenslåtte tre-flis-varianten) fjernet igjen som dødt kode -- StatTilePair (fast to fliser) er tilstrekkelig for begge seksjonene nå. Notert for senere: brukeren ser for seg at et fritekst-notatfelt havner i "Annet"-seksjonen etter hvert. Verifisert: ekte typesjekket produksjonsbuild kjørt og bekreftet, ny rute /my-rounds/[id]/scorecard listet. Ingen ny backend-endring (anyway_strokes/strokes_received fantes allerede i RoundHoleOut). Rullet ut live 2026-07-25 (to runder, feilrettingen rullet ut rett etter originalen), ren frontend-endring, ingen migrasjon, teeoff.no upåvirket.

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

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

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

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

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

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

Sju punkter fra faktisk bruk, BYGGET OG LIVE 2026-07-24 (rapportert av bruker som selv testet scorekort-skjermen): (1) currentHole respekterte aldri round.start_hole (useState<number>(1) + prev || start_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.

Tre nye brukerpunkter, ALLE BYGGET, SCRATCH-VERIFISERT OG LIVE 2026-07-25:

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

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

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

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

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

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


ADR-035: Dashbord-redesign — organisasjon blir implisitt

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

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

Beslutning A — Hva "usynlig organisasjon" betyr konkret

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

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

Reversibilitet

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

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


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

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

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

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

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

Datamodell (arbeidsnavn, avgjøres ved migrasjonsskriving):

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

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

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

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

Autorisasjonssjekk (app-lag, plain_connection()-mønsteret fra ADR-033 Beslutning A — INGEN RLS, samme begrunnelse som der):

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

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

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

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

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

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

Personvernhensyn, bevisst innebygd i designet, ikke tilføyd i etterkant:

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Beslutning A — To atskilte HCP-verdier

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

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

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

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

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

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

Beslutning D — Spilleform (slagspill/matchspill), selvdeklarert

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

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

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

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

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

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

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

Konsekvens — hva denne ADR-en IKKE dekker

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

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

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

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

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

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

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

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

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

Beslutning C — Delt-ball-formater: round_hole gjenbruker hole_scores nullable-nøkkel-mønster

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

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

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

Ingen eksisterende motorstøtte for skins i det hele tatt (ikke et turnering-format). Bruker valgte at DEN SOM SETTER OPP runden skal kunne velge BEGGE akser uavhengig — ikke ett fastlåst standardvalg:

  • round.skins_scoring: 'net' (HCP-slag trekkes fra, WHS-vanlig) | 'gross'.
  • round.skins_tie_handling: 'carry' (uavgjort hulls "skin" ruller til neste hull, vinneren der tar hele potten) | 'split' (uavgjort hulls skin deles likt mellom de tied spillerne i stedet).

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

Beslutning E — HCP-telling per format

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

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

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

Konsekvens — hva som bygges i DENNE runden vs. senere

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


ADR-040: «Det store grepet» — sammenhengende rundeoppsett + delt

Score/Scorekort/Leaderboard-navigasjon

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

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

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

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

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

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

Bygget og verifisert denne runden (2026-08-02):

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

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

**Steg 4-5 (V0-prompt 2, den delte Score/Scorekort/Leaderboard-fane-raden

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

ADR-040 («det store grepet») er dermed HELT FERDIG, alle 5 steg, backend + frontend, live:

  1. Backend: allowance_override-migrasjon + kobling
  2. Kode-fiks: lag-sortering
  3. V0-prompt 1: den samlede rundeoppsett-veiviseren
  4. V0-prompt 2: den delte Score/Scorekort/Leaderboard-fane-raden
  5. Integrering + browserverifisering + utrulling

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

Frontend: to nye sider (/organizations/[id]/order-of-merit liste

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

  • RLS, diskriminert på scope for lag-chat/oppslagstavle).

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

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

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

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

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

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

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

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

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

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


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

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

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

Avklart med bruker (AskUserQuestion, to spørsmål) FØR bygging:

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

Bygget, alt i round-leaderboard.tsx (+ to eksisterende filer fikk en anker-id):

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

Tillegg 2026-08-08 (del 2) — ball-steget viser nå ALLTID kartet, med løpende (sanntids) posisjon og avstand, og allerede målte slag viser nå satellittfoto i listen. Umiddelbart etter tillegget over, bruker: "Jeg får ikke sett slagene jeg allerede har målt... jeg ønsker å kunne se på et satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om jeg velger 'min posisjon nå', så skal jeg se start og slutt på et satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se lengden så langt i sanntid mens jeg nærmer meg ballen." Dette endrer Beslutning B sitt "kart lastes kun ved eksplisitt 'velg punkt på kart'"-prinsipp BEVISST for ball-steget spesifikt (fortsatt uendret for start-steget):

  • Ball-steget slo sammen "GPS vs. kart"-valget til ÉN alltid-synlig kart-visning. Den forrige separate GPS-only-idle-skjermen (bare en "Jeg er ved ballen nå"-knapp, ingen kart) og det egne end_map-steget er fjernet -- MapPointPicker rendres nå direkte når "end"-steget nås, alltid med referencePoint=startPoint. MapPointPicker viste opprinnelig BEGGE bekreftelsesknappene når referencePoint var satt: "Bekreft ballens posisjon" (trykket punkt, end_method: "map_tap") og "Jeg er ved ballen nå" (siste sporede posisjon, end_method: "gps"). Korrigert 2026-08-09 (samme dag, etter faktisk bruk): bruker, "bekreft ballens posisjon er unødvendig" -- trykk-for-å-plassere-ballen fjernet igjen. Ball-steget bekrefter nå UTELUKKENDE via sporet GPS-posisjon; kartet der er rent informativt (referansepunkt + sanntidsposisjon/-avstand), ikke lenger klikkbart for plassering. Backendens end_method-kolonne/CHECK aksepterer fortsatt begge verdier uendret (historiske map_tap-rader fra den korte perioden dette var aktivt skal fortsatt leses korrekt) -- kun frontend-UI-et endret, ingen migrasjon. Start-steget er UPÅVIRKET: trykk-for-å-plassere fungerer der fortsatt akkurat som før.
  • Løpende posisjonssporing (watchPosition), kun på ball-steget. Dette er et bevisst, avgrenset unntak fra "ingen løpende watchPosition" -- den opprinnelige begrunnelsen for det forbudet var KART- lastnings-kostnad (Mapbox fakturerer initialisering), og watchPosition i seg selv utløser ALDRI en ny kartinitialisering eller et nytt Mapbox-API-kall -- det er ren nettleser-GPS som kun oppdaterer en eksisterende markørs posisjon på det ALLEREDE lastede kartet. Samme ETT-kart-instans-prinsipp står dermed fortsatt fast; kun selve markør-/avstandsdataene oppdateres kontinuerlig. watchPosition ryddes opp (clearWatch) når komponenten avmonteres.
  • Sanntids-avstand vises som et stort tall nederst på kartet, beregnet med haversineMeters(referencePoint, livePosition) (samme rene funksjon som allerede fantes i frontend/lib/geo.ts, tidligere brukt kun for forhåndsvisningen -- shot-measurement-sheet.tsx sin egen duplikate Haversine-implementasjon fjernet til fordel for denne, ren opprydding uten atferdsendring).
  • Kostnadskonsekvens, eksplisitt notert: ball-steget laster nå Mapbox-kartet for HVERT målt slag, uansett om brukeren til slutt bekrefter via GPS eller kart-trykk (siden kartet uansett vises for at brukeren skal se referansepunktet + sanntids-avstand mens de går). Start-steget er UENDRET (kart lastes fortsatt kun ved eksplisitt "velg punkt på kart") -- kun halvparten av et målt slags to punkter utløser nå alltid en kartlastning. Vurdert og akseptert: 50 000 gratis kartlastninger/mnd (Beslutning A) gir god margin for forventet volum.
  • Allerede målte slag i listen (round-detail.tsx, ShotList) viser nå et lite satellitt-thumbnail (160×160, samme offentlige, URL- restrikterte token og samme pin-s-a/pin-s-b-fargekonvensjon som forhåndsvisningen) bygget direkte fra ShotRecords allerede-returnerte start_lat/start_lng/end_lat/end_lng (utvidet fra kun id/club/distance_meters/shared_round_message_id) -- løser "jeg burde jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke bare klubbe/avstand som tekst, men faktisk HVOR slaget ble slått. Ingen backend-endring nødvendig -- ShotOut returnerte allerede alle fire koordinatene, kun frontend-typen/rendringen manglet dem.

Backend: app/routers/rounds.py fikk ShotIn/ShotOut/ShotShareIn og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share), alle bygget på den eksisterende _get_accessible_round_or_404 (samme "eier ELLER lenket medspiller kan føre for hele flighten"-regel som update_hole/side-PATCH allerede bruker). _resolve_round_message_author_name flyttet fra round_messages.py til rounds.py (som allerede var den importerte, ikke-importerende parten av de to filene) for å unngå en sirkulær import da share_shot trengte å gjenbruke den uendret.

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

Bevisst utenfor omfang: redigering av et allerede logget slag (slett og mål på nytt i stedet), en historisk "vis alle slag på kart etter runden"-visualisering.

ADR-049: /velkommen — landingsside for innlogget-men-ikke-fullført-profil

Reist av brukeren 2026-08-09: "Skjemaet med personlig informasjon vises for tidlig for ikke registrerte spillere etter at de logger seg inn... Jeg tror det beste er at man kommer til en side med oppfordring til å installere som app (som vanlig) og med deaktiverte knapper for runde, turneringen og bli med med kode. Trykker man på noen av disse skal man få beskjed om at personlig informasjon må fylles ut først, med lenke til skjemaet. Under dette bør TeeCup presenteres." Bekreftet: ny, avgrenset side (ikke en full erstatning av /dashboard, se drøftingen i chatten) — dashbordet for FULLFØRT profil er uendret.

Problemet: en innlogget bruker med ufullstendig profil ble tidligere sendt RETT til /account sitt påtvungne skjema ("Fullfør profilen din"), uten noen kontekst om hva de nettopp logget seg inn på. Brukeren observerte dette som en reell feil i praksis (antas: forvirring/frafall), ikke bare en teoretisk innvending.

Løsning: ny side frontend/app/velkommen/page.tsx + frontend/components/teecup/velkommen.tsx, satt inn i redirect-kjeden mellom innlogging og /account:

  • frontend/app/page.tsx og frontend/app/logg-inn/page.tsx sine server-side redirects, OG dashboard.tsx sin klient-side profile_complete-vakt (loadMe()), peker nå til /velkommen i stedet for /account når profilen er ufullstendig. /velkommen selv redirecter videre til /dashboard (komplett profil) eller /logg-inn (ikke innlogget) — kan altså ikke nås "feil" via direkte URL.
  • Siden viser, i rekkefølge: TeeCup-ordmerke + "Logg ut", en personlig hilsen (me.first_name ?? me.display_name, samme navneformat-regel som dashbordets hilsen, CLAUDE.md), den eksisterende InstallPrompt-komponenten UENDRET (samme PWA- oppfordring som dashbordet allerede bruker), en primær "Fullfør profilen din"-CTA, TRE synlige men visuelt låste hurtighandlinger ("Ny runde"/"Ny turnering"/"Bli med med kode" — hengelås-ikon, aria-disabled, IKKE HTML disabled siden de fortsatt skal være klikkbare for å forklare hvorfor), og en presentasjon av TeeCup (innhold hentet fra teecup-beskrivelse.md, skrevet om til kort UI-tekst — ikke limt inn rått som markdown).
  • Trykk på en låst handling viser en delt påminnelse (role="alert", skjermleser-varslet automatisk) med lenke videre til /account, i stedet for å navigere — samme "forklar, ikke bare blokkér"-prinsipp brukeren ba om.
  • Bygget med samme "clubhouse"-palett-tokens (--tee-strong, --clubhouse-*) og samme TeeCupWordmark/InstallPrompt-komponenter som /logg-inn og det allerede reskinnede dashbordet (2026-08-08, se CHANGELOG.md) — ikke funnet opp på nytt, gjenbruker den etablerte retningen for akkurat denne delen av appen.

Bevisst avgrenset omfang: ingen ny server-side håndheving av profile_complete er lagt til på andre sider (/my-rounds, /my-friends osv. sjekker i dag kun innlogging, ikke profil-status — en pre-eksisterende, ikke relatert inkonsistens, ikke rørt her). Kun de tre inngangspunktene som faktisk styrte hvor en ufullstendig-profil- bruker havnet (root, /logg-inn, dashbordets egen klient-vakt) er endret.

ADR-050: Sikkerhetsgjennomgang 2026-08-09 — rate limiting, sikkerhetshoder, inputvalidering

Brukeren ba om en full sikkerhetsgjennomgang ("sjekk alle felter som kan fylles ut, og sjekk alle URL-er... lar siden/appen seg hacke?"). En dedikert agent gjennomgikk autentisering, HELE autorisasjonslaget på tvers av 16 routere, input-validering, filopplasting, CORS/nettverk og hemmeligheter i git-historikken. Konklusjon: autorisasjons-/IDOR-laget er uvanlig grundig og konsekvent (ingen bekreftet IDOR i noe skrive- endepunkt) — de reelle funnene var manglende rate limiting (HØY), manglende sikkerhetshoder (LAV-MIDDELS), inkonsekvent input-validering (LAV), og én liten informasjonslekkasje i ett BBB-endepunkt (informativt). Bruker: "tett sikkerhetshullene først."

Beslutning A — Rate limiting, i minnet, ikke Redis (ennå). Ny app/rate_limit.py: en enkel fast-vindu-teller (RateLimiter) brukt enten som FastAPI-dependency (IP-basert, via client_ip() som stoler på X-Forwarded-For — trygt KUN fordi appen ikke er nåbar unntatt gjennom Caddy, samme tillitsmodell som should_use_secure_cookies()) eller kalt direkte med en egendefinert nøkkel. Lagt til på fem app/routers/auth.py -endepunkter: request-link/login-password/2fa/email/request (IP-basert, 5-10 forsøk/10 min — ressursen er ikke entydig knyttet til én konto), og 2fa/verify/2fa/setup/confirm (nøkkel = pending-/innlogget bruker-ID, 8 forsøk/5 min — her ER ressursen én bestemt kontos kode, en angriper med en gyldig pending-sesjon kunne ellers omgått IP-basert begrensning ved å bytte IP). Trygt i minnet KUN fordi teecup_api kjører som ÉN uvicorn-prosess (ingen --workers-flagg) — samme kjente begrensning som andre in-memory-cacher i appen (se punkt 2 i listen under). Ved fremtidig skalering til flere workers/containere MÅ dette flyttes til Redis.

Beslutning B — Sikkerhetshoder i Caddy, ikke Next.js-middleware. Lagt til i /opt/teeoff/deploy/Caddyfile sin teecup.golf-blokk (delt fil med teeoff.no, KUN teecup.golf-blokken endret): X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Strict-Transport-Security, og en Content-Security-Policy. CSP-en er bevisst IKKE en streng nonce-basert policy — appen bruker inline style-attributter mange steder (React style={{...}}, hele "clubhouse"-paletten) og Next.js sin hydrering kan trenge inline script, så 'unsafe-inline' er beholdt for script-src/style-src for å unngå å knekke appen blindt. Verdien ligger i det som ER strammet inn: object-src/frame-ancestors/base-uri blokkerer hele angrepsklasser, og connect-src/img-src er begrenset til KUN Mapbox (slagmåling, ADR-048) — en fremtidig XSS-bug kan ikke enkelt eksfiltrere data til en vilkårlig tredjeparts-vert. Bekreftet i ekte nettleser mot produksjon: ingen CSP-brudd i konsollen, et ekte Mapbox-kall (200 OK) fungerer uendret.

Reell driftsfallgruve funnet under utrulling, verdt å dokumentere: docker exec teeoff_caddy caddy reload (OG en direkte admin-API-/load -PUT) rapporterte begge suksess uten feil, men endringen slo likevel ALDRI igjennom — md5sum av filen inne i containeren avvek fra filen på verten. Rotårsak: Docker sin bind-mount av EN ENKELT FIL (./deploy/ Caddyfile:/etc/caddy/Caddyfile:ro) er bundet til INODE-en filen hadde ved containerens oppstart. Et skriveverktøy som lagrer atomisk (skriv til midlertidig fil + rename(), vanlig og trygt mønster generelt) bytter ut inode-en på verten — den kjørende containerens mount fortsetter da å referere den GAMLE, nå frikoblede inode-en, usynlig for reload/admin- API (som begge leser filen på nytt, men containerens FILSYSTEM-syn av stien er allerede feil). Løsning: docker restart teeoff_caddy (ikke bare reload) — dette er en STÅENDE fallgruve for enhver fremtidig Caddyfile-endring, ikke unikt for denne runden. Kort, ufarlig avbrudd for BEGGE sidene (teeoff.no og teecup.golf) ved restart, siden de deler samme Caddy-container.

Beslutning C — Inputvalidering, konsistens fremfor nye regler. Ingen nye valideringsprinsipper — kun manglende max_length/ge/le lagt til der de manglet, etter EKSAKT samme mønster som allerede fantes andre steder i samme fil (f.eks. PlayerCreate.handicap_index fikk samme ge=-10, le=54 som auth.py sin ProfileUpdate.handicap_index allerede hadde). Én reell feil unngått underveis: PersonalCourseTeeRatingIn.par ble først satt til ge=3, le=6 (feilaktig antatt å være per-hull-par, som PersonalCourseHoleIn.par) — DB-skjemaet (020_personal_rounds.sql, ingen CHECK-constraint på den kolonnen) avslørte at det faktisk er BANENS TOTALE par brukt i WHS-beregningen, rettet til ge=54, le=90 før utrulling. Endret: rounds.py (PersonalCourseTeeRatingIn/ PersonalCourseTeeIn.name/PersonalCourseCreate.name), players.py (PlayerCreate/PlayerUpdate, begge — handicap_index/mobile/ nickname/country/club/club_member_number), round_messages.py (CommentIn.body, delt/importert av messaging.py sine to bruksseder — IKKE duplisert), samt tre Form(default=None)-felt (round_messages.py

  • to i messaging.py) som fikk max_length=2000 (tekst) eller max_length=20 (invitasjonskode, som er 6 tegn generert, se tournaments.py._generate_join_code).

Beslutning D — BBB-endepunkt: autorisasjon flyttet FØR formatsjekk. update_bbb_hole i rounds.py kalte _get_accessible_round_or_404 ETTER å ha lest og validert play_format/holes_planned — en bruker UTEN tilgang til en fremmed runde kunne dermed skille "runden finnes ikke" (404) fra "runden finnes, feil format" (400) via feilmeldingen, uten å oppnå noen skrivetilgang. Byttet rekkefølge: autorisasjon leses FØRST, deretter format/hull-omfang. round_row kan ikke lenger være None etter en vellykket autorisasjonssjekk, så den separate null-sjekken er fjernet (var uansett aldri nåbar etter omorganiseringen).

Scratch-verifisert (tjuesjuende scratch-miljø denne økten): rate limiting bekreftet å faktisk utløse 429 ved riktig terskel (10./11. forsøk på login-password, 6. forsøk på request-link), gyldig/ugyldig input-validering bekreftet begge veier (HCP over 54 avvist, gyldig HCP akseptert, for lang mobil avvist), BBB-endepunktet bekreftet å returnere 403 NOT_AUTHORIZED (ikke lenger 400 format-lekkasje) for en bruker uten tilgang, OG fortsatt 200 OK for den faktiske eieren (regresjonssjekk). teecup_dbs ACL bekreftet uendret før/etter, scratch-ressurser ryddet opp fullstendig. CSP verifisert i ekte nettleser mot produksjon (se Beslutning B).

Bevisst utenfor omfang denne runden (nevnt av agenten, ikke fulgt opp): full linje-for-linje-gjennomgang av individual_tournaments.py/ tournaments.py/order_of_merit.py/courses.py (kun stikkprøvd), Web Push-abonnement-kapring-teori (praktisk risiko vurdert svært lav, push-endepunkt-URL-er er ugjettbare), noen faktisk penetrasjonstest (kun statisk kodegjennomgang).

ADR-051: 13-årsgrense for kontoregistrering + presentasjonstekst-korrigeringer

Brukeren, 2026-08-10, etter å ha lest PDF-vedlegget "Barns personvern i golfapp" (en Gemini-samtale om GDPR/personvernkrav for mindreårige i en GPS+UGC-app): "Vi må gjøre noe med de som er barn. For det første: Tillatt aldri de som er under 13 år å registrere seg i appen." Samtidig, to relaterte presentasjonskorrigeringer: et annet PDF-vedlegg ("Installere PWA fra Chrome på iPhone") viste at installasjonsteksten feilaktig hevdet at PWA-installasjon KREVER Safari på iOS -- siden iOS 16.4 gjelder ikke det lenger. Og: "Jeg tror ikke vi skal nevne golfklubber i beskrivelsen av TeeCup," pluss et ønske om å fremheve at turneringsadministrasjon/-presentasjon fungerer like godt på PC.

Beslutning A — 13-årsgrense håndheves i PATCH /auth/profile, ikke i et eget registreringssteg. Siden profile_complete (fødselsdato er ett av de obligatoriske feltene) allerede er den ENESTE veien forbi /velkommen-sperren (ADR-049) og inn i resten av appen, er dette det naturlige, allerede-eksisterende knutepunktet -- en under-13-åring kan rett og slett aldri fullføre profilen sin, og kommer dermed aldri forbi /velkommens låste hurtighandlinger. Ingen ny tabell/kolonne, ingen nytt flagg. Håndhevet SERVER-SIDE i app/routers/auth.py sin update_profile (alder beregnet fra body.birth_date, avvist med 400 UNDER_MINIMUM_AGE hvis under 13 -- eksakt dagsgrense, ikke bare årstall), PLUSS klientside i BEGGE skjemaer som setter fødselsdato (ProfileOnboarding og ProfileSection i account-settings.tsx, delt computeAge()-hjelpefunksjon) for umiddelbar tilbakemelding før serveren i det hele tatt kontaktes.

Bevisst IKKE utvidet til player-tabellen (organisasjonens spillerpool, players.py/registration.py). Dette er en annen datamodell: en player-rad representerer en ROSTER-oppføring en ARRANGØR/klubb legger inn om en person, IKKE nødvendigvis en person med egen app-innlogging (player.user_id er nullable, akkurat denne frikoblingen er selve poenget). Brukerens instruks var "de som er under 13 år å registrere seg" -- altså SELV opprette en app-konto, ikke at en voksen arrangør registrerer et barn som deltaker i en turnering (det siste er faktisk PDF-vedleggets EGEN anbefalte, tryggere løsning for yngre spillere -- "sub-accounts" ført av en voksen, se PDF-ens punkt 2 under "Anbefalte løsninger"). Å blokkere player-tabellen ville brutt akkurat den mekanismen.

Beslutning B — PWA-installasjonstekst skiller nå Safari/Chrome på iOS. install-prompt.tsx sin isIOS()-sjekk fanger ALLE iOS- nettlesere (deler WebKit-motor), men Del-ikonets PLASSERING er ulik (Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet) -- generisk "i Safari"-tekst var dermed direkte feil for en Chrome-bruker, ikke bare unøyaktig. Ny iosBrowser()-funksjon skiller på CriOS (Chrome) i user agent-strengen (vanlig "Chrome"-sniffing ville feilaktig også truffet Safari, som deler samme motor). Ukjente/andre iOS- nettlesere får en nøytral "i nettleseren din"-tekst i stedet for å gjette.

Beslutning C — presentasjonstekst korrigert i to filer. teecup-beskrivelse.md OG velkommen.tsx sin "Hva er TeeCup?"-seksjon (sistnevnte var bevisst skrevet om fra førstnevnte, ikke limt inn rått, se ADR-049) -- begge fikk "golfklubber" fjernet fra målgruppe- beskrivelsen (både "hva det er i dag" og "hva det skal bli"-seksjonene i beskrivelses-dokumentet), og et nytt punkt lagt til om at turneringsadministrasjon/-presentasjon fungerer minst like godt på PC som på telefon. "Klubbhus-stemning" (personlighet/tone-beskrivelsen, navnet på selve designretningen) er UENDRET -- det er ikke en målgruppe-påstand, kun en stemningsbeskrivelse.

Scratch-verifisert (tjueåttende scratch-miljø denne økten): eksakt dagsgrense bekreftet med tre testtilfeller mot ekte API -- 10-åring avvist (400 UNDER_MINIMUM_AGE), nøyaktig 13 år i dag akseptert (200, profile_complete: true), én dag under 13 år avvist (400) -- samme grensesnitt-endepunkt, kun fødselsdatoen endret mellom kallene. Bekreftet i ekte nettleser: klientside-feilmeldingen vises umiddelbart med rød kant ved en 2018-fødselsdato, "Fortsett"-knappen forblir deaktivert. Presentasjonsteksten på /velkommen bekreftet uten "golfklubber" og med det nye PC-punktet. tsc --noEmit/py_compile rene, ingen konsollfeil. teecup_dbs ACL bekreftet uendret, scratch-ressurser ryddet opp fullstendig.

Bevisst utenfor omfang denne runden (drøftet med bruker, avtalt som egen fremtidig runde): rapportering/blokkering/moderering av brukergenerert innhold (bilder/kommentarer) og "privat som standard for mindreårige"-personvern for GPS-/rundedeling -- begge reelle, substansielle krav fra PDF-vedlegget for at appen skal godkjennes i App Store/Google Play med UGC+GPS, men et eget, avgrenset prosjekt (admin- panel, databasefiltrering, 24-timers responstid), ikke noe som hører hjemme i samme runde som en enkel aldersgrense.


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

Scratch-verifisert i tre lag, alle i ekte nettleser/API, ingen enhetstester på TS-siden (ingen testinfrastruktur for det i prosjektet ennå):

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

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

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


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

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

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

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

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

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

Beslutning C — tre dekningsområder, valgt etter risiko, ikke fullstendighet. 17 tester totalt:

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

Beslutning A — Vitest, ren logikk-testing (lib/**), ingen komponenttester ennå. Node-miljø, ingen jsdom/@testing-library i denne runden — det finnes ingen komponenttester å kjøre ennå, og node-miljø er raskere/enklere når det ikke trengs. Tre moduler valgt etter samme risikobaserte prinsipp som backend-rundene:

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

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

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

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

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

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


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

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

Direkte rettet, ingen V0 (4 av 8):

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

Beslutning B — kopier inn i BEGGE eksisterende banemodeller, ikke en tredje. Bekreftet med bruker (AskUserQuestion): GolfAPI-importerte baner skal kunne brukes i BÅDE org-turneringer OG frittstående runder.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bekreftet med bruker (AskUserQuestion) og ett tillegg i etterkant:

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

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

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

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

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

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

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

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

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

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

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

To reelle bugs funnet UNDER ende-til-ende-verifisering mot en ekte, tidligere fullført runde (ikke bare isolert forhåndsvisning):

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

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


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

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

Bekreftet med bruker (AskUserQuestion, to runder):

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

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

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

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

Frontend -- ny UI-overflate, bygget via V0 (standingregel i CLAUDE.md/minne). Skrev en detaljert V0-prompt for FlagPlantSheet (GPS auto-fanget → "Hullet du ut?" Ja/Nei → evt. "Landet du på green?"

  • meter/cm → bekreftelse), sendt til bruker. Bygde parallelt en tydelig MERKET midlertidig håndkodet versjon (samme props-kontrakt) for å kunne verifisere hele backend+integrasjonen uten å vente. **V0-eksporten (zip
  1. kom tilbake samme dag** og erstattet den midlertidige versjonen i BÅDE round-detail.tsx (FlagPlantSheet) og individual-tournament-detail.tsx (FlagPlantSheetOrg, egen kopi per filens konvensjon -- fikk i tillegg expectedLap-prop lagt til kallstedet, som den midlertidige org-varianten manglet). Selvstendig GPS-håndtering (ingen kartkomponent -- det er "Del B"), server-avvisning vist som vedvarende inline-feil (ikke en forsvinnende toast).

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

Ny delt modul app/hole_history.py (mønster: app/blind_draw.py/ app/team_authz.py, ikke db-fri som handicap_engine.py siden dette er datainnhenting, ikke handicap-regler). Kjernen er bane-broen mellom to helt forskjellige identitetssystemer:

  • Teeoff: round.teeoff_facility_slug+teeoff_course_id (frittstående) <-> course.external_course_ref = f"{facility_slug}:{teeoff_course_id}" når course.source='official' (org-turnering) -- eksakt samme streng-format som import_official_course() i courses.py allerede bygger den med, verifisert mot kilden før bruk.
  • GolfAPI: personal_course.external_golfapi_course_id <-> course.external_course_ref når course.source='international' -- begge lagrer samme rå GolfAPI-ID, uten prefiks.
  • Custom (round.course_source='custom' med personal_course.external_ golfapi_course_id IS NULL, eller course.source='custom') gir bevisst None fra begge resolve-funksjonene -- kalleren skjuler da historikk-panelet stille, ingen feilmelding.

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

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

To tynne endepunkt, ett i hver router, begge kaller inn i samme hole_history_for_user() og returnerer SAMME kombinerte historikk uansett hvilken side spørringen kom fra:

  • GET /rounds/{id}/participants/{pid}/holes/{n}/history (rounds.py)
  • GET /orgs/{org}/tournaments/{tid}/rounds/{rid}/participants/{pid}/holes/{n}/history (individual_tournaments.py)

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

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

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

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

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


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

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

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

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

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

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

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

Ingen migrasjon -- ren frontend-tilstandssynk-fiks.

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


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

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

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

Lag-OOM sin leaderboard (ADR-043 Beslutning C, aldri bygget):

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

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

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

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

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


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

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

Samme-bane-håndheving (app-laget, ingen migrasjon): eclectic- aggregering på tvers av turneringer er meningsløs hvis turneringene ikke er spilt på samme bane (ulike hull-oppsett/par/stroke index). Håndhevet på TO steder, samme "fail loudly"-filosofi som ADR-068/ADR-019 Beslutning C:

  • link_tournament_to_order_of_merit: avviser lenking av en turnering hvis OOM-en allerede er aggregation_mode='eclectic' OG den nye turneringens bane avviker fra en allerede lenket turnerings bane.
  • update_order_of_merit: avviser å endre aggregation_mode TIL 'eclectic' hvis turneringer med ulik bane allerede er lenket.

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

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

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

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

Frontend: "Eclectic"-valget lagt til begge steder aggregation_mode settes, gated identisk til (og speilende) server- sidens regel (kind==='player' && result_type IN ('stableford','gross','net')) slik at UI-et aldri tilbyr et valg serveren uansett ville avvist:

  • order-of-merit-list.tsx (opprettelsesskjema): ny eclecticAllowed()- helper, tredje SegmentButton vist betinget, PLUSS auto-reset til 'sum' hvis brukeren endrer kind/resultType ETTER å ha valgt Eclectic slik at den ikke lenger er gyldig (kind/resultType er fritt redigerbare før opprettelse, i motsetning til detail-siden under).
  • order-of-merit-detail.tsx (innstillinger, eksisterende OOM): samme eclecticAllowed()-mønster, men INGEN reset-logikk trengs -- kind/ result_type er låst etter opprettelse her (vist eksplisitt i UI-en, se ADR-074), så gatingen er ren visning av et fast faktum. Hjelpetekst lagt til under Eclectic-valget som forklarer samme-bane-kravet.

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

Verifisert: tsc --noEmit rent + 45/45 vitest. Egen scratch-database

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

Ingen migrasjon -- ren frontend-endring.

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


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

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

Strukturell splitt, begge flyter:

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

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

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

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

Ingen migrasjon -- ren frontend-endring.

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


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

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

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

Flytting, begge flyter:

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

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

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

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

Verifisert: tsc --noEmit rent + 45/45 vitest. Egen scratch-database

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

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

Ingen migrasjon -- ren frontend-endring.

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


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

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

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

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

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

RLS-grensen avgjør transaksjonsstrukturen -- verifisert direkte mot skjemaet, ikke antatt. To grupper tabeller:

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

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

Konfliktkart (fra full FK-inventering):

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

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

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

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

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

Verifisert:

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

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


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

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

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

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

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

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

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

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

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

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

Verifisert:

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

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


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

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

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

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

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

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

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

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

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

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

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

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

Ingen migrasjon.

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


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

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

To atskilte fikser, bevisst i rekkefølge:

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

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

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

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

Verifisert:

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

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

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

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

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

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


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

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

Ny løsning, bekreftet med bruker over flere spørsmål/svar-runder:

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

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

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

Kjent, akseptert etterfølger-oppgave: eksisterende Tjøme/Nesbyen- data har INGEN hazard_group satt ennå -- bunkeren+vannet fra skjermbildet vises derfor fortsatt som enkeltpunkt (uendret, korrekt oppførsel) inntil noen manuelt tagger dem like.

Frontend: hole-target-distance.tsx fikk groupDiagramHazards() som erstatter den gamle per-punkt-mappingen -- grupperer UTELUKKENDE på delt, ikke-null hazard_group. hole-diagram-view.tsx skrevet om fra kontinuerlig crossToLeftPct/MAX_CROSS_METERS-logikk til grid-cols-3-layout med tre uavhengige baner, hver med egen kollisjons-forskyvning. TargetDistance-fallback (baner uten tee-koordinater) er UENDRET.

Verifisert: tsc --noEmit rent, full vitest run 55/55, full pytest 111/111 (migrasjon 080 kjørt problemfritt i scratch-testsuiten sin egen per-kjøring-database). Egen scratch-database+scratch teecup_api (rebygd lokalt bilde for å inkludere backend-endringene)

  • lokal next dev, presist kjent syntetisk datasett: to ATSKILTE venstre fairway-bunkere (én paret via hazard_group, én enkeltstående) -- bekreftet TO atskilte ikoner, IKKE slått sammen, riktig forkant(54m)/bakkant(63m) på den parede, riktig enkelttall(103m) på den enkeltstående. Vannhinder (senter, enkeltpunkt, 130m) og rock (høyre, generic-ikon, 18m) begge riktig plassert og med riktig enkelttall. Green(40px)/tee bekreftet alltid midtbane. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned.

Rullet ut 2026-08-18 -- bruker bekreftet migrasjon 080 eksplisitt ("Jeg bekrefter"). ALTER TABLE ... ADD COLUMN hazard_group text kjørt mot ekte teecup_db (begge tabeller, kolonner bekreftet via \d), deretter docker compose build teecup_api teecup_frontend && up -d (egen bekreftelse). Rene containerlogger, https://teecup.golf/logg- inn 200 OK.

Gjenstår: liste over _HAZARD_CONFIG-typer uten eget ikon leveres til bruker (i dag kun rock/"Fjellknaus"). V0-prompt for polert visning skrives etter at bruker har godkjent den hånd-kodede versjonen.


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