Frontend er bevisst ikke hånd-kodet denne gangen — jeg husket korrigeringen fra rundeskjermene tidligere i prosjektet, så jeg har i stedet skrevet en V0-prompt (i FEATURE_BACKLOG.md) for en ny /my-friends-side, klar til å kjøres når du vil.
3085 lines
178 KiB
Markdown
3085 lines
178 KiB
Markdown
# TeeCup — Arkitektur-beslutningslogg (ADR)
|
||
|
||
> Dette dokumentet er den autoritative kilden til *hva* som er bestemt og *hvorfor*.
|
||
> Det leses av mennesker og av AI-modeller (Claude, Gemini) i starten av hver økt.
|
||
> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares.
|
||
|
||
**Status:** Levende dokument
|
||
**Sist oppdatert:** 2026-07-22
|
||
|
||
---
|
||
|
||
## Kontekst
|
||
|
||
TeeCup er en kommersiell (SaaS) webapplikasjon for å opprette, administrere og
|
||
gjennomføre golfturneringer i «Ryder Cup»-format. Den driftes på samme VPS som
|
||
`teeoff.no`, men skal fungere som et isolert økosystem med eget subdomene
|
||
(`teecup.teeoff.no`) og egen database (`teecup_db`).
|
||
|
||
Målgruppe: klubber, bedrifter og vennegjenger som arrangerer turneringer over tid.
|
||
|
||
---
|
||
|
||
## ADR-001 — Tenant = Organisasjon
|
||
|
||
**Beslutning:** Isolasjonsenheten («tenant») er en **organisasjon**, ikke en turnering.
|
||
|
||
En organisasjon er et bevisst nøytralt begrep som dekker klubb, bedrift *og*
|
||
vennegjeng. Ved å ikke kalle den «klubb» i datamodellen unngår vi refaktorering
|
||
den dagen første bedriftskunde kommer.
|
||
|
||
**Hierarki:**
|
||
|
||
```
|
||
Organisasjon (tenant — det som isoleres og faktureres)
|
||
├── Medlemmer/spillere (gjenbrukbare på tvers av turneringer)
|
||
├── Egendefinerte baner
|
||
└── Turneringer
|
||
├── Lag
|
||
└── Matcher
|
||
└── Scores
|
||
```
|
||
|
||
**Begrunnelse:** En turnering er en hendelse med start og slutt, ikke en kunde.
|
||
Gjenbrukbare ting (spillere, baner, historikk) må leve over turneringens levetid.
|
||
Turnering er derfor en entitet *inne i* en organisasjon.
|
||
|
||
**Konsekvens:** `tenant_id` i alle skjemaer heter `organization_id`.
|
||
|
||
---
|
||
|
||
## ADR-002 — Bruker og organisasjonsmedlemskap er adskilt
|
||
|
||
**Beslutning:** Identitet (innlogging) og medlemskap i en organisasjon er to
|
||
forskjellige ting. Én `user` kan ha flere `organization_memberships`.
|
||
|
||
**Begrunnelse:** Samme person spiller ofte både klubbturneringen og
|
||
jobbturneringen. En bruker må kunne krysse organisasjoner med samme innlogging.
|
||
Dette er lett å bygge inn fra start og smertefullt å legge til senere.
|
||
|
||
**Konsekvens:** Utelukker «database-per-tenant» (se ADR-003), fordi en bruker som
|
||
krysser organisasjoner da måtte eksistere i flere databaser samtidig.
|
||
|
||
---
|
||
|
||
## ADR-003 — Isolasjonsstrategi: Shared schema + Row-Level Security
|
||
|
||
**Beslutning:** Én database (`teecup_db`), delte tabeller, isolert på
|
||
`organization_id`-kolonne, håndhevet av PostgreSQL **Row-Level Security (RLS)**.
|
||
|
||
**Vurderte alternativer:**
|
||
|
||
| Strategi | For | Mot | Valgt |
|
||
|---|---|---|---|
|
||
| Shared schema + `organization_id` + RLS | Enkel drift, billig, skalerer til mange org. | Krever disiplin; RLS må settes riktig | **Ja** |
|
||
| Database/schema per tenant | Sterk isolasjon | Tung migrering; bryter med ADR-002 | Nei |
|
||
|
||
**Begrunnelse:** RLS flytter isolasjonen fra applikasjonskoden (der én glemt
|
||
`WHERE organization_id = ...` lekker data mellom kunder) ned til databasen, som
|
||
håndhever den uansett hva koden gjør. Kombinert med ADR-002 er dette det eneste
|
||
praktiske valget.
|
||
|
||
**Konsekvens / oppgave:** Hver økt/tilkobling må sette `SET app.current_org` (eller
|
||
tilsvarende) slik at RLS-policyen kan filtrere. Dette må inn i tilkoblingslaget
|
||
tidlig, ikke ettermonteres.
|
||
|
||
---
|
||
|
||
## ADR-004 — Banedata fra `teeoff_db` via enveis, lesende API-kontrakt
|
||
|
||
**Beslutning:** TeeCup henter offisielle banedata fra hovedplattformen gjennom et
|
||
veldefinert, lesende API — **ikke** via direkte databasekobling på tvers.
|
||
|
||
**Begrunnelse:** En direkte kobling ville låst TeeCup til hovedplattformens
|
||
skjemaendringer, og et brudd ett sted ville tatt ned begge produktene.
|
||
`teeoff_db` er «master» for offisielle banedata; alt brukergenerert innhold
|
||
(egendefinerte baner, turneringer, brukere) forblir strengt i `teecup_db`.
|
||
|
||
**Konsekvens:** API-kontrakten mot TeeOff må versjoneres og behandles som en
|
||
ekstern avhengighet, selv om den kjører på samme server.
|
||
|
||
---
|
||
|
||
## ADR-005 — Handicap-motoren er et frittstående, testet bibliotek
|
||
|
||
**Beslutning:** All handicap- og score-logikk bygges som en ren Python-modul uten
|
||
avhengigheter til database, API eller web-rammeverk. Den testes grundig i
|
||
isolasjon.
|
||
|
||
**Begrunnelse:** Dette er produktets hjerte og den mest risikofylte biten. Feil
|
||
her koster mest troverdighet. Ved å isolere den kan reglene enhetstestes mot kjente
|
||
fasitverdier uavhengig av resten av systemet.
|
||
|
||
**Konsekvens / viktig presisering:** Prosentbaserte «allowances» (f.eks. 75 %,
|
||
90 %, 3/4) er **konfigurasjon, ikke hardkodet logikk**. Motoren vet ikke at
|
||
«fourball = 90 %»; den mottar tildelingen som parameter. De konkrete
|
||
standardprosentene per format må verifiseres mot gjeldende WHS/lokale regler før
|
||
produksjon — de skal ikke antas fra hukommelse.
|
||
|
||
---
|
||
|
||
## ADR-006 — Teknologistack
|
||
|
||
**Beslutning:**
|
||
- **Backend:** Python + FastAPI (gjenbruker stacken fra TeeOff → enklere vedlikehold).
|
||
- **Database:** PostgreSQL (+ PostGIS der banegeometri trengs).
|
||
- **Frontend:** React (Vite) som PWA, offline-first (Service Workers + IndexedDB).
|
||
|
||
**Begrunnelse:** Offline-first er *kjernefunksjonalitet*, ikke luksus — golfbaner har
|
||
ofte dårlig mobildekning, og score må kunne registreres uten nett og synkes senere.
|
||
|
||
---
|
||
|
||
## ADR-007 — Miksede formater via økter, og spiller-pool
|
||
|
||
**Beslutning:** En turnering er en **ordnet sekvens av økter** (sessions), ikke ett
|
||
enkelt format. Hver økt bærer format, hullomfang og allowance. Ryder Cup =
|
||
foursome/18 → fourball/18 → singles/18 er tre økter.
|
||
|
||
Hvert lag har en **pool** (`team_roster`) som kan være større enn antall
|
||
matchplasser. Deltakelse avgjøres per match via `match_participant`. En reserve
|
||
er en spiller i poolen uten deltaker-rad i en gitt økt; en spiller kan spille
|
||
kun enkelte økter (f.eks. bare singelen).
|
||
|
||
**Begrunnelse:** Dette er selve Ryder Cup-strukturen. Å modellere format på
|
||
turneringsnivå ville gjort miksede formater umulig; å modellere deltakelse på
|
||
turneringsnivå ville gjort reserver og delvis deltakelse umulig.
|
||
|
||
**Konsekvens:** Handicap-snapshot fryses per turnering i `team_roster`
|
||
(`handicap_index_snapshot`) for reproduserbare resultater.
|
||
|
||
---
|
||
|
||
## ADR-008 — 9-hulls-slag: "slagene som faller på 18-hulls-kortet"
|
||
|
||
**Beslutning:** For 9-hulls-økter (front/back) brukes spillerens fulle
|
||
18-hulls-tildeling, og slagene fordeles over **hele** 18-hulls stroke index —
|
||
deretter tas kun de spilte hullene ut. Spilleren mottar slag på de spilte hullene
|
||
der SI ≤ mottatte slag.
|
||
|
||
**Vurdert alternativ:** WHS' formelle 9-hulls course handicap (eget 9-hulls
|
||
rating, grovt sagt halvparten). Kan gi et litt annet totaltall. Ikke valgt som
|
||
standard fordi ikke alle baner publiserer 9-hulls-ratinger, og den valgte
|
||
metoden er den vanlige i vennegjeng-/klubbmatch-spill.
|
||
|
||
**Kritisk implementasjonsdetalj:** Man må IKKE sende bare de ni spilte hullene inn
|
||
i slagfordelingen med et 18-hulls slagtall — det gir feil ved høye slagtall
|
||
(12 slag på back-9 blir da 10 i stedet for riktige 6). Motoren har derfor
|
||
`allocate_over_played_holes(...)` som fordeler over alle 18 og så tar ut de spilte.
|
||
Dekket av test `test_nine_hole_twelve_strokes_is_six_not_ten`.
|
||
|
||
**Konsekvens:** `tee_rating` beholder likevel front/back-omfang, slik at WHS'
|
||
alternativ kan tilbys senere som konfig per turnering uten skjemaendring.
|
||
|
||
---
|
||
|
||
## ADR-009 — Egen innlogging, uavhengig av teeoff
|
||
|
||
**Beslutning:** TeeCup har egne brukerkontoer (`app_user`), uavhengig av teeoffs
|
||
innlogging.
|
||
|
||
**Begrunnelse:** TeeCup selges kommersielt til klubber/bedrifter som kanskje aldri
|
||
har hørt om teeoff.no. Delt innlogging ville vært forvirrende for dem og koblet de
|
||
to systemenes auth tettere sammen enn ellers ønskelig. Styrker isolasjonen fra
|
||
ADR-004.
|
||
|
||
**Konsekvens:** Egen auth (registrering, Google/magic-link e.l.), egne secrets
|
||
(jf. åpent spørsmål 1), egen sesjonshåndtering. Kaptein- og tilskuer-roller
|
||
defineres innenfor TeeCups eget auth-lag.
|
||
|
||
---
|
||
|
||
## ADR-010 — Feed krever innlogging (ingen anonym lesesti i v1)
|
||
|
||
**Beslutning:** Den «offentlige» runde-feeden er felles for begge lag i
|
||
turneringen, men bak innlogging. Ingen verdenssynlig lenke i v1.
|
||
|
||
**Begrunnelse:** «Offentlig» betyr her «alle innloggede deltakere i turneringen»,
|
||
ikke «hvem som helst med en lenke». Dette fjerner den uautentiserte lesestien og
|
||
den tunge modereringen (som ellers kreves når innhold er synlig for verden) fra
|
||
v1.
|
||
|
||
**Konsekvens:** Synlighet modelleres som kanal-scope (lag / turnering). En ekte
|
||
verdenssynlig delingslenke kan legges til senere som egen bryter, med moderering,
|
||
uten å bygge om.
|
||
|
||
---
|
||
|
||
## ADR-011 — Turneringsstruktur: lagformat i v1, bracket som egen fremtidig type
|
||
|
||
**Beslutning:** v1 bygges for Ryder Cup-lagformatet (økter → uavhengige matcher →
|
||
summerte poeng), og låses til **nøyaktig to lag**.
|
||
|
||
**Begrunnelse:** En match/flight er alltid to-sidig. Tre lag ville krevd
|
||
kryss-oppgjør (A–B, A–C, B–C), nok spillere til å møte to motstandere samtidig,
|
||
balansert oppsett, og tre-veis blind draw — mye kompleksitet for lite gevinst. To
|
||
lag er både enklere og riktigere for formatet. Et knockout-bracket (f.eks. 128
|
||
spillere, utslagsmatcher, finale + bronsefinale) er en helt egen turneringstype:
|
||
rundene er *avhengige* (vinner av match A møter vinner av match B), og krever
|
||
progresjon mellom matcher, seeding, fripass og bronsegren — noe dagens modell ikke
|
||
har.
|
||
|
||
**Konsekvens:** To-lags-grensen håndheves i app-laget (validering ved
|
||
turneringsoppsett), ikke med DB-trigger. Match-modellen holdes generell (to sider,
|
||
vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme senere
|
||
uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig.
|
||
Knockout er fanget som egen type i FEATURE_BACKLOG.md.
|
||
|
||
**Merk (2026-07-19):** brukeren har reist ønske om flere turneringsformater
|
||
UTOVER Ryder Cup-lagformatet (f.eks. «Københavner» — se
|
||
FEATURE_BACKLOG.md sitt eget punkt for alle fire eksemplene). Minst ett av
|
||
disse («Københavner»: alle-mot-alle poengfordeling i et felt av spillere,
|
||
ikke to lag i det hele tatt) passer IKKE inn i denne ADR-ens to-lags-modell —
|
||
en fremtidig, egen ADR trengs når/hvis dette tas fatt på, samme mønster som
|
||
knockout-punktet over. Kun notert her ennå, ikke designet eller bygget.
|
||
|
||
---
|
||
|
||
## ADR-012 — To scoring-moduser per økt
|
||
|
||
**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per
|
||
hull) eller `hole_result` (registrer bare hvem som vant hullet / delt).
|
||
|
||
**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller
|
||
raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang.
|
||
|
||
**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og
|
||
hull-resultat). `hole_result` skriver til ny tabell `match_hole_result`
|
||
(vinnende side per hull, NULL = delt). Begge mater samme
|
||
`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus.
|
||
|
||
---
|
||
|
||
## ADR-013 — Blind draw via lås per lag per økt
|
||
|
||
**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har
|
||
låst. Modellert med tabell `lineup_lock` (én rad per lag per økt).
|
||
|
||
**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og
|
||
oppgjørene avsløres samtidig.
|
||
|
||
**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men
|
||
motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge
|
||
lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet
|
||
(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme
|
||
organisasjon (jf. ADR-010).
|
||
|
||
---
|
||
|
||
## ADR-014 — Konfigurerbar handicap-pipeline: fire uavhengige brytere
|
||
|
||
**Beslutning:** Handicap-anvendelsen i en økt er FIRE uavhengige, valgfrie steg —
|
||
ikke bare én allowance-prosent:
|
||
|
||
1. **Bruk handicap** (av/på) — helt av gir scratch-spill.
|
||
2. **Bruk course handicap** (av/på) — om slope/rating-justeringen
|
||
(`course_handicap_raw`) påføres, eller om rå `handicap_index` brukes direkte.
|
||
3. **HCP-prosent** (allowance-strategien, allerede dekket av ADR-005 /
|
||
`session.allowance_override`).
|
||
4. **Bruk matchplay-handicap** (av/på) — om resultatet konverteres til relative
|
||
slag (`match_play_strokes`: beste enhet spiller «av 0», resten får
|
||
differansen), eller brukes som absolutt Playing Handicap.
|
||
|
||
**Begrunnelse:** Skjermbilder fra Golf GameBook (referanseprodukt, delt
|
||
2026-07-16) viser nøyaktig denne firedelte bryter-strukturen per runde/format i
|
||
deres oppsettsdialog. Deres standard-prosenter (foursome 50 %, better ball/
|
||
fourball 90 %, singles 100 %) stemmer eksakt med `DEFAULT_MATCHPLAY_ALLOWANCES`
|
||
i `handicap_engine.py` — uavhengig bekreftelse på at defaultene er riktige,
|
||
slik ADR-005 krever verifisert. Motoren støtter allerede alle fire som atskilte,
|
||
komponerbare funksjonskall (`course_handicap_raw`, en `AllowanceStrategy`,
|
||
`match_play_strokes`) — hver kan hoppes over uavhengig av de andre — men
|
||
API-et/skjemaet eksponerer dem ikke som brytere ennå.
|
||
|
||
**Konsekvens:** `session.allowance_override` (jsonb) skal utvides til å bære
|
||
alle fire bryterne, ikke bare prosent, når motor-/scoring-integrasjonen bygges
|
||
(se FEATURE_BACKLOG.md). Ingen skjemaendring nødvendig (feltet er allerede
|
||
jsonb), men API-lagets validering og motor-kall må håndtere: `bruk_handicap =
|
||
false` → hopp over hele kjeden (brutto = netto), `bruk_course_handicap = false`
|
||
→ bruk `handicap_index`/`handicap_index_snapshot` direkte uten
|
||
slope/rating-justering, `bruk_matchplay_handicap = false` → bruk absolutt
|
||
Playing Handicap i stedet for å kalle `match_play_strokes`.
|
||
|
||
---
|
||
|
||
## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt
|
||
|
||
**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session`
|
||
får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes`
|
||
(minutter mellom hver "flight"). En matchs `tee_time` er
|
||
`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved
|
||
lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak:
|
||
`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må
|
||
justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien
|
||
når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av
|
||
`hole_config`).
|
||
|
||
**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier
|
||
at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver
|
||
flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett
|
||
tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av
|
||
synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der
|
||
den avledede regelen ikke holder.
|
||
|
||
**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon
|
||
`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir
|
||
`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet
|
||
PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved
|
||
verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher.
|
||
|
||
**Beslutning B — én uniform feilrespons på tvers av HELE API-et:**
|
||
`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster
|
||
`HTTPException` (39 steder ved innføring), via en liten factory
|
||
`app_error(status_code, code, message)` i `app/errors.py` — ingen egen
|
||
exception-klasse, ingen global exception handler. Kodene er en liten,
|
||
gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) —
|
||
f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`,
|
||
`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst
|
||
UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`.
|
||
|
||
**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne
|
||
runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend —
|
||
den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et
|
||
forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt
|
||
kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk
|
||
inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å
|
||
måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å
|
||
ettermontere når klientkode allerede har begynt å parse tekststrenger.
|
||
|
||
**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke
|
||
`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se
|
||
`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer
|
||
noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en
|
||
semantisk feil kode.
|
||
|
||
**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:**
|
||
`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en`
|
||
foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten
|
||
sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side
|
||
(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra
|
||
forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som
|
||
ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse
|
||
overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen
|
||
endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå
|
||
(egen, senere sak — profilinnstillinger).
|
||
|
||
**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens
|
||
valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP.
|
||
At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en
|
||
enhet) IKKE skal endre den lagrede preferansen er bevisst: en
|
||
innloggingshandling bør ikke ha den overraskende bivirkningen å endre
|
||
brukerens varige profilinnstilling.
|
||
|
||
---
|
||
|
||
## ADR-016 — Frontend som eneste offentlige overflate, API-et server-side proxyet
|
||
|
||
**Beslutning:** `teecup.teeoff.no` peker (Caddy) på Next.js-frontenden
|
||
(`teecup_frontend`), IKKE direkte på FastAPI-et. Frontenden proxyer selv
|
||
kjente API-sti-prefikser (`/auth/*`, `/orgs/*`, `/health` — som til sammen
|
||
dekker HELE dagens API-overflate, verifisert med et grep av samtlige
|
||
rutedefinisjoner) videre til `teecup_api:8000` server-side, via Next.js sin
|
||
egen `rewrites()`-mekanisme (`frontend/next.config.mjs`). API-et er ikke
|
||
lenger separat Caddy-rutet eller offentlig eksponert under eget navn.
|
||
|
||
**Begrunnelse:** Alt kjører dermed under samme opprinnelse (origin) sett fra
|
||
nettleseren — ingen CORS-konfigurasjon trengs, og HttpOnly-sesjonscookien
|
||
(ADR-009) fungerer helt uendret enten kallet "egentlig" går til frontend
|
||
eller API. Alternativet (eget subdomene for API-et, `SameSite=None`-cookie
|
||
eller CORS-hull) ville svekket cookie-sikkerhetsmodellen som allerede var
|
||
bevisst bygget stram. Samme mønster generaliserer til alle fremtidige
|
||
skjermer uten videre Caddy-endringer — nye API-ruter under `/auth`, `/orgs`
|
||
trenger ingen ny proxy-regel, kun nye Next.js-sider som kaller dem med
|
||
relative URL-er.
|
||
|
||
**Reell fallgruve funnet og fikset ved bygging (2026-07-17):** Next.js sin
|
||
`rewrites()` løses ved BUILD-tid for `output: "standalone"` (bakes inn i
|
||
server-bunten), ikke ved container-oppstart. En `docker run -e
|
||
TEECUP_API_ORIGIN=...` ved kjøretid ble derfor stille ignorert (falt tilbake
|
||
til default `localhost:8000`, som ikke fantes i containeren — proxy-kall
|
||
feilet med `ECONNREFUSED`). Løst med en Docker build-time `ARG
|
||
TEECUP_API_ORIGIN` (default `http://teecup_api:8000`, matcher alltid det
|
||
delte nettverkets tjenestenavn) i `frontend/Dockerfile`, satt via
|
||
`docker-compose.yml` sin `build.args`. Generell lærdom for fremtidige
|
||
Next.js/Docker-oppsett i dette prosjektet: alt som brukes inne i
|
||
`next.config.mjs` er en BUILD-tids verdi, ikke en runtime-verdi, med mindre
|
||
det eksplisitt leses på nytt et sted som faktisk kjører per request (en
|
||
route handler, ikke selve config-filen).
|
||
|
||
**Konsekvens:** Enhver fremtidig ny API-sti-prefiks (utenfor `/auth`,
|
||
`/orgs`, `/health`) MÅ legges til i `frontend/next.config.mjs` sin
|
||
`rewrites()`-liste, ellers blir den utilgjengelig fra nettleseren selv om
|
||
API-et selv fungerer (kun nåbar internt på Docker-nettverket). `teecup_api`
|
||
sin port er ikke lenger tenkt nåbar direkte utenfra i prod.
|
||
|
||
---
|
||
|
||
## ADR-017 — Selvregistrering og utvidet spillerprofil
|
||
|
||
**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live.
|
||
Dagens modell antar at organisator kjenner og legger inn alle spillere selv
|
||
— i praksis vet organisator ofte ikke hvem som faktisk blir med før de
|
||
melder seg på selv.
|
||
|
||
**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En
|
||
delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede
|
||
uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen
|
||
magic-link, ingen konto kreves for å melde seg på.
|
||
|
||
**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig
|
||
friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer
|
||
gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen.
|
||
|
||
**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator-
|
||
forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på
|
||
eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved
|
||
påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en
|
||
duplikat. `player.user_id` kobles først når noen med matchende e-post
|
||
faktisk logger inn via magic-link — `verify_magic_link` utvides til også å
|
||
slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`.
|
||
|
||
**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint —
|
||
familier deler av og til e-post (forelder melder på barn), en hard unik-
|
||
regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt,
|
||
applikasjonslags-oppslag.
|
||
|
||
**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra
|
||
`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny
|
||
tabell `tournament_registration` (status: `pending`/`confirmed`/
|
||
`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`:
|
||
`registration_capacity` (nullable — organisators valg om det i det hele
|
||
tatt skal være en grense), `registration_overflow_policy`
|
||
(`waitlist`/`closed`, kun relevant når kapasitet er satt),
|
||
`registration_requires_approval` (boolean). Rekkefølge ved en ny
|
||
påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd →
|
||
`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller
|
||
`confirmed`, avhengig av godkjenningsbryteren.
|
||
|
||
**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag".
|
||
Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville
|
||
gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som
|
||
aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige
|
||
organisator-beslutninger — å låse ett svar for alle turneringer ville vært
|
||
feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være
|
||
konfigurerbare valg, ikke faste regler).
|
||
|
||
**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`:
|
||
`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år,
|
||
fødselsdato er ikke det), `nickname`, `country`, `club`,
|
||
`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et
|
||
avviser innsending (400) uten `consent: true`, og
|
||
`tournament_registration.consent_given_at` er beviset på at det faktisk ble
|
||
gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på
|
||
`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen
|
||
samtykket knytter seg til.
|
||
|
||
**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis
|
||
allerede dekket av `match_participant.tee_id` (per match, ikke per
|
||
spillerprofil, siden det kan variere fra runde til runde).
|
||
|
||
**Beslutning E — `public_tournament_org()`: den eneste broen fra en
|
||
uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig
|
||
påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører
|
||
til — og uten `app.current_org` satt slipper RLS ingen rader gjennom
|
||
(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever
|
||
`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt
|
||
med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot
|
||
kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster
|
||
som den selvrefererende org-bootstrapen (`app/routers/organizations.py`),
|
||
nå for et lese-oppslag i stedet for en innsetting.
|
||
|
||
**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner
|
||
deretter en vanlig `org_connection(org_id)` og fortsetter med normal
|
||
RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en
|
||
uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst
|
||
smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
|
||
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
|
||
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
|
||
|
||
**Migrasjon:** `007_registration_and_player_fields.sql`. Kontosammenkoblingen
|
||
i Beslutning B (e-post → `player.user_id` ved innlogging) fikk sin egen
|
||
`SECURITY DEFINER`-bro (`link_player_by_email()`, samme mønster som
|
||
Beslutning E) i en oppfølgende migrasjon, `008_link_player_by_email.sql`.
|
||
|
||
---
|
||
|
||
## ADR-018 — Landingssider: synlighet, org-profil, delbart innhold
|
||
|
||
**Kontekst:** Reist av brukeren rett etter at registrerings-API-et
|
||
(ADR-017) var live — turneringer og organisasjoner bør ha egne, delbare
|
||
landingssider (hero, tekst, program, sponsorer, påmelding for turnering;
|
||
klubbprofil + turneringsliste for organisasjon). Brukeren krevde eksplisitt
|
||
at synlighet må være et VALG: offentlig, kun org-medlemmer, eller kun
|
||
turnering-deltakere.
|
||
|
||
**Beslutning A — Trenivås synlighet, ett felt per nivå.**
|
||
`tournament.visibility` (`'public'`/`'org'`/`'participants'`, default
|
||
`'org'`) og `organization.public_profile` (boolean, default `false`).
|
||
Trygg standard: ingen eksisterende eller nyopprettet turnering/org blir
|
||
offentlig av seg selv. "Kun deltakere" gir ikke mening på org-nivå (en
|
||
organisasjon har ikke "deltakere"), derfor en enklere bryter der, ikke
|
||
samme trenivå-enum som turnering.
|
||
|
||
**Beslutning B — RLS beskytter TENANT-grenser, ikke INNHOLDS-synlighet.**
|
||
`org_isolation`-policyen håndhever kun at org A aldri ser org B sin data —
|
||
den sier ingenting om hvem INNENFOR riktig org-kontekst som får se hva.
|
||
Det viste seg at det eksisterende `GET /public/tournaments/{id}` (ADR-017)
|
||
allerede leser fullt turneringsinnhold uten noen synlighetssjekk i det
|
||
hele tatt, fordi RLS er fornøyd så snart org-konteksten er riktig satt —
|
||
uansett hvem (eller om noen) som spør.
|
||
|
||
**Konsekvens:** `visibility` MÅ håndheves eksplisitt i applikasjonslaget på
|
||
hvert offentlig lese-endepunkt (og på registrering, se Beslutning D), ikke
|
||
forventes løst av RLS.
|
||
|
||
**Beslutning C — Ny autorisasjonsvei: "deltaker".** For
|
||
`visibility='participants'` må leseren enten være org-medlem, ELLER
|
||
innlogget med en `player`-rad (koblet via `player.user_id`, ADR-017
|
||
Beslutning B) som har en `tournament_registration`- eller
|
||
`team_roster`-rad for NØYAKTIG denne turneringen. Ny
|
||
`get_current_user_optional`-avhengighet i `app/auth.py` — som
|
||
`get_current_user`, men returnerer `None` i stedet for å kaste 401, siden
|
||
et offentlig endepunkt skal fungere for en anonym leser også (bare med
|
||
`visibility='public'`-tilgang).
|
||
|
||
**Beslutning D — Registrering følger SAMME synlighetsgrense som
|
||
landingssiden.** Kan du ikke se turneringen, kan du heller ikke melde deg
|
||
på den — ingen særbehandling av `/register`-endepunktet.
|
||
|
||
**Beslutning E — Turnering-landingsside: innhold nå, bilder som inerte
|
||
felt.** Nye felt: `tournament.description` (presenterende tekst),
|
||
`tournament.hero_image_key` (inert til MinIO-runden — kun kolonnen, ingen
|
||
opplastingslogikk, sparer en fremtidig migrasjon for null kostnad nå). Ny
|
||
tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert av samme
|
||
grunn). Program vises via `GET /public/tournaments/{id}/sessions`, som
|
||
gjenbruker `_fetch_sessions()` (nytt uttrekk fra `tournaments.py` sin
|
||
`list_sessions`, delt mellom den innloggede og den offentlige ruten) —
|
||
blind draw-hemmelighold (ADR-013) arves automatisk, ikke reimplementert.
|
||
**Bevisst utenfor omfang denne runden:** lag-/roster-visning på
|
||
landingssiden (må også respektere blind draw-lås) — egen, senere sak.
|
||
|
||
**Beslutning F — Org-landingsside: slug + tredje SECURITY DEFINER-bro.**
|
||
`organization.slug` (unik når satt, `CHECK (NOT public_profile OR slug IS
|
||
NOT NULL)`). `GET /public/orgs/{slug}` løser org via
|
||
`public_org_by_slug(slug)` — samme mønster som `public_tournament_org()`/
|
||
`link_player_by_email()` (007/008), nå tredje instans. Returnerer `NULL`
|
||
for BÅDE "finnes ikke" og "finnes, men er privat" — samme
|
||
anti-enumerering som magic-link (ADR-009), ikke en tilfeldighet at
|
||
mønsteret gjentas.
|
||
|
||
**Bevisst utsatt til egen, senere runde:** hero-/sponsorbilder (krever
|
||
MinIO — arkitektur-invarianten finnes fra før, se CLAUDE.md, men er aldri
|
||
satt opp i praksis).
|
||
|
||
**Migrasjon:** `009_landing_pages_and_visibility.sql`.
|
||
|
||
---
|
||
|
||
## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag
|
||
|
||
**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt
|
||
i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner
|
||
fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter
|
||
program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator
|
||
må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff.
|
||
|
||
**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun
|
||
lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og
|
||
returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`,
|
||
`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/
|
||
`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i
|
||
`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities?
|
||
view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`,
|
||
…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig
|
||
baneregistrering) — teecups `hole`-tabell krever begge NOT NULL.
|
||
|
||
**Beslutning A — Import ved organisators eksplisitte valg, ikke live
|
||
oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger
|
||
én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en
|
||
vanlig `course`-rad med `source='official'` og
|
||
`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import
|
||
er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden
|
||
(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN
|
||
endring.
|
||
|
||
**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL
|
||
midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til
|
||
teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs
|
||
oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på
|
||
importtidspunktet — samme reproduserbarhets-prinsipp som handicap-
|
||
snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer
|
||
teeoff en rating i etterkant, skal ikke en allerede opprettet turnering
|
||
plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige
|
||
"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en
|
||
teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte
|
||
baner.
|
||
|
||
**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny
|
||
hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme
|
||
`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det
|
||
offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse
|
||
endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på
|
||
teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun
|
||
nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx`
|
||
(async HTTP-klient), lagt til i `app/requirements.txt`.
|
||
|
||
**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen
|
||
skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en
|
||
tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil
|
||
(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis
|
||
importert bane med hull som senere feiler i handicap-beregning. Hele
|
||
importen kjører i én DB-transaksjon (`org_connection` sin eksisterende
|
||
`conn.transaction()`), så en feil midtveis ruller automatisk tilbake.
|
||
|
||
**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen
|
||
front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt).
|
||
Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den
|
||
formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak
|
||
av spilte hull dekker front/back-økter.
|
||
|
||
**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en
|
||
duplikat-rad.** Ny partiell unik indeks `(organization_id,
|
||
external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon
|
||
`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det
|
||
med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme
|
||
banedata.
|
||
|
||
**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen
|
||
db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
|
||
`app/routers/courses.py` utvides med `GET .../courses/official-search` og
|
||
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_
|
||
ref.sql`.
|
||
|
||
---
|
||
|
||
## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen
|
||
ting brukes"-serien var ferdig. Tre relaterte, men separate mangler:
|
||
|
||
1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller
|
||
org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare
|
||
har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i
|
||
det hele tatt — dette var reelt ikke gjennomtenkt tidligere.
|
||
2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva
|
||
stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som
|
||
de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup).
|
||
3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående
|
||
match — brukeren viste et skjermbilde av referanseproduktets fargekodede
|
||
matchrader (rød/beige etter ledende side) som ønsket retning.
|
||
|
||
**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som
|
||
OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra
|
||
et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,
|
||
altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik
|
||
(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som
|
||
`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget
|
||
kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og
|
||
sender brukeren til `/t/{id}?code=...`.
|
||
|
||
**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt
|
||
muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org-
|
||
medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering
|
||
ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse.
|
||
Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men
|
||
6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind
|
||
gjetting upraktisk uten separat rate-limiting — akseptabelt for et
|
||
tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som
|
||
`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md).
|
||
|
||
**Konsekvens:** `GET /public/tournaments/{id}` og
|
||
`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter;
|
||
matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige
|
||
`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro
|
||
`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde
|
||
instans av samme mønster som `public_tournament_org()` (007),
|
||
`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen
|
||
kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut
|
||
en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir
|
||
etterspurt.
|
||
|
||
**Beslutning B — Projisert stilling: pågående matchers nåværende leder får
|
||
full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort
|
||
match brukes `match.leading_side` (se Beslutning C) til å tildele hele
|
||
øktens `points_per_match` til den ledende siden i den PROJISERTE summen —
|
||
"AS" (all square) eller en match som ennå ikke har noen registrerte hull
|
||
splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte
|
||
matcher bidrar likt til både faktisk og projisert sum (de er jo allerede
|
||
det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser
|
||
"hvis det sluttet nå"-tavler.
|
||
|
||
**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som
|
||
`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()`
|
||
(`app/routers/scoring.py`) beregner den allerede tilgjengelige
|
||
`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending
|
||
uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i
|
||
stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen
|
||
("2 UP (A)"), slik at frontend kan style etter et strukturert felt
|
||
(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til
|
||
BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister
|
||
(Beslutning D).
|
||
|
||
**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny
|
||
backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere
|
||
steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE
|
||
`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme
|
||
fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen
|
||
ny fargemodell innført.
|
||
|
||
**Migrasjon:** `011_join_code_and_leading_side.sql`.
|
||
|
||
---
|
||
|
||
## ADR-021 — Passord (valgfritt tillegg) + valgfri 2FA (TOTP eller e-post)
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, sammen med et konkret sesjons-
|
||
problem: dagens magic-link-cookie (ADR-009, 30 dager) fungerte visstnok ikke
|
||
som tiltenkt — brukeren måtte be om ny innloggingskode ved HVERT besøk til
|
||
`teecup.teeoff.no`. **Diagnostisert og FIKSET samme dag** (ikke en del av
|
||
ADR-021s videre omfang, men verdt å nevne her siden det var starten på denne
|
||
tråden): kodegjennomgangen av `app/auth.py`/`app/routers/auth.py` fant ingen
|
||
feil i selve cookie-settingen, og brukerens ekte, ferske cookie (hentet fra
|
||
nettleseren på forespørsel) bekreftet 30 dagers levetid, `Secure`/`HttpOnly`/
|
||
`SameSite=Lax` alt korrekt. Rot-årsaken var derfor IKKE en cookie-/backend-
|
||
bug, men en manglende sjekk i frontend: `app/page.tsx` (rot-siden) viste
|
||
ALLTID innloggingsskjemaet uten noensinne å sjekke om en gyldig sesjon
|
||
allerede fantes — `Dashboard` sjekket `/auth/me` og sendte til `/` ved
|
||
MANGLENDE sesjon, men ingenting gjorde det motsatte. Fikset med en
|
||
server-side sesjonssjekk (leser cookien via `next/headers`, kaller
|
||
`/auth/me` direkte mot `TEECUP_API_ORIGIN` server-til-server, samme mønster
|
||
som `generateMetadata` i `app/t/[id]/page.tsx`) som sender en allerede
|
||
innlogget bruker rett til `/dashboard`. Verifisert med brukerens ekte cookie:
|
||
uten cookie → 200 (skjema), med gyldig cookie → 307 til `/dashboard`. Rullet
|
||
ut live, kun `teecup_frontend`, ingen migrasjon, `teeoff.no` upåvirket.
|
||
|
||
Utover selve bugen ønsket brukeren eksplisitt: e-post/brukernavn+passord
|
||
(må håndtere spesialtegn og mellomrom korrekt) SOM ET TILLEGG til
|
||
(ikke erstatning for) den passordløse innloggingen, samt topartsautentisering.
|
||
|
||
**Beslutning A — Passord er et valgfritt, sidestilt alternativ, ikke
|
||
påkrevd.** Login-skjermet får en tredje modus (ved siden av e-post-magic-link
|
||
og invitasjonskode, ADR-020) for e-post+passord. En bruker setter selv et
|
||
passord når hen ønsker det (egen "sett passord"-handling, krever en allerede
|
||
gyldig sesjon — samme "du må bevise identitet FØRST" som andre sensitive
|
||
endringer) — INGEN eksisterende eller ny bruker tvinges til å sette passord.
|
||
Magic-link fortsetter å fungere uendret for alle, uavhengig av om et passord
|
||
i tillegg er satt.
|
||
|
||
**Beslutning B — Passord-hashing: Argon2id, ikke bcrypt.** Bcrypt trunkerer
|
||
stille ved 72 BYTES (et kjent fallgruve-mønster — to ulike passord som deler
|
||
de første 72 bytene hasher likt) og har historiske NUL-byte-kvirker i enkelte
|
||
implementasjoner. Siden brukeren eksplisitt ber om korrekt håndtering av
|
||
spesialtegn/mellomrom (dvs. lengre, mer varierte passord/passfraser er
|
||
forventet brukt), velges Argon2id (`argon2-cffi`) — minnehardt, OWASPs
|
||
anbefalte standard i dag, ingen lengde-fallgruve. Lagres i ny
|
||
`app_user.password_hash` (nullable — NULL betyr "ikke satt", faller da
|
||
tilbake til kun magic-link).
|
||
|
||
**Beslutning C — 2FA: brukeren velger metode selv, TOTP ELLER e-post-
|
||
engangskode.** `app_user.two_factor_method` (nullable, `'totp'`/`'email'`).
|
||
- **TOTP:** `app_user.totp_secret` (bare satt når metoden er `'totp'`),
|
||
`pyotp` for generering/verifisering (RFC 6238-standard, virker med Google
|
||
Authenticator/Authy/1Password etc. uten videre). QR-kode for oppsett
|
||
generert server-side (`qrcode`-biblioteket + eksisterende Pillow-
|
||
avhengighet, samme mønster som AVIF-konverteringen) — ingen ny ekstern
|
||
tjeneste, ingen løpende kostnad.
|
||
- **E-post-engangskode:** gjenbruker eksisterende SMTP-oppsett (`app/email.py`,
|
||
ADR-009) — ny, kort 6-sifret kode, samme hash-og-utløp-mønster som
|
||
`magic_link_token` (ny tabell `two_factor_code`, 5 minutters gyldighet).
|
||
Svakere som eneste faktor (e-post-kontoen blir da reelt sett den ENESTE
|
||
hemmeligheten), men krever ingen ny infrastruktur og er brukerens eget,
|
||
informerte valg mellom de to metodene.
|
||
- **Bevisst UTENFOR omfang:** SMS-2FA — krever en betalt tredjeparts
|
||
SMS-leverandør (f.eks. Twilio), løpende kostnad per melding, ny ekstern
|
||
avhengighet. Ikke bygget denne runden; kan legges til som en tredje metode
|
||
senere uten å røre TOTP/e-post-sporene, siden `two_factor_method` allerede
|
||
er et åpent tekstfelt, ikke en hardkodet to-verdi-enum i skjemaet.
|
||
|
||
**Beslutning D — 2FA er PÅKREVD for org-eier/admin, valgfritt ellers.**
|
||
Ved innlogging (uansett om via magic-link eller passord): har brukeren
|
||
`organization_membership.role IN ('owner','admin')` i MINST én organisasjon
|
||
OG `two_factor_method IS NULL`, gis IKKE en full sesjon — brukeren tvinges
|
||
inn i et "sett opp 2FA nå"-steg først. En vanlig `member`-rolle (eller en
|
||
bruker uten noe org-medlemskap ennå) kan fortsette å bruke appen helt uten
|
||
2FA om hen ønsker det. **Begrunnelse:** organisator-roller har skrivetilgang
|
||
til andre spilleres personopplysninger (ADR-017) og kontroll over hele
|
||
turneringer — et kompromittert organisator-passord/magic-link er en langt
|
||
alvorligere hendelse enn en kompromittert spillerkonto. Håndheves ved HVER
|
||
innlogging (ikke bare første gang), siden en bruker kan BLI eier av en ny
|
||
org (`POST /orgs`, ADR ingen restriksjon — se avklaringen under) etter at
|
||
kontoen allerede eksisterer uten 2FA.
|
||
|
||
**Beslutning E — To-stegs innlogging via en `stage`-claim i sesjons-JWT-en,
|
||
ikke en egen tabell for "pågående innlogging".** Når 2FA kreves (enten fordi
|
||
brukeren selv har slått det på, eller fordi Beslutning D tvinger det),
|
||
utsteder primær-autentisering (magic-link-verifisering ELLER passord-innlogging)
|
||
et KORTLEVD (5 min) JWT med `stage: "pending_2fa"` i stedet for en full
|
||
30-dagers sesjon. En ny avhengighet (`get_pending_2fa_user`, speiler
|
||
`get_current_user`) godtar KUN denne mellomtilstanden, og eksponeres bare på
|
||
2FA-verifiserings-/oppsett-endepunktene — `get_current_user` (brukt av ALLE
|
||
andre endepunkter) avviser eksplisitt en `pending_2fa`-claim som ugyldig,
|
||
slik at en ufullstendig innlogging ALDRI gir reell tilgang til noe. Først når
|
||
riktig TOTP-/e-post-kode verifiseres, byttes denne inn mot den vanlige fulle
|
||
sesjonscookien (samme 30-dagers levetid som i dag).
|
||
|
||
**Avklaring underveis, ikke en ny beslutning:** brukeren spurte samtidig om
|
||
«kan en bruker eie flere organisasjoner» er tenkt gjennom. Bekreftet: JA,
|
||
dette var alltid en del av modellen (ADR-002 — bruker og org-medlemskap er
|
||
bevisst atskilt nettopp for at én bruker skal kunne krysse flere
|
||
organisasjoner). `POST /orgs` (`app/routers/organizations.py`) har INGEN
|
||
begrensning på hvor mange organisasjoner én bruker kan opprette/eie —
|
||
hver ny org gir automatisk `role='owner'` for oppretteren, uavhengig av
|
||
eksisterende medlemskap. Allerede testet i praksis: dashbordets org-bytter,
|
||
kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
|
||
(«N = antall organisasjoner brukeren tilhører») forutsetter alle nettopp
|
||
dette. Ingen kodeendring nødvendig — kun bekreftelse.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
|
||
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CLAUDE.md-status for
|
||
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
|
||
fikset under scratch-testing).
|
||
|
||
---
|
||
|
||
## ADR-022 — Organisasjonseierskap: dele, invitere, frasi seg, superadmin
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, rett etter ADR-021. Et reelt,
|
||
mer FUNDAMENTALT hull ble synlig under gjennomgangen: `organization_
|
||
membership` har i dag INGEN vei til å legge til et nytt medlem i det hele
|
||
tatt etter at organisasjonen er opprettet — den ENESTE raden som noensinne
|
||
settes inn er grunnleggerens egen `owner`-rad (`POST /orgs`,
|
||
`app/routers/organizations.py`). Det finnes ingen invitasjon, ingen
|
||
rollestyring, ingen måte å fjerne noen på. «Del eierskap»-ønsket er derfor
|
||
bare den mest synlige kanten av et bredere manglende felt: hele
|
||
medlemskaps-livssyklusen etter opprettelse.
|
||
|
||
**Beslutning A — Flere eiere er allerede støttet av SKJEMAET, kun API-et
|
||
mangler.** `organization_membership.role` har ingen unikhetsbegrensning som
|
||
hindrer flere `owner`-rader for samme organisasjon — dette var aldri en
|
||
sperre, bare et ubrukt hull. Ingen skjemaendring nødvendig for selve
|
||
flereeiere-støtten, kun nye endepunkter.
|
||
|
||
**Beslutning B — E-post-basert invitasjon, samme mønster som spiller-
|
||
sammenkobling (ADR-017 Beslutning B), ikke et helt nytt konsept.** Ny
|
||
`POST /orgs/{id}/invitations {email, role}` oppretter en
|
||
`organization_invitation`-rad (e-post, rolle, token-hash, utløper, hvem som
|
||
inviterte) og sender en e-post via eksisterende SMTP-infrastruktur
|
||
(`app/email.py`). Den inviterte trenger IKKE ha en konto fra før — lenken
|
||
tar dem til innlogging (magic-link, evt. passord etter ADR-021), og
|
||
`verify_magic_link` utvides til å sjekke ventende invitasjoner på e-posten
|
||
sin (samme "kjør på hver innlogging, idempotent" mønster som
|
||
`link_player_by_email`) og sette inn `organization_membership`-raden da.
|
||
**Rolle-grense ved invitasjon:** en `owner` kan invitere til ENHVER rolle
|
||
(owner/admin/member); en `admin` kan KUN invitere til `member` — å la en
|
||
admin invitere en ny eier ville vært en reell privilegie-eskaleringsvei
|
||
(en admin gir seg selv/en alliert eierskap). Dette er en bevisst, ikke
|
||
åpen, avgrensning — minste-privilegium-prinsippet, samme resonnement som
|
||
`team_authz.py` sin eksisterende owner/admin-splitt.
|
||
|
||
**Beslutning C — Rollestyring og fjerning, med et «siste eier»-vern.**
|
||
Ny `PATCH /orgs/{id}/memberships/{membership_id} {role}` (kun `owner`,
|
||
UNNTATT at en bruker alltid kan senke SIN EGEN rolle selv — «frasi seg
|
||
eierskapet» er en selvbetjent handling, ikke noe som må be en annen eier om
|
||
lov) og `DELETE /orgs/{id}/memberships/{membership_id}` (kun `owner`, eller
|
||
selv for å forlate organisasjonen). Begge avviser handlingen med en klar
|
||
409 (`LAST_OWNER`) hvis den ville latt organisasjonen stå igjen med NULL
|
||
eiere — samme «TOCTOU-trygg med `FOR UPDATE`»-mønster som ADR-011s
|
||
to-lags-grense. En enslig eier må altså enten forfremme noen andre til
|
||
eier FØRST, eller be en superadmin om hjelp (Beslutning D) hvis
|
||
organisasjonen skal forlates helt.
|
||
|
||
**Beslutning D — Superadmin er et manuelt tildelt, ikke selvbetjent, flagg.**
|
||
Ny `app_user.is_super_admin` (boolean, default `false`). INGEN API-endepunkt
|
||
lar noen sette dette flagget på seg selv ELLER andre — det settes kun
|
||
direkte i databasen av en driftsansvarlig (samme tillitsnivå som å kjøre en
|
||
migrasjon), bevisst utenfor appens eget autorisasjonssystem. Årsak: et
|
||
selvbetjent «bli superadmin»-endepunkt ville vært selve
|
||
sikkerhetshullet det er ment å ikke være. Med flagget satt får brukeren en
|
||
NY autorisasjonssti (`get_current_user_or_superadmin`, parallell til
|
||
`get_authorized_org` — sjekker `is_super_admin` FØR det vanlige
|
||
org-medlemskaps-kravet) som lar dem kalle de samme rolle-/medlemskaps-
|
||
endepunktene på ENHVER organisasjon, ikke bare de de selv er medlem av.
|
||
**Bevisst avgrenset:** superadmin-stien dekker KUN medlemskap/rolle-
|
||
styring i denne runden (nøyaktig det brukeren spurte om — «sette hvem som
|
||
helst som eiere av hvilken som helst organisasjon»), ikke generell
|
||
skriveadgang til turnering-/spiller-data i andres organisasjoner. En
|
||
bredere «support/drift kan se alt»-rolle er en egen, senere beslutning om
|
||
den blir etterspurt.
|
||
|
||
**Beslutning E — Fjernet/frasigende eiers roster-/spillerdata forblir
|
||
URØRT.** Bekreftet av bruker 2026-07-19. Kun `organization_membership`-raden
|
||
endres/fjernes ved rollestyring eller fjerning — `team_roster`/`player`-rader
|
||
(deltakelse-historikk) røres aldri av disse handlingene. Organisasjons-
|
||
STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
|
||
det.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
|
||
migrasjon `012`). Se CLAUDE.md-status for full byggerunde.
|
||
|
||
---
|
||
|
||
## ADR-023: Brukerroller — kaptein som reell autorisasjon, deltaker-avgrenset scoring
|
||
|
||
Reist 2026-07-19, direkte oppfølging av det lenge åpne «Brukerroller»-punktet
|
||
i FEATURE_BACKLOG.md (der siden prosjektets start beskrevet som turnerings-
|
||
admin/lagkaptein/spiller/tilskuer, men aldri fullt ut avgjort). Fire
|
||
delspørsmål, alle avklart eksplisitt med bruker før bygging.
|
||
|
||
**Beslutning A — Kaptein gir eksklusive rettigheter til å sette opp/låse
|
||
laget, ikke lenger «hvem som helst rostret».** `app/team_authz.py` sin
|
||
`user_is_team_captain` (erstatter `user_may_act_for_team`) krever
|
||
`team_roster.is_captain = true` for brukeren (eller org-eier/admin, uendret
|
||
fra 2026-07-18-runden) — brukt av `matches.py` sin `add_participant`/
|
||
`remove_participant`/`lock_lineup`. Ny feilkode `NOT_TEAM_CAPTAIN` (403,
|
||
erstatter `NOT_ROSTERED_ON_TEAM` for disse tre stedene).
|
||
|
||
**Bevisst unntak, funnet ved å faktisk sjekke ekte produksjonsdata FØR
|
||
utrulling, ikke antatt:** har et lag INGEN utpekt kaptein i det hele tatt,
|
||
godtas enhver rostret spiller i stedet — uten dette ville en kaptein-only-
|
||
regel umiddelbart LÅST ute et helt lag fra å sette opp seg selv. Sjekket mot
|
||
ekte `teecup_db`: laget «De Unge» i «De Gamle er Eldst» har i dag 0 av 2
|
||
roster-rader merket kaptein — dette er altså ikke et hypotetisk
|
||
kantscenario, det ville rammet en reell, allerede opprettet turnering med
|
||
en gang. Har laget FØRST fått en kaptein, gjelder utelukkende den.
|
||
|
||
**Beslutning B — «Kun én kaptein per lag» håndheves nå.** `PATCH .../
|
||
roster/{id}` og `POST .../roster` (`app/routers/tournaments.py`) fjerner
|
||
automatisk kapteinmerket fra enhver annen roster-rad på SAMME lag når en ny
|
||
kaptein settes, i samme transaksjon. Nødvendig konsekvens av Beslutning A —
|
||
uten dette ville det vært uklart hvem som faktisk har myndighet hvis flere
|
||
er merket. Sjekket mot ekte data: ingen eksisterende lag hadde flere
|
||
kapteiner (kun opprydningsbehovet «0 kapteiner» fantes reelt), så ingen
|
||
data-migrering var nødvendig.
|
||
|
||
**Beslutning C — Score-føring/-korrigering begrenses til matchens faktiske
|
||
deltakere.** `app/team_authz.py` sin nye `user_is_match_participant` krever
|
||
en `match_participant`-rad for brukeren i AKKURAT den matchen (valgfritt
|
||
begrenset til én side via `team_side` — brukt for `stroke`-modus sin
|
||
side-spesifikke innsending, `None`/hvilken som helst side for `hole_result`-
|
||
modus siden begge sider kan rapportere). Erstatter den brede
|
||
«rostret på laget»-sjekken i `app/routers/scoring.py` sin
|
||
`submit_hole_score`/`submit_hole_result`. UAVHENGIG av kapteinmerket — å
|
||
være kaptein gir ikke i seg selv rett til å føre score for en match man
|
||
selv ikke spiller; org-eier/admin har samme unntak som før. Ny feilkode
|
||
`NOT_MATCH_PARTICIPANT` (403, erstatter `NOT_ROSTERED_ON_TEAM` her).
|
||
|
||
**Beslutning D — «Tilskuer»-rollen utsettes bevisst.** Ingen kode denne
|
||
runden. Offentlig/deltaker-lesetilgang finnes allerede
|
||
(`tournament.visibility` + `get_current_user_optional`, ADR-018) — et
|
||
formelt tilskuer-begrep bør defineres sammen med det ennå ubesluttede
|
||
synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
|
||
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
|
||
autorisasjonslogikk, ingen skjemaendring). Se CLAUDE.md-status for
|
||
scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-024: Walkover/konsesjon
|
||
|
||
Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
|
||
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
|
||
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
|
||
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
|
||
delspørsmål, alle avklart eksplisitt med bruker før bygging.
|
||
|
||
**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
|
||
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
|
||
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
|
||
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
|
||
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
|
||
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
|
||
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
|
||
`user_is_team_captain`.
|
||
|
||
**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
|
||
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
|
||
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
|
||
varslingsmekanisme bygget.
|
||
|
||
**Beslutning C — Både match- og turnering-nivå, i samme runde.**
|
||
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
|
||
match. `POST /orgs/{id}/tournaments/{id}/concede`
|
||
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
|
||
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
|
||
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
|
||
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
|
||
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
|
||
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
|
||
dekker det allerede (rapporter bare hvem som vant hullet).
|
||
|
||
**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
|
||
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
|
||
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
|
||
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
|
||
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
|
||
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
|
||
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
|
||
spilt før konsesjonen.
|
||
|
||
**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
|
||
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
|
||
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
|
||
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
|
||
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
|
||
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
|
||
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
|
||
egne strenger (f.eks. "9&7 (A)").
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
|
||
ingen skjemaendring). Se CLAUDE.md-status for scratch-verifisering og
|
||
utrulling.
|
||
|
||
---
|
||
|
||
## ADR-025: Kommunikasjon — lag-chat + offentlig runde-feed
|
||
|
||
Reist 2026-07-19, rett etter turnering-status-runden. Dekker to ganske ulike
|
||
ting under samme paraply: lag-intern chat («det hemmelige rommet») og en
|
||
offentlig runde-feed («Banter Board»). Fire beslutninger avklart eksplisitt
|
||
med bruker (AskUserQuestion) før bygging.
|
||
|
||
**Beslutning A — Begge bygges i samme runde**, ikke lag-chat først som egen
|
||
runde. Deler mye infrastruktur (meldingsmodell, sanntid-levering), så
|
||
designes/bygges sammen selv om feeden isolert sett hadde flere åpne
|
||
spørsmål.
|
||
|
||
**Beslutning B — Sanntid via WebSockets**, ikke polling. Løser samtidig det
|
||
tidligere åpne "sanntid vs. polling"-spørsmålet i FEATURE_BACKLOG.md
|
||
generelt (samme mekanisme kan gjenbrukes for leaderboard/andre skjermer
|
||
senere, selv om denne runden kun kobler den til meldinger).
|
||
**Viktig driftsbegrensning, videreført fra et allerede kjent åpent
|
||
arkitekturspørsmål (se «Åpne spørsmål» punkt 2 i dette dokumentet):**
|
||
tilkoblingsregisteret er en in-memory Python-struktur i `teecup_api`-
|
||
prosessen. Med kun én container/prosess (dagens oppsett) er dette trygt;
|
||
skaleres API-et til flere prosesser/containere senere, må broadcast flyttes
|
||
til noe delt (Redis pub/sub e.l.) — samme klasse begrensning som den
|
||
allerede aksepterte in-memory-cachen.
|
||
|
||
**Beslutning C — Lag-chat er EKTE privat: kun rostrede spillere på laget,
|
||
INGEN unntak for org-eier/admin.** Et bevisst avvik fra appens ellers
|
||
gjennomgående mønster (kaptein-/deltaker-sjekkene i `team_authz.py` har
|
||
alltid en org-admin-fallback, se ADR-023). Ny, egen autorisasjonsfunksjon
|
||
`user_is_rostered_on_team` (uten fallback) brukt KUN her — de eksisterende
|
||
funksjonene med org-admin-unntak røres ikke, siden de fortsatt er riktige
|
||
for sine egne bruksområder (oppsett/scoring, der en organisator uten
|
||
dette ville stått fast tidlig i en turnering).
|
||
|
||
**Beslutning D — Bilder med fra start**, ikke utsatt. Gjenbruker
|
||
`app/storage.py` sin allerede byggede og bevist MinIO+AVIF-konverterings-
|
||
pipeline (samme mønster som turnering-hero-bilder/sponsorlogoer, ADR-018) —
|
||
ingen ny opplastingsinfrastruktur trengs, kun en ny `prefix="messages"`.
|
||
|
||
**Datamodell:** delt `message`-tabell (migrasjon `013_messaging.sql`) med
|
||
en `scope`-diskriminator (`team`/`tournament_feed`) i stedet for to separate
|
||
tabeller — meldingsformen (tekst + valgfritt bilde) er identisk, kun
|
||
synlighet/autorisasjon skiller dem. `author_display_name` FRYSES ved
|
||
skrivetidspunkt (samme prinsipp som `handicap_index_snapshot`, ADR-007) —
|
||
utledet server-side fra avsenderens `player`-rad i org-en (hvis den finnes),
|
||
ellers e-postens lokaldel som fallback (en org-ansatt uten egen spillerprofil
|
||
kan fortsatt poste i den offentlige feeden).
|
||
|
||
**Offentlig feed — synlighet og posterett, to atskilte spørsmål:**
|
||
LESING gjenbruker `registration.py` sitt eksisterende trenivå-mønster
|
||
(`tournament.visibility` + `get_current_user_optional` + deltaker-sjekk,
|
||
ADR-018) uendret — ingen ny synlighetsmekanisme. POSTING er derimot
|
||
STRENGERE enn lesing: en anonym leser på en `public`-synlig turnering kan
|
||
lese feeden, men må logge inn OG være enten org-medlem eller faktisk
|
||
deltaker/registrert i NØYAKTIG denne turneringen for å få poste — hindrer at
|
||
en helt urelatert innlogget bruker (konto et helt annet sted i systemet) kan
|
||
poste på en fremmed offentlig turnering-side bare fordi den er synlig.
|
||
Moderering: forfatteren selv, ELLER org-eier/admin, kan slette et
|
||
feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
|
||
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
|
||
moderere det).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CLAUDE.md-status for scratch-
|
||
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
|
||
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
||
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
|
||
infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
|
||
|
||
---
|
||
|
||
## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort
|
||
|
||
Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
|
||
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
|
||
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
|
||
som nå finnes).
|
||
|
||
**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
|
||
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
|
||
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
|
||
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
|
||
trenivå-visibility + `get_current_user_optional`-mønster som all annen
|
||
offentlig lesing, gjenbrukt helt uendret.
|
||
|
||
**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
|
||
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
|
||
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
|
||
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
|
||
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
|
||
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
|
||
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
|
||
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
|
||
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
|
||
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
|
||
visibility-sjekk.
|
||
|
||
**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
|
||
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
|
||
kun leaderboard+matchliste som opprinnelig anbefalt).
|
||
|
||
**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
|
||
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
|
||
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
|
||
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
|
||
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
|
||
`None`-bruker.
|
||
|
||
**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
|
||
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
|
||
NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
|
||
TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
|
||
denne sjekken kunne noen med tilgang til én offentlig turnering i en
|
||
organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
|
||
(inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
|
||
samme klasse hull som ADR-018 Beslutning B advarte om generelt
|
||
("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
|
||
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
|
||
låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
|
||
matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
|
||
scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
|
||
inneholde registrerte hull før reveal, selv om det ikke er normal flyt).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
|
||
endepunkter + refaktorering av eksisterende spørringer til delte
|
||
funksjoner). Se CLAUDE.md-status for scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-027: Sanntid for "Følg live"-siden
|
||
|
||
Reist 2026-07-19, rett etter ADR-026 (tilskuer-rolle) — brukeren valgte min
|
||
egen anbefaling: `/t/[id]/live` krevde omlasting for å se nye resultater,
|
||
litt selvmotsigende for en side som heter "Følg live". Løser samtidig det
|
||
tidligere åpne "koble leaderboard til sanntid"-punktet i FEATURE_BACKLOG.md.
|
||
|
||
**Gjenbruker WebSocket-mekanismen fra ADR-025 (meldinger), men "noe endret
|
||
seg, hent på nytt"-signal i stedet for å sende selve dataene** — å bygge/
|
||
sende hele leaderboard+matchliste+scorekort-formen over WS ville duplisert
|
||
betydelig beregningslogikk (leaderboardets projeksjons-regnestykke, blind
|
||
draw-filtrering osv.). Klienten reagerer på signalet ved å kalle de samme
|
||
REST-endepunktene på nytt (ADR-026), akkurat som ved førstegangslasting —
|
||
kun for det som faktisk er synlig/åpent på skjermen (leaderboard alltid,
|
||
en økts matcher kun hvis økten er utvidet, et scorekort kun hvis det er
|
||
åpnet).
|
||
|
||
**Ny, RUTEFRI modul `app/realtime.py`** for selve tilkoblingsregisteret og
|
||
kringkastingsfunksjonen — verken i `messaging.py`, `scoring.py` eller
|
||
`tournaments.py`. Årsak: `registration.py` (som eier selve
|
||
`/public/...`-endepunktene) importerer allerede fra `scoring.py`/
|
||
`tournaments.py`/`matches.py`, og `messaging.py` importerer fra
|
||
`registration.py` — å plassere kringkastingsfunksjonen i noen av routerne
|
||
ville skapt en sirkulær import. `app/realtime.py` ligger bevisst BAK alle
|
||
routere i importgrafen.
|
||
|
||
**Kringkastingen er lagt INN I de delte funksjonene selv**
|
||
(`recompute_and_cache_match_state`, `apply_concession`), ikke som noe
|
||
kallerne må huske å gjøre etterpå — samme selv-ansvarlig-mønster som andre
|
||
sentrale funksjoner i prosjektet. `apply_concession` fikk en ny påkrevd
|
||
`tournament_id`-parameter kun for dette formålet.
|
||
|
||
**Samme kjente in-memory-per-prosess-begrensning som ADR-025** (se «Åpne
|
||
spørsmål» under) — trygt med dagens ene `teecup_api`-container.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon, ingen ny
|
||
Caddy-rute (gjenbruker `/ws/*`-ruten fra ADR-025 uendret). Se CLAUDE.md-
|
||
status for scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-028: PWA — installasjon + offline scoreregistrering
|
||
|
||
Reist 2026-07-19, brukeren valgte å ta fatt på PWA (stod som "neste steg" i
|
||
CLAUDE.md siden ADR-006 vedtok prinsippet helt i starten av prosjektet — aldri
|
||
bygget). To beslutninger avklart eksplisitt med bruker (AskUserQuestion) før
|
||
bygging.
|
||
|
||
**Beslutning A — Full offline scoreregistrering i v1, ikke bare
|
||
installasjon.** Bruker valgte det mest ambisiøse alternativet: BÅDE
|
||
manifest/ikoner/service worker (installerbar app) OG at scorekort-skjermen
|
||
skal fungere uten nett — skriv til en lokal IndexedDB-kø, synk automatisk når
|
||
nettet er tilbake. Matcher ADR-006s opprinnelige formulering ordrett
|
||
("score må kunne registreres uten nett og synkes senere").
|
||
|
||
**Beslutning B — Omfang av selve offline-skrivingen: kun `hole-scores`/
|
||
`hole-results`, ingenting annet.** De to eksisterende scoreregistrerings-
|
||
endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke
|
||
glemt:
|
||
- Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett.
|
||
- Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer
|
||
komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen
|
||
ADR-006 faktisk siktet til.
|
||
- Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter
|
||
nett i v1, uendret.
|
||
|
||
**Beslutning C — Køen lever i klientkoden (IndexedDB, `lib/offline-
|
||
queue.ts`), IKKE i service workeren, og bruker `window` sitt `online`-
|
||
event, IKKE Background Sync API.** To bevisste forenklinger:
|
||
1. Å gi umiddelbar, presis UI-tilbakemelding ("lagret lokalt, venter på
|
||
synk", pending-antall) er enklere og mer testbart fra selve
|
||
React-komponenten enn fra en service worker sin `fetch`-handler.
|
||
2. Background Sync API (som ville gitt synk selv om appen er lukket) støttes
|
||
IKKE av iOS Safari i det hele tatt — en stor andel av klubb-/
|
||
vennegjeng-brukerne er trolig på iPhone, og et rent
|
||
Background-Sync-avhengig design ville derfor vært brutt for dem. Et
|
||
`window.addEventListener("online", ...)`-mønster (pluss en manuell
|
||
"Synkroniser nå"-knapp i UI-et som reserve) fungerer overalt, på
|
||
bekostning av at synk krever at appen faktisk er åpen når nettet kommer
|
||
tilbake — akseptabelt for v1.
|
||
|
||
`submitStroke`/`submitHoleResult` (`components/session-scorecard.tsx`)
|
||
sjekker `navigator.onLine` FØRST (unngår en unødvendig ventetid på et
|
||
nettverkskall som uansett vil feile), og fanger ellers en EKTE nettverksfeil
|
||
i fetch-kallet separat fra et avvist HTTP-svar (`res.ok === false`, f.eks.
|
||
409 "matchen er avgjort") — kun den førstnevnte køordner, sistnevnte viser
|
||
fortsatt den vanlige feilteksten uendret. Et lokalt overlay
|
||
(`pendingStrokes`/`pendingResults`, ikke persistert i selve
|
||
`scorecard`-staten) viser køede verdier umiddelbart i UI-et, merket
|
||
"Lagret lokalt · venter på synk", inkludert i hull-navigasjonens
|
||
registrert-markering.
|
||
|
||
**Konfliktmodell: samme tillitsnivå som resten av appen, ingen ny
|
||
mekanisme.** Server-siden er allerede en upsert per hull (`ON CONFLICT ...
|
||
DO UPDATE`) — siste innsending vinner, ingen audit-trail (kjent, tidligere
|
||
dokumentert åpent spørsmål, se «Scoring-autorisasjon» i FEATURE_BACKLOG.md).
|
||
Køen sender i rekkefølge (aldri parallelt) for å respektere lokal
|
||
innsendingsrekkefølge ved flere endringer av samme hull offline. Et
|
||
definitivt HTTP-avvist forsøk ved synk (typisk: matchen ble avgjort på en
|
||
ANNEN enhet mens denne var offline) fjernes fra køen og vises som en
|
||
feilmelding — blir ALDRI hengende for alltid.
|
||
|
||
**Service worker-strategi: nettverk først, cache som fallback — bevisst
|
||
IKKE stale-while-revalidate.** `public/sw.js` cacher (a) side-navigasjon og
|
||
(b) GET-kall under `/orgs/*`. Begge prøver ekte nettverk FØRST og faller
|
||
kun tilbake til cache ved reell feil. En stale-while-revalidate-strategi ble
|
||
vurdert og avvist: scorekortet gjør et `refetchScorecard()`-kall RETT ETTER
|
||
hver innsending, og må da alltid få fersk data — en umiddelbart utdatert
|
||
cache ville vist feil matchstatus/hull-tall rett etter en vellykket
|
||
innsending. `/auth/*` og `/public/*` caches bevisst ikke — omfanget er
|
||
begrenset til akkurat det scoreregistrerings-flyten trenger.
|
||
|
||
**Kjent, akseptert begrensning (ikke løst i v1):** Cache Storage er nøklet
|
||
på URL, ikke på innlogget bruker — deler flere kontoer samme enhet/
|
||
nettleser, kan en offline-fallback teoretisk vise data cachet av en
|
||
TIDLIGERE innlogget bruker på samme enhet. Ingen cache-tømming ved
|
||
utlogging bygget. Samme tillitsnivå/trusselmodell som appen ellers opererer
|
||
med (tillitsbasert klubb-/vennegjeng-verktøy, jf. `team_authz.py` sin
|
||
begrunnelse i ADR-023/025).
|
||
|
||
**Ikoner: enkelt, midlertidig sett generert programmatisk (grønt
|
||
golf-flagg), ikke endelig design.** Bruker valgte å generere nå fremfor å
|
||
vente på ekte design, MED eksplisitt beskjed om at disse skal erstattes
|
||
senere — notert i FEATURE_BACKLOG.md. Erstattet samtidig den gamle
|
||
`public/apple-icon.png` (v0.app sin generiske plassholder-logo, ikke
|
||
TeeCup-merkevare i det hele tatt) med samme nye ikon, av konsistens.
|
||
|
||
**Ikke testet i ekte nettleser (viktig, ikke bare en formalitet):**
|
||
verifisert med typesjekket produksjonsbuild (samme `Dockerfile` som
|
||
deployes) og en kort container-boot med `curl` (manifest/service worker/
|
||
ikoner/offline.html svarer riktig), men INGEN faktisk browser-basert
|
||
offline-test (DevTools "Offline"-modus, "Legg til på hjemskjerm") er
|
||
gjennomført denne runden — ingen nettleserverktøy tilgjengelig i denne
|
||
økten. Anbefales sterkt at brukeren selv tester scorekort-siden med Chrome
|
||
DevTools sin Offline-bryter før tillit legges til flyten i skarp bruk.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Bruker bekreftet eksplisitt, ingen
|
||
migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
|
||
`teecup_api` som en bivirkning av `docker compose up --build` sin
|
||
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
|
||
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
|
||
200 over ekte https, `teeoff.no` upåvirket. Se CLAUDE.md-status for full
|
||
byggerunde.
|
||
|
||
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
|
||
nettleser-basert offline-test (Chrome DevTools Offline-modus, ekte "Legg
|
||
til på hjemskjerm") er fortsatt ikke gjort — ingen nettleserverktøy
|
||
tilgjengelig i byggeøkten. Brukeren har bedt om at dette noteres eksplisitt
|
||
for oppfølging, se FEATURE_BACKLOG.md sin PWA-tabell og CLAUDE.md sin
|
||
"Neste steg"-liste.
|
||
|
||
---
|
||
|
||
## ADR-029: Kjønn hører til tee-RATINGEN, ikke selve utslaget
|
||
|
||
Reist av brukeren 2026-07-19, som del av en runde med fire rapporterte
|
||
UX-/korrekthetshull fra faktisk testing av blind draw og scorekort. Startet
|
||
som en antatt frontend-fiks ("tee-valget bør følge spillerens kjønn
|
||
automatisk"), men brukeren presiserte at premisset mitt var feil: en
|
||
golfbane har IKKE fysisk kjønnsdelte utslag -- begge kjønn kan som regel
|
||
spille fra ethvert utslag. Det eneste som faktisk varierer per kjønn er om
|
||
klubben har VALGT å slope (rate) et gitt utslag for det respektive kjønnet
|
||
(noen klubber sloper bevisst ikke det lengste utslaget for damer).
|
||
|
||
**Bekreftet problem, ikke antatt:** `tee.gender` (migrasjon 001) la kjønn på
|
||
selve utslaget, ikke ratingen. Sjekket mot ekte, importert produksjonsdata
|
||
(Tjøme Golfklubb, ADR-019): alle fire utslagene lå som RENE navnepar --
|
||
"32"/m + "32"/f, "44"/m + "44"/f, osv. -- to separate `tee`-rader for
|
||
akkurat samme fysiske utslag. Blind draw-skjermen viste dette som "velg
|
||
Dame- eller Herre-tee", som om det var to ulike steder å slå fra.
|
||
|
||
**Beslutning A -- `gender` flyttes fra `tee` til `tee_rating`.** Ett fysisk
|
||
utslag (`tee`, identifisert kun ved navn) kan ha 0, 1 eller 2
|
||
kjønnsspesifikke ratinger. `tee_rating` sin unikhet endres fra
|
||
`(tee_id, scope)` til `(tee_id, scope, gender)`. `gender` på `tee_rating`
|
||
er `NOT NULL CHECK IN ('m','f')` -- 'x' (gyldig på PLAYER-nivå) gir ikke
|
||
mening for en WHS-rating, som alltid er for ett bestemt kjønn.
|
||
|
||
**Beslutning B -- tee-valget blir helt automatisk, ingen manuell
|
||
kjønnsvelger (bekreftet med bruker, valgte det anbefalte alternativet).**
|
||
Organisator/kaptein velger KUN fysisk utslag i blind draw
|
||
(`session-blind-draw.tsx`, ingen "H"/"D"-suffiks lenger). Riktig
|
||
kjønnsspesifikk rating løses AUTOMATISK server-side fra spillerens
|
||
registrerte `player.gender` -- `app/handicap.py` sin
|
||
`compute_and_store_side_handicaps` joiner nå `tee_rating` på
|
||
`(tee_id, scope='full_18', gender = player.gender)` i tillegg til den
|
||
tidligere ADR-028-fiksen (alltid full_18, uavhengig av hole_config).
|
||
|
||
**Beslutning C -- manglende rating/kjønn feiler tydelig FØR innsetting,
|
||
ingen stille fallback (bekreftet med bruker, valgte det anbefalte
|
||
alternativet).** `matches.py` sin `add_participant` validerer, når
|
||
`config.use_handicap` er sann: (1) spilleren har et registrert kjønn --
|
||
avvist med `VALIDATION_FAILED` hvis ikke ("sett kjønn på spilleren
|
||
først"), (2) valgt utslag har faktisk en `tee_rating` for NØYAKTIG det
|
||
kjønnet -- avvist med `VALIDATION_FAILED` hvis ikke ("dette utslaget har
|
||
ingen dame/herre-rating -- velg et annet utslag"). Samme "fail loudly"-
|
||
prinsipp som ADR-019 sin importvalidering og den eksisterende
|
||
handicap_index-sjekken (2026-07-18) rett ved siden av. `_remap_course`
|
||
(bane-bytte midt i en økt) fikk samme presisering: matcher fortsatt på
|
||
tee-navn, men sjekker nå at den NYE tee-en har en rating for hver berørte
|
||
spillers kjønn, ikke bare at navnet finnes.
|
||
|
||
**Konsekvens for import/opprettelse:** ADR-019 sin teeoff-import
|
||
(`import_official_course`) lager nå ÉN `tee`-rad per fysisk teeoff-utslag
|
||
(tidligere: to, én per kjønn), med inntil to `tee_rating`-rader under.
|
||
Manuell tee-opprettelse (`POST .../courses/{id}/tees`) redesignet
|
||
tilsvarende: `TeeCreate.ratings` er nå en liste (1-2 elementer, distinkte
|
||
kjønn) i stedet for ett flatt kjønn+rating-sett -- ingen reell
|
||
bruker-/produksjonsdata brukte dette endepunktet fra før (kun Tjøme, som
|
||
er offisielt importert), så ingen bakoverkompatibilitet var nødvendig.
|
||
|
||
**Migrasjon `014_tee_gender_to_rating.sql`:** flytter `gender` til
|
||
`tee_rating`, slår deretter sammen eksisterende kjønns-par-tee-rader til
|
||
én fysisk tee-rad per (bane, navn) -- velger laveste id som "beholder",
|
||
flytter alle `tee_rating`- og `match_participant.tee_id`-referanser dit,
|
||
sletter duplikatene. Bekreftet TRYGT å kjøre mot ekte data FØR skriving
|
||
(read-only sjekk): alle 8 eksisterende tee-rader (Tjøme) er rene m/f-par,
|
||
ingen `gender='x'`-rader, ingen gruppe med mer enn to rader.
|
||
|
||
**Scratch-verifisert grundig, flere runder:** (1) selve
|
||
fletting-migrasjonen kjørt mot syntetisk data som gjenskaper Tjøme-
|
||
mønsteret nøyaktig, inkl. én `match_participant`-rad som pekte til
|
||
DUPLIKATEN (ikke beholderen) -- bekreftet korrekt reparert til beholderens
|
||
id etterpå, og et utslag med KUN én rating (ingen duplikat) forblir
|
||
urørt. (2) Manuell tee-opprettelse: to ratinger på ett utslag, kun én
|
||
rating (simulerer "klubben har ikke slopet dette kjønnet"), duplikat
|
||
kjønn i samme innsending avvist. (3) `GET tees` viser riktig sammenslått
|
||
struktur (2 fysiske utslag, ikke 3 rader). (4) `add_participant`: kvinne
|
||
+ utslag med dame-rating lykkes; mann + utslag som KUN har dame-rating
|
||
avvist tydelig; spiller uten registrert kjønn avvist tydelig; en full
|
||
kjønnsblandet singel-match scoret korrekt end-to-end. (5) Offisiell
|
||
import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb) -- bekreftet
|
||
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8 doble
|
||
rader. (6) `_remap_course`: bane-bytte til en bane UTEN matchende
|
||
kjønnsrating avvist tydelig, bane-bytte til en bane MED matchende
|
||
rating lykket.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon 014 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt: Tjømes 8 tee-rader slått sammen
|
||
til 4 fysiske utslag, alle 8 `tee_rating`-rader fikk riktig `gender`,
|
||
`match_participant`-referansene forble gyldige (0 brutte fremmednøkler
|
||
etter migrasjonen, bekreftet med en direkte spørring). Begge containere
|
||
(`teecup_api`, `teecup_frontend`) bygget og redeployet, `/health`/
|
||
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||
|
||
---
|
||
|
||
## ADR-030: Dashbord-datoer utledes fra øktene, ikke et separat felt
|
||
|
||
Reist av brukeren 2026-07-19/20, direkte oppfølging av det tidligere
|
||
dokumenterte UI-hullet ("Dashboard-turneringskortet viser 'Ingen datoer
|
||
satt'", notert 2026-07-19). Brukeren viste et faktisk skjermbilde: en
|
||
turnering med en økt tydelig planlagt til "lør. 11. juli, 10:50" på
|
||
Program-fanen, mens dashbord-kortet fortsatt viste "Ingen datoer satt".
|
||
|
||
**Root cause, bekreftet ved kodegjennomgang (samme konklusjon som forrige
|
||
runde, nå faktisk fikset):** dashbord-kortet leser `tournament.start_date`/
|
||
`end_date` — et eget felt på selve turneringen (ADR-015) — som INGEN UI
|
||
noensinne har hatt en vei til å sette. Fullstendig atskilt fra
|
||
`session.scheduled_at` (som Program-fanen korrekt viser).
|
||
|
||
**Beslutning: utled datospennet fra øktenes `scheduled_at` i
|
||
`list_tournaments`, ikke bygg et manuelt datofelt-skjema.** To kilder til
|
||
sannhet (et manuelt "turnering-dato"-felt OG faktiske økt-tidspunkter) ville
|
||
uunngåelig kommet ut av synk — turneringens reelle datoer ER ganske enkelt
|
||
når rundene faktisk er satt til å spilles. `GET /orgs/{id}/tournaments`
|
||
gjør nå `COALESCE(t.start_date, MIN(økt.scheduled_at))` /
|
||
`COALESCE(t.end_date, MAX(økt.scheduled_at))` — et eksplisitt satt
|
||
`start_date`/`end_date` (om det noensinne blir gitt en skrivevei senere)
|
||
vinner fortsatt over det utledede spennet, men i praksis er det alltid det
|
||
utledede spennet som vises i dag. En turnering uten noen tidsplanlagte
|
||
økter viser fortsatt riktig "Ingen datoer satt" (ikke en feil).
|
||
|
||
**Konsekvens:** kun `list_tournaments` sin spørring endret (ikke
|
||
`_TOURNAMENT_COLUMNS`, som fortsatt brukes uendret av opprett-/
|
||
PATCH-endepunktene sine `RETURNING`-klausuler — de trenger ikke
|
||
sesjons-utledningen rett etter en skriveoperasjon). Ingen migrasjon.
|
||
|
||
**Scratch-verifisert:** turnering uten økter → `null`/`null` (ikke feil);
|
||
turnering med to økter (11./12. juli) → riktig utledet spenn; en turnering
|
||
med et EKSPLISITT satt `start_date`/`end_date` ved opprettelse beholder
|
||
fortsatt sin egen verdi selv med økter senere lagt til (bekrefter
|
||
COALESCE-prioriteringen).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Bruker bekreftet eksplisitt, ingen
|
||
migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
|
||
11. juli 2026 (utledet fra dens økt) i stedet for "Ingen datoer satt".
|
||
|
||
---
|
||
|
||
## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil
|
||
|
||
Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
|
||
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
|
||
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
|
||
hjemmeklubb).
|
||
|
||
**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
|
||
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
|
||
org-medlem — også for en bruker som KUN er spiller (koblet via
|
||
`player.user_id`, ADR-017 B), aldri organisator.
|
||
|
||
**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
|
||
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
|
||
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
|
||
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
|
||
— ingen tvungen valg mellom to identiteter.
|
||
|
||
**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
|
||
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
|
||
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
|
||
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
|
||
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
|
||
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
|
||
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
|
||
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
|
||
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
|
||
konsepter, ikke ett duplisert.
|
||
|
||
**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
|
||
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
|
||
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
|
||
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
|
||
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).
|
||
|
||
**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
|
||
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
|
||
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
|
||
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
|
||
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
|
||
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
|
||
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
|
||
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
|
||
brukes for `organizations`).
|
||
|
||
**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
|
||
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
|
||
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
|
||
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
|
||
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
|
||
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
|
||
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
|
||
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
|
||
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
|
||
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
|
||
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
|
||
finnes intet scenario der en organisator ønsker å skjule en turnering for
|
||
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
|
||
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
|
||
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
|
||
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.
|
||
|
||
**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
|
||
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
|
||
til lagets private chat eller det organisator-vendte scorekortet — disse
|
||
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
|
||
strengere sperre enn deltaker-status alene, brukt av dusinvis av
|
||
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
|
||
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
|
||
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
|
||
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.
|
||
|
||
**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
|
||
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
|
||
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
|
||
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
|
||
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
|
||
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
|
||
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
|
||
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
|
||
CLAUDE.md-status for full byggerunde.
|
||
|
||
---
|
||
|
||
## ADR-032: Verifisert e-postbytte + mobil på personlig profil
|
||
|
||
Reist av brukeren 2026-07-20, samme dag og rett etter ADR-031: "identifikatoren"
|
||
(e-post) manglet i den nye personlige profilen, og mobil (med landsnummer)
|
||
burde være en opsjon.
|
||
|
||
**Beslutning A — mobil er et rent, enkelt tillegg til `ProfileUpdate`
|
||
(samme PATCH som resten av profilen), splittet i to felt.**
|
||
`mobile_country_code` (f.eks. `"+47"`) og `mobile_number` er separate
|
||
kolonner på `app_user` (migrasjon `016_profile_contact.sql`) — ikke én
|
||
sammensatt streng — slik at frontend kan tilby en egen landsnummer-
|
||
velger uten å måtte parse en fritekststreng i etterkant.
|
||
|
||
**Beslutning B — e-post er IKKE en del av den vanlige profil-PATCH-en, og
|
||
kan det aldri bli.** E-post er innloggings-identifikatoren (magic-link-
|
||
mål) — en enkel PATCH (som de andre feltene) ville latt en skrivefeil
|
||
ELLER en kapret sesjon stjele kontoen for godt, ingen verifisering av at
|
||
den NYE adressen faktisk eies av noen. Løst med et eget, to-stegs
|
||
bekreftelsesløp, samme `token_hash`+`expires_at`+`consumed_at`-mønster
|
||
som `magic_link_token` (004), gjenbrukt for et nytt formål: ny tabell
|
||
`email_change_token` (bruker_id, ny e-post, token-hash, utløp).
|
||
`POST /auth/profile/email` (krever gyldig sesjon — du må bevise at du
|
||
ER kontoen i dag) genererer tokenet og sender en bekreftelseslenke til
|
||
DEN NYE adressen (ikke den gamle — beviser eierskap av MÅLET, ikke bare
|
||
at avsenderen fortsatt er innlogget). `POST /auth/profile/email/confirm`
|
||
(ingen sesjon påkrevd — samme mønster som selve magic-link-verifiseringen,
|
||
siden lenken kan åpnes på en annen enhet/nettleser enn den som ba om
|
||
byttet) forbruker tokenet atomisk og gjennomfører selve byttet. E-posten
|
||
endres IKKE før lenken faktisk åpnes.
|
||
|
||
**Konsekvens:** duplikat-sjekk (er den ønskede adressen allerede en annen
|
||
kontos?) gjøres TO ganger — én gang ved forespørsel (rask
|
||
tilbakemelding), én gang igjen rett før selve `UPDATE`-en ved bekreftelse
|
||
(kan ha blitt tatt av noen andre i mellomtiden) — pluss den eksisterende
|
||
unike indeksen (`app_user_email_unique`, migrasjon 004) som siste
|
||
bakstopper via `translate_db_errors()`.
|
||
|
||
**Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig
|
||
profil-PATCH endrer aldri e-post; bytte til en allerede brukt adresse
|
||
avvist (409); e-post FORBLIR uendret helt til lenken bekreftes; ugyldig
|
||
bekreftelseskode avvist; gyldig kode fullfører byttet; SAMME kode kan
|
||
ikke brukes to ganger; en helt ny innlogging med den GAMLE adressen
|
||
oppretter nå en fersk, tom konto (beviser byttet er reelt og fullstendig,
|
||
ikke kosmetisk). Ekte typesjekket produksjonsbuild kjørt og bekreftet
|
||
(ny `/verify-email`-rute listet).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 016 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
|
||
upåvirket.
|
||
|
||
---
|
||
|
||
## ADR-033: Frittstående rundeføring med detaljert statistikk
|
||
|
||
Reist av brukeren 2026-07-21 (se FEATURE_BACKLOG.md), utdypet 2026-07-22 med
|
||
konkrete statistikk-felt og en eksplisitt ambisjon: **dette skal bli
|
||
appens hovedfokus** — når rundeføring med detaljert statistikk er på
|
||
plass, skal turneringsoppsett deretter gjøres ekstremt enkelt. Dette er
|
||
den største enkeltbeslutningen i prosjektet siden ADR-001, fordi den
|
||
direkte utfordrer tenant-invarianten ("organisasjon er isolasjonsenheten")
|
||
CLAUDE.md hittil har krevd en ny ADR for å bryte.
|
||
|
||
Fire load-bærende delbeslutninger ble avklart eksplisitt med bruker
|
||
(AskUserQuestion) FØR resten av denne ADR-en ble skrevet. De tre
|
||
HCP-PDF-ene (se referanse i CLAUDE.md) ble lest i sin helhet som
|
||
forberedelse til Beslutning F/G, i tråd med prosjektets stående instruks
|
||
om å ikke anta HCP-regler fra hukommelse.
|
||
|
||
### Beslutning A — Eierskapsmønster: nytt, parallelt, IKKE en skjult organisasjon
|
||
|
||
En frittstående runde eies av en BRUKER (`app_user.id`), ikke en
|
||
organisasjon. Ingen ny RLS-policy-familie trengs for dette — prosjektet
|
||
har ALLEREDE et etablert, bevist mønster for nøyaktig denne typen data:
|
||
personlig profil (migrasjon 015), sekundær e-post (017) og
|
||
HCP-historikk (018) bruker alle `plain_connection()` (ingen
|
||
`app.current_org` satt) med eksplisitt `WHERE user_id = $1`-filtrering i
|
||
hver spørring, i stedet for RLS. Frittstående runder gjenbruker dette
|
||
mønsteret uendret — nye tabeller (`round`, `round_participant`,
|
||
`round_hole_stat`, se Beslutning B) har en `owner_user_id`-kolonne, INGEN
|
||
`organization_id`, og INGEN RLS-policy — autorisasjon håndheves i
|
||
app-laget (eier ser/redigerer egne runder; en deltaker uten konto har
|
||
ingen egen tilgang, se Beslutning D).
|
||
|
||
**Begrunnelse for å avvise "usynlig personlig organisasjon"-alternativet:**
|
||
en skjult organisasjon måtte for alltid filtreres bort fra ENHVER
|
||
org-listing/-bytter/-medlemsside/fremtidig fakturering — en varig
|
||
lekkasjerisiko som vokser for hver ny org-scopet skjerm som bygges
|
||
fremover. Det parallelle mønsteret er ikke bare konseptuelt riktigere,
|
||
det er også ALLEREDE bygget og bevist for personlig data — dette er
|
||
mindre nytt arbeid enn først antatt i brainstorm-runden.
|
||
|
||
### Beslutning B — Statistikk-datamodell: fast sett navngitte felt, ikke fri slag-for-slag-logg
|
||
|
||
Per hull, i tillegg til slagtall (som i dag): kølle brukt ved utslag,
|
||
utslags-resultat (fairway/høyre/venstre — kun relevant på par 4/5),
|
||
innspill-resultat (traff/lang/kort/høyre/venstre), antall putter, lengde
|
||
på første putt, antall chip, antall bunkerslag, antall straffeslag.
|
||
|
||
**Presis definisjon av "innspillsslaget"** (nødvendig for at GIR skal
|
||
kunne beregnes automatisk, uavhengig av hullets par): SISTE slag før
|
||
første putt. På en par 3 er dette utslaget selv; på en par 4 normalt
|
||
2. slag; på en par 5 kan det være 2. ELLER 3. slag (f.eks. ved en
|
||
layup) — modellen trenger ikke vite hvilket slagnummer det var, kun at
|
||
det faktisk er det siste FØR putting startet.
|
||
|
||
**GIR er DERIVERT, ikke tastet inn direkte:** Green In Regulation = sant
|
||
hvis innspill-resultat er "traff" OG antall slag brukt til da er ≤
|
||
(hullets par − 2). Utslag-resultat og innspill-resultat er derimot
|
||
OBSERVERTE felt spilleren selv taster inn — appen har ingen GPS og kan
|
||
ikke oppdage dette selv.
|
||
|
||
**UX-prinsipp, ikke bare et skjema-valg:** ALLE detalj-felt er valgfrie
|
||
per hull. Rask "bare slagtall"-registrering skal alltid fungere uendret
|
||
— dette er bevisst for å unngå at "hovedfokus" i praksis blir for
|
||
tungvint til daglig bruk (samme klasse avveining som gjorde at
|
||
detaljerte felt ble utsatt fra scorekort-rundene tidligere i prosjektet).
|
||
|
||
**Avvist:** fri slag-for-slag-logging (hvert slag = egen rad). Ville
|
||
dekket samme behov, men med vesentlig tyngre registrering og et mer
|
||
komplekst skjema fra dag én, uten at brukerens beskrevne behov faktisk
|
||
krever det.
|
||
|
||
### Beslutning C — Banedata for frittstående runder: LIVE oppslag mot teeoff, bekreftet av bruker
|
||
|
||
Custom-baner er i dag org-scopet (`course.organization_id`) — dette
|
||
fungerer ikke uten organisasjon. **Bekreftet med bruker 2026-07-22:**
|
||
offisielle baner slås opp LIVE mot teeoff sitt API ved behov (samme
|
||
lesende API som ADR-019, IKKE en importert/kopiert kopi som i
|
||
turnering-flyten). ADR-019 sin "importer, ikke slå opp live"-beslutning
|
||
var begrunnet i turneringers behov for reproduserbarhet (et resultat skal
|
||
ikke endre seg retroaktivt hvis banedata oppdateres) — en frittstående
|
||
statistikk-runde har ikke samme behov, og live oppslag unngår i tillegg
|
||
dagens duplisering (hver org som importerer "Tjøme Golfklubb" i dag lager
|
||
sin egen kopi). Egendefinerte baner som ikke finnes i teeoff: global
|
||
banekatalog uten organisasjonstilknytning, søk-før-opprett for å begrense
|
||
duplikater (denne delen — selve den globale katalogen for CUSTOM baner —
|
||
er fortsatt kun min anbefaling, ikke eksplisitt bekreftet punkt for
|
||
punkt, men følger naturlig av at live-oppslag-beslutningen er tatt).
|
||
|
||
### Beslutning D — Deltakere i flighten uten TeeCup-konto: gjenbruk eksisterende mønster
|
||
|
||
`round_participant` får en nullable `user_id` (ekte TeeCup-bruker som
|
||
spiller med) og et `guest_name`-fritekstfelt (ingen konto). Samme
|
||
konsept som `player` uten `user_id` i org-sammenheng. E-post-basert
|
||
kobling i etterkant (samme mønster som `link_player_by_email`,
|
||
migrasjon 008) er en naturlig, men IKKE besluttet, senere utvidelse —
|
||
notert som åpent punkt under, ikke bygget i første omgang.
|
||
|
||
### Beslutning E — Hullantall og starthull: ingen tvang, kun standardvalg
|
||
|
||
18/første ni/siste ni er standardvalg i UI-et, ikke en database-
|
||
begrensning. Spilleren velger starthull fritt (gjenbruk av samme
|
||
`start_hole`-konsept som `session.start_hole`, ADR-015). En runde kan
|
||
avsluttes etter et hvilket som helst antall hull uten et forhånds-
|
||
deklarert mål — statistikk og par-sum regnes alltid fra hullene FAKTISK
|
||
spilt.
|
||
|
||
### Beslutning F — HCP-tellende runde: presist kildebelagt fra WHS Rules of Handicapping 2024
|
||
|
||
Brukeren lastet opp den offisielle kilden 2026-07-22 ("WHS Rules of
|
||
Handicapping 2024", USGA/R&A — IKKE en tredjeparts-blogg som de to andre
|
||
PDF-ene). Dette erstatter den tidligere, mer omtrentlige "minst 9
|
||
hull"-antakelsen med presise regler (**Rule 2.2**):
|
||
|
||
- **18-hulls-type runde:** minimum **10 av 18 hull** må spilles for at
|
||
scoren skal være akseptabel. De resterende (inntil 8) uspilte hullene
|
||
fylles med en "expected score" (se under), IKKE net par (net par-
|
||
metoden ble erstattet av expected-score-metoden i 2024-revisjonen —
|
||
et prinsipielt skifte fra tidligere WHS-versjoner, verdt å merke seg
|
||
siden eldre kilder/hukommelse fortsatt kan referere net par).
|
||
- **9-hulls-type runde:** ALLE 9 hull i det spesifikke, ratede 9-hulls-
|
||
settet (front ELLER back, de eneste to som normalt har egen Course/
|
||
Slope Rating) må spilles. Færre enn 9 hull totalt → scoren er IKKE
|
||
akseptabel for HCP-formål i det hele tatt, uansett grunn.
|
||
- **Konsekvens for Beslutning E (fritt valgt starthull):** den frie
|
||
starthull-friheten gjelder fullt ut for CASUAL, ikke-HCP-tellende
|
||
logging. For at en runde skal telle mot HCP, må de spilte hullene
|
||
derimot samsvare med enten (a) et sammenhengende 10-18-hulls-utsnitt av
|
||
banens 18-hulls-rating, eller (b) nøyaktig banens ratede front-9 eller
|
||
back-9 — en vilkårlig 9-hulls-strekning (f.eks. hull 5-13) har ingen
|
||
egen Course/Slope Rating og kan derfor aldri bli HCP-tellende. Dette må
|
||
kommuniseres tydelig i UI-et, ikke bare håndheves stille i motoren.
|
||
|
||
### Beslutning G — HCP-indeksberegning bygges NÅ, presist kildebelagt (WHS Rules of Handicapping 2024)
|
||
|
||
Full kjede, alle tall/formler hentet direkte fra kilden (Rule 3/5/6),
|
||
ikke hukommelse:
|
||
|
||
1. **Net Double Bogey** (maks hull-score for HCP-formål) = hullets par +
|
||
2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
|
||
dekket av eksisterende `allocate_strokes_by_index`/
|
||
`allocate_over_played_holes`, ingen endring.
|
||
2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik
|
||
fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme
|
||
(Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av
|
||
sertifisert WHS-programvare, UTEN at selve formelen er publisert i
|
||
regelboken (samme mønster som PCC) — kan derfor ikke bygges presist.
|
||
**Brukeren instruerte eksplisitt** å bruke WHS sin egen, presist
|
||
DEFINERTE "Net Par"-term i stedet (Rule 3.2b/2 — normalt reservert for
|
||
spesielle godkjente tilfeller, men her vedtatt som TeeCups generelle
|
||
policy): for hvert uspilt hull antas spilleren å ha skåret sin Net Par
|
||
= hullets par + mottatte handicapslag på det hullet (samme formel som
|
||
Net Double Bogey, uten +2-leddet) — tilsvarer 2 Stableford-poeng per
|
||
uspilt hull. Summeres inn i Adjusted Gross Score FØR standard 18-hulls
|
||
Score Differential-formelen brukes. **Viktig konsekvens:** dette gjør
|
||
at en 9-hulls-runde nå KAN telle fullt mot HCP-indeksen (de resterende
|
||
9 hullene fylles med Net Par, hele runden går gjennom SAMME 18-hulls-
|
||
formel) — det tidligere spørsmålet om en egen 9-hulls-differensial-
|
||
formel (Rule 5.1b) er dermed ikke lenger nødvendig å bygge separat.
|
||
Fortsatt gyldig kun når minimumsantallet (Beslutning F) er oppfylt.
|
||
3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av
|
||
"most likely score" (allerede tatte slag + sannsynlig antall til
|
||
fullføring, tabell basert på ballens avstand fra hullet + eventuelle
|
||
straffeslag) eller net double bogey.
|
||
4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) ×
|
||
(Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til
|
||
nærmeste tidel (,5 rundes opp).
|
||
5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.**
|
||
Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå
|
||
som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel
|
||
som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt
|
||
bort, ikke oversett.
|
||
6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste
|
||
20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20
|
||
runder i historikken brukes en egen opptrappingstabell (f.eks. 3
|
||
runder → laveste 1 med justering −2,0; 9-11 runder → snitt av laveste
|
||
3, ingen justering; osv. — full tabell i Rule 5.2a, IKKE en enkel
|
||
"gjennomsnitt av alt"-tilnærming for nye spillere).
|
||
7. **Low Handicap Index** (Rule 5.7): laveste indeks siste 365 dager —
|
||
nødvendig referansepunkt for cap-mekanismen under, må lagres/spores
|
||
per bruker.
|
||
8. **Soft cap / hard cap** (Rule 5.8): øker en oppdatert indeks mer enn
|
||
3,0 slag over Low Handicap Index, begrenses overskytende beløp til
|
||
50 %; mer enn 5,0 slag over Low Handicap Index er et absolutt tak.
|
||
Ingen nedre grense på hvor mye indeksen kan SYNKE.
|
||
9. **Maksimal indeks** (Rule 5.3) = 54,0 — samsvarer med det allerede
|
||
satte `le=54`-taket i `ProfileUpdate` fra profil-fullførings-runden
|
||
(2026-07-22), god konsistens-bekreftelse.
|
||
10. **9-hulls Course Handicap** (Rule 6.1b) — **NY, presis detalj,
|
||
AVVIKER fra 18-hulls-formelen:** `(Index ÷ 2, avrundet til nærmeste
|
||
tidel) × (9-hulls Slope ÷ 113) + (9-hulls Course Rating − 9-hulls
|
||
Par)`. Dette er IKKE det samme som å bruke full indeks mot en
|
||
9-hulls rating — indeksen halveres først. **Denne formelen gjelder
|
||
frittstående 9-hulls-RUNDER (denne ADR-en), IKKE det eksisterende
|
||
front_9/back_9-øktoppsettet i turnering-flyten** (ADR-008/2026-07-19-
|
||
fiksen, som bevisst bruker full_18-rating for HELT ANDRE grunner —
|
||
slagfordeling innad i en turneringsmatch, ikke offisiell HCP-
|
||
runde-innsending. De to må IKKE forveksles eller slås sammen uten en
|
||
egen vurdering.)
|
||
11. **18-hulls Course Handicap** (Rule 6.1a) = `Index × (Slope ÷ 113) +
|
||
(Course Rating − Par)` — allerede korrekt implementert
|
||
(`course_handicap_raw`, verifisert ved grep), ingen endring.
|
||
12. **Playing Handicap** (Rule 6.2) — uendret, allerede korrekt dekket av
|
||
eksisterende allowance-strategier (ADR-014).
|
||
|
||
**Dette er i hovedsak en HELT NY komponent i `handicap_engine.py`**
|
||
(punktene 4-9), ikke en utvidelse av det eksisterende — dagens motor tar
|
||
alltid indeksen som et KJENT input; den har aldri regnet UT en indeks fra
|
||
en historie av runder. Punkt 10 er en presisering/utvidelse av
|
||
eksisterende kode for 9-hulls-tilfellet. Holdes ren og testet isolert,
|
||
som resten av `handicap_engine.py` (ADR-005).
|
||
|
||
**Bevisst UTENFOR omfang i første byggerunde, til tross for at kilden nå
|
||
finnes** (for å holde v1 håndterbar — presist avgrenset, ikke bare
|
||
utsatt i vage vendinger):
|
||
- **Playing Conditions Calculation (PCC)** (Rule 5.6) — full prosedyre
|
||
funnet og forstått (statistisk sammenligning av dagens faktiske scorer
|
||
mot forventet, justering −1,0 til +3,0, krever minst 8 aksepterte
|
||
scorer på banen samme dag blant spillere med indeks ≤36,0). Ikke
|
||
bygget nå — krever et helt annet datagrunnlag (ALLE spilte runder på
|
||
en bane en gitt dag på tvers av ALLE brukere) enn det en enkelt
|
||
frittstående runde naturlig gir. Runder telles inn UTEN PCC-justering
|
||
til dette tas som egen, senere runde.
|
||
- **Exceptional Score-reduksjon** (Rule 5.9) — funnet og forstått (en
|
||
differensial 7,0-9,9 slag bedre enn gjeldende indeks gir automatisk
|
||
−1,0 på de siste 20 differensialene, 10,0+ gir −2,0). Ikke bygget i
|
||
v1, notert for senere presisjon.
|
||
- **Initial indeks fra færre enn 3 runder, Handicap Committee-skjønn**
|
||
(Rule 5.2a) — TeeCup har ingen "Handicap Committee"-rolle; en
|
||
forenklet, automatisk variant av opptrappingstabellen brukes i stedet,
|
||
uten menneskelig overstyring i v1.
|
||
|
||
**Åpent, ikke besluttet:** når en reell WHS-indeks kan beregnes fra
|
||
runder, skal manuell redigering av `app_user.handicap_index`
|
||
(eksisterende `PATCH /auth/profile`, ADR-031) fortsatt tillates ved
|
||
siden av (f.eks. for en spiller uten noen TeeCup-runder ennå), eller skal
|
||
feltet bli read-only/auto-beregnet så snart minst én HCP-tellende runde
|
||
finnes? Påvirker om `handicap_history` (018) skal gjenbrukes uendret
|
||
eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra
|
||
runde".
|
||
|
||
**Skjemaet (migrasjon `020_personal_rounds.sql`) er ✅ SKREVET OG
|
||
SCRATCH-VERIFISERT 2026-07-22,** som andre byggesteg (etter motoren).
|
||
Sju nye tabeller: `round` (header, `owner_user_id`-eid, ingen RLS),
|
||
`round_participant` (deltakere — lenket bruker ELLER gjestenavn, XOR-
|
||
håndhevet via CHECK; maks én markert eier per runde via partiell unik
|
||
indeks), `round_hole` (rating-SNAPSHOT + statistikk per deltaker per
|
||
hull — GIR er bevisst IKKE en egen kolonne, kun deriverbar ved lesing:
|
||
`approach_result='hit' AND (score-putts) <= par-2`, verifisert eksakt
|
||
mot ekte testdata), pluss fire tabeller for den globale banekatalogen
|
||
(`personal_course`/`_hole`/`_tee`/`_tee_rating` — sistnevnte BEVISST uten
|
||
`scope`-kolonne, ulikt org-tabellen, siden Net-Par-tilnærmingen gjør
|
||
9-hulls-spesifikk rating overflødig). `round`/`personal_course_*` har
|
||
INGEN RLS (Beslutning A) — autorisasjon i app-laget.
|
||
**Scratch-verifisert, 9 sjekker** (kjørt som `teecup_app_scratch`, ikke
|
||
superbruker): duplikat kjønn på samme utslag avvist, teeoff+custom-felt
|
||
samtidig avvist (CHECK), verken/begge user_id+guest_name avvist (XOR-
|
||
CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per
|
||
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
|
||
av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå
|
||
urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på
|
||
eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,**
|
||
bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet,
|
||
`test_isolation.sql` fortsatt 12/12 mot ekte database. **Gjenstår:**
|
||
API-lag og frontend — ingen av disse er startet.
|
||
|
||
**API-laget er ✅ BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-22,**
|
||
som tredje byggesteg. Ny `app/routers/rounds.py` (registrert i `main.py`):
|
||
`POST/GET /rounds` (opprett/list egne runder), `GET/DELETE /rounds/{id}`,
|
||
`POST/DELETE /rounds/{id}/participants` (kun gjester i v1, se moduldoc),
|
||
`PATCH /rounds/{id}/participants/{pid}/holes/{n}` (hull-for-hull-
|
||
registrering), `POST /rounds/{id}/complete` (kjører hele motor-kjeden:
|
||
Adjusted Gross Score → Score Differential → `counts_for_handicap` via
|
||
`round_counts_for_handicap`), pluss `GET/POST /personal-courses` for den
|
||
globale banekatalogen. Ny motor-funksjon lagt til underveis:
|
||
`round_counts_for_handicap(played_holes_count, holes_planned)` — to
|
||
distinkte terskler (Rule 2.2a: min 10/18 ved 18-hulls-intensjon; Rule
|
||
2.2b: ALLE 9 ved 9-hulls-intensjon, ikke "minst 9"), testet (2 nye
|
||
tester, 43/43 totalt i `handicap_engine.py`).
|
||
**Reelt hull funnet OG fikset FØR API-et kunne fullføres:**
|
||
`round_participant` manglet kolonner for selve rating-tallene (Course
|
||
Rating/Slope Rating/Par) brukt til å beregne Course Handicap — kun
|
||
`round.tee_name_snapshot` (navn) fantes, ikke tallene. Ny migrasjon
|
||
`021_round_participant_rating_snapshot.sql` (tre nye nullable kolonner)
|
||
skrevet, scratch-verifisert sammen med resten, og rullet ut.
|
||
**Scratch-verifisert grundig, 26 sjekker** (isolert scratch-rolle+MinIO+
|
||
engangs API-container): full livssyklus for en custom-bane-runde
|
||
(course_handicap_snapshot regnet riktig — Index 15/Slope 128/Rating
|
||
71.5/Par 72 → 16, verifisert for hånd), en gjest UTEN HCP (ingen
|
||
snapshot/differensial, teller aldri), 18/18 spilt → tellende med korrekt
|
||
differensial (16.3, verifisert for hånd), 9 av 18 spilt ved 18-hulls-
|
||
intensjon → IKKE tellende, 9 av 9 spilt ved 9-hulls-intensjon → TELLENDE
|
||
(Net Par fyller resten av de 18, se Beslutning G punkt 2), full
|
||
autorisasjons-isolasjon (en fremmed bruker avvist 403 fra både lesing og
|
||
hull-oppdatering, egen runde-liste tom), kan ikke fjerne eieren, slett-
|
||
runde-kaskade, ufullstendig profil avvist fra å opprette runde. **Egen,
|
||
separat verifisering av teeoff-LIVE-oppslaget** (Beslutning C) mot den
|
||
ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
|
||
anlegg som ADR-019s opprinnelige verifisering) — bekreftet at INGEN
|
||
`course`/`hole`/`tee`-rad skrives noe sted, kun et navn-snapshot
|
||
("Borregaard Golfklubb – Hovedbanen") og et rating-snapshot; en andre
|
||
deltaker lagt til samme runde utløste et FERSK, uavhengig live-oppslag
|
||
mot teeoff (ikke gjenbruk av cachet data). `test_isolation.sql` fortsatt
|
||
12/12.
|
||
**Rullet ut mot ekte systemer 2026-07-22:** migrasjon 021 kjørt mot ekte
|
||
`teecup_db` (kolonner bekreftet, `test_isolation.sql` fortsatt 12/12),
|
||
`docker compose up -d --build teecup_api` (kun backend — ingen frontend-
|
||
skjerm bygget for dette ennå), boot-et rent, `/health`/`/dashboard` → 200,
|
||
`teeoff.no` upåvirket. `frontend/next.config.mjs` sin `rewrites()` fikk
|
||
`/rounds/*` og `/personal-courses/*` lagt til proaktivt (samme lærdom som
|
||
ADR-016/medlemsside-hendelsen — enhver ny API-prefiks MÅ inn her FØR en
|
||
frontend-side bygges) — denne ENDRINGEN ligger IKKE deployet ennå (ingen
|
||
frontend-kode bruker den), tas med i neste frontend-runde.
|
||
**Frontend BYGGET OG RULLET UT 2026-07-23** — fjerde og siste lag
|
||
(engine → skjema → API → frontend). Tre nødvendige tillegg til
|
||
`app/routers/rounds.py` funnet og bygget UNDER frontend-designet, ikke
|
||
antatt på forhånd: `GET /rounds/official-search`/`{slug}` (samme
|
||
teeoff-søkemønster som `courses.py`, men uten org-kontekst — frittstående
|
||
runder har ingen), `GET /personal-courses/{id}` (detalj med
|
||
utslag+kjønn — søk-endepunktet returnerte kun navn), og
|
||
`GET .../participants/{id}/holes` (et reelt hull: `RoundOut` bar aldri
|
||
hull-nivå-data, så ingen skjerm kunne vise gjeldende tilstand ved
|
||
gjenlasting). Hull-PATCH endret til å returnere hele den oppdaterte raden
|
||
i stedet for `{"ok": true}`.
|
||
**Reelt kontraktsfunn, bekreftet i scratch FØR frontend stolte på det:**
|
||
hull-PATCH-endepunktet er IKKE et ekte delvis-PATCH — det skriver ALLE
|
||
felt ved hvert kall (arvet fra hvordan `HoleUpdate`-modellen alltid har
|
||
defaultverdier for utelatte felt). Et PATCH som kun sender `score` ville
|
||
derfor stille NULLSTILT `putts` og alle andre allerede lagrede felt.
|
||
Løst ved at `round-detail.tsx` alltid slår sammen med gjeldende
|
||
hull-data før hver PATCH, aldri sender et isolert feltnavn alene —
|
||
verifisert eksplisitt i en egen scratch-test som FØRST beviste
|
||
nullstillings-oppførselen uten merge, DERETTER beviste at
|
||
merge-mønsteret unngår den.
|
||
**Nye sider:** `/rounds` (liste over egne runder), `/rounds/new`
|
||
(bane-kilde teeoff vs. egen — for egen bane: søk-før-opprett, samme idé
|
||
som org-banenes gjenbrukbare katalog; utslag filtrert til kun de som har
|
||
rating for brukerens registrerte kjønn, ADR-029s automatikk-prinsipp
|
||
gjenbrukt her selv om HCP-motoren er en helt annen), `/rounds/[id]`
|
||
(deltaker-faner — eier + gjester, ingen ekte kontokobling i v1 per
|
||
Beslutning D; hull-navigasjon fra runde-ens starthull; slag/putt-
|
||
tallvelgere i samme visuelle stil som `session-scorecard.tsx` sin
|
||
`StrokePicker`; kølle/retning/innspill/chip/bunker/straffeslag/
|
||
putt-avstand bak en «flere detaljer»-utvidelse; GIR utledet og vist
|
||
KLIENTSIDE ved lesing, aldri lagret — nøyaktig Beslutning B sitt prinsipp;
|
||
fullfør-runde med HCP-differensial-sammendrag, låser videre redigering).
|
||
Lenket fra dashbordet som «Egne runder» (header-lenke + en egen kort på
|
||
forsiden) — bevisst adskilt navn fra det eksisterende «Mine runder»
|
||
(ADR-031, turnering-deltakelse) for å unngå at de to konseptene blandes
|
||
sammen i UI-et, selv om begge bokstavelig talt handler om "runder".
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container, samme mønster som resten
|
||
av ADR-033): 22 automatiserte sjekker (egendefinert-bane-opprettelse med
|
||
to utslag/to kjønn, søk, detalj, PATCH-kontraktsbeviset over,
|
||
GIR-derivering for et konstruert par4/score3/putt1-tilfelle, gjest-
|
||
fjerning, kryss-bruker-autorisasjon 403, full fullføring med differensial)
|
||
PLUSS en separat, egen test av HELE teeoff-baserte opprettelsesløpet mot
|
||
den ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
|
||
anlegg som tidligere ADR-019/033-verifiseringer) som bekreftet
|
||
`course_handicap_snapshot` ble beregnet riktig fra live-hentet
|
||
rating. Ekte typesjekket PRODUKSJONSBUILD kjørt via
|
||
`docker build --target builder` (nøyaktig samme steg `Dockerfile` bruker
|
||
i prod, ikke `next dev`) — kompilerte rent, alle nye ruter listet.
|
||
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon i denne del-runden, `docker compose up -d --build teecup_api
|
||
teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard`/
|
||
`/rounds` → 200 over ekte https, `teeoff.no` upåvirket.
|
||
**Bevisst utenfor omfang, ikke bygget denne runden:** ekte kontokobling
|
||
for gjeste-deltakere (Beslutning D), automatisk oppdatering av
|
||
`app_user.handicap_index`/`handicap_history` ved fullført tellende runde
|
||
(åpent spørsmål i Beslutning G, fortsatt ubesvart), shotgun-start
|
||
(egen, separat ADR-034), GPS/avstandsmåling (se eget notat i
|
||
FEATURE_BACKLOG.md, krever data ingen kilde har i dag).
|
||
|
||
**Frontend ERSTATTET med V0-designet versjon 2026-07-23, samme dag som
|
||
den hånd-bygde frontend-en over ble rullet ut:** brukeren påpekte
|
||
(berettiget) at de tre nye skjermene var hånd-kodet av meg direkte i
|
||
stedet for designet i V0 -- et avvik fra prosjektets etablerte mønster
|
||
gjennom HELE resten av appen. Bekreftet med bruker at arbeidsmåten
|
||
fortsatt skal være: JEG skriver V0-prompten, BRUKEREN kjører den i
|
||
v0.app og sender koden tilbake, JEG integrerer (samme flyt som alltid,
|
||
ikke endret). Skrev tre detaljerte prompter (liste/opprett/hull-
|
||
registrering, inkl. eksplisitt tilgjengelighetskrav i hver) -- bruker
|
||
lastet opp tre zip-eksporter (`tee-cup-login-screen (10/11/12).zip`).
|
||
**Samme "full re-eksport hver gang"-mønster som ALLE tidligere V0-runder:**
|
||
diffet mot levende tre FØR noe ble tatt inn -- kun fire filer var reelt
|
||
nye/relevante (`round-card.tsx`, `own-rounds.tsx`, `new-round.tsx`,
|
||
`round-detail.tsx` + tre `app/rounds*/page.tsx`-ruter), resten
|
||
(login-form, dashboard, config, ui/*) var forventede full-reverts og ble
|
||
IKKE tatt inn. **Samme kjente V0-feil dukket opp igjen, hoppet bevisst
|
||
over:** `app/clubs/[id]/page.tsx` med feilnavngitt `[id]`-parameter
|
||
(egentlig en slug) -- identisk feil som ble rettet i klubbside-runden
|
||
under ADR-018, V0 gjenskaper den tydeligvis når prosjektet re-genereres.
|
||
**Mine tre hånd-bygde komponenter (`personal-rounds.tsx`, `round-new.tsx`,
|
||
og min opprinnelige `round-detail.tsx`) er ERSTATTET, ikke supplert** --
|
||
V0s presentasjon beholdt, datalaget skrevet om fra mock til ekte fetch
|
||
(samme "V0 leverte mock, jeg kabler ekte data"-mønster som enhver annen
|
||
skjerm i appen). Reelle tilpasninger utover ren om-kabling:
|
||
1. V0s teeoff-søkemodell antok hele bane+utslag-lista lå ferdig i
|
||
søkeresultatet -- det ekte API-et er et to-stegs oppslag (søk →
|
||
facility-detalj). Løst med en `resolving`-tilstand i
|
||
`OfficialSearchStep` (eneste strukturelle tillegg til V0s JSX).
|
||
2. La til et tredje kjønnsvalg "Annet" i gjeste-skjemaets `ChoiceRow`
|
||
(V0 hadde kun Mann/Kvinne) -- matcher appens ellers etablerte
|
||
Herre/Dame/Annet-konvensjon (`account-settings.tsx` m.fl.), og
|
||
API-ets `GuestParticipantCreate.gender` støtter allerede `x`.
|
||
3. Beholdt merge-før-PATCH-sikringen fra forrige rundes kontraktsfunn
|
||
(hull-PATCH skriver alle felt, ikke bare det endrede) -- V0 kjente
|
||
naturligvis ikke til denne kontraktsdetaljen, lagt inn i
|
||
`updateStat()` uendret i prinsipp fra min opprinnelige versjon.
|
||
4. Fjernet V0s dev-only forhåndsvisningskontroller (`ReviewToggle` i
|
||
`own-rounds.tsx`, den nederste "Forhåndsvis: Pågår/Fullført"-linjen i
|
||
`round-detail.tsx`) -- samme opprydningsmønster som ALLE tidligere
|
||
V0-runder i prosjektet.
|
||
Ingen backend-endring i denne del-runden (samme API-kontrakt som allerede
|
||
var scratch-verifisert). **Verifisert:** ekte typesjekket
|
||
produksjonsbuild (`docker build --target builder`) kompilerte rent, alle
|
||
ruter listet. Ingen ny interaktiv nettleser-test utført (intet slikt
|
||
verktøy tilgjengelig i denne økten) -- kun kodegjennomgang + typesjekk,
|
||
flagget eksplisitt til bruker.
|
||
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: `docker
|
||
compose up -d --build teecup_frontend` (gjenskapte også `teecup_api` som
|
||
vanlig compose-bivirkning, ingen backend-kode rørt), begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/rounds`/`/rounds/new` → 200 over
|
||
ekte https, `teeoff.no` upåvirket. De tre V0-zip-ene slettet fra
|
||
prosjektroten etter fullført integrering.
|
||
|
||
**Reell produksjonsbug funnet OG FIKSET 2026-07-23, rapportert av bruker
|
||
med to skjermbilder (`/rounds` ga "Klarte ikke å hente rundene dine",
|
||
`/rounds/new` sitt siste steg ga "Klarte ikke å opprette runden"):**
|
||
rot-årsak var en EKSAKT navnekollisjon mellom frontend-sidens toppnivå-
|
||
prefiks (`/rounds`) og backend-APIets ressursprefiks (samme `/rounds`,
|
||
`app/routers/rounds.py`) -- en verre variant av den allerede dokumenterte
|
||
"afterFiles"-fellen fra ADR-016s medlemsside-hendelse, denne gangen
|
||
rammende BEGGE retninger samtidig:
|
||
- `/rounds` (eksakt sti): en STATISK frontend-side. Statiske sider
|
||
sjekkes FØR rewrites, så siden vant presedens -- klientens
|
||
`fetch("/rounds")`/`POST /rounds` traff ALDRI backend, fikk Next sin
|
||
egen HTML tilbake i stedet for JSON (stille `res.json()`-parsefeil,
|
||
fanget av try/catch, viste den generiske feilteksten).
|
||
- `/rounds/[id]` (DYNAMISK side): her sjekkes rewrites FØR dynamiske
|
||
sider, så rewrite-regelen vant presedens i stedet -- selve
|
||
rundedetalj-SIDEN var dermed fullstendig UOPPNÅELIG (ville vist rå
|
||
backend-JSON i stedet for UI-et), bekreftet direkte med `curl` FØR
|
||
fiksen (anonymt `GET /rounds/00000000-...` ga ekte backend-JSON i
|
||
stedet for Next sin HTML).
|
||
Det andre skjermbildets utslagsnavn "55/50/44/32" ble UNDERSØKT OG
|
||
BEKREFTET Å IKKE VÆRE EN BUG -- lest direkte fra ekte `teeoff_api` (`GET
|
||
tjome-golfklubb`): Tjøme Golfklubb sine faktiske utslagsnavn i teeoff ER
|
||
bokstavelig talt disse tallene (lengde i hundremeter, ikke fargenavn) --
|
||
data gjengitt korrekt, ingen kode-endring nødvendig for dette punktet.
|
||
**Fikset ved samme prinsipp som medlemsside-hendelsen: flytt siden, ikke
|
||
APIet.** Alle tre frontend-rutene flyttet til et helt nytt, ikke-
|
||
overlappende toppnivå-prefiks `/my-rounds/*` (`app/rounds/` →
|
||
`app/my-rounds/`), API-et (`/rounds/*`) uendret. Åtte interne
|
||
navigasjonsreferanser oppdatert på tvers av `round-card.tsx`/
|
||
`own-rounds.tsx`/`new-round.tsx`/`round-detail.tsx`/`dashboard.tsx` --
|
||
ekte `fetch()`-kall til API-et (samme filer) bevisst latt urørt, kun
|
||
`<Link href>`/`router.push`/`router.replace` endret. Utvidet
|
||
`next.config.mjs` sin allerede eksisterende advarselskommentar med denne
|
||
nye, verre varianten av samme fellesklasse, som en fremtidig påminnelse:
|
||
et rewrite-prefiks og en frontend-sides toppnivå-segment må ALDRI være
|
||
identisk streng.
|
||
**Verifisert presist FØR og ETTER utrulling** (ikke bare "bygget uten
|
||
feil"): et `curl` mot ekte produksjon FØR fiksen bekreftet nøyaktig
|
||
mekanismen i begge retninger (se over). Ekte typesjekket
|
||
produksjonsbuild etterpå viste selv at rutetreet nå lister `/my-rounds`,
|
||
`/my-rounds/[id]`, `/my-rounds/new` i stedet for de gamle `/rounds`-
|
||
rutene. **Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: kun
|
||
`teecup_frontend` (gjenskapte `teecup_api` som vanlig bivirkning, ingen
|
||
backend-kode rørt). Verifisert ETTERPÅ med et nytt sett `curl`-kall mot
|
||
ekte https: anonymt `GET /rounds` ga nå korrekt backend-JSON
|
||
(`NOT_AUTHENTICATED`, IKKE Next sin HTML som før), anonymt `GET
|
||
/rounds/<uuid>` fortsatt korrekt backend-JSON (uendret, som forventet),
|
||
og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/<uuid>` ga nå
|
||
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
|
||
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Nok en runde brukerrapporterte punkter, ALLE BYGGET OG LIVE 2026-07-24,
|
||
samme dag:** (1) Utslagstidspunkt (`round.started_at`, migrasjon
|
||
`023_round_start_time.sql`, valgfritt) + tidsbruk beregnet klientside
|
||
som `completed_at − started_at` når runden fullføres. Bekreftet
|
||
eksplisitt med bruker: "Ferdig"-tidspunktet er den ALLEREDE eksisterende
|
||
"Fullfør runde"-knappen, ingen ny handling/kolonne. (2) "Idx" i hull-
|
||
overskriften byttet til "Hcp". (3) Slag-tastaturets numpad merker nå
|
||
knappen som tilsvarer hullets par med en liten "par"-bildetekst.
|
||
(4) Hull-navigasjonens "Forrige"/"Neste" respekterte tidligere ALLTID
|
||
18 hull uansett `holes_planned` -- en 9-hulls runde 10-18 hoppet feilaktig
|
||
til hull 9 ved "Forrige" fra hull 10. Fikset: navigasjonsrekkefølgen
|
||
bygges nå med `holes_planned` som lengde, ikke hardkodet 18.
|
||
(5) Putter/Chip/Bunker/Straffeslag/Anywayslag kan nå aldri velges høyere
|
||
enn antall registrerte slag på hullet (`NumberPicker` fikk en
|
||
`maxValue`-prop, `Stepper` en `max`-prop) -- ingen vits i å tilby et
|
||
selvmotsigende tall. (6) "Slett runde" bygget i UI-et (backendens
|
||
`DELETE /rounds/{id}` fantes fra før, men hadde aldri fått en
|
||
frontend-knapp) -- bekreftelsesdialog, sletter for alle (kaskade
|
||
fjerner automatisk alle deltakere/hull, guest-spillere har uansett ingen
|
||
egen konto å bevare noe for).
|
||
(7) **Ny `PATCH /rounds/{id}`** -- retter opp feil bane/utslag eller
|
||
feil antall hull ETTER opprettelse, uten å røre allerede registrerte
|
||
slag/putter/etc. Speiler samme filosofi som turnering-øktenes
|
||
`_remap_course` (ADR-tidligere runde), men enklere: ingen tee-navn-
|
||
matching på tvers av kjønn siden hver deltaker valideres eksplisitt mot
|
||
den NYE banens rating for sitt eget kjønn FØR noe skrives (hele byttet
|
||
avvises 400 hvis ÉN deltaker ville mistet HCP-sporing). Bevisst
|
||
AVVIST (409) etter at runden er fullført -- ulikt turnering-øktenes
|
||
bane-bytte, som bevisst tillater dette selv etter avgjørelse; her er
|
||
omfanget mindre (ingen re-beregning av differensial bygget for dette
|
||
tilfellet). Frontend: ny "Rediger runde"-seksjon på rundesiden (antall
|
||
hull som enkelt 9/18-valg, "Bytt bane" som en kompakt søke-flyt --
|
||
teeoff-søk ELLER egen-bane-søk, samme mønster som ved opprettelse, bare
|
||
kondensert). Etter et vellykket bytte hentes hull-data på nytt for
|
||
ALLE allerede lastede deltakere (par/stroke-index kan ha endret seg for
|
||
alle, ikke bare aktiv spiller).
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 22 sjekker for
|
||
rediger/slett-rundene (bane-bytte med FAKTISK ulike par/stroke-index-
|
||
verdier mellom to egendefinerte baner, bekreftet at allerede registrerte
|
||
slag på hull 1-3 var UENDRET etter byttet mens par/stroke-index OG
|
||
course_handicap_snapshot var oppdatert; avvist bytte til en bane uten
|
||
rating for deltakerens kjønn, bekreftet at INGENTING ble endret ved
|
||
avvisning; avvist bane/hull-endring etter fullføring, 409; slett-runde
|
||
+ idempotent 404 + kryss-bruker-autorisasjon 403) pluss 8 sjekker for
|
||
utslagstid/tidsbruk. `test_isolation.sql` 12/12. Ekte typesjekket
|
||
produksjonsbuild kompilerte rent begge ganger.
|
||
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||
migrasjon 023 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||
--build teecup_api teecup_frontend`, begge containere boot-et rent,
|
||
`/health`/`/dashboard`/`/my-rounds` → 200 over ekte https, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**To til brukerpunkter, ALLE BYGGET OG LIVE 2026-07-25, samme dag:**
|
||
(1) `PATCH /rounds/{id}` utvidet med `start_hole`/`started_at`/
|
||
`completed_at` -- start_hole kan rettes uansett (ren metadata, aldri
|
||
sperret av fullført-status), started_at kan justeres når som helst,
|
||
completed_at kan KUN justeres på en runde som allerede ER fullført
|
||
(avvist 400 ellers -- denne PATCH-en fullfører aldri runden selv, kun
|
||
korrigerer et allerede satt tidspunkt fra "Fullfør runde"). Ny
|
||
validering: completed_at må være etter started_at, ellers 400. Løser
|
||
brukerens konkrete case: glemmer å trykke "Fullfør runde" i flere timer,
|
||
vil rette opp tidsbruken i etterkant. `EditRoundPanel` sin "Bane/antall
|
||
hull"-blokk forblir sperret post-fullføring (uendret fra forrige runde),
|
||
mens et nytt tidspunkt-skjema (Utslagstid alltid, Fullført-tidspunkt kun
|
||
hvis fullført) nå vises uansett fullført-status.
|
||
(2) **Nærmeste offisielle baner** i "Ny runde"-flyten -- nytt
|
||
`GET /rounds/official-search/nearby?lat=&lng=&limit=` (Haversine-formel,
|
||
sortert stigende, MÅ registreres FØR `/rounds/official-search/{slug}` i
|
||
routeren, samme presedens-lærdom som ADR-020s "by-code"). Henter ALLE
|
||
174 teeoff-anleggenes lat/lng via samme `search_facilities("")`-kall som
|
||
allerede finnes (ingen ny teeoff-avhengighet), beregner avstand i Python
|
||
per forespørsel (ingen cache -- datasettet er lite nok). Frontend: ny
|
||
`NearbyClubs`-komponent i `OfficialSearchStep`, ber om
|
||
`navigator.geolocation` ved mount, viser inntil 5 nærmeste med
|
||
nærmeste tydelig merket + avstand (m under 1 km, ellers km) -- rett
|
||
FØR søkefeltet, som bedt om. Avslått/manglende posisjon feiler helt
|
||
stille (ingen feilmelding), søket fungerer uendret som fallback.
|
||
**Scratch-verifisert:** 14 sjekker (start_hole-endring, tidspunkt-
|
||
korreksjon i begge retninger inkl. de to nye valideringsreglene,
|
||
`holes_planned` fortsatt sperret post-fullføring uendret, OG et ekte
|
||
`nearby`-kall mot den kjørende `teeoff_api`-containeren fra Tjømes egne
|
||
koordinater som korrekt fant Tjøme selv som nærmeste/nest-nærmeste
|
||
treff). `test_isolation.sql` 12/12 (ingen skjemaendring). Ekte
|
||
typesjekket produksjonsbuild kompilerte rent.
|
||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
|
||
→ 200, `teeoff.no` upåvirket.
|
||
|
||
**Reell UX-bug funnet OG fikset, samme dag 2026-07-25, rapportert av
|
||
bruker med skjermbilder av en konkurrerende golf-app (Golf Game Book)
|
||
som referanse:** brukeren rapporterte at "All statistikk" ikke viste
|
||
noe utover slag/putter under selve registreringen. **Bekreftet direkte
|
||
mot ekte `teecup_db` (kun lesing) at dette IKKE var en datafeil** --
|
||
brukerens faktiske runde hadde `stat_level='full'` lagret korrekt. Rot-
|
||
årsaken var et REELT presentasjonsproblem: alle detaljfeltene (kølle,
|
||
utslag, innspill, chip, bunker, straffeslag, putt-avstand, anywayslag)
|
||
lå bak en "Score"/"Statistikk"-fane (V0-runden 2026-07-24s bevisste
|
||
designvalg) som brukeren aldri oppdaget -- "aktivert full statistikk"
|
||
ga i praksis ingen synlig endring uten et ekstra, ikke-annonsert
|
||
tastetrykk. **Fikset ved å fjerne fane-løsningen helt:** alle feltene
|
||
vises nå ALLTID samlet under slag/putter i én sammenhengende scroll når
|
||
`statLevel==="full"` (samme prinsipp brukeren opprinnelig ba om FØR
|
||
V0-rundens sveip/fane-vurdering) -- fjerner enhver tvetydighet om
|
||
hvorvidt statistikken faktisk er slått på. `PanelTabs`-komponenten og
|
||
`panelTab`-state fjernet som død kode.
|
||
**Samtidig bygget: "Så langt i runden"-oversikt**, direkte etterspurt
|
||
("jeg trenger et grensesnitt som viser scoren min så langt") med
|
||
referansebildene som inspirasjon for FUNKSJONEN (ikke kopiert
|
||
utseendemessig). Ny komponent `ScoreSoFar` i `round-detail.tsx`: en
|
||
kompakt alltid-synlig linje ("Så langt: X hull · Y slag · +Z til par")
|
||
rett under spillerfanene, pluss en "Vis full oversikt"-knapp (samme
|
||
etablerte mønster som `session-scorecard.tsx` sin `HoleSummaryTable`
|
||
for turnering-scoring) som åpner en tabell: hull, par, score, netto,
|
||
løpende sum. **Ny backend-beregning for å muliggjøre netto-kolonnen:**
|
||
`RoundHoleOut` fikk et nytt felt `strokes_received` (`list_holes` i
|
||
`app/routers/rounds.py`) -- utledet fra deltakerens
|
||
`course_handicap_snapshot` + hullenes `stroke_index` via den
|
||
ALLEREDE eksisterende `allocate_strokes_by_index()` fra
|
||
`handicap_engine.py` (samme allokeringsalgoritme som brukes overalt
|
||
ellers i appen, ingen ny logikk) -- `None` for en deltaker uten
|
||
beregnet HCP (f.eks. en gjest uten oppgitt handicap), aldri lagret,
|
||
kun beregnet ved lesing.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 11 sjekker, inkl. et
|
||
presist talleksempel (course rating 72.0/slope 113/HCP 10.0 → course
|
||
handicap nøyaktig 10, bekreftet at de 10 laveste stroke-indeksene fikk
|
||
nøyaktig 1 slag hver og de resterende 8 fikk 0, sum(strokes_received)
|
||
== course_handicap), et registrert hull som ga korrekt netto (score 5 −
|
||
1 mottatt slag = 4), og en gjest UTEN HCP som korrekt fikk
|
||
`strokes_received: null` på alle 18 hull. `test_isolation.sql` 12/12
|
||
(ingen skjemaendring). Ekte typesjekket produksjonsbuild kompilerte
|
||
rent.
|
||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon, kun `teecup_api`+`teecup_frontend` redeployet.
|
||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Reell produksjonsregresjon rapportert AV BRUKEREN samme dag, rett
|
||
etter utrulling over, funnet og fikset umiddelbart:** "Ingenting er
|
||
klikkbart i den avanserte statistikken." Første antagelse (frontend-
|
||
CSS/event-håndtering) ble IKKE bekreftet ved kodegjennomgang -- all
|
||
onClick-kabling i `DirectionCross`/`Stepper`/`ChoiceRow`/`NumberPicker`
|
||
var korrekt. Root cause funnet ved å faktisk GJENSKAPE brukerens
|
||
klikk-sekvens mot en fersk, isolert scratch-container (samme mønster
|
||
som resten av runden) i stedet for å gjette videre: `update_hole`
|
||
(PATCH-endepunktet for hull-registrering) konstruerte fortsatt
|
||
`RoundHoleOut(**dict(row))` UTEN det nye påkrevde
|
||
`strokes_received`-feltet fra runden rett over -- en Pydantic
|
||
`ValidationError` (500) på HVER ENESTE hull-lagring, ikke bare de
|
||
"avanserte" feltene. Frontend sin `updateStat()` svelger `!res.ok`
|
||
stille uten feilmelding, så symptomet fremsto nøyaktig som "ingenting
|
||
skjer når jeg trykker" for ALLE felt (Slag/Putter inkludert) -- brukeren
|
||
merket det trolig først på de avanserte feltene siden Slag/Putter fra
|
||
TIDLIGERE runder allerede hadde lagrede verdier som så riktige ut ved
|
||
åpning.
|
||
**Fikset:** `update_hole` beregner nå `strokes_received` for akkurat det
|
||
oppdaterte hullet (henter deltakerens `course_handicap_snapshot` +
|
||
ALLE 18 sine `stroke_index` -- samme allokeringsalgoritme som
|
||
`list_holes`, siden fordelingen avhenger av hele rundens
|
||
stroke-indeks-rekkefølge, ikke bare ett hull) og sender den med i
|
||
responsen.
|
||
**Scratch-verifisert på nytt, presist mot akkurat denne regresjonen:**
|
||
14 sjekker som gjenskaper brukerens EKSAKTE klikk-rekkefølge (Slag →
|
||
kølle → Utslag-retning → Innspill-retning → Chip → første putt-bøtte →
|
||
Anywayslag, pluss et klikk på et avansert felt FØR Slag i det hele tatt
|
||
er satt, og en gjest uten HCP) -- alle 200 med riktig ekko, `GET` etterpå
|
||
bekrefter faktisk lagring, gjest uten HCP gir korrekt `strokes_received:
|
||
null` uten å krasje. `test_isolation.sql` uendret (ingen skjemaendring,
|
||
ren Python-fiks). **Rullet ut live 2026-07-25**, bruker bekreftet
|
||
eksplisitt: kun `teecup_api` redeployet, `/health`/`/dashboard` → 200,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Rediger/Fullfør/Slett tonet ned og flyttet til toppen + auto-scroll ved
|
||
hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at
|
||
"Fullfør runde"/"Slett runde" lå som store, fremtredende knapper RETT
|
||
under "Neste hull"-navigasjonen nederst i hull-panelet -- altfor lett å
|
||
trykke feil ved et uhell mens man bare skulle bla mellom hull. Flyttet
|
||
alle tre (Rediger/Fullfør/Slett) til en samlet rad ØVERST på siden,
|
||
tonet ned til samme nøytrale "trigger"-stil (ikke lenger store,
|
||
fargede knapper) -- brukeren må nå aktivt scrolle OPP forbi hele
|
||
hull-registreringen for å nå dem. Samtidig rapportert, i samme runde:
|
||
"Neste hull"/"Forrige" lastet nytt innhold, men brukeren ble stående
|
||
scrollet nede der knappene er, med det nye hullets Slag-felt utenfor
|
||
skjermen. Løst med `holePanelRef` + `scrollIntoView({block:"start"})`
|
||
kalt fra `goPrev`/`goNext` OG fra hull-navigasjonens direkte hull-valg,
|
||
med `scroll-mt-28` på panelet for å unngå at den sticky headeren dekker
|
||
toppen. Ren frontend-endring, ingen backend/migrasjon. Ekte typesjekket
|
||
build kjørt og bekreftet (alle 19 ruter), rullet ut, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle-
|
||
statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme
|
||
dag (2026-07-25):** brukeren etterspurte flere detaljer i
|
||
rundeoversikten: netto- og stableford-sum, hvor man kan se totalt antall
|
||
putter/chip/bunker/straffeslag/anywayslag for runden, grafisk fremstilt
|
||
prosentvis fordeling av fairwaytreff og innspillstreff, og gjennomsnittlig
|
||
score til par (to desimaler) splittet på fairwaytreff-vs-bom og
|
||
innspill-treff-vs-bom.
|
||
**Ingen backend-endring nødvendig** -- alt beregnes klientside fra data
|
||
`GET .../holes` allerede returnerer (score/par/strokes_received/putts/
|
||
chip_count/bunker_shot_count/penalty_strokes/tee_shot_result/
|
||
approach_result/anyway_strokes). `ScoreSoFar`-komponenten
|
||
(round-detail.tsx) utvidet med: en `StatPill`-rutenett (Slag/Til par/
|
||
Netto/Stableford/Putt/Chip/Bunker/Straffeslag/Anywayslag -- hver vist
|
||
kun hvis det faktisk finnes registrert data for akkurat det feltet), en
|
||
ny Stableford-kolonne i hull-for-hull-tabellen (kun når netto er
|
||
beregnbart), og to nye `DistributionBar`-seksjoner (Fairwaytreff/
|
||
Innspill) -- generalisert N-kategori-variant av samme visuelle idé som
|
||
`SegmentedBar` i tournament-leaderboard.tsx (ett fargesegmentert
|
||
rektangel + prosent-legend), ikke noe chart-bibliotek lagt til.
|
||
**Presisert i kode-kommentar, bevisst valg:** appen har ingen egen
|
||
"spilleform"-innstilling for frittstående runder -- stableford beregnes
|
||
derfor alltid ut fra netto score når det er mulig (krever registrert
|
||
HCP, samme forutsetning som netto), uavhengig av om brukeren
|
||
"egentlig" spiller slagspill eller stableford. Standard stableford-
|
||
poengtabell brukt (netto par = 2 poeng, ett poeng mer/mindre per slag
|
||
bedre/dårligere enn par, gulv på 0).
|
||
Gjennomsnittlig score-til-par (fairway/innspill, treff-vs-bom) bruker
|
||
BRUTTO score (ikke netto) relativt til par, formatert med fortegn og to
|
||
desimaler (`formatSignedAvg`), matcher brukerens eksplisitte
|
||
spesifikasjon. Fairwaytreff ekskluderer par-3-hull (samme regel som
|
||
registrerings-skjemaet, som aldri viser Utslag-retning der).
|
||
**Logikken verifisert manuelt mot et regnet eksempel** (4 hull, blandet
|
||
par 3/4/5, ulike utslag-/innspillresultater) FØR utrulling -- stableford-
|
||
sum, netto-sum og begge gjennomsnitts-splittene stemte med
|
||
håndregning. Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle
|
||
19 ruter). **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt:
|
||
ren frontend-endring, ingen migrasjon, `/health`/`/my-rounds` → 200,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video,
|
||
BYGGET OG LIVE samme dag (2026-07-25):** brukeren lastet opp en 26
|
||
sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og
|
||
ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat.
|
||
Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker-
|
||
container, ikke installert på verten -- ryddet opp etterpå). Innhold
|
||
identifisert: score-fordeling (par/bogey/dobbel bogey/verre),
|
||
snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut +
|
||
per-hulltype + kryss med fairway + score-splitt, bom-retning på green
|
||
som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype
|
||
+ med/uten GIR, én-putt% etter puttlengde + lengdefordeling hit/miss,
|
||
chip-fordeling, scrambling%/sand save%, bunker/straffeslag per runde +
|
||
score-splitt.
|
||
**Bevisste valg for å unngå plagiat, skrevet inn i selve V0-promptet:**
|
||
egen visuell identitet (TeeCups presise grønn/oransje, ikke konkurrentens
|
||
fargekoding), fritt valg av chart-type/layout/rekkefølge til V0 selv,
|
||
INGEN kopiering av konkurrentens eksakte ordlyd/fargekoding/ikonografi.
|
||
Puttlengde-bøttene i promptet er TeeCups EGNE seks bøtter
|
||
(<1m/<2m/<3m/<5m/<8m/8m+, samme som ADR-033 allerede lagrer) -- bevisst
|
||
ANDRE enn videoens fem bøtter (<1m/1-2/2-4/4-8/+), både fordi det unngår
|
||
en direkte kopi og fordi det er det datamodellen faktisk allerede
|
||
produserer. "Lengste drive" (krever GPS/avstandsmåling appen ikke har)
|
||
utelatt fra promptet.
|
||
**Zip 14 mottatt og integrert samme dag:** diffet mot live-treet FØR noe
|
||
ble tatt inn (samme rutine som alltid) -- kun to reelt nye filer
|
||
(`components/round-stats.tsx`, en ny `RoundStats`-skjerm bygget med
|
||
kollapsbare `StatCard`-seksjoner, conic-gradient-donuter med sentertall,
|
||
avviks-stolper fra en null-linje, og et kompass-rutenett for bom-retning
|
||
-- en tydelig ANNEN visuell løsning enn konkurrentens skjermbilder, ikke
|
||
en klone). Resten av eksporten var V0s vanlige uvitende reverts, inkl.
|
||
sin egen `/rounds/[id]`-ruteversjon fra FØR `/my-rounds`-omdøpingen
|
||
(se lenger opp, "Reell produksjonsbug...") -- korrekt hoppet over. Ny
|
||
rute lagt til som `app/my-rounds/[id]/stats/page.tsx` (ikke
|
||
`app/rounds/[id]/stats` som eksporten foreslo), samme
|
||
kollisjon-unngåelse. `app/globals.css` sin nye `--chart-1..6`
|
||
data-viz-fargeskala (god->dårlig-skala, brukt sammen med
|
||
tekst-/tall-etiketter) slått sammen inn i alle tre temablokkene;
|
||
V0s egne, mer omtrentlige `--primary`/`--ring`/`--brand-orange`-verdier
|
||
i SAMME diff ble bevisst IKKE tatt inn -- beholdt de presise OKLCH-
|
||
verdiene utregnet i ADR-016.
|
||
**Datalag skrevet fullstendig om fra mock:** ny `computeStats()`-
|
||
funksjon i `round-stats.tsx` regner ut alt fra rå `GET .../holes`-data
|
||
(samme endepunkt round-detail.tsx allerede bruker -- ingen ny backend).
|
||
Hver seksjon skjules helt når det ikke finnes nok data for den (samme
|
||
"vis kun det som faktisk finnes"-prinsipp som `ScoreSoFar`). Ny enkel
|
||
spillervelger (pill-rad) lagt til når runden har flere deltakere,
|
||
default til eieren. `CompletedBanner` i round-detail.tsx fikk en "Se
|
||
full rundestatistikk"-lenke (fantes i V0s egen, ellers reverterte fil --
|
||
portert manuelt inn i vår live versjon i stedet for å ta hele filen).
|
||
**Matematikken verifisert FØR utrulling, ikke bare "kompilerer":**
|
||
`computeStats()`-logikken portert til et frittstående Node-script og
|
||
kjørt mot et hånd-etterregnet 6-hulls syntetisk datasett (blandet par
|
||
3/4/5, fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle utledede
|
||
tall (score-kategorier, snitt-til-par per hulltype, fairway-splitt,
|
||
GIR%/kryss-fairway/score-splitt, bom-retning, putt-fordeling,
|
||
scrambling%, sand save%) stemte med manuell utregning. Ekte typesjekket
|
||
produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet som
|
||
ny rute).
|
||
**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon,
|
||
`/health`/`/my-rounds` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Rundeliste-info + scorekort-redesign, BYGGET OG LIVE samme dag
|
||
(2026-07-25):** brukeren delte en ny skjermopptaksvideo (video 2, 18
|
||
frames analysert ved 1,5 fps) av to problemer: "Egne runder"-listen
|
||
manglet ALL score-informasjon (kun tee/hull/dato/spillerantall/status),
|
||
og selve scorekort-registreringen (rapportert "veldig dårlig
|
||
designet") stablet fem ulike kontrolltyper (numpad, retningskors,
|
||
steppere, pill-rutenett, pill-rad) uten hierarki, pluss at "Runde
|
||
fullført"-siden gjentok NØYAKTIG samme tall tre ganger (CompletedBanner-
|
||
differensial, "Så langt"-oppsummeringslinjen, og StatPill-rutenettet
|
||
under). Bedt om å reflektere kort og eventuelt skrive V0-prompt(er).
|
||
**Reflektert og handlet i samme runde** (ikke bare skrevet ned): pekte ut
|
||
at score/til-par mangler helt fra listekortet som det klareste,
|
||
konkrete hullet; anbefalte å samle alt utover Slag/Putter bak én
|
||
kollapsbar "Flere detaljer"-seksjon som den viktigste enkeltfiksen for
|
||
scorekort-rotet; og fikset duplikat-problemet PÅ "Runde fullført"-siden
|
||
DIREKTE (ikke via V0) siden det bare er snakk om å SKJULE innhold, ikke
|
||
designe noe nytt: `ScoreSoFar` skjules nå helt når runden er fullført
|
||
(`{!completed && <ScoreSoFar .../>}`) -- CompletedBanner sin "Se full
|
||
rundestatistikk"-lenke dekker akkurat det samme, langt grundigere.
|
||
**Ny backend-beregning FØR V0-prompten** (slik at prompten kunne
|
||
referere til ekte, tilgjengelig data): `_load_round_out`
|
||
(`app/routers/rounds.py`) kjører nå en liten aggregatspørring mot
|
||
`round_hole` for eierens egen deltaker-rad ved hver lasting --
|
||
`owner_holes_played`/`owner_total_score`/`owner_score_to_par` (null
|
||
til minst ett hull er registrert). Verifisert med 10 scratch-sjekker,
|
||
inkl. at en gjests egen score IKKE lekker inn i eierens aggregat.
|
||
**To V0-prompter skrevet** (round-card-redesign med prominent
|
||
score-flis/hull-fremdrift-bar/differensial; scorekort-redesign med
|
||
Slag+Putter+Avstand-første-putt alltid synlig og alt annet bak "Flere
|
||
detaljer", pluss en bevisst ikke-bygget reservasjon av layout-plass for
|
||
en fremtidig avstandsmåling-funksjon brukeren bekreftet er planlagt).
|
||
**Zip 15 og 16 mottatt samme dag** (samme v0.app-prosjekt, kontinuerlig
|
||
-- `round-card.tsx`/`own-rounds.tsx` byte-for-byte identiske i begge
|
||
zip-ene; zip 16s `round-detail.tsx` var den nyeste med selve
|
||
kollaps-redesignet, zip 15s tilsvarende fil manglet det og ble derfor
|
||
forkastet til fordel for zip 16).
|
||
`round-card.tsx`: ny `ScoreTile` -- prominent resultat+til-par-flis
|
||
(fargekodet under/over par uten å stole på farge alene, tekst+tall
|
||
alltid med) for fullførte/scorede runder, en hull-fremdrift-progressbar
|
||
("6/18 hull spilt") for runder som fortsatt pågår, pluss en HCP-
|
||
differensial-chip i metadata-raden når runden faktisk telte. Fullt
|
||
`aria-label` på hele kortet for skjermlesere. `own-rounds.tsx` sitt
|
||
datalag skrevet om fra mock til ekte fetch, kobler de nye `owner_*`-
|
||
feltene fra backend + eierens `score_differential` (kun vist når
|
||
`counts_for_handicap`).
|
||
`round-detail.tsx`: ny kollapsbar "Flere detaljer"-seksjon (lukket som
|
||
default, `ChevronDown`-rotasjon på toggle) som nå rommer kølle/utslag-
|
||
retning/innspill-retning/chip-bunker-straffeslag/anywayslag -- Slag,
|
||
Putter og Avstand første putt forblir alltid synlig over kollapsen,
|
||
uendret rekkefølge fra den forrige runden. **Viktig integrasjonsdisiplin:**
|
||
V0s eksport hadde reversert til en MYE eldre mock-baseline (manglet
|
||
StatLevel-gating, Anywayslag, bøtte-basert puttlengde, kølle-bag fra
|
||
profil, maxValue-capping, "Hullet er spilt"-avkrysningen som bevisst BLE
|
||
FJERNET en tidligere runde, "Tid brukt" i CompletedBanner) -- kun selve
|
||
kollaps-mekanismen og plasseringen av "Flere detaljer" ble hentet ut og
|
||
lagt oppå den fullt oppdaterte, LIVE koden. Ingen av de tidligere
|
||
byggede funksjonene gikk tapt. Lagt til en kort kode-kommentar (ikke
|
||
noe bygget UI) som reserverer plass ved siden av hull-headeren til en
|
||
fremtidig avstandsmåling-indikator.
|
||
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet
|
||
(alle 19 ruter). Ingen ny scratch-backend-runde nødvendig utover
|
||
owner-score-testen over (ingen ny domenelogikk i selve rundeliste-/
|
||
scorekort-visningen, kun lesing av allerede-testede felt).
|
||
**Rullet ut live 2026-07-25**, ren frontend-endring + den lille
|
||
backend-tilføyelsen over, ingen migrasjon, `/health`/`/my-rounds` →
|
||
200, `teeoff.no` upåvirket.
|
||
|
||
**Manglende scorekort på statistikk-siden, funnet og fikset SAMME dag
|
||
(2026-07-25):** brukeren spurte rett etter forrige runde: "Hvor er
|
||
scorekortet? (Gjerne også med statistikk?)" -- et ekte, uforutsett hull
|
||
i forrige rundes endring. Da `ScoreSoFar` ("Så langt i runden") ble
|
||
skjult for fullførte runder (for å fjerne dobbel informasjon, se over),
|
||
forsvant OGSÅ det eneste stedet den rå hull-for-hull-tabellen (Hull/
|
||
Par/Score/Netto/Sum) fantes -- ingen tilsvarende tabell ble noensinne
|
||
lagt til på den nye `round-stats.tsx`-siden, som kun inneholdt UTLEDET/
|
||
aggregert statistikk (donuter, kategori-stolper, avviks-visualiseringer),
|
||
aldri de faktiske rå tallene per hull. Med andre ord: brukeren fikk
|
||
riktignok fjernet duplikatet, men mistet samtidig tilgang til selve
|
||
scorekortet -- en reell regresjon, ikke bare en presentasjonsdetalj.
|
||
**Fikset ved å legge til en ny "Scorekort"-seksjon FØRST på
|
||
`round-stats.tsx`** (før "Scorer"-kategoriseksjonen), åpen som default
|
||
(i motsetning til resten av seksjonene som starter kollapsbare men
|
||
åpne -- denne er det brukeren eksplisitt spurte etter, så den skal ikke
|
||
kreve et ekstra trykk). Samme tabellmønster som `ScoreSoFar` sin
|
||
tidligere tabell hadde (Hull/Par/Score/Netto/Stableford/Sum, Stableford-
|
||
kolonnen vist kun når netto er beregnbart). To type-tilføyelser var
|
||
nødvendig: `start_hole: number` lagt til `round-stats.tsx` sin
|
||
`ApiRound`-type (manglet fra før, siden ingen tidligere seksjon på
|
||
denne siden trengte rundens faktiske start-/rekkefølge) og
|
||
`strokes_received: number | null` lagt til `ApiHole`-typen (feltet kom
|
||
allerede fra backend -- lagt til i forrige runde for `list_holes` --
|
||
men ble aldri lest av denne siden før nå). Ny `holeOrder`/
|
||
`orderedHoles`-beregning i `RoundStats` gjenbruker EKSAKT samme
|
||
sirkulære start_hole-formel som round-detail.tsx, slik at en 9-hulls
|
||
runde som starter på hull 10 vises i riktig spillerekkefølge (10-18),
|
||
ikke bare rå hullnummer 1-9.
|
||
**Lærdom notert for fremtidige runder:** når et helt panel flyttes/
|
||
skjules for å fjerne duplisert informasjon, må hver del av det gamle
|
||
panelet spores til et nytt hjem FØR det fjernes -- ikke bare de delene
|
||
som åpenbart var "statistikk". Denne runden ble oppdaget kun fordi
|
||
brukeren faktisk lette etter scorekortet rett etterpå, ikke ved egen
|
||
verifisering før utrulling.
|
||
Ekte typesjekket produksjonsbuild kjørt og bekreftet. **Rullet ut live
|
||
2026-07-25**, ren frontend-endring, ingen backend/migrasjon,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Scorekort-visning: ny presentasjonsregel + V0-prompt skrevet, IKKE
|
||
bygget ennå (2026-07-25):** brukeren delte et referansebilde av et
|
||
tradisjonelt fysisk golf-scorekort (horisontal layout, hull 1-9/10-18
|
||
som KOLONNER, Slope/Par/Score/Net som rader, "Ut"/"Inn"-sum-kolonne
|
||
YTTERST TIL HØYRE for hver ni-hulls-halvdel) og formulerte en generell
|
||
presentasjonsregel: **listes hullene horisontalt (som kolonner), skal
|
||
summeringen stå TIL HØYRE; listes hullene vertikalt (som rader), skal
|
||
summeringen stå UNDER.** Den nylig byggede "Scorekort"-seksjonen på
|
||
`round-stats.tsx` (se punktet rett over) lister hull VERTIKALT (én rad
|
||
per hull) med en løpende sum-KOLONNE innimellom hver rad -- ikke i tråd
|
||
med regelen (en vertikal liste burde hatt en avsluttende sumrad
|
||
UNDERST, ikke en kolonne). Bedt om å tenke gjennom dette og skrive et
|
||
V0-prompt for et "fabelaktig" scorekort inspirert av (ikke et plagiat
|
||
av) referansebildet.
|
||
**Bevisste avvik fra referansebildet for å unngå plagiat, skrevet inn i
|
||
selve promptet:** egen visuell identitet (TeeCups grønn/oransje, ikke
|
||
bildets rød-sirkel/blå-firkant-fargekoding for birdie/bogey), egen
|
||
term ("Hcp" for hullets slag-fordelings-rangering -- IKKE "Slope", som
|
||
i golf-terminologi betyr banens/tee-ens helhetlige vanskelighetsgrad,
|
||
et helt annet tall enn det bildet faktisk viser per hull), en ekstra
|
||
Stableford-rad (finnes ikke i referansen, men vi beregner det allerede
|
||
andre steder), og INGEN kopiering av bildets spiller-header-komposisjon
|
||
(navn/HCP/posisjon-boksen) -- kun selve tabell-strukturen er
|
||
inspirasjonskilden.
|
||
**Presist håndtert i promptet, IKKE triviell:** appen støtter allerede
|
||
vilkårlig `start_hole` + `holes_planned` (9 ELLER 18, sirkulær
|
||
rekkefølge, se "Manglende scorekort"-punktet over) -- en STIV
|
||
fysisk "hull 1-9 er alltid Ut" ville vært feil for en 9-hulls runde som
|
||
starter på hull 10. Promptet ber derfor om ÉN 9-kolonners blokk når
|
||
`holes_planned=9`, TO blokker (første/andre halvdel AV SPILLEREKKEFØLGEN,
|
||
ikke nødvendigvis fysisk hull 1-9/10-18) når `holes_planned=18` -- den
|
||
faktiske hull-til-blokk-tildelingen løses av meg i datalaget ved
|
||
integrering, som med alle tidligere V0-runder.
|
||
**Venter på V0-eksport** før noe bygges. Når den kommer, erstatter den
|
||
den eksisterende vertikale "Scorekort"-tabellen på `round-stats.tsx`
|
||
(bygget rett over samme dag) -- ikke en ny, separat skjerm.
|
||
**Notert som en generell prinsipp-lærdom, relevant utover akkurat dette
|
||
scorekortet:** samme horisontal-til-høyre/vertikal-til-under-regel bør
|
||
vurderes senere for turnering-scorekortet (`session-scorecard.tsx`,
|
||
match-play) den dagen det scorekortet også skal pusses -- ikke i
|
||
omfang nå, bare notert for konsistens.
|
||
|
||
**Presisering av promptet samme dag, FØR noe ble sendt til V0:**
|
||
brukeren spurte eksplisitt om et "if REALLY necessary"-unntak for
|
||
horisontal scroll (opprinnelig formulering) faktisk fanget opp målet
|
||
om ALDRI å måtte scrolle. Vurdert og svart nei -- reell breddekonflikt,
|
||
ikke bare en formulering-detalj: 9 hull + 1 sum-kolonne (+ en
|
||
label-kolonne) får ikke plass på en telefonskjerm med normal
|
||
"lesbar uten briller"-tekststørrelse, og et unntak formulert som en
|
||
myk fallback ville sannsynligvis latt V0 falle tilbake til scroll uansett,
|
||
siden tilgjengelighetskravet rett under ga et påskudd. **Løst ved å
|
||
gjøre "ingen scroll" til et HARDT krav i promptet, OG eksplisitt fortelle
|
||
V0 hvordan det oppnås** (kompakte, fete, høykontrast-siffer i selve
|
||
rutenettet -- samme konvensjon som et fysisk scorekort bruker, også
|
||
synlig i brukerens eget referansebilde -- mens "lesbar uten
|
||
briller"-kravet eksplisitt avgrenses til labels/knapper/løpetekst, ikke
|
||
hvert enkelt rutenett-siffer). Smale forkortede rad-labels ("Hcp",
|
||
"Par", "Score", "Netto") i en trang venstre-gutter i stedet for en bred
|
||
tekstkolonne, for å frigjøre bredde til de 9 hull-kolonnene.
|
||
|
||
**Zip 17 mottatt, horisontalt scorekort BYGGET OG LIVE samme dag
|
||
(2026-07-25):** V0 leverte akkurat det det reviderte promptet ba om --
|
||
en EKTE HTML `<table>` med `<colgroup>` (fast `w-9`-label-kolonne, auto
|
||
for hver hull-kolonne, `w-[11%]` sum-kolonne) og `table-fixed`, ingen
|
||
scroll-container noe sted. Score-cellene bruker FORM (sirkel = under
|
||
par, firkant = over par) + fylt/ufylt (fylt = 2+ slag av) i stedet for
|
||
farge alene, pluss en egen liten symbolforklaring under rundesammendraget
|
||
-- tilfredsstiller "aldri stole på farge alene" uten å kopiere
|
||
referansebildets rød-sirkel/blå-firkant-konvensjon.
|
||
**Egen, ny dedikert side** `/my-rounds/[id]/scorecard`
|
||
(`components/round-scorecard.tsx`, ny `app/my-rounds/[id]/scorecard/
|
||
page.tsx`) -- IKKE slått sammen inn i `round-stats.tsx`, siden V0
|
||
designet komponenten med sin egen fulle side-chrome (sticky header,
|
||
rundesammendrag-strip), ikke som et innebygd tabell-fragment. Dette gir
|
||
en klar arbeidsdeling: `round-scorecard.tsx` = det rå scorekortet,
|
||
`round-stats.tsx` = utledet/aggregert statistikk -- samme prinsipp som
|
||
allerede etablert for `ScoreSoFar` vs. `round-stats.tsx` tidligere denne
|
||
økten.
|
||
**Bevisst forkastet fra V0s eksport:** V0s egen `strokesReceived()`-
|
||
funksjon var en generisk `Math.floor(hcp/18) + (index <= hcp%18 ? 1 :
|
||
0)`-modulo-formel -- byttet ut med backend sin ALLEREDE beregnede
|
||
`strokes_received` per hull (samme `allocate_strokes_by_index()` som
|
||
resten av appen bruker), for å unngå to parallelle, potensielt
|
||
avvikende implementasjoner av HCP-slagfordeling i samme app. Samme
|
||
sirkulære `start_hole`-rekkefølge som `round-detail.tsx`/
|
||
`round-stats.tsx` bruker for Ut/Inn-blokkene. Ny spillervelger
|
||
(pill-rad) lagt til for runder med flere deltakere, samme mønster som
|
||
`round-stats.tsx`.
|
||
**Opprydning i `round-stats.tsx`:** den midlertidige vertikale
|
||
"Scorekort"-tabellen (bygget tidligere samme dag som en rask fiks for
|
||
"Hvor er scorekortet?") er FJERNET og erstattet med en enkel, prominent
|
||
"Se scorekort"-lenke til den nye siden, rett under rundesammendraget --
|
||
`round-stats.tsx` er dermed nå rendyrket aggregert/utledet statistikk,
|
||
det rå scorekortet finnes kun ett sted. `stablefordPoints()`-
|
||
hjelpefunksjonen og `holeOrder`/`orderedHoles`-beregningen i
|
||
`round-stats.tsx`, som kun eksisterte for den fjernede tabellen, fjernet
|
||
som dødt kode.
|
||
**V0 la selv til en to-knappers layout i `CompletedBanner`**
|
||
(round-detail.tsx) -- "Se scorekort" (primær, fylt) ved siden av den
|
||
eksisterende "Se full rundestatistikk" (sekundær, omrisset) -- tatt inn
|
||
uendret bortsett fra å rette `/rounds/...`-hrefs til `/my-rounds/...`.
|
||
**Samtidig, urelatert, rapportert av bruker midt i integreringen:**
|
||
Anywayslag manglet helt fra `round-stats.tsx` sin "Chip, bunker og
|
||
straffeslag"-seksjon (verken `anyway_strokes` i `ApiHole`-typen eller
|
||
noe utledet total fantes). Lagt til `anyway_strokes` i typen, ny
|
||
`anywayPerRound`-aggregat i `computeStats()`.
|
||
**Feilrettet SAMME dag, rett etterpå:** min første fiks slo sammen
|
||
Anywayslag-tallet INN I den eksisterende "Chip, bunker og
|
||
straffeslag"-seksjonen og omdøpte HELE seksjonen til "Annet" -- feil,
|
||
påpekt av bruker. Rettet: "Chip, bunker og straffeslag" beholder sitt
|
||
opprinnelige navn og innhold UENDRET (Bunkerslag/Straffeslag-flisene
|
||
tilbake til to, ikke tre). Ny, EGEN "Annet"-seksjon lagt til RETT ETTER
|
||
den (ikke slått sammen), med kun Anywayslag-statistikk: total per runde
|
||
+ andel hull med minst ett anywayslag (samme "andel hull med..."-mønster
|
||
som straffeslag-statistikken). Ny `pctHolesWithAnyway`-beregning i
|
||
`computeStats()`. Den midlertidige `StatTileRow`-komponenten (bygget for
|
||
den feilaktig sammenslåtte tre-flis-varianten) fjernet igjen som dødt
|
||
kode -- `StatTilePair` (fast to fliser) er tilstrekkelig for begge
|
||
seksjonene nå. Notert for senere: brukeren ser for seg at et
|
||
fritekst-notatfelt havner i "Annet"-seksjonen etter hvert.
|
||
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet,
|
||
ny rute `/my-rounds/[id]/scorecard` listet. Ingen ny backend-endring
|
||
(`anyway_strokes`/`strokes_received` fantes allerede i `RoundHoleOut`).
|
||
**Rullet ut live 2026-07-25** (to runder, feilrettingen rullet ut rett
|
||
etter originalen), ren frontend-endring, ingen migrasjon, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
|
||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
||
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
|
||
`handicap_engine.py`: `net_par`, `max_hole_score_for_handicap`,
|
||
`adjusted_gross_score`, `round_half_up_decimal`, `score_differential`,
|
||
`handicap_index_from_differentials`, `low_handicap_index`,
|
||
`apply_index_caps`, `course_handicap_9_raw`/`course_handicap_9`.
|
||
**Bevisst avvik fra opprinnelig plan, instruert av bruker 2026-07-22:**
|
||
"Expected Score" (Rule 3.2b) sin upubliserte formel erstattes gjennomgående
|
||
av WHS sin egen, presist definerte "Net Par"-term (par + mottatte
|
||
handicapslag = 2 Stableford-poeng) for uspilte hull — gjelder BÅDE
|
||
ufullstendige 18-hulls-runder og konvertering av en 9-hulls-runde til
|
||
18-hulls-ekvivalent (Rule 5.1b sin egen separate 9-hulls-differensial-
|
||
formel er dermed bevisst IKKE implementert, se punkt 5 over).
|
||
**41/41 tester bestått** (`test_handicap_engine.py`, kjørt uten pytest —
|
||
ikke installert i miljøet, kun den innebygde selvsjekk-runneren), 17 nye
|
||
i tillegg til de 24 eksisterende. Flere verifisert mot regelbokens EGNE
|
||
tallregneeksempler, ikke bare intern konsistens: Rule 5.2a sine to
|
||
initial-indeks-eksempler (13,2 og 34,1, samt oppfølgingen til 37,4),
|
||
Rule 5.1c sine tre avrundingseksempler, Diagram 5.8 sin soft-/hard-cap-
|
||
oppførsel, og Diagram 3.1b sin Net-Double-Bogey-capping (der front-9 ble
|
||
verifisert eksakt mot diagrammet, mens back-9 sine åtte ikke-annoterte
|
||
scorer bevisst ble egenkomponert pga. usikker bilde-lesing av akkurat de
|
||
sifrene — se testens egen kommentar for full transparens om hva som er
|
||
kildebelagt og hva som ikke er det).
|
||
**Gjenstår:** migrasjon (nye tabeller for runde/deltaker/statistikk),
|
||
API-lag, frontend — ingen av disse er startet.
|
||
|
||
### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034)
|
||
|
||
Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per
|
||
match i stedet for per økt, samme klokkeslett for alle grupper), uten
|
||
reell avhengighet til rundeførings-arkitekturen over. Ikke behandlet
|
||
videre her.
|
||
|
||
### Ikke besluttet, gjenstår før bygging kan starte
|
||
|
||
- Den globale banekatalogen for EGENDEFINERTE (ikke-teeoff) baner
|
||
(del av Beslutning C) — naturlig konsekvens av live-oppslag-
|
||
beslutningen, men ikke bekreftet punkt for punkt ennå.
|
||
- Gjest-e-post-kobling (Beslutning D) — notert, ikke designet i detalj.
|
||
- Manuell vs. auto-beregnet HCP-indeks etter at motoren finnes
|
||
(Beslutning G) — notert, ikke avgjort.
|
||
- Skal turnering-scoring til slutt bruke SAMME statistikk-modell (reist i
|
||
brainstorm-runden 2026-07-22, FEATURE_BACKLOG.md) — ikke avgjort, ingen
|
||
konsekvens for denne ADR-ens omfang uansett.
|
||
- Eksakte tabellnavn/skjema (`round`/`round_participant`/
|
||
`round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig
|
||
fastlagt) — avgjøres ved migrasjonsskriving.
|
||
|
||
**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.**
|
||
Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-
|
||
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
|
||
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
||
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
||
**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:**
|
||
(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget
|
||
og testet i `handicap_engine.py` (43/43 tester), (2) full
|
||
databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_
|
||
participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot
|
||
ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget,
|
||
scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og
|
||
redeployet (`teecup_api`). **Gjenstår: HELE frontend-en** — ingenting
|
||
bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om
|
||
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
|
||
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.
|
||
|
||
**Sju punkter fra faktisk bruk, BYGGET OG LIVE 2026-07-24** (rapportert av
|
||
bruker som selv testet scorekort-skjermen): (1) `currentHole` respekterte
|
||
aldri `round.start_hole` (`useState<number>(1)` + `prev || start_hole` —
|
||
`1` er truthy i JS, så `||` ble en no-op), fikset ved å bruke `null` som
|
||
"ikke satt ennå"-tilstand og en sirkulær 18-hulls navigasjonsrekkefølge
|
||
fra starthullet. Trolig rot-årsak til det samtidig rapporterte GIR-
|
||
avviket: selve formelen (`slag − putter ≤ par − 2`) var allerede
|
||
matematisk identisk med regelen brukeren beskrev, men feil hull i fokus
|
||
ga feil par inn i en ellers korrekt formel. (2) Kølle-bag på personlig
|
||
profil — 28 faste kølletyper (`BAG_CLUBS` i `app/routers/auth.py`,
|
||
speilet i frontend), maks 14 (den ekte golfregelen, håndhevet i både
|
||
Pydantic og en CHECK-constraint), brukt som knapp-utvalg for "kølle
|
||
brukt ved utslaget" for runde-eieren (gjester har ingen profil, beholder
|
||
fritekst). (3) Nytt statistikkfelt "Anywayslag", siste punkt i "Flere
|
||
detaljer", samme tallvelger-stil som slag/putter (ikke en liten
|
||
stepper). (4) Valgfritt statistikknivå per deltaker
|
||
(`strokes_only`/`strokes_and_putts`/`full`, ny kolonne `round_participant.
|
||
stat_level`, default `strokes_only` — brukerens egen presisering: kun
|
||
slag er strengt tatt nødvendig for resultat/HCP). Nytt PATCH-endepunkt
|
||
`/rounds/{id}/participants/{id}` for å endre nivået underveis. (5) Putt-
|
||
avstand endret fra fritekst-tall til seks faste bøtter (`<1m`…`8m+`) —
|
||
`round_hole.first_putt_distance_m` (numeric) erstattet med
|
||
`first_putt_distance_bucket` (text+CHECK) i migrasjon
|
||
`022_round_stats_and_bag.sql`, ingen produksjonsdata å bevare (0 rader
|
||
hadde verdi). (6) "Hullet er spilt"-avkrysningen FJERNET helt — reelt
|
||
overflødig, `played` settes allerede automatisk når et slagtall velges.
|
||
(7) Direkte spørsmål om avkrysningens funksjon avdekket at brukeren
|
||
egentlig ville vite om `played` (bevisst enkel avledning, uendret) — i
|
||
samme svar reiste brukeren "plukket opp"-behovet for et fremtidig
|
||
Stableford-format (fanget i FEATURE_BACKLOG.md, IKKE bygget — krever en
|
||
helt egen scoring-format-designrunde).
|
||
**Punkt 6 (numpad-layout + retningskors + sveip-vurdering) BYGGET OG
|
||
LIVE 2026-07-24, samme dag** — V0-prompten (se FEATURE_BACKLOG.md)
|
||
kjørt av bruker, zip 13 mottatt og integrert. **V0s egen designbeslutning
|
||
på sveip-spørsmålet:** IKKE et sveip-panel, men trykk-baserte
|
||
"Score"/"Statistikk"-faner (`PanelTabs`) inni samme kort — begrunnet
|
||
med at skjermen allerede har to horisontalt scrollende rader
|
||
(spillerfaner, hull-navigasjon), så en tredje sveiperetning ville vært
|
||
forvirrende. Vurdert som et godt, veloverveid valg, beholdt uendret.
|
||
Retningskors (`DirectionCross`/`DirButton`, piler + `Target`-ikon)
|
||
brukt for Utslag (venstre/senter/høyre) og Innspill (fullt 5-veis
|
||
kors), tekstlabel beholdt på hver knapp (ikke ikon-only). Tallvelgerne
|
||
fikk ny numpad-layout (3 kolonner, `h-16`-knapper, fyller bredden).
|
||
**Reelt integreringsarbeid, ikke ren om-kabling:** siden V0 ikke kjente
|
||
til dagens datalag (bygget i en tidligere, separat runde samme dag),
|
||
måtte `PanelTabs`/`DirectionCross` flettes inn i EKSISTERENDE, allerede
|
||
fungerende kode — ikke erstatte den. Konkret: `PanelTabs` vises kun når
|
||
`statLevel==="full"` (ingen "Statistikk"-fane å bytte til ellers),
|
||
"Score"-innholdet (Slag, evt. Putter) vises direkte uten fane-UI når
|
||
nivået er lavere. `DirectionCross` erstattet kun de to `ChoiceRow`-
|
||
kallene for Utslag/Innspill — `ChoiceRow` selv beholdt uendret til
|
||
resten (Kjønn, Statistikknivå, putt-avstand-bøtter). Kølle-bag-picker,
|
||
anywayslag, putt-bøtter, `stat_level`-gating, merge-før-PATCH,
|
||
starthull-fiksen og alle `/my-rounds`-lenker fra tidligere samme dag
|
||
ALLE bevart uendret -- kun presentasjonslaget for tallvelgere/retning
|
||
byttet ut. V0s egen `PlayedToggle` (som den ikke visste var fjernet)
|
||
ble bevisst IKKE tatt inn igjen. Ekte typesjekket produksjonsbuild
|
||
kompilerte rent. **Rullet ut live**, kun `teecup_frontend` (+ vanlig
|
||
`teecup_api`-bivirkning), ingen migrasjon, `teeoff.no` upåvirket.
|
||
Zip 13 slettet fra prosjektroten.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 18 sjekker — kølle-bag
|
||
lagret/hentet riktig, ukjent kølletype avvist (422), mer enn 14 køller
|
||
avvist (422), `stat_level`-default og eksplisitt verdi ved
|
||
opprettelse/gjest-tilføyelse, PATCH av `stat_level` vedvarer etter
|
||
refetch, `anyway_strokes`/`first_putt_distance_bucket` lagret riktig,
|
||
ugyldig bøtte-verdi avvist (422), OG en bevisst re-bekreftelse av
|
||
merge-før-PATCH-kontrakten (et PATCH uten `anyway_strokes` nuller den
|
||
fortsatt — samme grunnkontrakt som forrige runde, ikke endret av denne
|
||
utvidelsen). `test_isolation.sql` fortsatt 12/12. Ekte typesjekket
|
||
produksjonsbuild kompilerte rent.
|
||
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||
migrasjon 022 kjørt mot ekte `teecup_db` (alle nye kolonner/CHECK-er
|
||
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
|
||
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
|
||
ekte https, `teeoff.no` upåvirket.
|
||
|
||
**Tre nye brukerpunkter, ALLE BYGGET, SCRATCH-VERIFISERT OG LIVE
|
||
2026-07-25:**
|
||
1. **Enkeltbane-anlegg dropper det overflødige banenavnet.** Brukeren
|
||
påpekte at Tjøme Golfklubb (og de aller fleste andre norske anlegg —
|
||
kartlagt ved å skanne alle 174 teeoff-anlegg: kun 15 har mer enn én
|
||
bane) fikk et unødvendig sammensatt navn ("Tjøme Golfklubb –
|
||
Hovedbanen") siden anlegget uansett bare har ÉN bane. Fikset i BEGGE
|
||
stedene navnet bygges (`app/routers/courses.py` sin
|
||
`import_official_course`, `app/routers/rounds.py` sin
|
||
`_resolve_teeoff_course`): har `facility["courses"]` nøyaktig ett
|
||
element, brukes kun anleggsnavnet; har det flere (bekreftet fortsatt
|
||
riktig for f.eks. Ålesund Golfklubb, som har "Solnør Gaard" og "Moa
|
||
Golfsenter"), beholdes det kombinerte "Anlegg – Bane"-navnet uendret.
|
||
Ingen migrasjon (ren navnelogikk, ingen lagret data endret av seg selv
|
||
— kun FREMTIDIGE importer/runde-opprettelser får det nye navnet).
|
||
2. **Runder kan nå navngis** (`round.name`, migrasjon
|
||
`024_round_name.sql`, nullable). Valgfritt felt lagt til
|
||
`RoundCreate`/`RoundUpdate`/`RoundOut` i `app/routers/rounds.py` — PATCH-
|
||
kontrakten følger samme "tom streng = fjern, utelatt = ikke rør"-mønster
|
||
som resten av `RoundUpdate` sine felt (ikke et ekte `exclude_unset`,
|
||
konsistent med hvordan `holes_planned`/`start_hole` allerede
|
||
håndteres i samme endepunkt). Vises på tvers av `round-card.tsx`
|
||
(rundeliste), `round-detail.tsx` (header + redigerbart i "Rediger
|
||
runde"-panelet), `round-scorecard.tsx` og `round-stats.tsx` — alle
|
||
fire faller tilbake til `course_name_snapshot` når navn ikke er satt,
|
||
og viser banenavnet som sekundær informasjon når et eget navn ER satt
|
||
(ikke bare erstattet det, siden banen fortsatt er relevant
|
||
informasjon).
|
||
3. **Land+hjemmeklubb i profilen, redesignet (ADR-031/032s
|
||
`ProfileOnboarding`/`ProfileSection`).** Brukeren ba om at Land skal stå
|
||
FØR Hjemmeklubb, og at Hjemmeklubb skal være en nedtrekksliste fra
|
||
teeoff, filtrert på valgt land. Avklart eksplisitt med bruker
|
||
(AskUserQuestion, begge anbefalte valg): "Land" er nå en ekte
|
||
`<select>` (`COUNTRIES = ["Norge"]`, ett element foreløpig — bevisst
|
||
klargjøring for fremtidig flerspråklighet, ingen backend-endring
|
||
trengs når flere land legges til, kun listen utvides), og "Hjemmeklubb"
|
||
er en søkbar kombinasjons-boks (`HomeClubField`, debounce 250ms) som
|
||
gjenbruker det ALLEREDE EKSISTERENDE `/rounds/official-search`-
|
||
endepunktet (org-uavhengig, tilgjengelig for enhver innlogget bruker
|
||
siden ADR-033) — ingen ny backend-kode i det hele tatt. Teeoff filtrerer
|
||
allerede bort upubliserte/nedlagte anlegg server-side (`is_published`),
|
||
så "nedlagte og ikke operative klubber skal ikke inkluderes" er dekket
|
||
uten egen filtrering på teecup-siden. Begge komponentene delt mellom
|
||
`ProfileOnboarding` (obligatorisk profil-fullføring) og `ProfileSection`
|
||
(kontoinnstillinger) i `account-settings.tsx`, ingen duplisert
|
||
implementasjon.
|
||
**Bevisst IKKE bygget:** hard validering som avviser fritekst utenfor
|
||
søkeresultatene — eksisterende, allerede lagrede `home_club`-verdier
|
||
(fritekst fra før denne runden) forblir gyldige og redigerbare, feltet
|
||
oppfører seg som "skriv for å filtrere, klikk for å velge" fremfor en
|
||
strengt låst `<select>`, for å unngå å gjøre eksisterende profiler
|
||
utilgjengelige.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container, samt et direkte
|
||
engangs-oppslag mot ekte `teeoff_api` for å kartlegge hvilke anlegg som
|
||
faktisk har mer enn én bane): 22 sjekker — enkeltbane-navn uten suffiks
|
||
(Tjøme), flerbane-navn med suffiks uendret (Ålesund), rundenavn lagret
|
||
ved opprettelse, rundenavn med i rundeliste, PATCH omdøper, PATCH med
|
||
tom streng fjerner navnet, PATCH uten navn-felt lar det urørt,
|
||
`official-search`-endepunktet (brukt av det nye profilfeltet) fortsatt
|
||
fungerer identisk. `test_isolation.sql` fortsatt 12/12. Ekte
|
||
typesjekket produksjonsbuild kompilerte rent, alle 21 ruter listet.
|
||
**Rullet ut mot ekte systemer 2026-07-25**, bruker bekreftet
|
||
eksplisitt: migrasjon 024 kjørt mot ekte `teecup_db` (kolonne
|
||
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
|
||
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
|
||
ekte https, `teeoff.no` upåvirket.
|
||
|
||
---
|
||
|
||
## 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 BYGGET OG SCRATCH-VERIFISERT
|
||
2026-07-25** — 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. **IKKE rullet ut mot ekte systemer ennå** — venter på
|
||
brukerens bekreftelse. **Frontend IKKE bygget** — V0-prompt skrevet i
|
||
FEATURE_BACKLOG.md, klar til å kjøres (ny rute foreslått som
|
||
`/my-friends`, ikke `/friends` — det er nå API-prefikset). Fase 2
|
||
(rundevisibilitet) og fase 3 (ekte medspillere, inkl. den nylig avklarte
|
||
"skriv for hele flighten"-regelen) er fortsatt kun designet, ikke bygget.
|
||
Se "Venner og kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for
|
||
full detalj og V0-prompten.
|
||
|
||
---
|
||
|
||
## Åpne spørsmål (ikke besluttet ennå)
|
||
|
||
Disse må avklares før eller under de relevante fasene:
|
||
|
||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
||
`JWT_SECRET`. TeeCup bør bruke separate, uavhengige secrets. *(Sikkerhet)*
|
||
2. **Cache over flere prosesser:** In-memory `dict`-cache på `app.state` deles ikke
|
||
mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
|
||
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
|
||
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
|
||
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
|
||
UI-løsning spesifiseres senere.
|
||
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
|
||
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
|
||
på at f.eks. et foursome ikke får per-spiller-scorer.
|
||
|
||
---
|
||
|
||
## Utviklingsplan (rekkefølge)
|
||
|
||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||
2. ✅ Denne beslutningsloggen (dette dokumentet)
|
||
3. ✅ Handicap-motor som frittstående, testet bibliotek (ADR-005) — 24 tester, R&A-verifisert
|
||
4. ✅ Databaseskjema (`001_initial_schema.sql`) — RLS, miksede formater, pool
|
||
5. ⏩ Backend-API + regelmotor-integrasjon (neste)
|
||
6. ⬜ Frontend (admin + score-registrering)
|
||
7. ⬜ PWA & offline-synk
|