Historikk-panelet flyttet ut av selve slagvinduet/-arket i begge flyter: inn i PlayerHoleCards-kortet (frittstående runder), som nytt "overview"- steg med auto-hopp når tomt (org-turneringer). Klikk åpner nå en ny delt full-historikk-skjerm (hole-history-detail.tsx) med aggregerte tall og en score-fordeling som fargede stolper -- samme CategoryBar-mønster som round-stats.tsx, ingen ny graf-avhengighet. Ingen backend-endring. tsc + 45/45 vitest, scratch-verifisert i begge flyter inkl. kombinert historikk på tvers av runde-/turneringstyper og auto-hopp for hull uten historikk (lys+mørk). Rullet ut. Med dette er alle tre forbedringene i score-registreringen (ADR-077/078/079) fullført. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7828 lines
452 KiB
Markdown
7828 lines
452 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.
|
||
|
||
**Merk (2026-07-26), presisert og utvidet:** brukeren bekreftet at ønsket
|
||
er STØRRE enn Københavner som ett format blant flere — TeeCup skal etter
|
||
hvert støtte ekte INDIVIDUELLE turneringer (helt uten lag) som eget
|
||
generelt tilfelle, disse skal kunne gå over FLERE RUNDER, og det skal
|
||
være mulig å sette opp et Order of Merit (sesong-sammenlagt på tvers av
|
||
flere separate arrangementer). Full analyse, inkl. en reell strukturell
|
||
kollisjon med ADR-033 sitt bevisst org-uavhengige rundesystem, i
|
||
FEATURE_BACKLOG.md ("Utvidelse 2026-07-26: individuelle turneringer,
|
||
flerrunde-turneringer, og Order of Merit"). Ren notat-runde, ingen ADR
|
||
skrevet ennå.
|
||
|
||
---
|
||
|
||
## ADR-012 — To scoring-moduser per økt
|
||
|
||
**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per
|
||
hull) eller `hole_result` (registrer bare hvem som vant hullet / delt).
|
||
|
||
**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller
|
||
raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang.
|
||
|
||
**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og
|
||
hull-resultat). `hole_result` skriver til ny tabell `match_hole_result`
|
||
(vinnende side per hull, NULL = delt). Begge mater samme
|
||
`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus.
|
||
|
||
---
|
||
|
||
## ADR-013 — Blind draw via lås per lag per økt
|
||
|
||
**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har
|
||
låst. Modellert med tabell `lineup_lock` (én rad per lag per økt).
|
||
|
||
**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og
|
||
oppgjørene avsløres samtidig.
|
||
|
||
**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men
|
||
motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge
|
||
lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet
|
||
(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme
|
||
organisasjon (jf. ADR-010).
|
||
|
||
---
|
||
|
||
## ADR-014 — Konfigurerbar handicap-pipeline: fire uavhengige brytere
|
||
|
||
**Beslutning:** Handicap-anvendelsen i en økt er FIRE uavhengige, valgfrie steg —
|
||
ikke bare én allowance-prosent:
|
||
|
||
1. **Bruk handicap** (av/på) — helt av gir scratch-spill.
|
||
2. **Bruk course handicap** (av/på) — om slope/rating-justeringen
|
||
(`course_handicap_raw`) påføres, eller om rå `handicap_index` brukes direkte.
|
||
3. **HCP-prosent** (allowance-strategien, allerede dekket av ADR-005 /
|
||
`session.allowance_override`).
|
||
4. **Bruk matchplay-handicap** (av/på) — om resultatet konverteres til relative
|
||
slag (`match_play_strokes`: beste enhet spiller «av 0», resten får
|
||
differansen), eller brukes som absolutt Playing Handicap.
|
||
|
||
**Begrunnelse:** Skjermbilder fra Golf GameBook (referanseprodukt, delt
|
||
2026-07-16) viser nøyaktig denne firedelte bryter-strukturen per runde/format i
|
||
deres oppsettsdialog. Deres standard-prosenter (foursome 50 %, better ball/
|
||
fourball 90 %, singles 100 %) stemmer eksakt med `DEFAULT_MATCHPLAY_ALLOWANCES`
|
||
i `handicap_engine.py` — uavhengig bekreftelse på at defaultene er riktige,
|
||
slik ADR-005 krever verifisert. Motoren støtter allerede alle fire som atskilte,
|
||
komponerbare funksjonskall (`course_handicap_raw`, en `AllowanceStrategy`,
|
||
`match_play_strokes`) — hver kan hoppes over uavhengig av de andre — men
|
||
API-et/skjemaet eksponerer dem ikke som brytere ennå.
|
||
|
||
**Konsekvens:** `session.allowance_override` (jsonb) skal utvides til å bære
|
||
alle fire bryterne, ikke bare prosent, når motor-/scoring-integrasjonen bygges
|
||
(se FEATURE_BACKLOG.md). Ingen skjemaendring nødvendig (feltet er allerede
|
||
jsonb), men API-lagets validering og motor-kall må håndtere: `bruk_handicap =
|
||
false` → hopp over hele kjeden (brutto = netto), `bruk_course_handicap = false`
|
||
→ bruk `handicap_index`/`handicap_index_snapshot` direkte uten
|
||
slope/rating-justering, `bruk_matchplay_handicap = false` → bruk absolutt
|
||
Playing Handicap i stedet for å kalle `match_play_strokes`.
|
||
|
||
---
|
||
|
||
## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt
|
||
|
||
**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session`
|
||
får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes`
|
||
(minutter mellom hver "flight"). En matchs `tee_time` er
|
||
`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved
|
||
lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak:
|
||
`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må
|
||
justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien
|
||
når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av
|
||
`hole_config`).
|
||
|
||
**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier
|
||
at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver
|
||
flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett
|
||
tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av
|
||
synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der
|
||
den avledede regelen ikke holder.
|
||
|
||
**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon
|
||
`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir
|
||
`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet
|
||
PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved
|
||
verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher.
|
||
|
||
**Beslutning B — én uniform feilrespons på tvers av HELE API-et:**
|
||
`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster
|
||
`HTTPException` (39 steder ved innføring), via en liten factory
|
||
`app_error(status_code, code, message)` i `app/errors.py` — ingen egen
|
||
exception-klasse, ingen global exception handler. Kodene er en liten,
|
||
gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) —
|
||
f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`,
|
||
`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst
|
||
UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`.
|
||
|
||
**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne
|
||
runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend —
|
||
den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et
|
||
forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt
|
||
kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk
|
||
inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å
|
||
måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å
|
||
ettermontere når klientkode allerede har begynt å parse tekststrenger.
|
||
|
||
**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke
|
||
`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se
|
||
`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer
|
||
noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en
|
||
semantisk feil kode.
|
||
|
||
**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:**
|
||
`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en`
|
||
foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten
|
||
sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side
|
||
(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra
|
||
forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som
|
||
ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse
|
||
overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen
|
||
endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå
|
||
(egen, senere sak — profilinnstillinger).
|
||
|
||
**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens
|
||
valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP.
|
||
At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en
|
||
enhet) IKKE skal endre den lagrede preferansen er bevisst: en
|
||
innloggingshandling bør ikke ha den overraskende bivirkningen å endre
|
||
brukerens varige profilinnstilling.
|
||
|
||
---
|
||
|
||
## ADR-016 — Frontend som eneste offentlige overflate, API-et server-side proxyet
|
||
|
||
**Beslutning:** `teecup.teeoff.no` peker (Caddy) på Next.js-frontenden
|
||
(`teecup_frontend`), IKKE direkte på FastAPI-et. Frontenden proxyer selv
|
||
kjente API-sti-prefikser (`/auth/*`, `/orgs/*`, `/health` — som til sammen
|
||
dekker HELE dagens API-overflate, verifisert med et grep av samtlige
|
||
rutedefinisjoner) videre til `teecup_api:8000` server-side, via Next.js sin
|
||
egen `rewrites()`-mekanisme (`frontend/next.config.mjs`). API-et er ikke
|
||
lenger separat Caddy-rutet eller offentlig eksponert under eget navn.
|
||
|
||
**Begrunnelse:** Alt kjører dermed under samme opprinnelse (origin) sett fra
|
||
nettleseren — ingen CORS-konfigurasjon trengs, og HttpOnly-sesjonscookien
|
||
(ADR-009) fungerer helt uendret enten kallet "egentlig" går til frontend
|
||
eller API. Alternativet (eget subdomene for API-et, `SameSite=None`-cookie
|
||
eller CORS-hull) ville svekket cookie-sikkerhetsmodellen som allerede var
|
||
bevisst bygget stram. Samme mønster generaliserer til alle fremtidige
|
||
skjermer uten videre Caddy-endringer — nye API-ruter under `/auth`, `/orgs`
|
||
trenger ingen ny proxy-regel, kun nye Next.js-sider som kaller dem med
|
||
relative URL-er.
|
||
|
||
**Reell fallgruve funnet og fikset ved bygging (2026-07-17):** Next.js sin
|
||
`rewrites()` løses ved BUILD-tid for `output: "standalone"` (bakes inn i
|
||
server-bunten), ikke ved container-oppstart. En `docker run -e
|
||
TEECUP_API_ORIGIN=...` ved kjøretid ble derfor stille ignorert (falt tilbake
|
||
til default `localhost:8000`, som ikke fantes i containeren — proxy-kall
|
||
feilet med `ECONNREFUSED`). Løst med en Docker build-time `ARG
|
||
TEECUP_API_ORIGIN` (default `http://teecup_api:8000`, matcher alltid det
|
||
delte nettverkets tjenestenavn) i `frontend/Dockerfile`, satt via
|
||
`docker-compose.yml` sin `build.args`. Generell lærdom for fremtidige
|
||
Next.js/Docker-oppsett i dette prosjektet: alt som brukes inne i
|
||
`next.config.mjs` er en BUILD-tids verdi, ikke en runtime-verdi, med mindre
|
||
det eksplisitt leses på nytt et sted som faktisk kjører per request (en
|
||
route handler, ikke selve config-filen).
|
||
|
||
**Konsekvens:** Enhver fremtidig ny API-sti-prefiks (utenfor `/auth`,
|
||
`/orgs`, `/health`) MÅ legges til i `frontend/next.config.mjs` sin
|
||
`rewrites()`-liste, ellers blir den utilgjengelig fra nettleseren selv om
|
||
API-et selv fungerer (kun nåbar internt på Docker-nettverket). `teecup_api`
|
||
sin port er ikke lenger tenkt nåbar direkte utenfra i prod.
|
||
|
||
---
|
||
|
||
## ADR-017 — Selvregistrering og utvidet spillerprofil
|
||
|
||
**Kontekst:** Reist av brukeren rett etter at lag/roster-skjermen var live.
|
||
Dagens modell antar at organisator kjenner og legger inn alle spillere selv
|
||
— i praksis vet organisator ofte ikke hvem som faktisk blir med før de
|
||
melder seg på selv.
|
||
|
||
**Beslutning A — Påmelding er offentlig, krever IKKE innlogging.** En
|
||
delbar lenke (`/register/{tournament_id}` — turneringens UUID er allerede
|
||
uforutsigelig nok, ingen ny token-mekanisme) viser et minimalt skjema. Ingen
|
||
magic-link, ingen konto kreves for å melde seg på.
|
||
|
||
**Begrunnelse:** Å kreve innlogging FØR man kan melde seg på er unødvendig
|
||
friksjon for "jeg blir med lørdag"-bruksmønsteret. Kontosammenkobling skjer
|
||
gratis senere (Beslutning B), ikke som et eget steg i selve påmeldingen.
|
||
|
||
**Beslutning B — E-post er sammenkoblingsnøkkelen** mellom en organisator-
|
||
forhåndsopprettet `player`-rad og en spiller som senere melder seg selv på
|
||
eller logger inn. Finnes det en `player`-rad i org-en med samme e-post ved
|
||
påmelding, fylles manglende felt inn på DEN raden i stedet for å opprette en
|
||
duplikat. `player.user_id` kobles først når noen med matchende e-post
|
||
faktisk logger inn via magic-link — `verify_magic_link` utvides til også å
|
||
slå opp org-scopede `player`-rader på e-post, ikke bare `app_user`.
|
||
|
||
**Konsekvens:** `player.email` er bevisst IKKE en `UNIQUE`-constraint —
|
||
familier deler av og til e-post (forelder melder på barn), en hard unik-
|
||
regel ville krasje akkurat den vanlige situasjonen. Matching er et mykt,
|
||
applikasjonslags-oppslag.
|
||
|
||
**Beslutning C — Påmelding er et eget, lettvekts steg, atskilt fra
|
||
`team_roster`**, med konfigurerbar godkjenning, kapasitet og samtykke. Ny
|
||
tabell `tournament_registration` (status: `pending`/`confirmed`/
|
||
`waitlisted`/`declined`/`withdrawn`). Tre nye felt på `tournament`:
|
||
`registration_capacity` (nullable — organisators valg om det i det hele
|
||
tatt skal være en grense), `registration_overflow_policy`
|
||
(`waitlist`/`closed`, kun relevant når kapasitet er satt),
|
||
`registration_requires_approval` (boolean). Rekkefølge ved en ny
|
||
påmelding: (1) er fristen passert → avvis; (2) er kapasitet nådd →
|
||
`waitlisted` eller avvis, avhengig av policy; (3) ellers `pending` eller
|
||
`confirmed`, avhengig av godkjenningsbryteren.
|
||
|
||
**Begrunnelse:** `team_roster` betyr i dag "committed til et bestemt lag".
|
||
Å blande "vil kanskje spille" med "spiller garantert" i samme tabell ville
|
||
gjort det umulig å skille en påmeldt-men-ikke-plukket spiller fra en som
|
||
aldri var interessert. Kapasitet og godkjenning er to reelle, uavhengige
|
||
organisator-beslutninger — å låse ett svar for alle turneringer ville vært
|
||
feil for minst noen av dem (brukeren bekreftet eksplisitt: begge skal være
|
||
konfigurerbare valg, ikke faste regler).
|
||
|
||
**Beslutning D — Utvidet spillerprofil + samtykke.** Nye felt på `player`:
|
||
`mobile`, `email`, `birth_date` (IKKE alder — alder blir feil neste år,
|
||
fødselsdato er ikke det), `nickname`, `country`, `club`,
|
||
`club_member_number`. Samtykke er obligatorisk ved påmelding — API-et
|
||
avviser innsending (400) uten `consent: true`, og
|
||
`tournament_registration.consent_given_at` er beviset på at det faktisk ble
|
||
gitt, ikke bare antatt. Samtykket lever på REGISTRERINGEN, ikke på
|
||
`player`, fordi det er selve påmeldingshandlingen for DENNE turneringen
|
||
samtykket knytter seg til.
|
||
|
||
**Ikke et nytt felt:** "utslagssted for anledningen" er sannsynligvis
|
||
allerede dekket av `match_participant.tee_id` (per match, ikke per
|
||
spillerprofil, siden det kan variere fra runde til runde).
|
||
|
||
**Beslutning E — `public_tournament_org()`: den eneste broen fra en
|
||
uautentisert forespørsel til riktig RLS-kontekst.** Et offentlig
|
||
påmeldingskall kjenner en turnering-id, men ikke organisasjonen den hører
|
||
til — og uten `app.current_org` satt slipper RLS ingen rader gjennom
|
||
(heller ikke selve oppslaget for å FINNE riktig org). Løst med en snever
|
||
`SECURITY DEFINER`-SQL-funksjon (`p_tournament_id -> organization_id`, kjørt
|
||
med skaperens BYPASSRLS-rettigheter, `SET search_path = public` mot
|
||
kapring) — samme "løs kontekst-problemet FØR RLS kan håndheve noe"-mønster
|
||
som den selvrefererende org-bootstrapen (`app/routers/organizations.py`),
|
||
nå for et lese-oppslag i stedet for en innsetting.
|
||
|
||
**Konsekvens:** App-laget slår opp org-id via denne funksjonen FØRST, åpner
|
||
deretter en vanlig `org_connection(org_id)` og fortsetter med normal
|
||
RLS-håndhevelse for alt det faktiske arbeidet. Funksjonen eksponerer KUN en
|
||
uuid->uuid-kobling, ingenting annet fra `tournament`-raden — et bevisst
|
||
smalt unntak, ikke en generell RLS-omgåelse. Ethvert fremtidig offentlig
|
||
(uautentisert) endepunkt som trenger å slå opp org-kontekst fra en kjent
|
||
ressurs-id bør gjenbruke akkurat dette mønsteret, ikke finne opp et nytt.
|
||
|
||
**Migrasjon:** `007_registration_and_player_fields.sql`. Kontosammenkoblingen
|
||
i Beslutning B (e-post → `player.user_id` ved innlogging) fikk sin egen
|
||
`SECURITY DEFINER`-bro (`link_player_by_email()`, samme mønster som
|
||
Beslutning E) i en oppfølgende migrasjon, `008_link_player_by_email.sql`.
|
||
|
||
---
|
||
|
||
## ADR-018 — Landingssider: synlighet, org-profil, delbart innhold
|
||
|
||
**Kontekst:** Reist av brukeren rett etter at registrerings-API-et
|
||
(ADR-017) var live — turneringer og organisasjoner bør ha egne, delbare
|
||
landingssider (hero, tekst, program, sponsorer, påmelding for turnering;
|
||
klubbprofil + turneringsliste for organisasjon). Brukeren krevde eksplisitt
|
||
at synlighet må være et VALG: offentlig, kun org-medlemmer, eller kun
|
||
turnering-deltakere.
|
||
|
||
**Beslutning A — Trenivås synlighet, ett felt per nivå.**
|
||
`tournament.visibility` (`'public'`/`'org'`/`'participants'`, default
|
||
`'org'`) og `organization.public_profile` (boolean, default `false`).
|
||
Trygg standard: ingen eksisterende eller nyopprettet turnering/org blir
|
||
offentlig av seg selv. "Kun deltakere" gir ikke mening på org-nivå (en
|
||
organisasjon har ikke "deltakere"), derfor en enklere bryter der, ikke
|
||
samme trenivå-enum som turnering.
|
||
|
||
**Beslutning B — RLS beskytter TENANT-grenser, ikke INNHOLDS-synlighet.**
|
||
`org_isolation`-policyen håndhever kun at org A aldri ser org B sin data —
|
||
den sier ingenting om hvem INNENFOR riktig org-kontekst som får se hva.
|
||
Det viste seg at det eksisterende `GET /public/tournaments/{id}` (ADR-017)
|
||
allerede leser fullt turneringsinnhold uten noen synlighetssjekk i det
|
||
hele tatt, fordi RLS er fornøyd så snart org-konteksten er riktig satt —
|
||
uansett hvem (eller om noen) som spør.
|
||
|
||
**Konsekvens:** `visibility` MÅ håndheves eksplisitt i applikasjonslaget på
|
||
hvert offentlig lese-endepunkt (og på registrering, se Beslutning D), ikke
|
||
forventes løst av RLS.
|
||
|
||
**Beslutning C — Ny autorisasjonsvei: "deltaker".** For
|
||
`visibility='participants'` må leseren enten være org-medlem, ELLER
|
||
innlogget med en `player`-rad (koblet via `player.user_id`, ADR-017
|
||
Beslutning B) som har en `tournament_registration`- eller
|
||
`team_roster`-rad for NØYAKTIG denne turneringen. Ny
|
||
`get_current_user_optional`-avhengighet i `app/auth.py` — som
|
||
`get_current_user`, men returnerer `None` i stedet for å kaste 401, siden
|
||
et offentlig endepunkt skal fungere for en anonym leser også (bare med
|
||
`visibility='public'`-tilgang).
|
||
|
||
**Beslutning D — Registrering følger SAMME synlighetsgrense som
|
||
landingssiden.** Kan du ikke se turneringen, kan du heller ikke melde deg
|
||
på den — ingen særbehandling av `/register`-endepunktet.
|
||
|
||
**Beslutning E — Turnering-landingsside: innhold nå, bilder som inerte
|
||
felt.** Nye felt: `tournament.description` (presenterende tekst),
|
||
`tournament.hero_image_key` (inert til MinIO-runden — kun kolonnen, ingen
|
||
opplastingslogikk, sparer en fremtidig migrasjon for null kostnad nå). Ny
|
||
tabell `tournament_sponsor` (navn+lenke aktivt, `logo_key` inert av samme
|
||
grunn). Program vises via `GET /public/tournaments/{id}/sessions`, som
|
||
gjenbruker `_fetch_sessions()` (nytt uttrekk fra `tournaments.py` sin
|
||
`list_sessions`, delt mellom den innloggede og den offentlige ruten) —
|
||
blind draw-hemmelighold (ADR-013) arves automatisk, ikke reimplementert.
|
||
**Bevisst utenfor omfang denne runden:** lag-/roster-visning på
|
||
landingssiden (må også respektere blind draw-lås) — egen, senere sak.
|
||
|
||
**Beslutning F — Org-landingsside: slug + tredje SECURITY DEFINER-bro.**
|
||
`organization.slug` (unik når satt, `CHECK (NOT public_profile OR slug IS
|
||
NOT NULL)`). `GET /public/orgs/{slug}` løser org via
|
||
`public_org_by_slug(slug)` — samme mønster som `public_tournament_org()`/
|
||
`link_player_by_email()` (007/008), nå tredje instans. Returnerer `NULL`
|
||
for BÅDE "finnes ikke" og "finnes, men er privat" — samme
|
||
anti-enumerering som magic-link (ADR-009), ikke en tilfeldighet at
|
||
mønsteret gjentas.
|
||
|
||
**Bevisst utsatt til egen, senere runde:** hero-/sponsorbilder (krever
|
||
MinIO — arkitektur-invarianten finnes fra før, se CLAUDE.md, men er aldri
|
||
satt opp i praksis).
|
||
|
||
**Migrasjon:** `009_landing_pages_and_visibility.sql`.
|
||
|
||
---
|
||
|
||
## ADR-019 — Offisiell banedata: import (kopi), ikke live oppslag
|
||
|
||
**Kontekst:** ADR-004 vedtok prinsippet (lesende API, ikke delt database) helt
|
||
i starten av prosjektet, men ble aldri bygget — kun `source='custom'`-baner
|
||
fantes (organisator taster inn banen selv). Brukeren påpekte dette rett etter
|
||
program-skjerm-runden (2026-07-18): det er en reell mangel at en organisator
|
||
må taste inn en bane manuelt når banen faktisk allerede finnes i teeoff.
|
||
|
||
**Kartlagt før noe ble besluttet** (lest `/opt/teeoff/backend/main.py`, kun
|
||
lesing): `GET /api/facilities/{slug}` er offentlig, uten auth/API-nøkkel, og
|
||
returnerer allerede `courses[]` med `holes[]` (`hole_number`, `par`,
|
||
`hcp_index`) og `tees[]` (`name`, `cr_men`/`slope_men`, `cr_women`/
|
||
`slope_women`) — nøyaktig det `hole`/`tee`/`tee_rating`-tabellene i
|
||
`teecup_db` (migrasjon 001) allerede modellerer. `GET /api/facilities?
|
||
view=search` gir en lettvekts liste (`id`, `slug`, `name`, `city`, `county`,
|
||
…) egnet for søk. Par/hcp_index kan være NULL i teeoffs skjema (ufullstendig
|
||
baneregistrering) — teecups `hole`-tabell krever begge NOT NULL.
|
||
|
||
**Beslutning A — Import ved organisators eksplisitte valg, ikke live
|
||
oppslag ved hver bruk.** Organisator søker blant teeoff sine baner, velger
|
||
én, og teecup kopierer da bane+hull+tee+tee_rating INN i `teecup_db` som en
|
||
vanlig `course`-rad med `source='official'` og
|
||
`external_course_ref = '{facility_slug}:{teeoff_course_id}'`. Etter import
|
||
er raden en helt vanlig lokal `course`-rad — økt-/match-/handicap-koden
|
||
(som allerede kun kjenner `course_id` som fremmednøkkel) trenger INGEN
|
||
endring.
|
||
|
||
**Begrunnelse:** Handicap-motoren joiner `tee_rating`/`hole` direkte i SQL
|
||
midt i en database-transaksjon (`app/handicap.py`) — et live HTTP-kall til
|
||
teeoff derfra ville krevd at hver matchberegning avhenger av teeoffs
|
||
oppetid, og ville ikke vært transaksjonssikkert. Import fryser dataene på
|
||
importtidspunktet — samme reproduserbarhets-prinsipp som handicap-
|
||
snapshotten i ADR-007 (`team_roster.handicap_index_snapshot`): endrer
|
||
teeoff en rating i etterkant, skal ikke en allerede opprettet turnering
|
||
plutselig regne annerledes. Dette er også det som gjør ADR-004s opprinnelige
|
||
"et brudd ett sted skal ikke ta ned begge produktene"-begrunnelse reell: en
|
||
teeoff-nedetid blokkerer kun NYE importer, ikke bruk av allerede importerte
|
||
baner.
|
||
|
||
**Beslutning B — Server-til-server, internt Docker-nettverk, ingen ny
|
||
hemmelighet.** `teecup_api` kaller `http://teeoff_api:8000` direkte (samme
|
||
`teeoff_default`-nettverk begge allerede deler) — ikke via Caddy/det
|
||
offentlige domenet. Teeoff sitt API har ingen auth-mekanisme på disse
|
||
endepunktene i det hele tatt, så ingen ny credential trengs. CORS-listen på
|
||
teeoff-siden (ekskluderer teecups origin) er irrelevant — CORS gjelder kun
|
||
nettleser-fetch, ikke et backend-til-backend-kall. Ny avhengighet: `httpx`
|
||
(async HTTP-klient), lagt til i `app/requirements.txt`.
|
||
|
||
**Beslutning C — Ufullstendige teeoff-data feiler importen tydelig, importen
|
||
skjer aldri delvis.** Mangler et hull `par`/`hcp_index`, eller mangler en
|
||
tee både `cr_men` OG `cr_women`, avvises HELE importen med en klar feil
|
||
(`code: EXTERNAL_DATA_INCOMPLETE`) FØR noe skrives — ikke en delvis
|
||
importert bane med hull som senere feiler i handicap-beregning. Hele
|
||
importen kjører i én DB-transaksjon (`org_connection` sin eksisterende
|
||
`conn.transaction()`), så en feil midtveis ruller automatisk tilbake.
|
||
|
||
**Beslutning D — Kun `full_18`-rating importeres.** Teeoff har ingen egen
|
||
front9/back9-rating i sitt skjema (kun én CR/slope per tee, kjønnsdelt).
|
||
Samme valg som ADR-008 allerede gjorde bevisst for egendefinerte baner: den
|
||
formelle WHS 9-hulls-metoden er ikke i bruk, 18-hulls-tildelingen med uttak
|
||
av spilte hull dekker front/back-økter.
|
||
|
||
**Beslutning E — Reimport av samme teeoff-bane er en feil, ikke en
|
||
duplikat-rad.** Ny partiell unik indeks `(organization_id,
|
||
external_course_ref) WHERE external_course_ref IS NOT NULL` (migrasjon
|
||
`010`) — importerer en organisator samme teeoff-bane to ganger, avvises det
|
||
med den eksisterende `DUPLICATE`-koden (409), ikke en ny rad med samme
|
||
banedata.
|
||
|
||
**Konsekvens:** `app/teeoff_client.py` (ny, ren HTTP-klient, ingen
|
||
db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
|
||
`app/routers/courses.py` utvides med `GET .../courses/official-search` og
|
||
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_
|
||
ref.sql`.
|
||
|
||
**Tillegg 2026-08-03 (research, ikke bygget):** samme import-ved-eksplisitt-
|
||
valg-prinsipp er identifisert som riktig mal for en fremtidig internasjonal
|
||
banekilde (golfapi.io, for baner utenfor Norge/`teeoff_db`s dekning) — en
|
||
tredje `course.source`-verdi + `app/golfapi_client.py` ved siden av
|
||
`app/teeoff_client.py`, ikke en ny arkitektur. Ingen ADR-runde startet ennå,
|
||
kun notert. Full detalj i FEATURE_BACKLOG.md, «Internasjonale baner».
|
||
|
||
---
|
||
|
||
## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, rett etter at "bygg i rekkefølgen
|
||
ting brukes"-serien var ferdig. Tre relaterte, men separate mangler:
|
||
|
||
1. Den eneste veien inn til en turnering er en direkte lenke (`/t/[id]`) eller
|
||
org-ens klubbside (kun `public`-synlige turneringer). En spiller som bare
|
||
har fått muntlig beskjed ("du spiller lørdag") har i dag ingen vei inn i
|
||
det hele tatt — dette var reelt ikke gjennomtenkt tidligere.
|
||
2. Leaderboardet viser kun FAKTISK opptjente poeng. Ingen visning av hva
|
||
stillingen ville blitt om pågående, ikke-avgjorte matcher holder seg som
|
||
de står nå (vanlig i profesjonell golf-TV-dekning av Ryder Cup).
|
||
3. Ingen visuell indikasjon i matchlister på hvem som leder en pågående
|
||
match — brukeren viste et skjermbilde av referanseproduktets fargekodede
|
||
matchrader (rød/beige etter ledende side) som ønsket retning.
|
||
|
||
**Beslutning A — Kort, menneske-skrivbar invitasjonskode per turnering, som
|
||
OVERSTYRER `tournament.visibility`.** Ny `tournament.join_code` (6 tegn, fra
|
||
et alfabet uten forvekslingsbare tegn — `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`,
|
||
altså uten `0/O/1/I`), generert automatisk ved opprettelse, globalt unik
|
||
(kodeoppslag skjer FØR org-kontekst er kjent, samme problem som
|
||
`public_tournament_org()` løste for ADR-017). Login-skjermet får et eget
|
||
kode-felt (fungerer FØR innlogging) som løser koden til riktig turnering og
|
||
sender brukeren til `/t/{id}?code=...`.
|
||
|
||
**Bevisst valgt fremfor å la koden respektere synlighet:** en kode gitt
|
||
muntlig eller på en lapp ER selve invitasjonen — å likevel kreve org-
|
||
medlemskap eller deltakerstatus for en `org`/`participants`-synlig turnering
|
||
ville gjort koden verdiløs for akkurat den situasjonen den er ment å løse.
|
||
Koden er ikke hemmelig i sikkerhetsforstand (den er MENT å deles), men
|
||
6 tegn fra et 33-tegns alfabet (≈1,3 milliarder kombinasjoner) gjør blind
|
||
gjetting upraktisk uten separat rate-limiting — akseptabelt for et
|
||
tillitsbasert klubb-/vennegjeng-verktøy (samme trusselmodell-resonnement som
|
||
`team_authz.py`, se «Brukerroller» i FEATURE_BACKLOG.md).
|
||
|
||
**Konsekvens:** `GET /public/tournaments/{id}` og
|
||
`POST /public/tournaments/{id}/register` godtar en valgfri `code`-parameter;
|
||
matcher den turneringens `join_code` (case-insensitivt), hoppes den vanlige
|
||
`_check_visibility()`-sjekken helt over. Ny SECURITY DEFINER-bro
|
||
`public_tournament_by_code(code) RETURNS uuid` (tournament_id) — fjerde
|
||
instans av samme mønster som `public_tournament_org()` (007),
|
||
`link_player_by_email()` (008), `public_org_by_slug()` (009). Ingen
|
||
kode-regenerering bygget denne runden (organisator kan i dag ikke bytte ut
|
||
en lekket kode) — egen, senere sak i FEATURE_BACKLOG.md om det blir
|
||
etterspurt.
|
||
|
||
**Beslutning B — Projisert stilling: pågående matchers nåværende leder får
|
||
full poengsum, uavgjort/ikke-startet splittes likt.** For hver IKKE avgjort
|
||
match brukes `match.leading_side` (se Beslutning C) til å tildele hele
|
||
øktens `points_per_match` til den ledende siden i den PROJISERTE summen —
|
||
"AS" (all square) eller en match som ennå ikke har noen registrerte hull
|
||
splittes 0,5/0,5, samme regel som en faktisk halvert match. Avgjorte
|
||
matcher bidrar likt til både faktisk og projisert sum (de er jo allerede
|
||
det de blir). Speiler hvordan TV-dekning av Ryder Cup vanligvis viser
|
||
"hvis det sluttet nå"-tavler.
|
||
|
||
**Beslutning C — Ny cachet kolonne `match.leading_side`, samme mønster som
|
||
`status_text`/`points_side_a/b`.** `recompute_and_cache_match_state()`
|
||
(`app/routers/scoring.py`) beregner den allerede tilgjengelige
|
||
`MatchState.lead`-verdien (fortegn = ledende side) ved HVER hull-innsending
|
||
uansett om matchen er avgjort ennå — lagres nå også i en egen kolonne i
|
||
stedet for kun å ligge innbakt i den menneskelesbare `status_text`-strengen
|
||
("2 UP (A)"), slik at frontend kan style etter et strukturert felt
|
||
(`"a" | "b" | null`) i stedet for å parse norsk/engelsk tekst. Brukes til
|
||
BÅDE projisert stilling (Beslutning B) og fargekoding av matchlister
|
||
(Beslutning D).
|
||
|
||
**Beslutning D — Fargekoding er ren frontend-presentasjon, ingen ny
|
||
backend-modell.** Matchlister (blind draw sin avslørte visning, ev. flere
|
||
steder senere) farger den ledende sidens kant/bakgrunn med lagets EKSISTERENDE
|
||
`team.color` når `leading_side` er satt og matchen ikke er avgjort — samme
|
||
fargekilde som resten av appen (leaderboard, roster) allerede bruker, ingen
|
||
ny fargemodell innført.
|
||
|
||
**Migrasjon:** `011_join_code_and_leading_side.sql`.
|
||
|
||
---
|
||
|
||
## ADR-021 — Passord (valgfritt tillegg) + valgfri 2FA (TOTP eller e-post)
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, sammen med et konkret sesjons-
|
||
problem: dagens magic-link-cookie (ADR-009, 30 dager) fungerte visstnok ikke
|
||
som tiltenkt — brukeren måtte be om ny innloggingskode ved HVERT besøk til
|
||
`teecup.teeoff.no`. **Diagnostisert og FIKSET samme dag** (ikke en del av
|
||
ADR-021s videre omfang, men verdt å nevne her siden det var starten på denne
|
||
tråden): kodegjennomgangen av `app/auth.py`/`app/routers/auth.py` fant ingen
|
||
feil i selve cookie-settingen, og brukerens ekte, ferske cookie (hentet fra
|
||
nettleseren på forespørsel) bekreftet 30 dagers levetid, `Secure`/`HttpOnly`/
|
||
`SameSite=Lax` alt korrekt. Rot-årsaken var derfor IKKE en cookie-/backend-
|
||
bug, men en manglende sjekk i frontend: `app/page.tsx` (rot-siden) viste
|
||
ALLTID innloggingsskjemaet uten noensinne å sjekke om en gyldig sesjon
|
||
allerede fantes — `Dashboard` sjekket `/auth/me` og sendte til `/` ved
|
||
MANGLENDE sesjon, men ingenting gjorde det motsatte. Fikset med en
|
||
server-side sesjonssjekk (leser cookien via `next/headers`, kaller
|
||
`/auth/me` direkte mot `TEECUP_API_ORIGIN` server-til-server, samme mønster
|
||
som `generateMetadata` i `app/t/[id]/page.tsx`) som sender en allerede
|
||
innlogget bruker rett til `/dashboard`. Verifisert med brukerens ekte cookie:
|
||
uten cookie → 200 (skjema), med gyldig cookie → 307 til `/dashboard`. Rullet
|
||
ut live, kun `teecup_frontend`, ingen migrasjon, `teeoff.no` upåvirket.
|
||
|
||
Utover selve bugen ønsket brukeren eksplisitt: e-post/brukernavn+passord
|
||
(må håndtere spesialtegn og mellomrom korrekt) SOM ET TILLEGG til
|
||
(ikke erstatning for) den passordløse innloggingen, samt topartsautentisering.
|
||
|
||
**Beslutning A — Passord er et valgfritt, sidestilt alternativ, ikke
|
||
påkrevd.** Login-skjermet får en tredje modus (ved siden av e-post-magic-link
|
||
og invitasjonskode, ADR-020) for e-post+passord. En bruker setter selv et
|
||
passord når hen ønsker det (egen "sett passord"-handling, krever en allerede
|
||
gyldig sesjon — samme "du må bevise identitet FØRST" som andre sensitive
|
||
endringer) — INGEN eksisterende eller ny bruker tvinges til å sette passord.
|
||
Magic-link fortsetter å fungere uendret for alle, uavhengig av om et passord
|
||
i tillegg er satt.
|
||
|
||
**Beslutning B — Passord-hashing: Argon2id, ikke bcrypt.** Bcrypt trunkerer
|
||
stille ved 72 BYTES (et kjent fallgruve-mønster — to ulike passord som deler
|
||
de første 72 bytene hasher likt) og har historiske NUL-byte-kvirker i enkelte
|
||
implementasjoner. Siden brukeren eksplisitt ber om korrekt håndtering av
|
||
spesialtegn/mellomrom (dvs. lengre, mer varierte passord/passfraser er
|
||
forventet brukt), velges Argon2id (`argon2-cffi`) — minnehardt, OWASPs
|
||
anbefalte standard i dag, ingen lengde-fallgruve. Lagres i ny
|
||
`app_user.password_hash` (nullable — NULL betyr "ikke satt", faller da
|
||
tilbake til kun magic-link).
|
||
|
||
**Beslutning C — 2FA: brukeren velger metode selv, TOTP ELLER e-post-
|
||
engangskode.** `app_user.two_factor_method` (nullable, `'totp'`/`'email'`).
|
||
- **TOTP:** `app_user.totp_secret` (bare satt når metoden er `'totp'`),
|
||
`pyotp` for generering/verifisering (RFC 6238-standard, virker med Google
|
||
Authenticator/Authy/1Password etc. uten videre). QR-kode for oppsett
|
||
generert server-side (`qrcode`-biblioteket + eksisterende Pillow-
|
||
avhengighet, samme mønster som AVIF-konverteringen) — ingen ny ekstern
|
||
tjeneste, ingen løpende kostnad.
|
||
- **E-post-engangskode:** gjenbruker eksisterende SMTP-oppsett (`app/email.py`,
|
||
ADR-009) — ny, kort 6-sifret kode, samme hash-og-utløp-mønster som
|
||
`magic_link_token` (ny tabell `two_factor_code`, 5 minutters gyldighet).
|
||
Svakere som eneste faktor (e-post-kontoen blir da reelt sett den ENESTE
|
||
hemmeligheten), men krever ingen ny infrastruktur og er brukerens eget,
|
||
informerte valg mellom de to metodene.
|
||
- **Bevisst UTENFOR omfang:** SMS-2FA — krever en betalt tredjeparts
|
||
SMS-leverandør (f.eks. Twilio), løpende kostnad per melding, ny ekstern
|
||
avhengighet. Ikke bygget denne runden; kan legges til som en tredje metode
|
||
senere uten å røre TOTP/e-post-sporene, siden `two_factor_method` allerede
|
||
er et åpent tekstfelt, ikke en hardkodet to-verdi-enum i skjemaet.
|
||
|
||
**Beslutning D — 2FA er PÅKREVD for org-eier/admin, valgfritt ellers.**
|
||
Ved innlogging (uansett om via magic-link eller passord): har brukeren
|
||
`organization_membership.role IN ('owner','admin')` i MINST én organisasjon
|
||
OG `two_factor_method IS NULL`, gis IKKE en full sesjon — brukeren tvinges
|
||
inn i et "sett opp 2FA nå"-steg først. En vanlig `member`-rolle (eller en
|
||
bruker uten noe org-medlemskap ennå) kan fortsette å bruke appen helt uten
|
||
2FA om hen ønsker det. **Begrunnelse:** organisator-roller har skrivetilgang
|
||
til andre spilleres personopplysninger (ADR-017) og kontroll over hele
|
||
turneringer — et kompromittert organisator-passord/magic-link er en langt
|
||
alvorligere hendelse enn en kompromittert spillerkonto. Håndheves ved HVER
|
||
innlogging (ikke bare første gang), siden en bruker kan BLI eier av en ny
|
||
org (`POST /orgs`, ADR ingen restriksjon — se avklaringen under) etter at
|
||
kontoen allerede eksisterer uten 2FA.
|
||
|
||
**Beslutning E — To-stegs innlogging via en `stage`-claim i sesjons-JWT-en,
|
||
ikke en egen tabell for "pågående innlogging".** Når 2FA kreves (enten fordi
|
||
brukeren selv har slått det på, eller fordi Beslutning D tvinger det),
|
||
utsteder primær-autentisering (magic-link-verifisering ELLER passord-innlogging)
|
||
et KORTLEVD (5 min) JWT med `stage: "pending_2fa"` i stedet for en full
|
||
30-dagers sesjon. En ny avhengighet (`get_pending_2fa_user`, speiler
|
||
`get_current_user`) godtar KUN denne mellomtilstanden, og eksponeres bare på
|
||
2FA-verifiserings-/oppsett-endepunktene — `get_current_user` (brukt av ALLE
|
||
andre endepunkter) avviser eksplisitt en `pending_2fa`-claim som ugyldig,
|
||
slik at en ufullstendig innlogging ALDRI gir reell tilgang til noe. Først når
|
||
riktig TOTP-/e-post-kode verifiseres, byttes denne inn mot den vanlige fulle
|
||
sesjonscookien (samme 30-dagers levetid som i dag).
|
||
|
||
**Avklaring underveis, ikke en ny beslutning:** brukeren spurte samtidig om
|
||
«kan en bruker eie flere organisasjoner» er tenkt gjennom. Bekreftet: JA,
|
||
dette var alltid en del av modellen (ADR-002 — bruker og org-medlemskap er
|
||
bevisst atskilt nettopp for at én bruker skal kunne krysse flere
|
||
organisasjoner). `POST /orgs` (`app/routers/organizations.py`) har INGEN
|
||
begrensning på hvor mange organisasjoner én bruker kan opprette/eie —
|
||
hver ny org gir automatisk `role='owner'` for oppretteren, uavhengig av
|
||
eksisterende medlemskap. Allerede testet i praksis: dashbordets org-bytter,
|
||
kryss-org-isolasjonstestene, og `/auth/me` sin N+1-oppslagsstrategi
|
||
(«N = antall organisasjoner brukeren tilhører») forutsetter alle nettopp
|
||
dette. Ingen kodeendring nødvendig — kun bekreftelse.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon `012_password_2fa_and_
|
||
org_invitations.sql` kjørt mot ekte `teecup_db`. Se CHANGELOG.md for
|
||
full byggerunde (backend/frontend-detaljer, tre reelle bugs funnet og
|
||
fikset under scratch-testing).
|
||
|
||
---
|
||
|
||
## ADR-022 — Organisasjonseierskap: dele, invitere, frasi seg, superadmin
|
||
|
||
**Kontekst:** Reist av brukeren 2026-07-19, rett etter ADR-021. Et reelt,
|
||
mer FUNDAMENTALT hull ble synlig under gjennomgangen: `organization_
|
||
membership` har i dag INGEN vei til å legge til et nytt medlem i det hele
|
||
tatt etter at organisasjonen er opprettet — den ENESTE raden som noensinne
|
||
settes inn er grunnleggerens egen `owner`-rad (`POST /orgs`,
|
||
`app/routers/organizations.py`). Det finnes ingen invitasjon, ingen
|
||
rollestyring, ingen måte å fjerne noen på. «Del eierskap»-ønsket er derfor
|
||
bare den mest synlige kanten av et bredere manglende felt: hele
|
||
medlemskaps-livssyklusen etter opprettelse.
|
||
|
||
**Beslutning A — Flere eiere er allerede støttet av SKJEMAET, kun API-et
|
||
mangler.** `organization_membership.role` har ingen unikhetsbegrensning som
|
||
hindrer flere `owner`-rader for samme organisasjon — dette var aldri en
|
||
sperre, bare et ubrukt hull. Ingen skjemaendring nødvendig for selve
|
||
flereeiere-støtten, kun nye endepunkter.
|
||
|
||
**Beslutning B — E-post-basert invitasjon, samme mønster som spiller-
|
||
sammenkobling (ADR-017 Beslutning B), ikke et helt nytt konsept.** Ny
|
||
`POST /orgs/{id}/invitations {email, role}` oppretter en
|
||
`organization_invitation`-rad (e-post, rolle, token-hash, utløper, hvem som
|
||
inviterte) og sender en e-post via eksisterende SMTP-infrastruktur
|
||
(`app/email.py`). Den inviterte trenger IKKE ha en konto fra før — lenken
|
||
tar dem til innlogging (magic-link, evt. passord etter ADR-021), og
|
||
`verify_magic_link` utvides til å sjekke ventende invitasjoner på e-posten
|
||
sin (samme "kjør på hver innlogging, idempotent" mønster som
|
||
`link_player_by_email`) og sette inn `organization_membership`-raden da.
|
||
**Rolle-grense ved invitasjon:** en `owner` kan invitere til ENHVER rolle
|
||
(owner/admin/member); en `admin` kan KUN invitere til `member` — å la en
|
||
admin invitere en ny eier ville vært en reell privilegie-eskaleringsvei
|
||
(en admin gir seg selv/en alliert eierskap). Dette er en bevisst, ikke
|
||
åpen, avgrensning — minste-privilegium-prinsippet, samme resonnement som
|
||
`team_authz.py` sin eksisterende owner/admin-splitt.
|
||
|
||
**Beslutning C — Rollestyring og fjerning, med et «siste eier»-vern.**
|
||
Ny `PATCH /orgs/{id}/memberships/{membership_id} {role}` (kun `owner`,
|
||
UNNTATT at en bruker alltid kan senke SIN EGEN rolle selv — «frasi seg
|
||
eierskapet» er en selvbetjent handling, ikke noe som må be en annen eier om
|
||
lov) og `DELETE /orgs/{id}/memberships/{membership_id}` (kun `owner`, eller
|
||
selv for å forlate organisasjonen). Begge avviser handlingen med en klar
|
||
409 (`LAST_OWNER`) hvis den ville latt organisasjonen stå igjen med NULL
|
||
eiere — samme «TOCTOU-trygg med `FOR UPDATE`»-mønster som ADR-011s
|
||
to-lags-grense. En enslig eier må altså enten forfremme noen andre til
|
||
eier FØRST, eller be en superadmin om hjelp (Beslutning D) hvis
|
||
organisasjonen skal forlates helt.
|
||
|
||
**Beslutning D — Superadmin er et manuelt tildelt, ikke selvbetjent, flagg.**
|
||
Ny `app_user.is_super_admin` (boolean, default `false`). INGEN API-endepunkt
|
||
lar noen sette dette flagget på seg selv ELLER andre — det settes kun
|
||
direkte i databasen av en driftsansvarlig (samme tillitsnivå som å kjøre en
|
||
migrasjon), bevisst utenfor appens eget autorisasjonssystem. Årsak: et
|
||
selvbetjent «bli superadmin»-endepunkt ville vært selve
|
||
sikkerhetshullet det er ment å ikke være. Med flagget satt får brukeren en
|
||
NY autorisasjonssti (`get_current_user_or_superadmin`, parallell til
|
||
`get_authorized_org` — sjekker `is_super_admin` FØR det vanlige
|
||
org-medlemskaps-kravet) som lar dem kalle de samme rolle-/medlemskaps-
|
||
endepunktene på ENHVER organisasjon, ikke bare de de selv er medlem av.
|
||
**Bevisst avgrenset:** superadmin-stien dekker KUN medlemskap/rolle-
|
||
styring i denne runden (nøyaktig det brukeren spurte om — «sette hvem som
|
||
helst som eiere av hvilken som helst organisasjon»), ikke generell
|
||
skriveadgang til turnering-/spiller-data i andres organisasjoner. En
|
||
bredere «support/drift kan se alt»-rolle er en egen, senere beslutning om
|
||
den blir etterspurt.
|
||
|
||
**Beslutning E — Fjernet/frasigende eiers roster-/spillerdata forblir
|
||
URØRT.** Bekreftet av bruker 2026-07-19. Kun `organization_membership`-raden
|
||
endres/fjernes ved rollestyring eller fjerning — `team_roster`/`player`-rader
|
||
(deltakelse-historikk) røres aldri av disse handlingene. Organisasjons-
|
||
STYRING og DELTAKELSE er allerede modellert som separate ting, og forblir
|
||
det.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19**, samme runde som ADR-021 (samme
|
||
migrasjon `012`). Se CHANGELOG.md for full byggerunde.
|
||
|
||
---
|
||
|
||
## ADR-023: Brukerroller — kaptein som reell autorisasjon, deltaker-avgrenset scoring
|
||
|
||
Reist 2026-07-19, direkte oppfølging av det lenge åpne «Brukerroller»-punktet
|
||
i FEATURE_BACKLOG.md (der siden prosjektets start beskrevet som turnerings-
|
||
admin/lagkaptein/spiller/tilskuer, men aldri fullt ut avgjort). Fire
|
||
delspørsmål, alle avklart eksplisitt med bruker før bygging.
|
||
|
||
**Beslutning A — Kaptein gir eksklusive rettigheter til å sette opp/låse
|
||
laget, ikke lenger «hvem som helst rostret».** `app/team_authz.py` sin
|
||
`user_is_team_captain` (erstatter `user_may_act_for_team`) krever
|
||
`team_roster.is_captain = true` for brukeren (eller org-eier/admin, uendret
|
||
fra 2026-07-18-runden) — brukt av `matches.py` sin `add_participant`/
|
||
`remove_participant`/`lock_lineup`. Ny feilkode `NOT_TEAM_CAPTAIN` (403,
|
||
erstatter `NOT_ROSTERED_ON_TEAM` for disse tre stedene).
|
||
|
||
**Bevisst unntak, funnet ved å faktisk sjekke ekte produksjonsdata FØR
|
||
utrulling, ikke antatt:** har et lag INGEN utpekt kaptein i det hele tatt,
|
||
godtas enhver rostret spiller i stedet — uten dette ville en kaptein-only-
|
||
regel umiddelbart LÅST ute et helt lag fra å sette opp seg selv. Sjekket mot
|
||
ekte `teecup_db`: laget «De Unge» i «De Gamle er Eldst» har i dag 0 av 2
|
||
roster-rader merket kaptein — dette er altså ikke et hypotetisk
|
||
kantscenario, det ville rammet en reell, allerede opprettet turnering med
|
||
en gang. Har laget FØRST fått en kaptein, gjelder utelukkende den.
|
||
|
||
**Beslutning B — «Kun én kaptein per lag» håndheves nå.** `PATCH .../
|
||
roster/{id}` og `POST .../roster` (`app/routers/tournaments.py`) fjerner
|
||
automatisk kapteinmerket fra enhver annen roster-rad på SAMME lag når en ny
|
||
kaptein settes, i samme transaksjon. Nødvendig konsekvens av Beslutning A —
|
||
uten dette ville det vært uklart hvem som faktisk har myndighet hvis flere
|
||
er merket. Sjekket mot ekte data: ingen eksisterende lag hadde flere
|
||
kapteiner (kun opprydningsbehovet «0 kapteiner» fantes reelt), så ingen
|
||
data-migrering var nødvendig.
|
||
|
||
**Beslutning C — Score-føring/-korrigering begrenses til matchens faktiske
|
||
deltakere.** `app/team_authz.py` sin nye `user_is_match_participant` krever
|
||
en `match_participant`-rad for brukeren i AKKURAT den matchen (valgfritt
|
||
begrenset til én side via `team_side` — brukt for `stroke`-modus sin
|
||
side-spesifikke innsending, `None`/hvilken som helst side for `hole_result`-
|
||
modus siden begge sider kan rapportere). Erstatter den brede
|
||
«rostret på laget»-sjekken i `app/routers/scoring.py` sin
|
||
`submit_hole_score`/`submit_hole_result`. UAVHENGIG av kapteinmerket — å
|
||
være kaptein gir ikke i seg selv rett til å føre score for en match man
|
||
selv ikke spiller; org-eier/admin har samme unntak som før. Ny feilkode
|
||
`NOT_MATCH_PARTICIPANT` (403, erstatter `NOT_ROSTERED_ON_TEAM` her).
|
||
|
||
**Beslutning D — «Tilskuer»-rollen utsettes bevisst.** Ingen kode denne
|
||
runden. Offentlig/deltaker-lesetilgang finnes allerede
|
||
(`tournament.visibility` + `get_current_user_optional`, ADR-018) — et
|
||
formelt tilskuer-begrep bør defineres sammen med det ennå ubesluttede
|
||
synlighetsspørsmålet for «Banter Board»-feeden (FEATURE_BACKLOG), ikke
|
||
isolert her, for å unngå å bygge to overlappende synlighetsmodeller.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren
|
||
autorisasjonslogikk, ingen skjemaendring). Se CHANGELOG.md for
|
||
scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-024: Walkover/konsesjon
|
||
|
||
Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
|
||
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
|
||
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
|
||
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
|
||
delspørsmål, alle avklart eksplisitt med bruker før bygging.
|
||
|
||
**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
|
||
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
|
||
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
|
||
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
|
||
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
|
||
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
|
||
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
|
||
`user_is_team_captain`.
|
||
|
||
**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
|
||
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
|
||
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
|
||
varslingsmekanisme bygget.
|
||
|
||
**Beslutning C — Både match- og turnering-nivå, i samme runde.**
|
||
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
|
||
match. `POST /orgs/{id}/tournaments/{id}/concede`
|
||
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
|
||
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
|
||
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
|
||
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
|
||
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
|
||
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
|
||
dekker det allerede (rapporter bare hvem som vant hullet).
|
||
|
||
**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
|
||
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
|
||
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
|
||
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
|
||
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
|
||
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
|
||
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
|
||
spilt før konsesjonen.
|
||
|
||
**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
|
||
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
|
||
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
|
||
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
|
||
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
|
||
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
|
||
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
|
||
egne strenger (f.eks. "9&7 (A)").
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (ren applogikk,
|
||
ingen skjemaendring). Se CHANGELOG.md for scratch-verifisering og
|
||
utrulling.
|
||
|
||
---
|
||
|
||
## ADR-025: Kommunikasjon — lag-chat + offentlig runde-feed
|
||
|
||
Reist 2026-07-19, rett etter turnering-status-runden. Dekker to ganske ulike
|
||
ting under samme paraply: lag-intern chat («det hemmelige rommet») og en
|
||
offentlig runde-feed («Banter Board»). Fire beslutninger avklart eksplisitt
|
||
med bruker (AskUserQuestion) før bygging.
|
||
|
||
**Beslutning A — Begge bygges i samme runde**, ikke lag-chat først som egen
|
||
runde. Deler mye infrastruktur (meldingsmodell, sanntid-levering), så
|
||
designes/bygges sammen selv om feeden isolert sett hadde flere åpne
|
||
spørsmål.
|
||
|
||
**Beslutning B — Sanntid via WebSockets**, ikke polling. Løser samtidig det
|
||
tidligere åpne "sanntid vs. polling"-spørsmålet i FEATURE_BACKLOG.md
|
||
generelt (samme mekanisme kan gjenbrukes for leaderboard/andre skjermer
|
||
senere, selv om denne runden kun kobler den til meldinger).
|
||
**Viktig driftsbegrensning, videreført fra et allerede kjent åpent
|
||
arkitekturspørsmål (se «Åpne spørsmål» punkt 2 i dette dokumentet):**
|
||
tilkoblingsregisteret er en in-memory Python-struktur i `teecup_api`-
|
||
prosessen. Med kun én container/prosess (dagens oppsett) er dette trygt;
|
||
skaleres API-et til flere prosesser/containere senere, må broadcast flyttes
|
||
til noe delt (Redis pub/sub e.l.) — samme klasse begrensning som den
|
||
allerede aksepterte in-memory-cachen.
|
||
|
||
**Beslutning C — Lag-chat er EKTE privat: kun rostrede spillere på laget,
|
||
INGEN unntak for org-eier/admin.** Et bevisst avvik fra appens ellers
|
||
gjennomgående mønster (kaptein-/deltaker-sjekkene i `team_authz.py` har
|
||
alltid en org-admin-fallback, se ADR-023). Ny, egen autorisasjonsfunksjon
|
||
`user_is_rostered_on_team` (uten fallback) brukt KUN her — de eksisterende
|
||
funksjonene med org-admin-unntak røres ikke, siden de fortsatt er riktige
|
||
for sine egne bruksområder (oppsett/scoring, der en organisator uten
|
||
dette ville stått fast tidlig i en turnering).
|
||
|
||
**Beslutning D — Bilder med fra start**, ikke utsatt. Gjenbruker
|
||
`app/storage.py` sin allerede byggede og bevist MinIO+AVIF-konverterings-
|
||
pipeline (samme mønster som turnering-hero-bilder/sponsorlogoer, ADR-018) —
|
||
ingen ny opplastingsinfrastruktur trengs, kun en ny `prefix="messages"`.
|
||
|
||
**Datamodell:** delt `message`-tabell (migrasjon `013_messaging.sql`) med
|
||
en `scope`-diskriminator (`team`/`tournament_feed`) i stedet for to separate
|
||
tabeller — meldingsformen (tekst + valgfritt bilde) er identisk, kun
|
||
synlighet/autorisasjon skiller dem. `author_display_name` FRYSES ved
|
||
skrivetidspunkt (samme prinsipp som `handicap_index_snapshot`, ADR-007) —
|
||
utledet server-side fra avsenderens `player`-rad i org-en (hvis den finnes),
|
||
ellers e-postens lokaldel som fallback (en org-ansatt uten egen spillerprofil
|
||
kan fortsatt poste i den offentlige feeden).
|
||
|
||
**Offentlig feed — synlighet og posterett, to atskilte spørsmål:**
|
||
LESING gjenbruker `registration.py` sitt eksisterende trenivå-mønster
|
||
(`tournament.visibility` + `get_current_user_optional` + deltaker-sjekk,
|
||
ADR-018) uendret — ingen ny synlighetsmekanisme. POSTING er derimot
|
||
STRENGERE enn lesing: en anonym leser på en `public`-synlig turnering kan
|
||
lese feeden, men må logge inn OG være enten org-medlem eller faktisk
|
||
deltaker/registrert i NØYAKTIG denne turneringen for å få poste — hindrer at
|
||
en helt urelatert innlogget bruker (konto et helt annet sted i systemet) kan
|
||
poste på en fremmed offentlig turnering-side bare fordi den er synlig.
|
||
Moderering: forfatteren selv, ELLER org-eier/admin, kan slette et
|
||
feed-innlegg. Lag-chat har INGEN moderering utover forfatteren selv (rommet
|
||
er privat, org-admin har uansett ikke lesetilgang og kan derfor ikke
|
||
moderere det).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Se CHANGELOG.md for scratch-
|
||
verifisering og utrulling, inkl. en egen Caddy-rute (`/ws/*`) for
|
||
WebSocket-trafikk direkte til `teecup_api` (Next.js sin `rewrites()`
|
||
proxyer ikke WebSocket-oppgraderinger pålitelig — samme klasse
|
||
infrastrukturvalg som media-ruten i MinIO-runden, ADR-018).
|
||
|
||
---
|
||
|
||
## ADR-026: Tilskuer-rolle — offentlig leaderboard, matcher, scorekort
|
||
|
||
Reist 2026-07-19, rett etter Kommunikasjon (ADR-025), som gjorde det mulig å
|
||
definere "tilskuer" skikkelig (samme begrunnelse ble notert allerede i
|
||
FEATURE_BACKLOG.md fra starten av: bør avgjøres sammen med feed-synligheten,
|
||
som nå finnes).
|
||
|
||
**Kjernebeslutning: ingen ny rolle, ingen ny mekanisme.** "Tilskuer" er
|
||
IKKE en egen kontotype eller databasetabell — det er ganske enkelt: enhver
|
||
som kan SE en turnering (per `tournament.visibility`, ADR-018) kan nå også
|
||
følge den LIVE, ikke bare lese info-siden og programtidene. Samme
|
||
trenivå-visibility + `get_current_user_optional`-mønster som all annen
|
||
offentlig lesing, gjenbrukt helt uendret.
|
||
|
||
**Hva var det egentlige hullet:** `GET /orgs/.../leaderboard`,
|
||
`GET /orgs/.../sessions/{id}/matches` og `GET /orgs/.../matches/{id}/
|
||
scorecard` fantes allerede (bygget for organisatorer/spillere), men var
|
||
KUN tilgjengelige med org-medlemskap — en spectator med kun `/t/[id]`-
|
||
lenken (eller anonym på en `public`-synlig turnering) kunne aldri se dem.
|
||
Løst ved å ekstrahere den delte kjernelogikken til gjenbrukbare funksjoner
|
||
(`fetch_leaderboard` i tournaments.py, `fetch_matches` i matches.py,
|
||
`fetch_scorecard` i scoring.py — samme "gjort delt for gjenbruk"-mønster
|
||
som `recompute_and_cache_match_state`/`apply_concession` tidligere), og la
|
||
tre nye offentlige endepunkter i `registration.py` kalle dem, etter egen
|
||
visibility-sjekk.
|
||
|
||
**Omfang, valgt av bruker utover anbefalingen:** BÅDE leaderboard+
|
||
matchliste OG fullt hull-for-hull-scorekort per match, i samme runde (ikke
|
||
kun leaderboard+matchliste som opprinnelig anbefalt).
|
||
|
||
**`own_team_ids()` (blind_draw.py) gjort null-sikker:** tar nå
|
||
`user_id: str | None` — en anonym/ikke-tilknyttet leser har per definisjon
|
||
ingen egne lag, og skal derfor (korrekt, ikke en feil) kun se AVSLØRTE
|
||
matcher, akkurat som en tilfeldig org-medlem uten roster. Ingen ny
|
||
synlighetslogikk, bare eksisterende logikk gjort tilgjengelig for en
|
||
`None`-bruker.
|
||
|
||
**To NYE sikkerhetssjekker lagt til, funnet under design, ikke i etterkant:**
|
||
1. `session_id`/`match_id` i URL-en må eksplisitt verifiseres å høre til
|
||
NØYAKTIG `tournament_id` i samme URL — `org_connection()` setter kun
|
||
TENANT-grensen (RLS), ikke at stiens id-er faktisk henger sammen. Uten
|
||
denne sjekken kunne noen med tilgang til én offentlig turnering i en
|
||
organisasjon lest en HVILKEN SOM HELST økt/match i SAMME organisasjon
|
||
(inkl. en helt privat en) ved å gjette/prøve seg frem på id-er — nøyaktig
|
||
samme klasse hull som ADR-018 Beslutning B advarte om generelt
|
||
("RLS beskytter kun tenant-grenser, ikke innholds-synlighet").
|
||
2. Det offentlige scorekort-endepunktet krever eksplisitt at BEGGE lag har
|
||
låst oppstillingen for økten (blind draw, ADR-013) — leaderboard/
|
||
matchliste arver reveal-skjuling automatisk via `own_team_ids()`, men
|
||
scorekortet har ingen tilsvarende innebygd sjekk (kan i prinsippet
|
||
inneholde registrerte hull før reveal, selv om det ikke er normal flyt).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon (kun nye
|
||
endepunkter + refaktorering av eksisterende spørringer til delte
|
||
funksjoner). Se CHANGELOG.md for scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-027: Sanntid for "Følg live"-siden
|
||
|
||
Reist 2026-07-19, rett etter ADR-026 (tilskuer-rolle) — brukeren valgte min
|
||
egen anbefaling: `/t/[id]/live` krevde omlasting for å se nye resultater,
|
||
litt selvmotsigende for en side som heter "Følg live". Løser samtidig det
|
||
tidligere åpne "koble leaderboard til sanntid"-punktet i FEATURE_BACKLOG.md.
|
||
|
||
**Gjenbruker WebSocket-mekanismen fra ADR-025 (meldinger), men "noe endret
|
||
seg, hent på nytt"-signal i stedet for å sende selve dataene** — å bygge/
|
||
sende hele leaderboard+matchliste+scorekort-formen over WS ville duplisert
|
||
betydelig beregningslogikk (leaderboardets projeksjons-regnestykke, blind
|
||
draw-filtrering osv.). Klienten reagerer på signalet ved å kalle de samme
|
||
REST-endepunktene på nytt (ADR-026), akkurat som ved førstegangslasting —
|
||
kun for det som faktisk er synlig/åpent på skjermen (leaderboard alltid,
|
||
en økts matcher kun hvis økten er utvidet, et scorekort kun hvis det er
|
||
åpnet).
|
||
|
||
**Ny, RUTEFRI modul `app/realtime.py`** for selve tilkoblingsregisteret og
|
||
kringkastingsfunksjonen — verken i `messaging.py`, `scoring.py` eller
|
||
`tournaments.py`. Årsak: `registration.py` (som eier selve
|
||
`/public/...`-endepunktene) importerer allerede fra `scoring.py`/
|
||
`tournaments.py`/`matches.py`, og `messaging.py` importerer fra
|
||
`registration.py` — å plassere kringkastingsfunksjonen i noen av routerne
|
||
ville skapt en sirkulær import. `app/realtime.py` ligger bevisst BAK alle
|
||
routere i importgrafen.
|
||
|
||
**Kringkastingen er lagt INN I de delte funksjonene selv**
|
||
(`recompute_and_cache_match_state`, `apply_concession`), ikke som noe
|
||
kallerne må huske å gjøre etterpå — samme selv-ansvarlig-mønster som andre
|
||
sentrale funksjoner i prosjektet. `apply_concession` fikk en ny påkrevd
|
||
`tournament_id`-parameter kun for dette formålet.
|
||
|
||
**Samme kjente in-memory-per-prosess-begrensning som ADR-025** (se «Åpne
|
||
spørsmål» under) — trygt med dagens ene `teecup_api`-container.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Ingen migrasjon, ingen ny
|
||
Caddy-rute (gjenbruker `/ws/*`-ruten fra ADR-025 uendret). Se CLAUDE.md-
|
||
status for scratch-verifisering og utrulling.
|
||
|
||
---
|
||
|
||
## ADR-028: PWA — installasjon + offline scoreregistrering
|
||
|
||
Reist 2026-07-19, brukeren valgte å ta fatt på PWA (stod som "neste steg" i
|
||
CLAUDE.md siden ADR-006 vedtok prinsippet helt i starten av prosjektet — aldri
|
||
bygget). To beslutninger avklart eksplisitt med bruker (AskUserQuestion) før
|
||
bygging.
|
||
|
||
**Beslutning A — Full offline scoreregistrering i v1, ikke bare
|
||
installasjon.** Bruker valgte det mest ambisiøse alternativet: BÅDE
|
||
manifest/ikoner/service worker (installerbar app) OG at scorekort-skjermen
|
||
skal fungere uten nett — skriv til en lokal IndexedDB-kø, synk automatisk når
|
||
nettet er tilbake. Matcher ADR-006s opprinnelige formulering ordrett
|
||
("score må kunne registreres uten nett og synkes senere").
|
||
|
||
**Beslutning B — Omfang av selve offline-skrivingen: kun `hole-scores`/
|
||
`hole-results`, ingenting annet.** De to eksisterende scoreregistrerings-
|
||
endepunktene (ADR-012) er de eneste som køes. Bevisst UTENFOR omfang, ikke
|
||
glemt:
|
||
- Walkover/konsesjon (ADR-024) — sjeldnere handling, kan kreve nett.
|
||
- Chat/feed-posting (ADR-025) — bilder gjør en offline-kø vesentlig mer
|
||
komplisert (blob-lagring i IndexedDB), og er ikke "score"-handlingen
|
||
ADR-006 faktisk siktet til.
|
||
- Alle andre skrive-endepunkter (oppsett, roster, økter osv.) — forutsetter
|
||
nett i v1, uendret.
|
||
|
||
**Beslutning C — Køen lever i klientkoden (IndexedDB, `lib/offline-
|
||
queue.ts`), IKKE i service workeren, og bruker `window` sitt `online`-
|
||
event, IKKE Background Sync API.** To bevisste forenklinger:
|
||
1. Å gi umiddelbar, presis UI-tilbakemelding ("lagret lokalt, venter på
|
||
synk", pending-antall) er enklere og mer testbart fra selve
|
||
React-komponenten enn fra en service worker sin `fetch`-handler.
|
||
2. Background Sync API (som ville gitt synk selv om appen er lukket) støttes
|
||
IKKE av iOS Safari i det hele tatt — en stor andel av klubb-/
|
||
vennegjeng-brukerne er trolig på iPhone, og et rent
|
||
Background-Sync-avhengig design ville derfor vært brutt for dem. Et
|
||
`window.addEventListener("online", ...)`-mønster (pluss en manuell
|
||
"Synkroniser nå"-knapp i UI-et som reserve) fungerer overalt, på
|
||
bekostning av at synk krever at appen faktisk er åpen når nettet kommer
|
||
tilbake — akseptabelt for v1.
|
||
|
||
`submitStroke`/`submitHoleResult` (`components/session-scorecard.tsx`)
|
||
sjekker `navigator.onLine` FØRST (unngår en unødvendig ventetid på et
|
||
nettverkskall som uansett vil feile), og fanger ellers en EKTE nettverksfeil
|
||
i fetch-kallet separat fra et avvist HTTP-svar (`res.ok === false`, f.eks.
|
||
409 "matchen er avgjort") — kun den førstnevnte køordner, sistnevnte viser
|
||
fortsatt den vanlige feilteksten uendret. Et lokalt overlay
|
||
(`pendingStrokes`/`pendingResults`, ikke persistert i selve
|
||
`scorecard`-staten) viser køede verdier umiddelbart i UI-et, merket
|
||
"Lagret lokalt · venter på synk", inkludert i hull-navigasjonens
|
||
registrert-markering.
|
||
|
||
**Konfliktmodell: samme tillitsnivå som resten av appen, ingen ny
|
||
mekanisme.** Server-siden er allerede en upsert per hull (`ON CONFLICT ...
|
||
DO UPDATE`) — siste innsending vinner, ingen audit-trail (kjent, tidligere
|
||
dokumentert åpent spørsmål, se «Scoring-autorisasjon» i FEATURE_BACKLOG.md).
|
||
Køen sender i rekkefølge (aldri parallelt) for å respektere lokal
|
||
innsendingsrekkefølge ved flere endringer av samme hull offline. Et
|
||
definitivt HTTP-avvist forsøk ved synk (typisk: matchen ble avgjort på en
|
||
ANNEN enhet mens denne var offline) fjernes fra køen og vises som en
|
||
feilmelding — blir ALDRI hengende for alltid.
|
||
|
||
**Service worker-strategi: nettverk først, cache som fallback — bevisst
|
||
IKKE stale-while-revalidate.** `public/sw.js` cacher (a) side-navigasjon og
|
||
(b) GET-kall under `/orgs/*`. Begge prøver ekte nettverk FØRST og faller
|
||
kun tilbake til cache ved reell feil. En stale-while-revalidate-strategi ble
|
||
vurdert og avvist: scorekortet gjør et `refetchScorecard()`-kall RETT ETTER
|
||
hver innsending, og må da alltid få fersk data — en umiddelbart utdatert
|
||
cache ville vist feil matchstatus/hull-tall rett etter en vellykket
|
||
innsending. `/auth/*` og `/public/*` caches bevisst ikke — omfanget er
|
||
begrenset til akkurat det scoreregistrerings-flyten trenger.
|
||
|
||
**Kjent, akseptert begrensning (ikke løst i v1):** Cache Storage er nøklet
|
||
på URL, ikke på innlogget bruker — deler flere kontoer samme enhet/
|
||
nettleser, kan en offline-fallback teoretisk vise data cachet av en
|
||
TIDLIGERE innlogget bruker på samme enhet. Ingen cache-tømming ved
|
||
utlogging bygget. Samme tillitsnivå/trusselmodell som appen ellers opererer
|
||
med (tillitsbasert klubb-/vennegjeng-verktøy, jf. `team_authz.py` sin
|
||
begrunnelse i ADR-023/025).
|
||
|
||
**Ikoner: enkelt, midlertidig sett generert programmatisk (grønt
|
||
golf-flagg), ikke endelig design.** Bruker valgte å generere nå fremfor å
|
||
vente på ekte design, MED eksplisitt beskjed om at disse skal erstattes
|
||
senere — notert i FEATURE_BACKLOG.md. Erstattet samtidig den gamle
|
||
`public/apple-icon.png` (v0.app sin generiske plassholder-logo, ikke
|
||
TeeCup-merkevare i det hele tatt) med samme nye ikon, av konsistens.
|
||
|
||
**Ikke testet i ekte nettleser (viktig, ikke bare en formalitet):**
|
||
verifisert med typesjekket produksjonsbuild (samme `Dockerfile` som
|
||
deployes) og en kort container-boot med `curl` (manifest/service worker/
|
||
ikoner/offline.html svarer riktig), men INGEN faktisk browser-basert
|
||
offline-test (DevTools "Offline"-modus, "Legg til på hjemskjerm") er
|
||
gjennomført denne runden — ingen nettleserverktøy tilgjengelig i denne
|
||
økten. Anbefales sterkt at brukeren selv tester scorekort-siden med Chrome
|
||
DevTools sin Offline-bryter før tillit legges til flyten i skarp bruk.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Bruker bekreftet eksplisitt, ingen
|
||
migrasjon, kun `teecup_frontend` (og en ren, uendret gjenoppbygging av
|
||
`teecup_api` som en bivirkning av `docker compose up --build` sin
|
||
avhengighets-oppløsning — ingen backend-kode rørt denne runden). Verifisert:
|
||
`/health`/`dashboard` → 200, `/manifest.webmanifest`/`sw.js`/ikoner alle
|
||
200 over ekte https, `teeoff.no` upåvirket. Se CHANGELOG.md for full
|
||
byggerunde.
|
||
|
||
**Gjenstående, IKKE en del av "ferdig"-vurderingen over:** faktisk
|
||
nettleser-basert offline-test (Chrome DevTools Offline-modus, ekte "Legg
|
||
til på hjemskjerm") er fortsatt ikke gjort — ingen nettleserverktøy
|
||
tilgjengelig i byggeøkten. Brukeren har bedt om at dette noteres eksplisitt
|
||
for oppfølging, se FEATURE_BACKLOG.md sin PWA-tabell og CLAUDE.md sin
|
||
"Neste steg"-liste.
|
||
|
||
---
|
||
|
||
## ADR-029: Kjønn hører til tee-RATINGEN, ikke selve utslaget
|
||
|
||
Reist av brukeren 2026-07-19, som del av en runde med fire rapporterte
|
||
UX-/korrekthetshull fra faktisk testing av blind draw og scorekort. Startet
|
||
som en antatt frontend-fiks ("tee-valget bør følge spillerens kjønn
|
||
automatisk"), men brukeren presiserte at premisset mitt var feil: en
|
||
golfbane har IKKE fysisk kjønnsdelte utslag -- begge kjønn kan som regel
|
||
spille fra ethvert utslag. Det eneste som faktisk varierer per kjønn er om
|
||
klubben har VALGT å slope (rate) et gitt utslag for det respektive kjønnet
|
||
(noen klubber sloper bevisst ikke det lengste utslaget for damer).
|
||
|
||
**Bekreftet problem, ikke antatt:** `tee.gender` (migrasjon 001) la kjønn på
|
||
selve utslaget, ikke ratingen. Sjekket mot ekte, importert produksjonsdata
|
||
(Tjøme Golfklubb, ADR-019): alle fire utslagene lå som RENE navnepar --
|
||
"32"/m + "32"/f, "44"/m + "44"/f, osv. -- to separate `tee`-rader for
|
||
akkurat samme fysiske utslag. Blind draw-skjermen viste dette som "velg
|
||
Dame- eller Herre-tee", som om det var to ulike steder å slå fra.
|
||
|
||
**Beslutning A -- `gender` flyttes fra `tee` til `tee_rating`.** Ett fysisk
|
||
utslag (`tee`, identifisert kun ved navn) kan ha 0, 1 eller 2
|
||
kjønnsspesifikke ratinger. `tee_rating` sin unikhet endres fra
|
||
`(tee_id, scope)` til `(tee_id, scope, gender)`. `gender` på `tee_rating`
|
||
er `NOT NULL CHECK IN ('m','f')` -- 'x' (gyldig på PLAYER-nivå) gir ikke
|
||
mening for en WHS-rating, som alltid er for ett bestemt kjønn.
|
||
|
||
**Beslutning B -- tee-valget blir helt automatisk, ingen manuell
|
||
kjønnsvelger (bekreftet med bruker, valgte det anbefalte alternativet).**
|
||
Organisator/kaptein velger KUN fysisk utslag i blind draw
|
||
(`session-blind-draw.tsx`, ingen "H"/"D"-suffiks lenger). Riktig
|
||
kjønnsspesifikk rating løses AUTOMATISK server-side fra spillerens
|
||
registrerte `player.gender` -- `app/handicap.py` sin
|
||
`compute_and_store_side_handicaps` joiner nå `tee_rating` på
|
||
`(tee_id, scope='full_18', gender = player.gender)` i tillegg til den
|
||
tidligere ADR-028-fiksen (alltid full_18, uavhengig av hole_config).
|
||
|
||
**Beslutning C -- manglende rating/kjønn feiler tydelig FØR innsetting,
|
||
ingen stille fallback (bekreftet med bruker, valgte det anbefalte
|
||
alternativet).** `matches.py` sin `add_participant` validerer, når
|
||
`config.use_handicap` er sann: (1) spilleren har et registrert kjønn --
|
||
avvist med `VALIDATION_FAILED` hvis ikke ("sett kjønn på spilleren
|
||
først"), (2) valgt utslag har faktisk en `tee_rating` for NØYAKTIG det
|
||
kjønnet -- avvist med `VALIDATION_FAILED` hvis ikke ("dette utslaget har
|
||
ingen dame/herre-rating -- velg et annet utslag"). Samme "fail loudly"-
|
||
prinsipp som ADR-019 sin importvalidering og den eksisterende
|
||
handicap_index-sjekken (2026-07-18) rett ved siden av. `_remap_course`
|
||
(bane-bytte midt i en økt) fikk samme presisering: matcher fortsatt på
|
||
tee-navn, men sjekker nå at den NYE tee-en har en rating for hver berørte
|
||
spillers kjønn, ikke bare at navnet finnes.
|
||
|
||
**Konsekvens for import/opprettelse:** ADR-019 sin teeoff-import
|
||
(`import_official_course`) lager nå ÉN `tee`-rad per fysisk teeoff-utslag
|
||
(tidligere: to, én per kjønn), med inntil to `tee_rating`-rader under.
|
||
Manuell tee-opprettelse (`POST .../courses/{id}/tees`) redesignet
|
||
tilsvarende: `TeeCreate.ratings` er nå en liste (1-2 elementer, distinkte
|
||
kjønn) i stedet for ett flatt kjønn+rating-sett -- ingen reell
|
||
bruker-/produksjonsdata brukte dette endepunktet fra før (kun Tjøme, som
|
||
er offisielt importert), så ingen bakoverkompatibilitet var nødvendig.
|
||
|
||
**Migrasjon `014_tee_gender_to_rating.sql`:** flytter `gender` til
|
||
`tee_rating`, slår deretter sammen eksisterende kjønns-par-tee-rader til
|
||
én fysisk tee-rad per (bane, navn) -- velger laveste id som "beholder",
|
||
flytter alle `tee_rating`- og `match_participant.tee_id`-referanser dit,
|
||
sletter duplikatene. Bekreftet TRYGT å kjøre mot ekte data FØR skriving
|
||
(read-only sjekk): alle 8 eksisterende tee-rader (Tjøme) er rene m/f-par,
|
||
ingen `gender='x'`-rader, ingen gruppe med mer enn to rader.
|
||
|
||
**Scratch-verifisert grundig, flere runder:** (1) selve
|
||
fletting-migrasjonen kjørt mot syntetisk data som gjenskaper Tjøme-
|
||
mønsteret nøyaktig, inkl. én `match_participant`-rad som pekte til
|
||
DUPLIKATEN (ikke beholderen) -- bekreftet korrekt reparert til beholderens
|
||
id etterpå, og et utslag med KUN én rating (ingen duplikat) forblir
|
||
urørt. (2) Manuell tee-opprettelse: to ratinger på ett utslag, kun én
|
||
rating (simulerer "klubben har ikke slopet dette kjønnet"), duplikat
|
||
kjønn i samme innsending avvist. (3) `GET tees` viser riktig sammenslått
|
||
struktur (2 fysiske utslag, ikke 3 rader). (4) `add_participant`: kvinne
|
||
+ utslag med dame-rating lykkes; mann + utslag som KUN har dame-rating
|
||
avvist tydelig; spiller uten registrert kjønn avvist tydelig; en full
|
||
kjønnsblandet singel-match scoret korrekt end-to-end. (5) Offisiell
|
||
import kjørt mot EKTE `teeoff_api` (Borregaard Golfklubb) -- bekreftet
|
||
4 fysiske utslag importert med begge kjønnsratinger hver, ikke 8 doble
|
||
rader. (6) `_remap_course`: bane-bytte til en bane UTEN matchende
|
||
kjønnsrating avvist tydelig, bane-bytte til en bane MED matchende
|
||
rating lykket.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-19.** Migrasjon 014 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt: Tjømes 8 tee-rader slått sammen
|
||
til 4 fysiske utslag, alle 8 `tee_rating`-rader fikk riktig `gender`,
|
||
`match_participant`-referansene forble gyldige (0 brutte fremmednøkler
|
||
etter migrasjonen, bekreftet med en direkte spørring). Begge containere
|
||
(`teecup_api`, `teecup_frontend`) bygget og redeployet, `/health`/
|
||
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||
|
||
---
|
||
|
||
## ADR-030: Dashbord-datoer utledes fra øktene, ikke et separat felt
|
||
|
||
Reist av brukeren 2026-07-19/20, direkte oppfølging av det tidligere
|
||
dokumenterte UI-hullet ("Dashboard-turneringskortet viser 'Ingen datoer
|
||
satt'", notert 2026-07-19). Brukeren viste et faktisk skjermbilde: en
|
||
turnering med en økt tydelig planlagt til "lør. 11. juli, 10:50" på
|
||
Program-fanen, mens dashbord-kortet fortsatt viste "Ingen datoer satt".
|
||
|
||
**Root cause, bekreftet ved kodegjennomgang (samme konklusjon som forrige
|
||
runde, nå faktisk fikset):** dashbord-kortet leser `tournament.start_date`/
|
||
`end_date` — et eget felt på selve turneringen (ADR-015) — som INGEN UI
|
||
noensinne har hatt en vei til å sette. Fullstendig atskilt fra
|
||
`session.scheduled_at` (som Program-fanen korrekt viser).
|
||
|
||
**Beslutning: utled datospennet fra øktenes `scheduled_at` i
|
||
`list_tournaments`, ikke bygg et manuelt datofelt-skjema.** To kilder til
|
||
sannhet (et manuelt "turnering-dato"-felt OG faktiske økt-tidspunkter) ville
|
||
uunngåelig kommet ut av synk — turneringens reelle datoer ER ganske enkelt
|
||
når rundene faktisk er satt til å spilles. `GET /orgs/{id}/tournaments`
|
||
gjør nå `COALESCE(t.start_date, MIN(økt.scheduled_at))` /
|
||
`COALESCE(t.end_date, MAX(økt.scheduled_at))` — et eksplisitt satt
|
||
`start_date`/`end_date` (om det noensinne blir gitt en skrivevei senere)
|
||
vinner fortsatt over det utledede spennet, men i praksis er det alltid det
|
||
utledede spennet som vises i dag. En turnering uten noen tidsplanlagte
|
||
økter viser fortsatt riktig "Ingen datoer satt" (ikke en feil).
|
||
|
||
**Konsekvens:** kun `list_tournaments` sin spørring endret (ikke
|
||
`_TOURNAMENT_COLUMNS`, som fortsatt brukes uendret av opprett-/
|
||
PATCH-endepunktene sine `RETURNING`-klausuler — de trenger ikke
|
||
sesjons-utledningen rett etter en skriveoperasjon). Ingen migrasjon.
|
||
|
||
**Scratch-verifisert:** turnering uten økter → `null`/`null` (ikke feil);
|
||
turnering med to økter (11./12. juli) → riktig utledet spenn; en turnering
|
||
med et EKSPLISITT satt `start_date`/`end_date` ved opprettelse beholder
|
||
fortsatt sin egen verdi selv med økter senere lagt til (bekrefter
|
||
COALESCE-prioriteringen).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Bruker bekreftet eksplisitt, ingen
|
||
migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
|
||
11. juli 2026 (utledet fra dens økt) i stedet for "Ingen datoer satt".
|
||
|
||
---
|
||
|
||
## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil
|
||
|
||
Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
|
||
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
|
||
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
|
||
hjemmeklubb).
|
||
|
||
**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
|
||
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
|
||
org-medlem — også for en bruker som KUN er spiller (koblet via
|
||
`player.user_id`, ADR-017 B), aldri organisator.
|
||
|
||
**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
|
||
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
|
||
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
|
||
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
|
||
— ingen tvungen valg mellom to identiteter.
|
||
|
||
**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
|
||
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
|
||
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
|
||
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
|
||
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
|
||
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
|
||
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
|
||
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
|
||
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
|
||
konsepter, ikke ett duplisert.
|
||
|
||
**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
|
||
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
|
||
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
|
||
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
|
||
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).
|
||
|
||
**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
|
||
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
|
||
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
|
||
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
|
||
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
|
||
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
|
||
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
|
||
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
|
||
brukes for `organizations`).
|
||
|
||
**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
|
||
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
|
||
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
|
||
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
|
||
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
|
||
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
|
||
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
|
||
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
|
||
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
|
||
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
|
||
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
|
||
finnes intet scenario der en organisator ønsker å skjule en turnering for
|
||
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
|
||
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
|
||
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
|
||
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.
|
||
|
||
**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
|
||
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
|
||
til lagets private chat eller det organisator-vendte scorekortet — disse
|
||
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
|
||
strengere sperre enn deltaker-status alene, brukt av dusinvis av
|
||
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
|
||
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
|
||
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
|
||
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.
|
||
|
||
**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
|
||
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
|
||
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
|
||
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
|
||
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
|
||
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
|
||
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
|
||
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 015 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||
`/health`/`/dashboard`/`/account` → 200, `teeoff.no` upåvirket. Se
|
||
CHANGELOG.md for full byggerunde.
|
||
|
||
---
|
||
|
||
## ADR-032: Verifisert e-postbytte + mobil på personlig profil
|
||
|
||
Reist av brukeren 2026-07-20, samme dag og rett etter ADR-031: "identifikatoren"
|
||
(e-post) manglet i den nye personlige profilen, og mobil (med landsnummer)
|
||
burde være en opsjon.
|
||
|
||
**Beslutning A — mobil er et rent, enkelt tillegg til `ProfileUpdate`
|
||
(samme PATCH som resten av profilen), splittet i to felt.**
|
||
`mobile_country_code` (f.eks. `"+47"`) og `mobile_number` er separate
|
||
kolonner på `app_user` (migrasjon `016_profile_contact.sql`) — ikke én
|
||
sammensatt streng — slik at frontend kan tilby en egen landsnummer-
|
||
velger uten å måtte parse en fritekststreng i etterkant.
|
||
|
||
**Beslutning B — e-post er IKKE en del av den vanlige profil-PATCH-en, og
|
||
kan det aldri bli.** E-post er innloggings-identifikatoren (magic-link-
|
||
mål) — en enkel PATCH (som de andre feltene) ville latt en skrivefeil
|
||
ELLER en kapret sesjon stjele kontoen for godt, ingen verifisering av at
|
||
den NYE adressen faktisk eies av noen. Løst med et eget, to-stegs
|
||
bekreftelsesløp, samme `token_hash`+`expires_at`+`consumed_at`-mønster
|
||
som `magic_link_token` (004), gjenbrukt for et nytt formål: ny tabell
|
||
`email_change_token` (bruker_id, ny e-post, token-hash, utløp).
|
||
`POST /auth/profile/email` (krever gyldig sesjon — du må bevise at du
|
||
ER kontoen i dag) genererer tokenet og sender en bekreftelseslenke til
|
||
DEN NYE adressen (ikke den gamle — beviser eierskap av MÅLET, ikke bare
|
||
at avsenderen fortsatt er innlogget). `POST /auth/profile/email/confirm`
|
||
(ingen sesjon påkrevd — samme mønster som selve magic-link-verifiseringen,
|
||
siden lenken kan åpnes på en annen enhet/nettleser enn den som ba om
|
||
byttet) forbruker tokenet atomisk og gjennomfører selve byttet. E-posten
|
||
endres IKKE før lenken faktisk åpnes.
|
||
|
||
**Konsekvens:** duplikat-sjekk (er den ønskede adressen allerede en annen
|
||
kontos?) gjøres TO ganger — én gang ved forespørsel (rask
|
||
tilbakemelding), én gang igjen rett før selve `UPDATE`-en ved bekreftelse
|
||
(kan ha blitt tatt av noen andre i mellomtiden) — pluss den eksisterende
|
||
unike indeksen (`app_user_email_unique`, migrasjon 004) som siste
|
||
bakstopper via `translate_db_errors()`.
|
||
|
||
**Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig
|
||
profil-PATCH endrer aldri e-post; bytte til en allerede brukt adresse
|
||
avvist (409); e-post FORBLIR uendret helt til lenken bekreftes; ugyldig
|
||
bekreftelseskode avvist; gyldig kode fullfører byttet; SAMME kode kan
|
||
ikke brukes to ganger; en helt ny innlogging med den GAMLE adressen
|
||
oppretter nå en fersk, tom konto (beviser byttet er reelt og fullstendig,
|
||
ikke kosmetisk). Ekte typesjekket produksjonsbuild kjørt og bekreftet
|
||
(ny `/verify-email`-rute listet).
|
||
|
||
**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 016 kjørt mot ekte
|
||
`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet,
|
||
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
|
||
upåvirket.
|
||
|
||
---
|
||
|
||
## ADR-033: Frittstående rundeføring med detaljert statistikk
|
||
|
||
Reist av brukeren 2026-07-21 (se FEATURE_BACKLOG.md), utdypet 2026-07-22 med
|
||
konkrete statistikk-felt og en eksplisitt ambisjon: **dette skal bli
|
||
appens hovedfokus** — når rundeføring med detaljert statistikk er på
|
||
plass, skal turneringsoppsett deretter gjøres ekstremt enkelt. Dette er
|
||
den største enkeltbeslutningen i prosjektet siden ADR-001, fordi den
|
||
direkte utfordrer tenant-invarianten ("organisasjon er isolasjonsenheten")
|
||
CLAUDE.md hittil har krevd en ny ADR for å bryte.
|
||
|
||
Fire load-bærende delbeslutninger ble avklart eksplisitt med bruker
|
||
(AskUserQuestion) FØR resten av denne ADR-en ble skrevet. De tre
|
||
HCP-PDF-ene (se referanse i CLAUDE.md) ble lest i sin helhet som
|
||
forberedelse til Beslutning F/G, i tråd med prosjektets stående instruks
|
||
om å ikke anta HCP-regler fra hukommelse.
|
||
|
||
### Beslutning A — Eierskapsmønster: nytt, parallelt, IKKE en skjult organisasjon
|
||
|
||
En frittstående runde eies av en BRUKER (`app_user.id`), ikke en
|
||
organisasjon. Ingen ny RLS-policy-familie trengs for dette — prosjektet
|
||
har ALLEREDE et etablert, bevist mønster for nøyaktig denne typen data:
|
||
personlig profil (migrasjon 015), sekundær e-post (017) og
|
||
HCP-historikk (018) bruker alle `plain_connection()` (ingen
|
||
`app.current_org` satt) med eksplisitt `WHERE user_id = $1`-filtrering i
|
||
hver spørring, i stedet for RLS. Frittstående runder gjenbruker dette
|
||
mønsteret uendret — nye tabeller (`round`, `round_participant`,
|
||
`round_hole_stat`, se Beslutning B) har en `owner_user_id`-kolonne, INGEN
|
||
`organization_id`, og INGEN RLS-policy — autorisasjon håndheves i
|
||
app-laget (eier ser/redigerer egne runder; en deltaker uten konto har
|
||
ingen egen tilgang, se Beslutning D).
|
||
|
||
**Begrunnelse for å avvise "usynlig personlig organisasjon"-alternativet:**
|
||
en skjult organisasjon måtte for alltid filtreres bort fra ENHVER
|
||
org-listing/-bytter/-medlemsside/fremtidig fakturering — en varig
|
||
lekkasjerisiko som vokser for hver ny org-scopet skjerm som bygges
|
||
fremover. Det parallelle mønsteret er ikke bare konseptuelt riktigere,
|
||
det er også ALLEREDE bygget og bevist for personlig data — dette er
|
||
mindre nytt arbeid enn først antatt i brainstorm-runden.
|
||
|
||
### Beslutning B — Statistikk-datamodell: fast sett navngitte felt, ikke fri slag-for-slag-logg
|
||
|
||
Per hull, i tillegg til slagtall (som i dag): kølle brukt ved utslag,
|
||
utslags-resultat (fairway/høyre/venstre — kun relevant på par 4/5),
|
||
innspill-resultat (traff/lang/kort/høyre/venstre), antall putter, lengde
|
||
på første putt, antall chip, antall bunkerslag, antall straffeslag.
|
||
|
||
**Presis definisjon av "innspillsslaget"** (nødvendig for at GIR skal
|
||
kunne beregnes automatisk, uavhengig av hullets par): SISTE slag før
|
||
første putt. På en par 3 er dette utslaget selv; på en par 4 normalt
|
||
2. slag; på en par 5 kan det være 2. ELLER 3. slag (f.eks. ved en
|
||
layup) — modellen trenger ikke vite hvilket slagnummer det var, kun at
|
||
det faktisk er det siste FØR putting startet.
|
||
|
||
**GIR er DERIVERT, ikke tastet inn direkte:** Green In Regulation = sant
|
||
hvis innspill-resultat er "traff" OG antall slag brukt til da er ≤
|
||
(hullets par − 2). Utslag-resultat og innspill-resultat er derimot
|
||
OBSERVERTE felt spilleren selv taster inn — appen har ingen GPS og kan
|
||
ikke oppdage dette selv.
|
||
|
||
**UX-prinsipp, ikke bare et skjema-valg:** ALLE detalj-felt er valgfrie
|
||
per hull. Rask "bare slagtall"-registrering skal alltid fungere uendret
|
||
— dette er bevisst for å unngå at "hovedfokus" i praksis blir for
|
||
tungvint til daglig bruk (samme klasse avveining som gjorde at
|
||
detaljerte felt ble utsatt fra scorekort-rundene tidligere i prosjektet).
|
||
|
||
**Avvist:** fri slag-for-slag-logging (hvert slag = egen rad). Ville
|
||
dekket samme behov, men med vesentlig tyngre registrering og et mer
|
||
komplekst skjema fra dag én, uten at brukerens beskrevne behov faktisk
|
||
krever det.
|
||
|
||
### Beslutning C — Banedata for frittstående runder: LIVE oppslag mot teeoff, bekreftet av bruker
|
||
|
||
Custom-baner er i dag org-scopet (`course.organization_id`) — dette
|
||
fungerer ikke uten organisasjon. **Bekreftet med bruker 2026-07-22:**
|
||
offisielle baner slås opp LIVE mot teeoff sitt API ved behov (samme
|
||
lesende API som ADR-019, IKKE en importert/kopiert kopi som i
|
||
turnering-flyten). ADR-019 sin "importer, ikke slå opp live"-beslutning
|
||
var begrunnet i turneringers behov for reproduserbarhet (et resultat skal
|
||
ikke endre seg retroaktivt hvis banedata oppdateres) — en frittstående
|
||
statistikk-runde har ikke samme behov, og live oppslag unngår i tillegg
|
||
dagens duplisering (hver org som importerer "Tjøme Golfklubb" i dag lager
|
||
sin egen kopi). Egendefinerte baner som ikke finnes i teeoff: global
|
||
banekatalog uten organisasjonstilknytning, søk-før-opprett for å begrense
|
||
duplikater (denne delen — selve den globale katalogen for CUSTOM baner —
|
||
er fortsatt kun min anbefaling, ikke eksplisitt bekreftet punkt for
|
||
punkt, men følger naturlig av at live-oppslag-beslutningen er tatt).
|
||
|
||
### Beslutning D — Deltakere i flighten uten TeeCup-konto: gjenbruk eksisterende mønster
|
||
|
||
`round_participant` får en nullable `user_id` (ekte TeeCup-bruker som
|
||
spiller med) og et `guest_name`-fritekstfelt (ingen konto). Samme
|
||
konsept som `player` uten `user_id` i org-sammenheng. E-post-basert
|
||
kobling i etterkant (samme mønster som `link_player_by_email`,
|
||
migrasjon 008) er en naturlig, men IKKE besluttet, senere utvidelse —
|
||
notert som åpent punkt under, ikke bygget i første omgang.
|
||
|
||
### Beslutning E — Hullantall og starthull: ingen tvang, kun standardvalg
|
||
|
||
18/første ni/siste ni er standardvalg i UI-et, ikke en database-
|
||
begrensning. Spilleren velger starthull fritt (gjenbruk av samme
|
||
`start_hole`-konsept som `session.start_hole`, ADR-015). En runde kan
|
||
avsluttes etter et hvilket som helst antall hull uten et forhånds-
|
||
deklarert mål — statistikk og par-sum regnes alltid fra hullene FAKTISK
|
||
spilt.
|
||
|
||
### Beslutning F — HCP-tellende runde: presist kildebelagt fra WHS Rules of Handicapping 2024
|
||
|
||
Brukeren lastet opp den offisielle kilden 2026-07-22 ("WHS Rules of
|
||
Handicapping 2024", USGA/R&A — IKKE en tredjeparts-blogg som de to andre
|
||
PDF-ene). Dette erstatter den tidligere, mer omtrentlige "minst 9
|
||
hull"-antakelsen med presise regler (**Rule 2.2**):
|
||
|
||
- **18-hulls-type runde:** minimum **10 av 18 hull** må spilles for at
|
||
scoren skal være akseptabel. De resterende (inntil 8) uspilte hullene
|
||
fylles med en "expected score" (se under), IKKE net par (net par-
|
||
metoden ble erstattet av expected-score-metoden i 2024-revisjonen —
|
||
et prinsipielt skifte fra tidligere WHS-versjoner, verdt å merke seg
|
||
siden eldre kilder/hukommelse fortsatt kan referere net par).
|
||
- **9-hulls-type runde:** ALLE 9 hull i det spesifikke, ratede 9-hulls-
|
||
settet (front ELLER back, de eneste to som normalt har egen Course/
|
||
Slope Rating) må spilles. Færre enn 9 hull totalt → scoren er IKKE
|
||
akseptabel for HCP-formål i det hele tatt, uansett grunn.
|
||
- **Konsekvens for Beslutning E (fritt valgt starthull):** den frie
|
||
starthull-friheten gjelder fullt ut for CASUAL, ikke-HCP-tellende
|
||
logging. For at en runde skal telle mot HCP, må de spilte hullene
|
||
derimot samsvare med enten (a) et sammenhengende 10-18-hulls-utsnitt av
|
||
banens 18-hulls-rating, eller (b) nøyaktig banens ratede front-9 eller
|
||
back-9 — en vilkårlig 9-hulls-strekning (f.eks. hull 5-13) har ingen
|
||
egen Course/Slope Rating og kan derfor aldri bli HCP-tellende. Dette må
|
||
kommuniseres tydelig i UI-et, ikke bare håndheves stille i motoren.
|
||
|
||
### Beslutning G — HCP-indeksberegning bygges NÅ, presist kildebelagt (WHS Rules of Handicapping 2024)
|
||
|
||
Full kjede, alle tall/formler hentet direkte fra kilden (Rule 3/5/6),
|
||
ikke hukommelse:
|
||
|
||
1. **Net Double Bogey** (maks hull-score for HCP-formål) = hullets par +
|
||
2 + spillerens handicapslag på det hullet (Rule 3.1b) — allerede
|
||
dekket av eksisterende `allocate_strokes_by_index`/
|
||
`allocate_over_played_holes`, ingen endring.
|
||
2. **Uspilte hull — LØST 2026-07-22 med et bevisst, kildebelagt avvik
|
||
fra "Expected Score":** WHS sin offisielle "Expected Score"-mekanisme
|
||
(Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av
|
||
sertifisert WHS-programvare, UTEN at selve formelen er publisert i
|
||
regelboken (samme mønster som PCC) — kan derfor ikke bygges presist.
|
||
**Brukeren instruerte eksplisitt** å bruke WHS sin egen, presist
|
||
DEFINERTE "Net Par"-term i stedet (Rule 3.2b/2 — normalt reservert for
|
||
spesielle godkjente tilfeller, men her vedtatt som TeeCups generelle
|
||
policy): for hvert uspilt hull antas spilleren å ha skåret sin Net Par
|
||
= hullets par + mottatte handicapslag på det hullet (samme formel som
|
||
Net Double Bogey, uten +2-leddet) — tilsvarer 2 Stableford-poeng per
|
||
uspilt hull. Summeres inn i Adjusted Gross Score FØR standard 18-hulls
|
||
Score Differential-formelen brukes. **Viktig konsekvens:** dette gjør
|
||
at en 9-hulls-runde nå KAN telle fullt mot HCP-indeksen (de resterende
|
||
9 hullene fylles med Net Par, hele runden går gjennom SAMME 18-hulls-
|
||
formel) — det tidligere spørsmålet om en egen 9-hulls-differensial-
|
||
formel (Rule 5.1b) er dermed ikke lenger nødvendig å bygge separat.
|
||
Fortsatt gyldig kun når minimumsantallet (Beslutning F) er oppfylt.
|
||
3. **Ikke fullført hull (spilleren plukker opp)** (Rule 3.3): laveste av
|
||
"most likely score" (allerede tatte slag + sannsynlig antall til
|
||
fullføring, tabell basert på ballens avstand fra hullet + eventuelle
|
||
straffeslag) eller net double bogey.
|
||
4. **18-hulls Score Differential** (Rule 5.1a) = `(113 ÷ Slope Rating) ×
|
||
(Adjusted Gross Score − Course Rating − PCC-justering)`, avrundet til
|
||
nærmeste tidel (,5 rundes opp).
|
||
5. **(Rule 5.1b, WHS sin egen 9-hulls-differensial-formel) — IKKE brukt.**
|
||
Erstattet av Net-Par-tilnærmingen i punkt 2: en 9-hulls-runde regnes nå
|
||
som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME formel
|
||
som punkt 4. Nevnt her kun for å dokumentere at det bevisst er valgt
|
||
bort, ikke oversett.
|
||
6. **Handicap Index** (Rule 5.2) = gjennomsnitt av de beste 8 av de siste
|
||
20 Score Differentials, avrundet til nærmeste tidel. For færre enn 20
|
||
runder i historikken brukes en egen opptrappingstabell (f.eks. 3
|
||
runder → laveste 1 med justering −2,0; 9-11 runder → snitt av laveste
|
||
3, ingen justering; osv. — full tabell i Rule 5.2a, IKKE en enkel
|
||
"gjennomsnitt av alt"-tilnærming for nye spillere).
|
||
7. **Low Handicap Index** (Rule 5.7): laveste indeks siste 365 dager —
|
||
nødvendig referansepunkt for cap-mekanismen under, må lagres/spores
|
||
per bruker.
|
||
8. **Soft cap / hard cap** (Rule 5.8): øker en oppdatert indeks mer enn
|
||
3,0 slag over Low Handicap Index, begrenses overskytende beløp til
|
||
50 %; mer enn 5,0 slag over Low Handicap Index er et absolutt tak.
|
||
Ingen nedre grense på hvor mye indeksen kan SYNKE.
|
||
9. **Maksimal indeks** (Rule 5.3) = 54,0 — samsvarer med det allerede
|
||
satte `le=54`-taket i `ProfileUpdate` fra profil-fullførings-runden
|
||
(2026-07-22), god konsistens-bekreftelse.
|
||
10. **9-hulls Course Handicap** (Rule 6.1b) — **NY, presis detalj,
|
||
AVVIKER fra 18-hulls-formelen:** `(Index ÷ 2, avrundet til nærmeste
|
||
tidel) × (9-hulls Slope ÷ 113) + (9-hulls Course Rating − 9-hulls
|
||
Par)`. Dette er IKKE det samme som å bruke full indeks mot en
|
||
9-hulls rating — indeksen halveres først. **Denne formelen gjelder
|
||
frittstående 9-hulls-RUNDER (denne ADR-en), IKKE det eksisterende
|
||
front_9/back_9-øktoppsettet i turnering-flyten** (ADR-008/2026-07-19-
|
||
fiksen, som bevisst bruker full_18-rating for HELT ANDRE grunner —
|
||
slagfordeling innad i en turneringsmatch, ikke offisiell HCP-
|
||
runde-innsending. De to må IKKE forveksles eller slås sammen uten en
|
||
egen vurdering.)
|
||
11. **18-hulls Course Handicap** (Rule 6.1a) = `Index × (Slope ÷ 113) +
|
||
(Course Rating − Par)` — allerede korrekt implementert
|
||
(`course_handicap_raw`, verifisert ved grep), ingen endring.
|
||
12. **Playing Handicap** (Rule 6.2) — uendret, allerede korrekt dekket av
|
||
eksisterende allowance-strategier (ADR-014).
|
||
|
||
**Dette er i hovedsak en HELT NY komponent i `handicap_engine.py`**
|
||
(punktene 4-9), ikke en utvidelse av det eksisterende — dagens motor tar
|
||
alltid indeksen som et KJENT input; den har aldri regnet UT en indeks fra
|
||
en historie av runder. Punkt 10 er en presisering/utvidelse av
|
||
eksisterende kode for 9-hulls-tilfellet. Holdes ren og testet isolert,
|
||
som resten av `handicap_engine.py` (ADR-005).
|
||
|
||
**Bevisst UTENFOR omfang i første byggerunde, til tross for at kilden nå
|
||
finnes** (for å holde v1 håndterbar — presist avgrenset, ikke bare
|
||
utsatt i vage vendinger):
|
||
- **Playing Conditions Calculation (PCC)** (Rule 5.6) — full prosedyre
|
||
funnet og forstått (statistisk sammenligning av dagens faktiske scorer
|
||
mot forventet, justering −1,0 til +3,0, krever minst 8 aksepterte
|
||
scorer på banen samme dag blant spillere med indeks ≤36,0). Ikke
|
||
bygget nå — krever et helt annet datagrunnlag (ALLE spilte runder på
|
||
en bane en gitt dag på tvers av ALLE brukere) enn det en enkelt
|
||
frittstående runde naturlig gir. Runder telles inn UTEN PCC-justering
|
||
til dette tas som egen, senere runde.
|
||
- **Exceptional Score-reduksjon** (Rule 5.9) — funnet og forstått (en
|
||
differensial 7,0-9,9 slag bedre enn gjeldende indeks gir automatisk
|
||
−1,0 på de siste 20 differensialene, 10,0+ gir −2,0). Ikke bygget i
|
||
v1, notert for senere presisjon.
|
||
- **Initial indeks fra færre enn 3 runder, Handicap Committee-skjønn**
|
||
(Rule 5.2a) — TeeCup har ingen "Handicap Committee"-rolle; en
|
||
forenklet, automatisk variant av opptrappingstabellen brukes i stedet,
|
||
uten menneskelig overstyring i v1.
|
||
|
||
**Åpent, ikke besluttet:** når en reell WHS-indeks kan beregnes fra
|
||
runder, skal manuell redigering av `app_user.handicap_index`
|
||
(eksisterende `PATCH /auth/profile`, ADR-031) fortsatt tillates ved
|
||
siden av (f.eks. for en spiller uten noen TeeCup-runder ennå), eller skal
|
||
feltet bli read-only/auto-beregnet så snart minst én HCP-tellende runde
|
||
finnes? Påvirker om `handicap_history` (018) skal gjenbrukes uendret
|
||
eller trenger en ny kolonne som skiller "manuelt satt" fra "beregnet fra
|
||
runde".
|
||
|
||
**Skjemaet (migrasjon `020_personal_rounds.sql`) er ✅ SKREVET OG
|
||
SCRATCH-VERIFISERT 2026-07-22,** som andre byggesteg (etter motoren).
|
||
Sju nye tabeller: `round` (header, `owner_user_id`-eid, ingen RLS),
|
||
`round_participant` (deltakere — lenket bruker ELLER gjestenavn, XOR-
|
||
håndhevet via CHECK; maks én markert eier per runde via partiell unik
|
||
indeks), `round_hole` (rating-SNAPSHOT + statistikk per deltaker per
|
||
hull — GIR er bevisst IKKE en egen kolonne, kun deriverbar ved lesing:
|
||
`approach_result='hit' AND (score-putts) <= par-2`, verifisert eksakt
|
||
mot ekte testdata), pluss fire tabeller for den globale banekatalogen
|
||
(`personal_course`/`_hole`/`_tee`/`_tee_rating` — sistnevnte BEVISST uten
|
||
`scope`-kolonne, ulikt org-tabellen, siden Net-Par-tilnærmingen gjør
|
||
9-hulls-spesifikk rating overflødig). `round`/`personal_course_*` har
|
||
INGEN RLS (Beslutning A) — autorisasjon i app-laget.
|
||
**Scratch-verifisert, 9 sjekker** (kjørt som `teecup_app_scratch`, ikke
|
||
superbruker): duplikat kjønn på samme utslag avvist, teeoff+custom-felt
|
||
samtidig avvist (CHECK), verken/begge user_id+guest_name avvist (XOR-
|
||
CHECK), to markerte eiere på samme runde avvist, duplikat hullnummer per
|
||
deltaker avvist, ugyldig approach_result-verdi avvist, kaskade-sletting
|
||
av en runde fjerner alle dens deltakere+hull men lar ANDRE runder stå
|
||
urørt. `test_isolation.sql` fortsatt 12/12 (ingen RLS-regresjon på
|
||
eksisterende tabeller). **Rullet ut mot ekte `teecup_db` 2026-07-22,**
|
||
bruker bekreftet eksplisitt: alle sju tabeller bekreftet opprettet,
|
||
`test_isolation.sql` fortsatt 12/12 mot ekte database. **Gjenstår:**
|
||
API-lag og frontend — ingen av disse er startet.
|
||
|
||
**API-laget er ✅ BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE 2026-07-22,**
|
||
som tredje byggesteg. Ny `app/routers/rounds.py` (registrert i `main.py`):
|
||
`POST/GET /rounds` (opprett/list egne runder), `GET/DELETE /rounds/{id}`,
|
||
`POST/DELETE /rounds/{id}/participants` (kun gjester i v1, se moduldoc),
|
||
`PATCH /rounds/{id}/participants/{pid}/holes/{n}` (hull-for-hull-
|
||
registrering), `POST /rounds/{id}/complete` (kjører hele motor-kjeden:
|
||
Adjusted Gross Score → Score Differential → `counts_for_handicap` via
|
||
`round_counts_for_handicap`), pluss `GET/POST /personal-courses` for den
|
||
globale banekatalogen. Ny motor-funksjon lagt til underveis:
|
||
`round_counts_for_handicap(played_holes_count, holes_planned)` — to
|
||
distinkte terskler (Rule 2.2a: min 10/18 ved 18-hulls-intensjon; Rule
|
||
2.2b: ALLE 9 ved 9-hulls-intensjon, ikke "minst 9"), testet (2 nye
|
||
tester, 43/43 totalt i `handicap_engine.py`).
|
||
**Reelt hull funnet OG fikset FØR API-et kunne fullføres:**
|
||
`round_participant` manglet kolonner for selve rating-tallene (Course
|
||
Rating/Slope Rating/Par) brukt til å beregne Course Handicap — kun
|
||
`round.tee_name_snapshot` (navn) fantes, ikke tallene. Ny migrasjon
|
||
`021_round_participant_rating_snapshot.sql` (tre nye nullable kolonner)
|
||
skrevet, scratch-verifisert sammen med resten, og rullet ut.
|
||
**Scratch-verifisert grundig, 26 sjekker** (isolert scratch-rolle+MinIO+
|
||
engangs API-container): full livssyklus for en custom-bane-runde
|
||
(course_handicap_snapshot regnet riktig — Index 15/Slope 128/Rating
|
||
71.5/Par 72 → 16, verifisert for hånd), en gjest UTEN HCP (ingen
|
||
snapshot/differensial, teller aldri), 18/18 spilt → tellende med korrekt
|
||
differensial (16.3, verifisert for hånd), 9 av 18 spilt ved 18-hulls-
|
||
intensjon → IKKE tellende, 9 av 9 spilt ved 9-hulls-intensjon → TELLENDE
|
||
(Net Par fyller resten av de 18, se Beslutning G punkt 2), full
|
||
autorisasjons-isolasjon (en fremmed bruker avvist 403 fra både lesing og
|
||
hull-oppdatering, egen runde-liste tom), kan ikke fjerne eieren, slett-
|
||
runde-kaskade, ufullstendig profil avvist fra å opprette runde. **Egen,
|
||
separat verifisering av teeoff-LIVE-oppslaget** (Beslutning C) mot den
|
||
ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
|
||
anlegg som ADR-019s opprinnelige verifisering) — bekreftet at INGEN
|
||
`course`/`hole`/`tee`-rad skrives noe sted, kun et navn-snapshot
|
||
("Borregaard Golfklubb – Hovedbanen") og et rating-snapshot; en andre
|
||
deltaker lagt til samme runde utløste et FERSK, uavhengig live-oppslag
|
||
mot teeoff (ikke gjenbruk av cachet data). `test_isolation.sql` fortsatt
|
||
12/12.
|
||
**Rullet ut mot ekte systemer 2026-07-22:** migrasjon 021 kjørt mot ekte
|
||
`teecup_db` (kolonner bekreftet, `test_isolation.sql` fortsatt 12/12),
|
||
`docker compose up -d --build teecup_api` (kun backend — ingen frontend-
|
||
skjerm bygget for dette ennå), boot-et rent, `/health`/`/dashboard` → 200,
|
||
`teeoff.no` upåvirket. `frontend/next.config.mjs` sin `rewrites()` fikk
|
||
`/rounds/*` og `/personal-courses/*` lagt til proaktivt (samme lærdom som
|
||
ADR-016/medlemsside-hendelsen — enhver ny API-prefiks MÅ inn her FØR en
|
||
frontend-side bygges) — denne ENDRINGEN ligger IKKE deployet ennå (ingen
|
||
frontend-kode bruker den), tas med i neste frontend-runde.
|
||
**Frontend BYGGET OG RULLET UT 2026-07-23** — fjerde og siste lag
|
||
(engine → skjema → API → frontend). Tre nødvendige tillegg til
|
||
`app/routers/rounds.py` funnet og bygget UNDER frontend-designet, ikke
|
||
antatt på forhånd: `GET /rounds/official-search`/`{slug}` (samme
|
||
teeoff-søkemønster som `courses.py`, men uten org-kontekst — frittstående
|
||
runder har ingen), `GET /personal-courses/{id}` (detalj med
|
||
utslag+kjønn — søk-endepunktet returnerte kun navn), og
|
||
`GET .../participants/{id}/holes` (et reelt hull: `RoundOut` bar aldri
|
||
hull-nivå-data, så ingen skjerm kunne vise gjeldende tilstand ved
|
||
gjenlasting). Hull-PATCH endret til å returnere hele den oppdaterte raden
|
||
i stedet for `{"ok": true}`.
|
||
**Reelt kontraktsfunn, bekreftet i scratch FØR frontend stolte på det:**
|
||
hull-PATCH-endepunktet er IKKE et ekte delvis-PATCH — det skriver ALLE
|
||
felt ved hvert kall (arvet fra hvordan `HoleUpdate`-modellen alltid har
|
||
defaultverdier for utelatte felt). Et PATCH som kun sender `score` ville
|
||
derfor stille NULLSTILT `putts` og alle andre allerede lagrede felt.
|
||
Løst ved at `round-detail.tsx` alltid slår sammen med gjeldende
|
||
hull-data før hver PATCH, aldri sender et isolert feltnavn alene —
|
||
verifisert eksplisitt i en egen scratch-test som FØRST beviste
|
||
nullstillings-oppførselen uten merge, DERETTER beviste at
|
||
merge-mønsteret unngår den.
|
||
**Nye sider:** `/rounds` (liste over egne runder), `/rounds/new`
|
||
(bane-kilde teeoff vs. egen — for egen bane: søk-før-opprett, samme idé
|
||
som org-banenes gjenbrukbare katalog; utslag filtrert til kun de som har
|
||
rating for brukerens registrerte kjønn, ADR-029s automatikk-prinsipp
|
||
gjenbrukt her selv om HCP-motoren er en helt annen), `/rounds/[id]`
|
||
(deltaker-faner — eier + gjester, ingen ekte kontokobling i v1 per
|
||
Beslutning D; hull-navigasjon fra runde-ens starthull; slag/putt-
|
||
tallvelgere i samme visuelle stil som `session-scorecard.tsx` sin
|
||
`StrokePicker`; kølle/retning/innspill/chip/bunker/straffeslag/
|
||
putt-avstand bak en «flere detaljer»-utvidelse; GIR utledet og vist
|
||
KLIENTSIDE ved lesing, aldri lagret — nøyaktig Beslutning B sitt prinsipp;
|
||
fullfør-runde med HCP-differensial-sammendrag, låser videre redigering).
|
||
Lenket fra dashbordet som «Egne runder» (header-lenke + en egen kort på
|
||
forsiden) — bevisst adskilt navn fra det eksisterende «Mine runder»
|
||
(ADR-031, turnering-deltakelse) for å unngå at de to konseptene blandes
|
||
sammen i UI-et, selv om begge bokstavelig talt handler om "runder".
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container, samme mønster som resten
|
||
av ADR-033): 22 automatiserte sjekker (egendefinert-bane-opprettelse med
|
||
to utslag/to kjønn, søk, detalj, PATCH-kontraktsbeviset over,
|
||
GIR-derivering for et konstruert par4/score3/putt1-tilfelle, gjest-
|
||
fjerning, kryss-bruker-autorisasjon 403, full fullføring med differensial)
|
||
PLUSS en separat, egen test av HELE teeoff-baserte opprettelsesløpet mot
|
||
den ekte kjørende `teeoff_api`-containeren (Borregaard Golfklubb, samme
|
||
anlegg som tidligere ADR-019/033-verifiseringer) som bekreftet
|
||
`course_handicap_snapshot` ble beregnet riktig fra live-hentet
|
||
rating. Ekte typesjekket PRODUKSJONSBUILD kjørt via
|
||
`docker build --target builder` (nøyaktig samme steg `Dockerfile` bruker
|
||
i prod, ikke `next dev`) — kompilerte rent, alle nye ruter listet.
|
||
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon i denne del-runden, `docker compose up -d --build teecup_api
|
||
teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard`/
|
||
`/rounds` → 200 over ekte https, `teeoff.no` upåvirket.
|
||
**Bevisst utenfor omfang, ikke bygget denne runden:** ekte kontokobling
|
||
for gjeste-deltakere (Beslutning D), automatisk oppdatering av
|
||
`app_user.handicap_index`/`handicap_history` ved fullført tellende runde
|
||
(åpent spørsmål i Beslutning G, fortsatt ubesvart), shotgun-start
|
||
(egen, separat ADR-034), GPS/avstandsmåling (se eget notat i
|
||
FEATURE_BACKLOG.md, krever data ingen kilde har i dag).
|
||
|
||
**Frontend ERSTATTET med V0-designet versjon 2026-07-23, samme dag som
|
||
den hånd-bygde frontend-en over ble rullet ut:** brukeren påpekte
|
||
(berettiget) at de tre nye skjermene var hånd-kodet av meg direkte i
|
||
stedet for designet i V0 -- et avvik fra prosjektets etablerte mønster
|
||
gjennom HELE resten av appen. Bekreftet med bruker at arbeidsmåten
|
||
fortsatt skal være: JEG skriver V0-prompten, BRUKEREN kjører den i
|
||
v0.app og sender koden tilbake, JEG integrerer (samme flyt som alltid,
|
||
ikke endret). Skrev tre detaljerte prompter (liste/opprett/hull-
|
||
registrering, inkl. eksplisitt tilgjengelighetskrav i hver) -- bruker
|
||
lastet opp tre zip-eksporter (`tee-cup-login-screen (10/11/12).zip`).
|
||
**Samme "full re-eksport hver gang"-mønster som ALLE tidligere V0-runder:**
|
||
diffet mot levende tre FØR noe ble tatt inn -- kun fire filer var reelt
|
||
nye/relevante (`round-card.tsx`, `own-rounds.tsx`, `new-round.tsx`,
|
||
`round-detail.tsx` + tre `app/rounds*/page.tsx`-ruter), resten
|
||
(login-form, dashboard, config, ui/*) var forventede full-reverts og ble
|
||
IKKE tatt inn. **Samme kjente V0-feil dukket opp igjen, hoppet bevisst
|
||
over:** `app/clubs/[id]/page.tsx` med feilnavngitt `[id]`-parameter
|
||
(egentlig en slug) -- identisk feil som ble rettet i klubbside-runden
|
||
under ADR-018, V0 gjenskaper den tydeligvis når prosjektet re-genereres.
|
||
**Mine tre hånd-bygde komponenter (`personal-rounds.tsx`, `round-new.tsx`,
|
||
og min opprinnelige `round-detail.tsx`) er ERSTATTET, ikke supplert** --
|
||
V0s presentasjon beholdt, datalaget skrevet om fra mock til ekte fetch
|
||
(samme "V0 leverte mock, jeg kabler ekte data"-mønster som enhver annen
|
||
skjerm i appen). Reelle tilpasninger utover ren om-kabling:
|
||
1. V0s teeoff-søkemodell antok hele bane+utslag-lista lå ferdig i
|
||
søkeresultatet -- det ekte API-et er et to-stegs oppslag (søk →
|
||
facility-detalj). Løst med en `resolving`-tilstand i
|
||
`OfficialSearchStep` (eneste strukturelle tillegg til V0s JSX).
|
||
2. La til et tredje kjønnsvalg "Annet" i gjeste-skjemaets `ChoiceRow`
|
||
(V0 hadde kun Mann/Kvinne) -- matcher appens ellers etablerte
|
||
Herre/Dame/Annet-konvensjon (`account-settings.tsx` m.fl.), og
|
||
API-ets `GuestParticipantCreate.gender` støtter allerede `x`.
|
||
3. Beholdt merge-før-PATCH-sikringen fra forrige rundes kontraktsfunn
|
||
(hull-PATCH skriver alle felt, ikke bare det endrede) -- V0 kjente
|
||
naturligvis ikke til denne kontraktsdetaljen, lagt inn i
|
||
`updateStat()` uendret i prinsipp fra min opprinnelige versjon.
|
||
4. Fjernet V0s dev-only forhåndsvisningskontroller (`ReviewToggle` i
|
||
`own-rounds.tsx`, den nederste "Forhåndsvis: Pågår/Fullført"-linjen i
|
||
`round-detail.tsx`) -- samme opprydningsmønster som ALLE tidligere
|
||
V0-runder i prosjektet.
|
||
Ingen backend-endring i denne del-runden (samme API-kontrakt som allerede
|
||
var scratch-verifisert). **Verifisert:** ekte typesjekket
|
||
produksjonsbuild (`docker build --target builder`) kompilerte rent, alle
|
||
ruter listet. Ingen ny interaktiv nettleser-test utført (intet slikt
|
||
verktøy tilgjengelig i denne økten) -- kun kodegjennomgang + typesjekk,
|
||
flagget eksplisitt til bruker.
|
||
**Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: `docker
|
||
compose up -d --build teecup_frontend` (gjenskapte også `teecup_api` som
|
||
vanlig compose-bivirkning, ingen backend-kode rørt), begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/rounds`/`/rounds/new` → 200 over
|
||
ekte https, `teeoff.no` upåvirket. De tre V0-zip-ene slettet fra
|
||
prosjektroten etter fullført integrering.
|
||
|
||
**Reell produksjonsbug funnet OG FIKSET 2026-07-23, rapportert av bruker
|
||
med to skjermbilder (`/rounds` ga "Klarte ikke å hente rundene dine",
|
||
`/rounds/new` sitt siste steg ga "Klarte ikke å opprette runden"):**
|
||
rot-årsak var en EKSAKT navnekollisjon mellom frontend-sidens toppnivå-
|
||
prefiks (`/rounds`) og backend-APIets ressursprefiks (samme `/rounds`,
|
||
`app/routers/rounds.py`) -- en verre variant av den allerede dokumenterte
|
||
"afterFiles"-fellen fra ADR-016s medlemsside-hendelse, denne gangen
|
||
rammende BEGGE retninger samtidig:
|
||
- `/rounds` (eksakt sti): en STATISK frontend-side. Statiske sider
|
||
sjekkes FØR rewrites, så siden vant presedens -- klientens
|
||
`fetch("/rounds")`/`POST /rounds` traff ALDRI backend, fikk Next sin
|
||
egen HTML tilbake i stedet for JSON (stille `res.json()`-parsefeil,
|
||
fanget av try/catch, viste den generiske feilteksten).
|
||
- `/rounds/[id]` (DYNAMISK side): her sjekkes rewrites FØR dynamiske
|
||
sider, så rewrite-regelen vant presedens i stedet -- selve
|
||
rundedetalj-SIDEN var dermed fullstendig UOPPNÅELIG (ville vist rå
|
||
backend-JSON i stedet for UI-et), bekreftet direkte med `curl` FØR
|
||
fiksen (anonymt `GET /rounds/00000000-...` ga ekte backend-JSON i
|
||
stedet for Next sin HTML).
|
||
Det andre skjermbildets utslagsnavn "55/50/44/32" ble UNDERSØKT OG
|
||
BEKREFTET Å IKKE VÆRE EN BUG -- lest direkte fra ekte `teeoff_api` (`GET
|
||
tjome-golfklubb`): Tjøme Golfklubb sine faktiske utslagsnavn i teeoff ER
|
||
bokstavelig talt disse tallene (lengde i hundremeter, ikke fargenavn) --
|
||
data gjengitt korrekt, ingen kode-endring nødvendig for dette punktet.
|
||
**Fikset ved samme prinsipp som medlemsside-hendelsen: flytt siden, ikke
|
||
APIet.** Alle tre frontend-rutene flyttet til et helt nytt, ikke-
|
||
overlappende toppnivå-prefiks `/my-rounds/*` (`app/rounds/` →
|
||
`app/my-rounds/`), API-et (`/rounds/*`) uendret. Åtte interne
|
||
navigasjonsreferanser oppdatert på tvers av `round-card.tsx`/
|
||
`own-rounds.tsx`/`new-round.tsx`/`round-detail.tsx`/`dashboard.tsx` --
|
||
ekte `fetch()`-kall til API-et (samme filer) bevisst latt urørt, kun
|
||
`<Link href>`/`router.push`/`router.replace` endret. Utvidet
|
||
`next.config.mjs` sin allerede eksisterende advarselskommentar med denne
|
||
nye, verre varianten av samme fellesklasse, som en fremtidig påminnelse:
|
||
et rewrite-prefiks og en frontend-sides toppnivå-segment må ALDRI være
|
||
identisk streng.
|
||
**Verifisert presist FØR og ETTER utrulling** (ikke bare "bygget uten
|
||
feil"): et `curl` mot ekte produksjon FØR fiksen bekreftet nøyaktig
|
||
mekanismen i begge retninger (se over). Ekte typesjekket
|
||
produksjonsbuild etterpå viste selv at rutetreet nå lister `/my-rounds`,
|
||
`/my-rounds/[id]`, `/my-rounds/new` i stedet for de gamle `/rounds`-
|
||
rutene. **Rullet ut live 2026-07-23**, bruker bekreftet eksplisitt: kun
|
||
`teecup_frontend` (gjenskapte `teecup_api` som vanlig bivirkning, ingen
|
||
backend-kode rørt). Verifisert ETTERPÅ med et nytt sett `curl`-kall mot
|
||
ekte https: anonymt `GET /rounds` ga nå korrekt backend-JSON
|
||
(`NOT_AUTHENTICATED`, IKKE Next sin HTML som før), anonymt `GET
|
||
/rounds/<uuid>` fortsatt korrekt backend-JSON (uendret, som forventet),
|
||
og -- den avgjørende nye sjekken -- anonymt `GET /my-rounds/<uuid>` ga nå
|
||
faktisk `text/html` (selve React-siden, ikke lenger uoppnåelig).
|
||
`/dashboard`/`/my-rounds`/`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Nok en runde brukerrapporterte punkter, ALLE BYGGET OG LIVE 2026-07-24,
|
||
samme dag:** (1) Utslagstidspunkt (`round.started_at`, migrasjon
|
||
`023_round_start_time.sql`, valgfritt) + tidsbruk beregnet klientside
|
||
som `completed_at − started_at` når runden fullføres. Bekreftet
|
||
eksplisitt med bruker: "Ferdig"-tidspunktet er den ALLEREDE eksisterende
|
||
"Fullfør runde"-knappen, ingen ny handling/kolonne. (2) "Idx" i hull-
|
||
overskriften byttet til "Hcp". (3) Slag-tastaturets numpad merker nå
|
||
knappen som tilsvarer hullets par med en liten "par"-bildetekst.
|
||
(4) Hull-navigasjonens "Forrige"/"Neste" respekterte tidligere ALLTID
|
||
18 hull uansett `holes_planned` -- en 9-hulls runde 10-18 hoppet feilaktig
|
||
til hull 9 ved "Forrige" fra hull 10. Fikset: navigasjonsrekkefølgen
|
||
bygges nå med `holes_planned` som lengde, ikke hardkodet 18.
|
||
(5) Putter/Chip/Bunker/Straffeslag/Anywayslag kan nå aldri velges høyere
|
||
enn antall registrerte slag på hullet (`NumberPicker` fikk en
|
||
`maxValue`-prop, `Stepper` en `max`-prop) -- ingen vits i å tilby et
|
||
selvmotsigende tall. (6) "Slett runde" bygget i UI-et (backendens
|
||
`DELETE /rounds/{id}` fantes fra før, men hadde aldri fått en
|
||
frontend-knapp) -- bekreftelsesdialog, sletter for alle (kaskade
|
||
fjerner automatisk alle deltakere/hull, guest-spillere har uansett ingen
|
||
egen konto å bevare noe for).
|
||
(7) **Ny `PATCH /rounds/{id}`** -- retter opp feil bane/utslag eller
|
||
feil antall hull ETTER opprettelse, uten å røre allerede registrerte
|
||
slag/putter/etc. Speiler samme filosofi som turnering-øktenes
|
||
`_remap_course` (ADR-tidligere runde), men enklere: ingen tee-navn-
|
||
matching på tvers av kjønn siden hver deltaker valideres eksplisitt mot
|
||
den NYE banens rating for sitt eget kjønn FØR noe skrives (hele byttet
|
||
avvises 400 hvis ÉN deltaker ville mistet HCP-sporing). Bevisst
|
||
AVVIST (409) etter at runden er fullført -- ulikt turnering-øktenes
|
||
bane-bytte, som bevisst tillater dette selv etter avgjørelse; her er
|
||
omfanget mindre (ingen re-beregning av differensial bygget for dette
|
||
tilfellet). Frontend: ny "Rediger runde"-seksjon på rundesiden (antall
|
||
hull som enkelt 9/18-valg, "Bytt bane" som en kompakt søke-flyt --
|
||
teeoff-søk ELLER egen-bane-søk, samme mønster som ved opprettelse, bare
|
||
kondensert). Etter et vellykket bytte hentes hull-data på nytt for
|
||
ALLE allerede lastede deltakere (par/stroke-index kan ha endret seg for
|
||
alle, ikke bare aktiv spiller).
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 22 sjekker for
|
||
rediger/slett-rundene (bane-bytte med FAKTISK ulike par/stroke-index-
|
||
verdier mellom to egendefinerte baner, bekreftet at allerede registrerte
|
||
slag på hull 1-3 var UENDRET etter byttet mens par/stroke-index OG
|
||
course_handicap_snapshot var oppdatert; avvist bytte til en bane uten
|
||
rating for deltakerens kjønn, bekreftet at INGENTING ble endret ved
|
||
avvisning; avvist bane/hull-endring etter fullføring, 409; slett-runde
|
||
+ idempotent 404 + kryss-bruker-autorisasjon 403) pluss 8 sjekker for
|
||
utslagstid/tidsbruk. `test_isolation.sql` 12/12. Ekte typesjekket
|
||
produksjonsbuild kompilerte rent begge ganger.
|
||
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||
migrasjon 023 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||
--build teecup_api teecup_frontend`, begge containere boot-et rent,
|
||
`/health`/`/dashboard`/`/my-rounds` → 200 over ekte https, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**To til brukerpunkter, ALLE BYGGET OG LIVE 2026-07-25, samme dag:**
|
||
(1) `PATCH /rounds/{id}` utvidet med `start_hole`/`started_at`/
|
||
`completed_at` -- start_hole kan rettes uansett (ren metadata, aldri
|
||
sperret av fullført-status), started_at kan justeres når som helst,
|
||
completed_at kan KUN justeres på en runde som allerede ER fullført
|
||
(avvist 400 ellers -- denne PATCH-en fullfører aldri runden selv, kun
|
||
korrigerer et allerede satt tidspunkt fra "Fullfør runde"). Ny
|
||
validering: completed_at må være etter started_at, ellers 400. Løser
|
||
brukerens konkrete case: glemmer å trykke "Fullfør runde" i flere timer,
|
||
vil rette opp tidsbruken i etterkant. `EditRoundPanel` sin "Bane/antall
|
||
hull"-blokk forblir sperret post-fullføring (uendret fra forrige runde),
|
||
mens et nytt tidspunkt-skjema (Utslagstid alltid, Fullført-tidspunkt kun
|
||
hvis fullført) nå vises uansett fullført-status.
|
||
(2) **Nærmeste offisielle baner** i "Ny runde"-flyten -- nytt
|
||
`GET /rounds/official-search/nearby?lat=&lng=&limit=` (Haversine-formel,
|
||
sortert stigende, MÅ registreres FØR `/rounds/official-search/{slug}` i
|
||
routeren, samme presedens-lærdom som ADR-020s "by-code"). Henter ALLE
|
||
174 teeoff-anleggenes lat/lng via samme `search_facilities("")`-kall som
|
||
allerede finnes (ingen ny teeoff-avhengighet), beregner avstand i Python
|
||
per forespørsel (ingen cache -- datasettet er lite nok). Frontend: ny
|
||
`NearbyClubs`-komponent i `OfficialSearchStep`, ber om
|
||
`navigator.geolocation` ved mount, viser inntil 5 nærmeste med
|
||
nærmeste tydelig merket + avstand (m under 1 km, ellers km) -- rett
|
||
FØR søkefeltet, som bedt om. Avslått/manglende posisjon feiler helt
|
||
stille (ingen feilmelding), søket fungerer uendret som fallback.
|
||
**Scratch-verifisert:** 14 sjekker (start_hole-endring, tidspunkt-
|
||
korreksjon i begge retninger inkl. de to nye valideringsreglene,
|
||
`holes_planned` fortsatt sperret post-fullføring uendret, OG et ekte
|
||
`nearby`-kall mot den kjørende `teeoff_api`-containeren fra Tjømes egne
|
||
koordinater som korrekt fant Tjøme selv som nærmeste/nest-nærmeste
|
||
treff). `test_isolation.sql` 12/12 (ingen skjemaendring). Ekte
|
||
typesjekket produksjonsbuild kompilerte rent.
|
||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||
begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds/new`
|
||
→ 200, `teeoff.no` upåvirket.
|
||
|
||
**Reell UX-bug funnet OG fikset, samme dag 2026-07-25, rapportert av
|
||
bruker med skjermbilder av en konkurrerende golf-app (Golf Game Book)
|
||
som referanse:** brukeren rapporterte at "All statistikk" ikke viste
|
||
noe utover slag/putter under selve registreringen. **Bekreftet direkte
|
||
mot ekte `teecup_db` (kun lesing) at dette IKKE var en datafeil** --
|
||
brukerens faktiske runde hadde `stat_level='full'` lagret korrekt. Rot-
|
||
årsaken var et REELT presentasjonsproblem: alle detaljfeltene (kølle,
|
||
utslag, innspill, chip, bunker, straffeslag, putt-avstand, anywayslag)
|
||
lå bak en "Score"/"Statistikk"-fane (V0-runden 2026-07-24s bevisste
|
||
designvalg) som brukeren aldri oppdaget -- "aktivert full statistikk"
|
||
ga i praksis ingen synlig endring uten et ekstra, ikke-annonsert
|
||
tastetrykk. **Fikset ved å fjerne fane-løsningen helt:** alle feltene
|
||
vises nå ALLTID samlet under slag/putter i én sammenhengende scroll når
|
||
`statLevel==="full"` (samme prinsipp brukeren opprinnelig ba om FØR
|
||
V0-rundens sveip/fane-vurdering) -- fjerner enhver tvetydighet om
|
||
hvorvidt statistikken faktisk er slått på. `PanelTabs`-komponenten og
|
||
`panelTab`-state fjernet som død kode.
|
||
**Samtidig bygget: "Så langt i runden"-oversikt**, direkte etterspurt
|
||
("jeg trenger et grensesnitt som viser scoren min så langt") med
|
||
referansebildene som inspirasjon for FUNKSJONEN (ikke kopiert
|
||
utseendemessig). Ny komponent `ScoreSoFar` i `round-detail.tsx`: en
|
||
kompakt alltid-synlig linje ("Så langt: X hull · Y slag · +Z til par")
|
||
rett under spillerfanene, pluss en "Vis full oversikt"-knapp (samme
|
||
etablerte mønster som `session-scorecard.tsx` sin `HoleSummaryTable`
|
||
for turnering-scoring) som åpner en tabell: hull, par, score, netto,
|
||
løpende sum. **Ny backend-beregning for å muliggjøre netto-kolonnen:**
|
||
`RoundHoleOut` fikk et nytt felt `strokes_received` (`list_holes` i
|
||
`app/routers/rounds.py`) -- utledet fra deltakerens
|
||
`course_handicap_snapshot` + hullenes `stroke_index` via den
|
||
ALLEREDE eksisterende `allocate_strokes_by_index()` fra
|
||
`handicap_engine.py` (samme allokeringsalgoritme som brukes overalt
|
||
ellers i appen, ingen ny logikk) -- `None` for en deltaker uten
|
||
beregnet HCP (f.eks. en gjest uten oppgitt handicap), aldri lagret,
|
||
kun beregnet ved lesing.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 11 sjekker, inkl. et
|
||
presist talleksempel (course rating 72.0/slope 113/HCP 10.0 → course
|
||
handicap nøyaktig 10, bekreftet at de 10 laveste stroke-indeksene fikk
|
||
nøyaktig 1 slag hver og de resterende 8 fikk 0, sum(strokes_received)
|
||
== course_handicap), et registrert hull som ga korrekt netto (score 5 −
|
||
1 mottatt slag = 4), og en gjest UTEN HCP som korrekt fikk
|
||
`strokes_received: null` på alle 18 hull. `test_isolation.sql` 12/12
|
||
(ingen skjemaendring). Ekte typesjekket produksjonsbuild kompilerte
|
||
rent.
|
||
**Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon, kun `teecup_api`+`teecup_frontend` redeployet.
|
||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Reell produksjonsregresjon rapportert AV BRUKEREN samme dag, rett
|
||
etter utrulling over, funnet og fikset umiddelbart:** "Ingenting er
|
||
klikkbart i den avanserte statistikken." Første antagelse (frontend-
|
||
CSS/event-håndtering) ble IKKE bekreftet ved kodegjennomgang -- all
|
||
onClick-kabling i `DirectionCross`/`Stepper`/`ChoiceRow`/`NumberPicker`
|
||
var korrekt. Root cause funnet ved å faktisk GJENSKAPE brukerens
|
||
klikk-sekvens mot en fersk, isolert scratch-container (samme mønster
|
||
som resten av runden) i stedet for å gjette videre: `update_hole`
|
||
(PATCH-endepunktet for hull-registrering) konstruerte fortsatt
|
||
`RoundHoleOut(**dict(row))` UTEN det nye påkrevde
|
||
`strokes_received`-feltet fra runden rett over -- en Pydantic
|
||
`ValidationError` (500) på HVER ENESTE hull-lagring, ikke bare de
|
||
"avanserte" feltene. Frontend sin `updateStat()` svelger `!res.ok`
|
||
stille uten feilmelding, så symptomet fremsto nøyaktig som "ingenting
|
||
skjer når jeg trykker" for ALLE felt (Slag/Putter inkludert) -- brukeren
|
||
merket det trolig først på de avanserte feltene siden Slag/Putter fra
|
||
TIDLIGERE runder allerede hadde lagrede verdier som så riktige ut ved
|
||
åpning.
|
||
**Fikset:** `update_hole` beregner nå `strokes_received` for akkurat det
|
||
oppdaterte hullet (henter deltakerens `course_handicap_snapshot` +
|
||
ALLE 18 sine `stroke_index` -- samme allokeringsalgoritme som
|
||
`list_holes`, siden fordelingen avhenger av hele rundens
|
||
stroke-indeks-rekkefølge, ikke bare ett hull) og sender den med i
|
||
responsen.
|
||
**Scratch-verifisert på nytt, presist mot akkurat denne regresjonen:**
|
||
14 sjekker som gjenskaper brukerens EKSAKTE klikk-rekkefølge (Slag →
|
||
kølle → Utslag-retning → Innspill-retning → Chip → første putt-bøtte →
|
||
Anywayslag, pluss et klikk på et avansert felt FØR Slag i det hele tatt
|
||
er satt, og en gjest uten HCP) -- alle 200 med riktig ekko, `GET` etterpå
|
||
bekrefter faktisk lagring, gjest uten HCP gir korrekt `strokes_received:
|
||
null` uten å krasje. `test_isolation.sql` uendret (ingen skjemaendring,
|
||
ren Python-fiks). **Rullet ut live 2026-07-25**, bruker bekreftet
|
||
eksplisitt: kun `teecup_api` redeployet, `/health`/`/dashboard` → 200,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Rediger/Fullfør/Slett tonet ned og flyttet til toppen + auto-scroll ved
|
||
hull-bytte, LIVE samme dag (2026-07-25):** brukeren rapporterte at
|
||
"Fullfør runde"/"Slett runde" lå som store, fremtredende knapper RETT
|
||
under "Neste hull"-navigasjonen nederst i hull-panelet -- altfor lett å
|
||
trykke feil ved et uhell mens man bare skulle bla mellom hull. Flyttet
|
||
alle tre (Rediger/Fullfør/Slett) til en samlet rad ØVERST på siden,
|
||
tonet ned til samme nøytrale "trigger"-stil (ikke lenger store,
|
||
fargede knapper) -- brukeren må nå aktivt scrolle OPP forbi hele
|
||
hull-registreringen for å nå dem. Samtidig rapportert, i samme runde:
|
||
"Neste hull"/"Forrige" lastet nytt innhold, men brukeren ble stående
|
||
scrollet nede der knappene er, med det nye hullets Slag-felt utenfor
|
||
skjermen. Løst med `holePanelRef` + `scrollIntoView({block:"start"})`
|
||
kalt fra `goPrev`/`goNext` OG fra hull-navigasjonens direkte hull-valg,
|
||
med `scroll-mt-28` på panelet for å unngå at den sticky headeren dekker
|
||
toppen. Ren frontend-endring, ingen backend/migrasjon. Ekte typesjekket
|
||
build kjørt og bekreftet (alle 19 ruter), rullet ut, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**"Så langt i runden" utvidet med netto/stableford-sum, putt-/kølle-
|
||
statistikk-totaler og grafisk fairway-/innspill-fordeling, LIVE samme
|
||
dag (2026-07-25):** brukeren etterspurte flere detaljer i
|
||
rundeoversikten: netto- og stableford-sum, hvor man kan se totalt antall
|
||
putter/chip/bunker/straffeslag/anywayslag for runden, grafisk fremstilt
|
||
prosentvis fordeling av fairwaytreff og innspillstreff, og gjennomsnittlig
|
||
score til par (to desimaler) splittet på fairwaytreff-vs-bom og
|
||
innspill-treff-vs-bom.
|
||
**Ingen backend-endring nødvendig** -- alt beregnes klientside fra data
|
||
`GET .../holes` allerede returnerer (score/par/strokes_received/putts/
|
||
chip_count/bunker_shot_count/penalty_strokes/tee_shot_result/
|
||
approach_result/anyway_strokes). `ScoreSoFar`-komponenten
|
||
(round-detail.tsx) utvidet med: en `StatPill`-rutenett (Slag/Til par/
|
||
Netto/Stableford/Putt/Chip/Bunker/Straffeslag/Anywayslag -- hver vist
|
||
kun hvis det faktisk finnes registrert data for akkurat det feltet), en
|
||
ny Stableford-kolonne i hull-for-hull-tabellen (kun når netto er
|
||
beregnbart), og to nye `DistributionBar`-seksjoner (Fairwaytreff/
|
||
Innspill) -- generalisert N-kategori-variant av samme visuelle idé som
|
||
`SegmentedBar` i tournament-leaderboard.tsx (ett fargesegmentert
|
||
rektangel + prosent-legend), ikke noe chart-bibliotek lagt til.
|
||
**Presisert i kode-kommentar, bevisst valg:** appen har ingen egen
|
||
"spilleform"-innstilling for frittstående runder -- stableford beregnes
|
||
derfor alltid ut fra netto score når det er mulig (krever registrert
|
||
HCP, samme forutsetning som netto), uavhengig av om brukeren
|
||
"egentlig" spiller slagspill eller stableford. Standard stableford-
|
||
poengtabell brukt (netto par = 2 poeng, ett poeng mer/mindre per slag
|
||
bedre/dårligere enn par, gulv på 0).
|
||
Gjennomsnittlig score-til-par (fairway/innspill, treff-vs-bom) bruker
|
||
BRUTTO score (ikke netto) relativt til par, formatert med fortegn og to
|
||
desimaler (`formatSignedAvg`), matcher brukerens eksplisitte
|
||
spesifikasjon. Fairwaytreff ekskluderer par-3-hull (samme regel som
|
||
registrerings-skjemaet, som aldri viser Utslag-retning der).
|
||
**Logikken verifisert manuelt mot et regnet eksempel** (4 hull, blandet
|
||
par 3/4/5, ulike utslag-/innspillresultater) FØR utrulling -- stableford-
|
||
sum, netto-sum og begge gjennomsnitts-splittene stemte med
|
||
håndregning. Ekte typesjekket produksjonsbuild kjørt og bekreftet (alle
|
||
19 ruter). **Rullet ut live 2026-07-25**, bruker bekreftet eksplisitt:
|
||
ren frontend-endring, ingen migrasjon, `/health`/`/my-rounds` → 200,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Rundestatistikk-skjerm (dypdykk), inspirert av en konkurrent-video,
|
||
BYGGET OG LIVE samme dag (2026-07-25):** brukeren lastet opp en 26
|
||
sekunders skjermopptaksvideo av en KONKURRENT-apps statistikkskjerm og
|
||
ba eksplisitt om et V0-prompt inspirert av innholdet, IKKE et plagiat.
|
||
Video analysert bilde for bilde (ffmpeg kjørt i en engangs Docker-
|
||
container, ikke installert på verten -- ryddet opp etterpå). Innhold
|
||
identifisert: score-fordeling (par/bogey/dobbel bogey/verre),
|
||
snitt-til-par per hulltype, fairwayfordeling + score-splitt, GIR-donut +
|
||
per-hulltype + kryss med fairway + score-splitt, bom-retning på green
|
||
som et kompass-diagram, putt-fordeling (1/2/3-putt) + snitt per hulltype
|
||
+ med/uten GIR, én-putt% etter puttlengde + lengdefordeling hit/miss,
|
||
chip-fordeling, scrambling%/sand save%, bunker/straffeslag per runde +
|
||
score-splitt.
|
||
**Bevisste valg for å unngå plagiat, skrevet inn i selve V0-promptet:**
|
||
egen visuell identitet (TeeCups presise grønn/oransje, ikke konkurrentens
|
||
fargekoding), fritt valg av chart-type/layout/rekkefølge til V0 selv,
|
||
INGEN kopiering av konkurrentens eksakte ordlyd/fargekoding/ikonografi.
|
||
Puttlengde-bøttene i promptet er TeeCups EGNE seks bøtter
|
||
(<1m/<2m/<3m/<5m/<8m/8m+, samme som ADR-033 allerede lagrer) -- bevisst
|
||
ANDRE enn videoens fem bøtter (<1m/1-2/2-4/4-8/+), både fordi det unngår
|
||
en direkte kopi og fordi det er det datamodellen faktisk allerede
|
||
produserer. "Lengste drive" (krever GPS/avstandsmåling appen ikke har)
|
||
utelatt fra promptet.
|
||
**Zip 14 mottatt og integrert samme dag:** diffet mot live-treet FØR noe
|
||
ble tatt inn (samme rutine som alltid) -- kun to reelt nye filer
|
||
(`components/round-stats.tsx`, en ny `RoundStats`-skjerm bygget med
|
||
kollapsbare `StatCard`-seksjoner, conic-gradient-donuter med sentertall,
|
||
avviks-stolper fra en null-linje, og et kompass-rutenett for bom-retning
|
||
-- en tydelig ANNEN visuell løsning enn konkurrentens skjermbilder, ikke
|
||
en klone). Resten av eksporten var V0s vanlige uvitende reverts, inkl.
|
||
sin egen `/rounds/[id]`-ruteversjon fra FØR `/my-rounds`-omdøpingen
|
||
(se lenger opp, "Reell produksjonsbug...") -- korrekt hoppet over. Ny
|
||
rute lagt til som `app/my-rounds/[id]/stats/page.tsx` (ikke
|
||
`app/rounds/[id]/stats` som eksporten foreslo), samme
|
||
kollisjon-unngåelse. `app/globals.css` sin nye `--chart-1..6`
|
||
data-viz-fargeskala (god->dårlig-skala, brukt sammen med
|
||
tekst-/tall-etiketter) slått sammen inn i alle tre temablokkene;
|
||
V0s egne, mer omtrentlige `--primary`/`--ring`/`--brand-orange`-verdier
|
||
i SAMME diff ble bevisst IKKE tatt inn -- beholdt de presise OKLCH-
|
||
verdiene utregnet i ADR-016.
|
||
**Datalag skrevet fullstendig om fra mock:** ny `computeStats()`-
|
||
funksjon i `round-stats.tsx` regner ut alt fra rå `GET .../holes`-data
|
||
(samme endepunkt round-detail.tsx allerede bruker -- ingen ny backend).
|
||
Hver seksjon skjules helt når det ikke finnes nok data for den (samme
|
||
"vis kun det som faktisk finnes"-prinsipp som `ScoreSoFar`). Ny enkel
|
||
spillervelger (pill-rad) lagt til når runden har flere deltakere,
|
||
default til eieren. `CompletedBanner` i round-detail.tsx fikk en "Se
|
||
full rundestatistikk"-lenke (fantes i V0s egen, ellers reverterte fil --
|
||
portert manuelt inn i vår live versjon i stedet for å ta hele filen).
|
||
**Matematikken verifisert FØR utrulling, ikke bare "kompilerer":**
|
||
`computeStats()`-logikken portert til et frittstående Node-script og
|
||
kjørt mot et hånd-etterregnet 6-hulls syntetisk datasett (blandet par
|
||
3/4/5, fairwaytreff/-bom, GIR-treff/-bom, bunkerslag) -- alle utledede
|
||
tall (score-kategorier, snitt-til-par per hulltype, fairway-splitt,
|
||
GIR%/kryss-fairway/score-splitt, bom-retning, putt-fordeling,
|
||
scrambling%, sand save%) stemte med manuell utregning. Ekte typesjekket
|
||
produksjonsbuild kjørt og bekreftet (`/my-rounds/[id]/stats` listet som
|
||
ny rute).
|
||
**Rullet ut live 2026-07-25**, ren frontend-endring, ingen migrasjon,
|
||
`/health`/`/my-rounds` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Rundeliste-info + scorekort-redesign, BYGGET OG LIVE samme dag
|
||
(2026-07-25):** brukeren delte en ny skjermopptaksvideo (video 2, 18
|
||
frames analysert ved 1,5 fps) av to problemer: "Egne runder"-listen
|
||
manglet ALL score-informasjon (kun tee/hull/dato/spillerantall/status),
|
||
og selve scorekort-registreringen (rapportert "veldig dårlig
|
||
designet") stablet fem ulike kontrolltyper (numpad, retningskors,
|
||
steppere, pill-rutenett, pill-rad) uten hierarki, pluss at "Runde
|
||
fullført"-siden gjentok NØYAKTIG samme tall tre ganger (CompletedBanner-
|
||
differensial, "Så langt"-oppsummeringslinjen, og StatPill-rutenettet
|
||
under). Bedt om å reflektere kort og eventuelt skrive V0-prompt(er).
|
||
**Reflektert og handlet i samme runde** (ikke bare skrevet ned): pekte ut
|
||
at score/til-par mangler helt fra listekortet som det klareste,
|
||
konkrete hullet; anbefalte å samle alt utover Slag/Putter bak én
|
||
kollapsbar "Flere detaljer"-seksjon som den viktigste enkeltfiksen for
|
||
scorekort-rotet; og fikset duplikat-problemet PÅ "Runde fullført"-siden
|
||
DIREKTE (ikke via V0) siden det bare er snakk om å SKJULE innhold, ikke
|
||
designe noe nytt: `ScoreSoFar` skjules nå helt når runden er fullført
|
||
(`{!completed && <ScoreSoFar .../>}`) -- CompletedBanner sin "Se full
|
||
rundestatistikk"-lenke dekker akkurat det samme, langt grundigere.
|
||
**Ny backend-beregning FØR V0-prompten** (slik at prompten kunne
|
||
referere til ekte, tilgjengelig data): `_load_round_out`
|
||
(`app/routers/rounds.py`) kjører nå en liten aggregatspørring mot
|
||
`round_hole` for eierens egen deltaker-rad ved hver lasting --
|
||
`owner_holes_played`/`owner_total_score`/`owner_score_to_par` (null
|
||
til minst ett hull er registrert). Verifisert med 10 scratch-sjekker,
|
||
inkl. at en gjests egen score IKKE lekker inn i eierens aggregat.
|
||
**To V0-prompter skrevet** (round-card-redesign med prominent
|
||
score-flis/hull-fremdrift-bar/differensial; scorekort-redesign med
|
||
Slag+Putter+Avstand-første-putt alltid synlig og alt annet bak "Flere
|
||
detaljer", pluss en bevisst ikke-bygget reservasjon av layout-plass for
|
||
en fremtidig avstandsmåling-funksjon brukeren bekreftet er planlagt).
|
||
**Zip 15 og 16 mottatt samme dag** (samme v0.app-prosjekt, kontinuerlig
|
||
-- `round-card.tsx`/`own-rounds.tsx` byte-for-byte identiske i begge
|
||
zip-ene; zip 16s `round-detail.tsx` var den nyeste med selve
|
||
kollaps-redesignet, zip 15s tilsvarende fil manglet det og ble derfor
|
||
forkastet til fordel for zip 16).
|
||
`round-card.tsx`: ny `ScoreTile` -- prominent resultat+til-par-flis
|
||
(fargekodet under/over par uten å stole på farge alene, tekst+tall
|
||
alltid med) for fullførte/scorede runder, en hull-fremdrift-progressbar
|
||
("6/18 hull spilt") for runder som fortsatt pågår, pluss en HCP-
|
||
differensial-chip i metadata-raden når runden faktisk telte. Fullt
|
||
`aria-label` på hele kortet for skjermlesere. `own-rounds.tsx` sitt
|
||
datalag skrevet om fra mock til ekte fetch, kobler de nye `owner_*`-
|
||
feltene fra backend + eierens `score_differential` (kun vist når
|
||
`counts_for_handicap`).
|
||
`round-detail.tsx`: ny kollapsbar "Flere detaljer"-seksjon (lukket som
|
||
default, `ChevronDown`-rotasjon på toggle) som nå rommer kølle/utslag-
|
||
retning/innspill-retning/chip-bunker-straffeslag/anywayslag -- Slag,
|
||
Putter og Avstand første putt forblir alltid synlig over kollapsen,
|
||
uendret rekkefølge fra den forrige runden. **Viktig integrasjonsdisiplin:**
|
||
V0s eksport hadde reversert til en MYE eldre mock-baseline (manglet
|
||
StatLevel-gating, Anywayslag, bøtte-basert puttlengde, kølle-bag fra
|
||
profil, maxValue-capping, "Hullet er spilt"-avkrysningen som bevisst BLE
|
||
FJERNET en tidligere runde, "Tid brukt" i CompletedBanner) -- kun selve
|
||
kollaps-mekanismen og plasseringen av "Flere detaljer" ble hentet ut og
|
||
lagt oppå den fullt oppdaterte, LIVE koden. Ingen av de tidligere
|
||
byggede funksjonene gikk tapt. Lagt til en kort kode-kommentar (ikke
|
||
noe bygget UI) som reserverer plass ved siden av hull-headeren til en
|
||
fremtidig avstandsmåling-indikator.
|
||
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet
|
||
(alle 19 ruter). Ingen ny scratch-backend-runde nødvendig utover
|
||
owner-score-testen over (ingen ny domenelogikk i selve rundeliste-/
|
||
scorekort-visningen, kun lesing av allerede-testede felt).
|
||
**Rullet ut live 2026-07-25**, ren frontend-endring + den lille
|
||
backend-tilføyelsen over, ingen migrasjon, `/health`/`/my-rounds` →
|
||
200, `teeoff.no` upåvirket.
|
||
|
||
**Manglende scorekort på statistikk-siden, funnet og fikset SAMME dag
|
||
(2026-07-25):** brukeren spurte rett etter forrige runde: "Hvor er
|
||
scorekortet? (Gjerne også med statistikk?)" -- et ekte, uforutsett hull
|
||
i forrige rundes endring. Da `ScoreSoFar` ("Så langt i runden") ble
|
||
skjult for fullførte runder (for å fjerne dobbel informasjon, se over),
|
||
forsvant OGSÅ det eneste stedet den rå hull-for-hull-tabellen (Hull/
|
||
Par/Score/Netto/Sum) fantes -- ingen tilsvarende tabell ble noensinne
|
||
lagt til på den nye `round-stats.tsx`-siden, som kun inneholdt UTLEDET/
|
||
aggregert statistikk (donuter, kategori-stolper, avviks-visualiseringer),
|
||
aldri de faktiske rå tallene per hull. Med andre ord: brukeren fikk
|
||
riktignok fjernet duplikatet, men mistet samtidig tilgang til selve
|
||
scorekortet -- en reell regresjon, ikke bare en presentasjonsdetalj.
|
||
**Fikset ved å legge til en ny "Scorekort"-seksjon FØRST på
|
||
`round-stats.tsx`** (før "Scorer"-kategoriseksjonen), åpen som default
|
||
(i motsetning til resten av seksjonene som starter kollapsbare men
|
||
åpne -- denne er det brukeren eksplisitt spurte etter, så den skal ikke
|
||
kreve et ekstra trykk). Samme tabellmønster som `ScoreSoFar` sin
|
||
tidligere tabell hadde (Hull/Par/Score/Netto/Stableford/Sum, Stableford-
|
||
kolonnen vist kun når netto er beregnbart). To type-tilføyelser var
|
||
nødvendig: `start_hole: number` lagt til `round-stats.tsx` sin
|
||
`ApiRound`-type (manglet fra før, siden ingen tidligere seksjon på
|
||
denne siden trengte rundens faktiske start-/rekkefølge) og
|
||
`strokes_received: number | null` lagt til `ApiHole`-typen (feltet kom
|
||
allerede fra backend -- lagt til i forrige runde for `list_holes` --
|
||
men ble aldri lest av denne siden før nå). Ny `holeOrder`/
|
||
`orderedHoles`-beregning i `RoundStats` gjenbruker EKSAKT samme
|
||
sirkulære start_hole-formel som round-detail.tsx, slik at en 9-hulls
|
||
runde som starter på hull 10 vises i riktig spillerekkefølge (10-18),
|
||
ikke bare rå hullnummer 1-9.
|
||
**Lærdom notert for fremtidige runder:** når et helt panel flyttes/
|
||
skjules for å fjerne duplisert informasjon, må hver del av det gamle
|
||
panelet spores til et nytt hjem FØR det fjernes -- ikke bare de delene
|
||
som åpenbart var "statistikk". Denne runden ble oppdaget kun fordi
|
||
brukeren faktisk lette etter scorekortet rett etterpå, ikke ved egen
|
||
verifisering før utrulling.
|
||
Ekte typesjekket produksjonsbuild kjørt og bekreftet. **Rullet ut live
|
||
2026-07-25**, ren frontend-endring, ingen backend/migrasjon,
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Scorekort-visning: ny presentasjonsregel + V0-prompt skrevet, IKKE
|
||
bygget ennå (2026-07-25):** brukeren delte et referansebilde av et
|
||
tradisjonelt fysisk golf-scorekort (horisontal layout, hull 1-9/10-18
|
||
som KOLONNER, Slope/Par/Score/Net som rader, "Ut"/"Inn"-sum-kolonne
|
||
YTTERST TIL HØYRE for hver ni-hulls-halvdel) og formulerte en generell
|
||
presentasjonsregel: **listes hullene horisontalt (som kolonner), skal
|
||
summeringen stå TIL HØYRE; listes hullene vertikalt (som rader), skal
|
||
summeringen stå UNDER.** Den nylig byggede "Scorekort"-seksjonen på
|
||
`round-stats.tsx` (se punktet rett over) lister hull VERTIKALT (én rad
|
||
per hull) med en løpende sum-KOLONNE innimellom hver rad -- ikke i tråd
|
||
med regelen (en vertikal liste burde hatt en avsluttende sumrad
|
||
UNDERST, ikke en kolonne). Bedt om å tenke gjennom dette og skrive et
|
||
V0-prompt for et "fabelaktig" scorekort inspirert av (ikke et plagiat
|
||
av) referansebildet.
|
||
**Bevisste avvik fra referansebildet for å unngå plagiat, skrevet inn i
|
||
selve promptet:** egen visuell identitet (TeeCups grønn/oransje, ikke
|
||
bildets rød-sirkel/blå-firkant-fargekoding for birdie/bogey), egen
|
||
term ("Hcp" for hullets slag-fordelings-rangering -- IKKE "Slope", som
|
||
i golf-terminologi betyr banens/tee-ens helhetlige vanskelighetsgrad,
|
||
et helt annet tall enn det bildet faktisk viser per hull), en ekstra
|
||
Stableford-rad (finnes ikke i referansen, men vi beregner det allerede
|
||
andre steder), og INGEN kopiering av bildets spiller-header-komposisjon
|
||
(navn/HCP/posisjon-boksen) -- kun selve tabell-strukturen er
|
||
inspirasjonskilden.
|
||
**Presist håndtert i promptet, IKKE triviell:** appen støtter allerede
|
||
vilkårlig `start_hole` + `holes_planned` (9 ELLER 18, sirkulær
|
||
rekkefølge, se "Manglende scorekort"-punktet over) -- en STIV
|
||
fysisk "hull 1-9 er alltid Ut" ville vært feil for en 9-hulls runde som
|
||
starter på hull 10. Promptet ber derfor om ÉN 9-kolonners blokk når
|
||
`holes_planned=9`, TO blokker (første/andre halvdel AV SPILLEREKKEFØLGEN,
|
||
ikke nødvendigvis fysisk hull 1-9/10-18) når `holes_planned=18` -- den
|
||
faktiske hull-til-blokk-tildelingen løses av meg i datalaget ved
|
||
integrering, som med alle tidligere V0-runder.
|
||
**Venter på V0-eksport** før noe bygges. Når den kommer, erstatter den
|
||
den eksisterende vertikale "Scorekort"-tabellen på `round-stats.tsx`
|
||
(bygget rett over samme dag) -- ikke en ny, separat skjerm.
|
||
**Notert som en generell prinsipp-lærdom, relevant utover akkurat dette
|
||
scorekortet:** samme horisontal-til-høyre/vertikal-til-under-regel bør
|
||
vurderes senere for turnering-scorekortet (`session-scorecard.tsx`,
|
||
match-play) den dagen det scorekortet også skal pusses -- ikke i
|
||
omfang nå, bare notert for konsistens.
|
||
|
||
**Presisering av promptet samme dag, FØR noe ble sendt til V0:**
|
||
brukeren spurte eksplisitt om et "if REALLY necessary"-unntak for
|
||
horisontal scroll (opprinnelig formulering) faktisk fanget opp målet
|
||
om ALDRI å måtte scrolle. Vurdert og svart nei -- reell breddekonflikt,
|
||
ikke bare en formulering-detalj: 9 hull + 1 sum-kolonne (+ en
|
||
label-kolonne) får ikke plass på en telefonskjerm med normal
|
||
"lesbar uten briller"-tekststørrelse, og et unntak formulert som en
|
||
myk fallback ville sannsynligvis latt V0 falle tilbake til scroll uansett,
|
||
siden tilgjengelighetskravet rett under ga et påskudd. **Løst ved å
|
||
gjøre "ingen scroll" til et HARDT krav i promptet, OG eksplisitt fortelle
|
||
V0 hvordan det oppnås** (kompakte, fete, høykontrast-siffer i selve
|
||
rutenettet -- samme konvensjon som et fysisk scorekort bruker, også
|
||
synlig i brukerens eget referansebilde -- mens "lesbar uten
|
||
briller"-kravet eksplisitt avgrenses til labels/knapper/løpetekst, ikke
|
||
hvert enkelt rutenett-siffer). Smale forkortede rad-labels ("Hcp",
|
||
"Par", "Score", "Netto") i en trang venstre-gutter i stedet for en bred
|
||
tekstkolonne, for å frigjøre bredde til de 9 hull-kolonnene.
|
||
|
||
**Zip 17 mottatt, horisontalt scorekort BYGGET OG LIVE samme dag
|
||
(2026-07-25):** V0 leverte akkurat det det reviderte promptet ba om --
|
||
en EKTE HTML `<table>` med `<colgroup>` (fast `w-9`-label-kolonne, auto
|
||
for hver hull-kolonne, `w-[11%]` sum-kolonne) og `table-fixed`, ingen
|
||
scroll-container noe sted. Score-cellene bruker FORM (sirkel = under
|
||
par, firkant = over par) + fylt/ufylt (fylt = 2+ slag av) i stedet for
|
||
farge alene, pluss en egen liten symbolforklaring under rundesammendraget
|
||
-- tilfredsstiller "aldri stole på farge alene" uten å kopiere
|
||
referansebildets rød-sirkel/blå-firkant-konvensjon.
|
||
**Egen, ny dedikert side** `/my-rounds/[id]/scorecard`
|
||
(`components/round-scorecard.tsx`, ny `app/my-rounds/[id]/scorecard/
|
||
page.tsx`) -- IKKE slått sammen inn i `round-stats.tsx`, siden V0
|
||
designet komponenten med sin egen fulle side-chrome (sticky header,
|
||
rundesammendrag-strip), ikke som et innebygd tabell-fragment. Dette gir
|
||
en klar arbeidsdeling: `round-scorecard.tsx` = det rå scorekortet,
|
||
`round-stats.tsx` = utledet/aggregert statistikk -- samme prinsipp som
|
||
allerede etablert for `ScoreSoFar` vs. `round-stats.tsx` tidligere denne
|
||
økten.
|
||
**Bevisst forkastet fra V0s eksport:** V0s egen `strokesReceived()`-
|
||
funksjon var en generisk `Math.floor(hcp/18) + (index <= hcp%18 ? 1 :
|
||
0)`-modulo-formel -- byttet ut med backend sin ALLEREDE beregnede
|
||
`strokes_received` per hull (samme `allocate_strokes_by_index()` som
|
||
resten av appen bruker), for å unngå to parallelle, potensielt
|
||
avvikende implementasjoner av HCP-slagfordeling i samme app. Samme
|
||
sirkulære `start_hole`-rekkefølge som `round-detail.tsx`/
|
||
`round-stats.tsx` bruker for Ut/Inn-blokkene. Ny spillervelger
|
||
(pill-rad) lagt til for runder med flere deltakere, samme mønster som
|
||
`round-stats.tsx`.
|
||
**Opprydning i `round-stats.tsx`:** den midlertidige vertikale
|
||
"Scorekort"-tabellen (bygget tidligere samme dag som en rask fiks for
|
||
"Hvor er scorekortet?") er FJERNET og erstattet med en enkel, prominent
|
||
"Se scorekort"-lenke til den nye siden, rett under rundesammendraget --
|
||
`round-stats.tsx` er dermed nå rendyrket aggregert/utledet statistikk,
|
||
det rå scorekortet finnes kun ett sted. `stablefordPoints()`-
|
||
hjelpefunksjonen og `holeOrder`/`orderedHoles`-beregningen i
|
||
`round-stats.tsx`, som kun eksisterte for den fjernede tabellen, fjernet
|
||
som dødt kode.
|
||
**V0 la selv til en to-knappers layout i `CompletedBanner`**
|
||
(round-detail.tsx) -- "Se scorekort" (primær, fylt) ved siden av den
|
||
eksisterende "Se full rundestatistikk" (sekundær, omrisset) -- tatt inn
|
||
uendret bortsett fra å rette `/rounds/...`-hrefs til `/my-rounds/...`.
|
||
**Samtidig, urelatert, rapportert av bruker midt i integreringen:**
|
||
Anywayslag manglet helt fra `round-stats.tsx` sin "Chip, bunker og
|
||
straffeslag"-seksjon (verken `anyway_strokes` i `ApiHole`-typen eller
|
||
noe utledet total fantes). Lagt til `anyway_strokes` i typen, ny
|
||
`anywayPerRound`-aggregat i `computeStats()`.
|
||
**Feilrettet SAMME dag, rett etterpå:** min første fiks slo sammen
|
||
Anywayslag-tallet INN I den eksisterende "Chip, bunker og
|
||
straffeslag"-seksjonen og omdøpte HELE seksjonen til "Annet" -- feil,
|
||
påpekt av bruker. Rettet: "Chip, bunker og straffeslag" beholder sitt
|
||
opprinnelige navn og innhold UENDRET (Bunkerslag/Straffeslag-flisene
|
||
tilbake til to, ikke tre). Ny, EGEN "Annet"-seksjon lagt til RETT ETTER
|
||
den (ikke slått sammen), med kun Anywayslag-statistikk: total per runde
|
||
+ andel hull med minst ett anywayslag (samme "andel hull med..."-mønster
|
||
som straffeslag-statistikken). Ny `pctHolesWithAnyway`-beregning i
|
||
`computeStats()`. Den midlertidige `StatTileRow`-komponenten (bygget for
|
||
den feilaktig sammenslåtte tre-flis-varianten) fjernet igjen som dødt
|
||
kode -- `StatTilePair` (fast to fliser) er tilstrekkelig for begge
|
||
seksjonene nå. Notert for senere: brukeren ser for seg at et
|
||
fritekst-notatfelt havner i "Annet"-seksjonen etter hvert.
|
||
**Verifisert:** ekte typesjekket produksjonsbuild kjørt og bekreftet,
|
||
ny rute `/my-rounds/[id]/scorecard` listet. Ingen ny backend-endring
|
||
(`anyway_strokes`/`strokes_received` fantes allerede i `RoundHoleOut`).
|
||
**Rullet ut live 2026-07-25** (to runder, feilrettingen rullet ut rett
|
||
etter originalen), ren frontend-endring, ingen migrasjon, `teeoff.no`
|
||
upåvirket.
|
||
|
||
**Motor-komponenten (punkt 1-11) er ✅ BYGGET OG TESTET 2026-07-22,**
|
||
som første, isolerte byggesteg (ren Python, ingen DB/API/frontend ennå —
|
||
matcher ADR-005s "test i isolasjon FØR resten"). Nye funksjoner i
|
||
`handicap_engine.py`: `net_par`, `max_hole_score_for_handicap`,
|
||
`adjusted_gross_score`, `round_half_up_decimal`, `score_differential`,
|
||
`handicap_index_from_differentials`, `low_handicap_index`,
|
||
`apply_index_caps`, `course_handicap_9_raw`/`course_handicap_9`.
|
||
**Bevisst avvik fra opprinnelig plan, instruert av bruker 2026-07-22:**
|
||
"Expected Score" (Rule 3.2b) sin upubliserte formel erstattes gjennomgående
|
||
av WHS sin egen, presist definerte "Net Par"-term (par + mottatte
|
||
handicapslag = 2 Stableford-poeng) for uspilte hull — gjelder BÅDE
|
||
ufullstendige 18-hulls-runder og konvertering av en 9-hulls-runde til
|
||
18-hulls-ekvivalent (Rule 5.1b sin egen separate 9-hulls-differensial-
|
||
formel er dermed bevisst IKKE implementert, se punkt 5 over).
|
||
**41/41 tester bestått** (`test_handicap_engine.py`, kjørt uten pytest —
|
||
ikke installert i miljøet, kun den innebygde selvsjekk-runneren), 17 nye
|
||
i tillegg til de 24 eksisterende. Flere verifisert mot regelbokens EGNE
|
||
tallregneeksempler, ikke bare intern konsistens: Rule 5.2a sine to
|
||
initial-indeks-eksempler (13,2 og 34,1, samt oppfølgingen til 37,4),
|
||
Rule 5.1c sine tre avrundingseksempler, Diagram 5.8 sin soft-/hard-cap-
|
||
oppførsel, og Diagram 3.1b sin Net-Double-Bogey-capping (der front-9 ble
|
||
verifisert eksakt mot diagrammet, mens back-9 sine åtte ikke-annoterte
|
||
scorer bevisst ble egenkomponert pga. usikker bilde-lesing av akkurat de
|
||
sifrene — se testens egen kommentar for full transparens om hva som er
|
||
kildebelagt og hva som ikke er det).
|
||
**Gjenstår:** migrasjon (nye tabeller for runde/deltaker/statistikk),
|
||
API-lag, frontend — ingen av disse er startet.
|
||
|
||
### Beslutning H — Shotgun- vs. fortløpende start: EGEN, separat ADR (ADR-034)
|
||
|
||
Bekreftet med bruker: dette er et turnering/økt-konsept (start_hole per
|
||
match i stedet for per økt, samme klokkeslett for alle grupper), uten
|
||
reell avhengighet til rundeførings-arkitekturen over. Ikke behandlet
|
||
videre her.
|
||
|
||
### Ikke besluttet, gjenstår før bygging kan starte
|
||
|
||
- Den globale banekatalogen for EGENDEFINERTE (ikke-teeoff) baner
|
||
(del av Beslutning C) — naturlig konsekvens av live-oppslag-
|
||
beslutningen, men ikke bekreftet punkt for punkt ennå.
|
||
- Gjest-e-post-kobling (Beslutning D) — notert, ikke designet i detalj.
|
||
- Manuell vs. auto-beregnet HCP-indeks etter at motoren finnes
|
||
(Beslutning G) — notert, ikke avgjort.
|
||
- Skal turnering-scoring til slutt bruke SAMME statistikk-modell (reist i
|
||
brainstorm-runden 2026-07-22, FEATURE_BACKLOG.md) — ikke avgjort, ingen
|
||
konsekvens for denne ADR-ens omfang uansett.
|
||
- Eksakte tabellnavn/skjema (`round`/`round_participant`/
|
||
`round_hole_stat` er arbeidsnavn i denne ADR-en, ikke endelig
|
||
fastlagt) — avgjøres ved migrasjonsskriving.
|
||
|
||
**Status: 🔨 ADR skrevet OG kildebelagt 2026-07-22, BYGGING PÅBEGYNT.**
|
||
Alle tre store åpne punktene fra første utkast (banedata, WHS 9-hulls-
|
||
regel, full HCP-indeksformel) er enten eksplisitt bekreftet med bruker
|
||
(Beslutning C) eller presist kildebelagt fra den offisielle WHS Rules of
|
||
Handicapping 2024 (Beslutning F/G) — ikke lenger antatt eller tilnærmet.
|
||
**Tre byggesteg ferdig samme dag, alle rullet ut mot ekte systemer:**
|
||
(1) hele HCP-indeks-motor-komponenten (Beslutning G, punkt 1-11) bygget
|
||
og testet i `handicap_engine.py` (43/43 tester), (2) full
|
||
databasemigrasjon (`020_personal_rounds.sql` + rettefiksen `021_round_
|
||
participant_rating_snapshot.sql`, åtte tabeller/utvidelser) kjørt mot
|
||
ekte `teecup_db`, (3) fullt API-lag (`app/routers/rounds.py`) bygget,
|
||
scratch-verifisert (26 sjekker + egen teeoff-live-oppslag-test) og
|
||
redeployet (`teecup_api`). **Gjenstår: HELE frontend-en** — ingenting
|
||
bygget ennå. Notat fra bruker 2026-07-22 (IKKE designet): planer om
|
||
slaglengde-måling + avstand-til-punkter-på-banen (golf-GPS/rangefinder),
|
||
krever geografiske data ingen kilde har i dag — se FEATURE_BACKLOG.md.
|
||
|
||
**Sju punkter fra faktisk bruk, BYGGET OG LIVE 2026-07-24** (rapportert av
|
||
bruker som selv testet scorekort-skjermen): (1) `currentHole` respekterte
|
||
aldri `round.start_hole` (`useState<number>(1)` + `prev || start_hole` —
|
||
`1` er truthy i JS, så `||` ble en no-op), fikset ved å bruke `null` som
|
||
"ikke satt ennå"-tilstand og en sirkulær 18-hulls navigasjonsrekkefølge
|
||
fra starthullet. Trolig rot-årsak til det samtidig rapporterte GIR-
|
||
avviket: selve formelen (`slag − putter ≤ par − 2`) var allerede
|
||
matematisk identisk med regelen brukeren beskrev, men feil hull i fokus
|
||
ga feil par inn i en ellers korrekt formel. (2) Kølle-bag på personlig
|
||
profil — 28 faste kølletyper (`BAG_CLUBS` i `app/routers/auth.py`,
|
||
speilet i frontend), maks 14 (den ekte golfregelen, håndhevet i både
|
||
Pydantic og en CHECK-constraint), brukt som knapp-utvalg for "kølle
|
||
brukt ved utslaget" for runde-eieren (gjester har ingen profil, beholder
|
||
fritekst). (3) Nytt statistikkfelt "Anywayslag", siste punkt i "Flere
|
||
detaljer", samme tallvelger-stil som slag/putter (ikke en liten
|
||
stepper). (4) Valgfritt statistikknivå per deltaker
|
||
(`strokes_only`/`strokes_and_putts`/`full`, ny kolonne `round_participant.
|
||
stat_level`, default `strokes_only` — brukerens egen presisering: kun
|
||
slag er strengt tatt nødvendig for resultat/HCP). Nytt PATCH-endepunkt
|
||
`/rounds/{id}/participants/{id}` for å endre nivået underveis. (5) Putt-
|
||
avstand endret fra fritekst-tall til seks faste bøtter (`<1m`…`8m+`) —
|
||
`round_hole.first_putt_distance_m` (numeric) erstattet med
|
||
`first_putt_distance_bucket` (text+CHECK) i migrasjon
|
||
`022_round_stats_and_bag.sql`, ingen produksjonsdata å bevare (0 rader
|
||
hadde verdi). (6) "Hullet er spilt"-avkrysningen FJERNET helt — reelt
|
||
overflødig, `played` settes allerede automatisk når et slagtall velges.
|
||
(7) Direkte spørsmål om avkrysningens funksjon avdekket at brukeren
|
||
egentlig ville vite om `played` (bevisst enkel avledning, uendret) — i
|
||
samme svar reiste brukeren "plukket opp"-behovet for et fremtidig
|
||
Stableford-format (fanget i FEATURE_BACKLOG.md, IKKE bygget — krever en
|
||
helt egen scoring-format-designrunde).
|
||
**Punkt 6 (numpad-layout + retningskors + sveip-vurdering) BYGGET OG
|
||
LIVE 2026-07-24, samme dag** — V0-prompten (se FEATURE_BACKLOG.md)
|
||
kjørt av bruker, zip 13 mottatt og integrert. **V0s egen designbeslutning
|
||
på sveip-spørsmålet:** IKKE et sveip-panel, men trykk-baserte
|
||
"Score"/"Statistikk"-faner (`PanelTabs`) inni samme kort — begrunnet
|
||
med at skjermen allerede har to horisontalt scrollende rader
|
||
(spillerfaner, hull-navigasjon), så en tredje sveiperetning ville vært
|
||
forvirrende. Vurdert som et godt, veloverveid valg, beholdt uendret.
|
||
Retningskors (`DirectionCross`/`DirButton`, piler + `Target`-ikon)
|
||
brukt for Utslag (venstre/senter/høyre) og Innspill (fullt 5-veis
|
||
kors), tekstlabel beholdt på hver knapp (ikke ikon-only). Tallvelgerne
|
||
fikk ny numpad-layout (3 kolonner, `h-16`-knapper, fyller bredden).
|
||
**Reelt integreringsarbeid, ikke ren om-kabling:** siden V0 ikke kjente
|
||
til dagens datalag (bygget i en tidligere, separat runde samme dag),
|
||
måtte `PanelTabs`/`DirectionCross` flettes inn i EKSISTERENDE, allerede
|
||
fungerende kode — ikke erstatte den. Konkret: `PanelTabs` vises kun når
|
||
`statLevel==="full"` (ingen "Statistikk"-fane å bytte til ellers),
|
||
"Score"-innholdet (Slag, evt. Putter) vises direkte uten fane-UI når
|
||
nivået er lavere. `DirectionCross` erstattet kun de to `ChoiceRow`-
|
||
kallene for Utslag/Innspill — `ChoiceRow` selv beholdt uendret til
|
||
resten (Kjønn, Statistikknivå, putt-avstand-bøtter). Kølle-bag-picker,
|
||
anywayslag, putt-bøtter, `stat_level`-gating, merge-før-PATCH,
|
||
starthull-fiksen og alle `/my-rounds`-lenker fra tidligere samme dag
|
||
ALLE bevart uendret -- kun presentasjonslaget for tallvelgere/retning
|
||
byttet ut. V0s egen `PlayedToggle` (som den ikke visste var fjernet)
|
||
ble bevisst IKKE tatt inn igjen. Ekte typesjekket produksjonsbuild
|
||
kompilerte rent. **Rullet ut live**, kun `teecup_frontend` (+ vanlig
|
||
`teecup_api`-bivirkning), ingen migrasjon, `teeoff.no` upåvirket.
|
||
Zip 13 slettet fra prosjektroten.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container): 18 sjekker — kølle-bag
|
||
lagret/hentet riktig, ukjent kølletype avvist (422), mer enn 14 køller
|
||
avvist (422), `stat_level`-default og eksplisitt verdi ved
|
||
opprettelse/gjest-tilføyelse, PATCH av `stat_level` vedvarer etter
|
||
refetch, `anyway_strokes`/`first_putt_distance_bucket` lagret riktig,
|
||
ugyldig bøtte-verdi avvist (422), OG en bevisst re-bekreftelse av
|
||
merge-før-PATCH-kontrakten (et PATCH uten `anyway_strokes` nuller den
|
||
fortsatt — samme grunnkontrakt som forrige runde, ikke endret av denne
|
||
utvidelsen). `test_isolation.sql` fortsatt 12/12. Ekte typesjekket
|
||
produksjonsbuild kompilerte rent.
|
||
**Rullet ut mot ekte systemer 2026-07-24**, bruker bekreftet eksplisitt:
|
||
migrasjon 022 kjørt mot ekte `teecup_db` (alle nye kolonner/CHECK-er
|
||
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
|
||
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
|
||
ekte https, `teeoff.no` upåvirket.
|
||
|
||
**Tre nye brukerpunkter, ALLE BYGGET, SCRATCH-VERIFISERT OG LIVE
|
||
2026-07-25:**
|
||
1. **Enkeltbane-anlegg dropper det overflødige banenavnet.** Brukeren
|
||
påpekte at Tjøme Golfklubb (og de aller fleste andre norske anlegg —
|
||
kartlagt ved å skanne alle 174 teeoff-anlegg: kun 15 har mer enn én
|
||
bane) fikk et unødvendig sammensatt navn ("Tjøme Golfklubb –
|
||
Hovedbanen") siden anlegget uansett bare har ÉN bane. Fikset i BEGGE
|
||
stedene navnet bygges (`app/routers/courses.py` sin
|
||
`import_official_course`, `app/routers/rounds.py` sin
|
||
`_resolve_teeoff_course`): har `facility["courses"]` nøyaktig ett
|
||
element, brukes kun anleggsnavnet; har det flere (bekreftet fortsatt
|
||
riktig for f.eks. Ålesund Golfklubb, som har "Solnør Gaard" og "Moa
|
||
Golfsenter"), beholdes det kombinerte "Anlegg – Bane"-navnet uendret.
|
||
Ingen migrasjon (ren navnelogikk, ingen lagret data endret av seg selv
|
||
— kun FREMTIDIGE importer/runde-opprettelser får det nye navnet).
|
||
2. **Runder kan nå navngis** (`round.name`, migrasjon
|
||
`024_round_name.sql`, nullable). Valgfritt felt lagt til
|
||
`RoundCreate`/`RoundUpdate`/`RoundOut` i `app/routers/rounds.py` — PATCH-
|
||
kontrakten følger samme "tom streng = fjern, utelatt = ikke rør"-mønster
|
||
som resten av `RoundUpdate` sine felt (ikke et ekte `exclude_unset`,
|
||
konsistent med hvordan `holes_planned`/`start_hole` allerede
|
||
håndteres i samme endepunkt). Vises på tvers av `round-card.tsx`
|
||
(rundeliste), `round-detail.tsx` (header + redigerbart i "Rediger
|
||
runde"-panelet), `round-scorecard.tsx` og `round-stats.tsx` — alle
|
||
fire faller tilbake til `course_name_snapshot` når navn ikke er satt,
|
||
og viser banenavnet som sekundær informasjon når et eget navn ER satt
|
||
(ikke bare erstattet det, siden banen fortsatt er relevant
|
||
informasjon).
|
||
3. **Land+hjemmeklubb i profilen, redesignet (ADR-031/032s
|
||
`ProfileOnboarding`/`ProfileSection`).** Brukeren ba om at Land skal stå
|
||
FØR Hjemmeklubb, og at Hjemmeklubb skal være en nedtrekksliste fra
|
||
teeoff, filtrert på valgt land. Avklart eksplisitt med bruker
|
||
(AskUserQuestion, begge anbefalte valg): "Land" er nå en ekte
|
||
`<select>` (`COUNTRIES = ["Norge"]`, ett element foreløpig — bevisst
|
||
klargjøring for fremtidig flerspråklighet, ingen backend-endring
|
||
trengs når flere land legges til, kun listen utvides), og "Hjemmeklubb"
|
||
er en søkbar kombinasjons-boks (`HomeClubField`, debounce 250ms) som
|
||
gjenbruker det ALLEREDE EKSISTERENDE `/rounds/official-search`-
|
||
endepunktet (org-uavhengig, tilgjengelig for enhver innlogget bruker
|
||
siden ADR-033) — ingen ny backend-kode i det hele tatt. Teeoff filtrerer
|
||
allerede bort upubliserte/nedlagte anlegg server-side (`is_published`),
|
||
så "nedlagte og ikke operative klubber skal ikke inkluderes" er dekket
|
||
uten egen filtrering på teecup-siden. Begge komponentene delt mellom
|
||
`ProfileOnboarding` (obligatorisk profil-fullføring) og `ProfileSection`
|
||
(kontoinnstillinger) i `account-settings.tsx`, ingen duplisert
|
||
implementasjon.
|
||
**Bevisst IKKE bygget:** hard validering som avviser fritekst utenfor
|
||
søkeresultatene — eksisterende, allerede lagrede `home_club`-verdier
|
||
(fritekst fra før denne runden) forblir gyldige og redigerbare, feltet
|
||
oppfører seg som "skriv for å filtrere, klikk for å velge" fremfor en
|
||
strengt låst `<select>`, for å unngå å gjøre eksisterende profiler
|
||
utilgjengelige.
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-container, samt et direkte
|
||
engangs-oppslag mot ekte `teeoff_api` for å kartlegge hvilke anlegg som
|
||
faktisk har mer enn én bane): 22 sjekker — enkeltbane-navn uten suffiks
|
||
(Tjøme), flerbane-navn med suffiks uendret (Ålesund), rundenavn lagret
|
||
ved opprettelse, rundenavn med i rundeliste, PATCH omdøper, PATCH med
|
||
tom streng fjerner navnet, PATCH uten navn-felt lar det urørt,
|
||
`official-search`-endepunktet (brukt av det nye profilfeltet) fortsatt
|
||
fungerer identisk. `test_isolation.sql` fortsatt 12/12. Ekte
|
||
typesjekket produksjonsbuild kompilerte rent, alle 21 ruter listet.
|
||
**Rullet ut mot ekte systemer 2026-07-25**, bruker bekreftet
|
||
eksplisitt: migrasjon 024 kjørt mot ekte `teecup_db` (kolonne
|
||
bekreftet, `test_isolation.sql` fortsatt 12/12), deretter `docker
|
||
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||
boot-et rent, `/health`/`/dashboard`/`/my-rounds`/`/account` → 200 over
|
||
ekte https, `teeoff.no` upåvirket.
|
||
|
||
### Oppdatering 2026-07-26: utslag/HCP per deltaker, gjeste-kontaktinfo, og scoringsflyten redesignet (v2)
|
||
|
||
Tre beslutninger denne dagen utvider ADR-033 uten å endre grunn-
|
||
arkitekturen (Beslutning A-G står uendret).
|
||
|
||
**1. Utslagssted er nå PER DELTAKER, ikke per runde.** Reelt hull
|
||
oppdaget ved bruk: alle deltakere delte tidligere `round.
|
||
tee_name_snapshot` uansett kjønn/faktisk valg — en medspiller kunne
|
||
aldri spille fra et annet utslag enn eieren. Migrasjon `028_round_
|
||
participant_tee.sql` gir `round_participant` sitt eget `tee_name_
|
||
snapshot`, backfylt fra rundens eksisterende utslag for eksisterende
|
||
rader. `ParticipantCreate`/`ParticipantUpdate` fikk et valgfritt
|
||
`tee_name`; et bane-bytte (eksisterende `RoundUpdate`-mekanisme)
|
||
nullstiller nå eksplisitt ALLE deltakeres per-deltaker-utslag til det
|
||
nye standardutslaget (et bane-bytte gjør individuelle valg fra den
|
||
gamle banen meningsløse). HCP kan nå også redigeres per deltaker for
|
||
KUN denne runden (samme reproduserbarhets-unntak som resten av
|
||
`round_participant` — endrer aldri spillerens faktiske profil),
|
||
avvist etter at runden er fullført (samme presedens som bane-bytte).
|
||
|
||
**2. Gjester (`user_id IS NULL`) fikk et valgfritt kontaktfelt +
|
||
redigerbart navn/kjønn.** Migrasjon `029_round_participant_guest_
|
||
email.sql` (`guest_email`, nullable). `ParticipantUpdate` fikk `guest_
|
||
name`/`gender`/`guest_email` — eksplisitt avvist (400) for en LENKET
|
||
deltaker (disse feltene gir ingen mening for noen med egen konto).
|
||
`gender`-endring inngår i samme rating-rekalkulering som utslag/HCP
|
||
(påvirker hvilken rating som er gyldig).
|
||
|
||
**3. Scoringsflyten redesignet til en skjermovertagende veiviser --
|
||
to iterasjoner, andre erstattet den første samme dag.** Brukeren delte
|
||
en skjermopptaksvideo av en konkurrentapp (Golf GameBook) sin
|
||
scoreregistrering og spurte om prinsippene kunne forbedre TeeCup.
|
||
Videoen ble analysert bilde for bilde (ffmpeg i en engangs Docker-
|
||
container) og avdekket et "samlebånd"-mønster: taltastatur med
|
||
kontekstuelle golf-termer (Eagle/Birdie/Par/Bogey ut fra hullets par),
|
||
og at fullført registrering for én spiller automatisk åpner NESTE
|
||
spillers registrering for samme hull.
|
||
**Første forsøk (v1), BEVISST avvist fra en full modal:** vurdert som
|
||
unødvendig risikofylt å bygge en skjermovertagende steg-for-steg-flyt
|
||
korrekt uten visuell testing (intet nettleserverktøy tilgjengelig i
|
||
denne økten). I stedet: golf-term-taltastatur lagt til der det allerede
|
||
var (`NumberPicker`s nye `showGolfTerms`-modus), pluss en "Neste:
|
||
{navn}"/"Neste hull"-knapp nederst på den EKSISTERENDE, lange
|
||
inline-siden.
|
||
**Brukeren testet v1 live og avviste den eksplisitt:** "ingen forbedring
|
||
i det hele tatt", "visuelt like overveldende og rotete" — den
|
||
forsiktige, risikoreduserte tilnærmingen var utilstrekkelig; en ekte
|
||
skjermovertagende veiviser var det som faktisk krevdes, sammen med et
|
||
nytt, eksplisitt krav: akkumulert score-så-langt for RUNDEN synlig for
|
||
HVER spiller samtidig (ikke bare én valgt), matchende konkurrentappens
|
||
vedvarende "E"/"+1"-visning ved siden av hvert navn.
|
||
**v2 bygget samme dag, ERSTATTER v1 helt:** ny `ScoringWizard`-komponent
|
||
(fullskjerm, egen DOM-stack utenfor `<main>`), tre steg maks drevet av
|
||
`stat_level` ("strokes_only" → kun Slag; "strokes_and_putts" → + Putter/
|
||
avstand første putt; "full" → + ett samlet detalj-steg). Bevisst FÆRRE,
|
||
grovere steg enn konkurrentens egne 5-6 skjermer — en risikoreduksjon
|
||
uten visuell testing, og TeeCups stat_level-modell gjør en finere
|
||
oppdeling mindre naturlig uansett. Alle spillerne vises som en fast,
|
||
ikke-trykkbar kontekst-rad øverst i veiviseren (speiler konkurrent-
|
||
appens "mist aldri oversikten"-prinsipp — bevisst IKKE trykkbar, siden
|
||
GameBooks egen video heller ikke viste spillerbytte midt i et steg).
|
||
Hovedsiden forenklet radikalt: det gamle Slag/Putter/"flere detaljer"-
|
||
skjemaet er fjernet, erstattet med én kompakt liste (navn, "HCP X ·
|
||
{til-par} så langt (N hull)", en stor rund knapp som åpner veiviseren).
|
||
Datalasting utvidet til å hente ALLE deltakeres hull med det samme
|
||
(ikke lenger lat lasting kun for aktiv spiller), nødvendig for at
|
||
akkumulert score kan vises for alle samtidig.
|
||
**Reell driftsfeil funnet OG rettet SAMME dag, rapportert av bruker som
|
||
testet live:** den gamle `ScoreSoFar`("Så langt i runden")-boksen ble
|
||
ved en feil IKKE fjernet/flyttet i selve v2-bygget — den lå fortsatt
|
||
øverst, uendret, og fikk brukeren til å rapportere "ingen synlig
|
||
endring i det hele tatt" (toppen av siden var reelt uendret). Bekreftet
|
||
presist ved å hente den FAKTISK kjørende JS-bunten i produksjon og
|
||
søke i den — begge de gamle OG de nye tekst-strengene lå i samme bunt,
|
||
som beviste dette var en ekte plasseringsfeil, ikke en cache-/
|
||
utrullingsfeil. Rettet ved å flytte `ScoreSoFar` til UNDER scorings-
|
||
seksjonen (fortsatt tilgjengelig, bare ikke lenger i veien for
|
||
hovedoppgaven) — samme klasse feil hele denne rundens redesign forsøkte
|
||
å løse.
|
||
**Bevisst utenfor omfang, IKKE bygget:** de mest granulære trekkene fra
|
||
konkurrentappen (separate skjermer per detalj-felt, en illustrert
|
||
"bommet green"-grafikk, spillerbytte midt i et steg) — vurdert som for
|
||
stor risiko å bygge presist uten visuell testing, og TeeCups egen
|
||
stat_level-gruppering gjør en så fin oppdeling mindre naturlig uansett.
|
||
**Ingen ekte nettleser-interaksjonstest utført av noen av iterasjonene**
|
||
(intet slikt verktøy tilgjengelig denne økten) — kun logikk (Node-
|
||
simulering av stegmaskinen, rangering, akkumulert-score-beregning) og
|
||
et ekte API-rundtur-script (nøyaktig samme feltkombinasjon som
|
||
veiviseren sender) er verifisert. Full detalj i CLAUDE.md sin
|
||
statuslogg (2026-07-26).
|
||
|
||
---
|
||
|
||
## ADR-035: Dashbord-redesign — organisasjon blir implisitt
|
||
|
||
Direkte oppfølging av en refleksjonsrunde 2026-07-25: brukeren spurte
|
||
eksplisitt hvorfor organisasjon i det hele tatt trengs, gitt at en enkelt
|
||
bruker som vil sette opp ÉN turnering for en vennegjeng ikke får noe igjen
|
||
for opprettelses-seremonien. To alternativer ble veid opp mot hverandre:
|
||
|
||
- **A — bruker-eide turneringer** (samme mønster som ADR-033 sine
|
||
frittstående runder, `user_id`-eierskap, ingen RLS): AVVIST. Praktisk
|
||
talt HELE turnering-apparatet (lag/roster/blind draw/scoring/chat/
|
||
leaderboard/offentlig side, se CLAUDE.md sin arkitektur-invariant om
|
||
`organization_id` på alle domenetabeller) er bygget rundt RLS og
|
||
org-medlemskap. Å gjøre turneringer bruker-eide ville krevd enten en
|
||
full duplisering av dette apparatet i en parallell, RLS-fri variant,
|
||
eller å gjøre `organization_id` valgfri overalt — et brudd på
|
||
invarianten CLAUDE.md eksplisitt krever en ny ADR for, med en
|
||
reverseringskostnad som går GALT begge veier (migrere eksisterende
|
||
bruker-eide turneringer inn i organisasjoner senere, eller gjenoppfinne
|
||
medadministrasjon fra bunnen om den viser seg nødvendig uansett).
|
||
- **B — organisasjon beholdes, men opprettelsen gjøres usynlig/automatisk**:
|
||
VALGT. Rører verken skjema, RLS eller noen av de ni routerne som
|
||
allerede er bygget rundt organisasjon — alt fortsetter å virke uendret.
|
||
Kun UX-seremonien fjernes.
|
||
|
||
### Beslutning A — Hva "usynlig organisasjon" betyr konkret
|
||
|
||
`POST /orgs` (`app/routers/organizations.py`) krever i dag KUN `name` —
|
||
verifisert direkte i koden: `slug`/`public_profile` settes separat via
|
||
`PATCH /orgs/{id}` (ADR-018), ikke ved opprettelse. Dette betyr at
|
||
"usynlig opprettelse" krever **null backend-endring** — kun en endret
|
||
frontend-orkestrering:
|
||
|
||
- Trykker en bruker "Ny turnering" og har PRESIS ÉN organisasjon fra før:
|
||
den gjenbrukes direkte, ingen synlig org-steg.
|
||
- Har brukeren INGEN organisasjon ennå: `POST /orgs` kalles automatisk med
|
||
et generert navn (`"{fornavn} {etternavn}s turneringer"`, eller
|
||
`"Mine turneringer"` som fallback hvis navn mangler) FØR turnering-
|
||
opprettelsen vises — brukeren ser aldri et eget "opprett organisasjon"-
|
||
skjema.
|
||
- Har brukeren FLERE organisasjoner (f.eks. fordi de også er invitert inn
|
||
i en ekte klubb, ADR-022): et lett org-valg vises FØRST da — samme
|
||
`OrganizationView`-mønster som i dag, men kun synlig i dette ene
|
||
tilfellet, ikke som standard.
|
||
- Alt annet er UENDRET: en bruker som ønsker en ekte klubb-identitet
|
||
(navn, slug, offentlig side, medadministratorer) kan fortsatt navngi
|
||
orgen sin og bygge den ut senere — B fjerner kun ceremonien ved
|
||
FØRSTE opprettelse, ikke noen av de eksisterende org-funksjonene.
|
||
|
||
### Reversibilitet
|
||
|
||
B kan reverseres på en ettermiddag: legg navnesteget tilbake i UI-flyten
|
||
FØR "Ny turnering" fullføres. Ingen datamigrering — eksisterende org-rader
|
||
er identiske uansett om de ble navngitt av brukeren eller auto-generert.
|
||
|
||
**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se
|
||
"Dashbord: tom-tilstand"-seksjonen i FEATURE_BACKLOG.md for det fulle
|
||
blokk-forslaget og V0-prompten som ble skrevet i samme runde.
|
||
|
||
---
|
||
|
||
## ADR-036: Venner, kategorisert deling av runder, og tiered personsøk for medspillere
|
||
|
||
Reist av brukeren 2026-07-25, samme runde som ADR-035. To sammenhengende
|
||
behov: (1) når man legger til en medspiller på en frittstående runde, skal
|
||
det søkes opp EKTE personer — venner FØRST, deretter samme hjemmeklubb,
|
||
deretter samme land, til slutt globalt, uavhengig av om for- eller
|
||
etternavn skrives først, i samme UI-mønster som bane-/klubbsøket; (2)
|
||
appen skal ha et fullverdig vennekonsept, der venner (med mindre runden er
|
||
satt til "Privat") kan se livescoren din, og der DU kan kategorisere hver
|
||
venn i én eller flere av et fast sett grupper — uten at vennen selv vet
|
||
hvilke grupper du har puttet dem i.
|
||
|
||
**Direkte oppfølging av et allerede notert, men avvist punkt:** ADR-033
|
||
Beslutning D satte `round_participant.user_id` i skjemaet, men avgrenset
|
||
bevisst til KUN gjeste-deltakere i v1 ("en deltaker med `user_id` satt får
|
||
IKKE egen tilgang til runden"), med e-post-kobling notert som en mulig,
|
||
ikke-designet senere utvidelse. Denne ADR-en erstatter e-post-kobling-
|
||
ideen med noe som passer bedre til det brukeren faktisk ba om: ekte
|
||
personsøk (samme mønster som bane/klubb), ikke en e-postadresse man må
|
||
kjenne på forhånd.
|
||
|
||
### Beslutning A — Vennskap er gjensidig; kategorisering er privat og ensidig
|
||
|
||
Et vennskap krever en forespørsel + aksept (samme grunnmønster som
|
||
organisasjon-invitasjoner, ADR-022) — "venner ser livescoren din" gir kun
|
||
mening som et GJENSIDIG, bekreftet forhold, ikke en ensidig følging.
|
||
Kategoriseringen ("Make", "Golfvenner" osv.) er derimot et HELT SEPARAT,
|
||
privat attributt EIET av den som kategoriserer — A kan sette B i
|
||
"Golfvenner" uten at B noensinne får vite det, og B kategoriserer A helt
|
||
uavhengig (kan sette A i en annen gruppe, eller ingen). Dette er bevisst
|
||
samme idé som Facebooks "nære venner"-lister: vennskapet er symmetrisk,
|
||
men grupperingen er det ikke.
|
||
|
||
**Datamodell (arbeidsnavn, avgjøres ved migrasjonsskriving):**
|
||
- `friendship`: `requester_user_id`, `addressee_user_id`, `status`
|
||
(`pending`/`accepted`/`declined`), tidsstempler. Et UNIK
|
||
uttrykks-indeks på `(LEAST(requester_user_id, addressee_user_id),
|
||
GREATEST(...))` hindrer duplikate forespørsler i begge retninger
|
||
samtidig.
|
||
- Kategori er IKKE en egen tabell — et fast, CHECK-constrained sett
|
||
(samme mønster som `BAG_CLUBS`/`gender`/`stat_level` ellers i appen),
|
||
ikke brukerdefinerbart: `spouse` (Make), `close_family` (Nær familie),
|
||
`extended_family` (Storfamilie), `close_friends` (Nære venner),
|
||
`golf_friends` (Golfvenner), `colleagues` (Kollegaer), `business`
|
||
(Forretningsforbindelser), `classmates` (Studiekamerater),
|
||
`acquaintances` (Perifere bekjente), `other` (Ymse).
|
||
- `friend_categorization`: `owner_user_id`, `friend_user_id`, `category`
|
||
— én rad PER kategori en venn er satt i (en venn kan ha flere rader,
|
||
"de skal kunne være tilknyttet forskjellige kategorier"). Håndheves i
|
||
app-laget (skriving krever en `accepted`-vennskapsrad mellom de to),
|
||
ikke en direkte FK til `friendship` (unngår å måtte holde styr på
|
||
requester/addressee-retning to steder).
|
||
|
||
### Beslutning B — Rundevisibilitet: tre nivåer, samme struktur som turnering, men egen mekanisme
|
||
|
||
`round` får `visibility_mode` (`public`/`private`/`friends`, default
|
||
`private` — trygg standard, samme filosofi som RLS-policyenes "se
|
||
ingenting" ved manglende kontekst). Når `friends` er valgt: hvilke
|
||
KATEGORIER som får se runden velges eksplisitt (`round_visible_category`,
|
||
`round_id`+`category`) — ikke "alle venner", siden brukeren eksplisitt ba
|
||
om å velge blant gruppene sine ved rundestart.
|
||
|
||
**Viktig presisering, unngår en reell forvekslingsfelle:** dette er
|
||
ADSKILT fra om noen er lagt til som faktisk MEDSPILLER (Beslutning C
|
||
under). En medspiller du har lagt til ser ALLTID runden dere spiller
|
||
sammen, uavhengig av `visibility_mode` — visibility-nivået styrer kun
|
||
TREDJEPARTS innsyn (venner/offentligheten), ikke de som faktisk er med i
|
||
flighten.
|
||
|
||
**Autorisasjonssjekk** (app-lag, `plain_connection()`-mønsteret fra
|
||
ADR-033 Beslutning A — INGEN RLS, samme begrunnelse som der):
|
||
1. `viewer == round.owner_user_id` → alltid tilgang.
|
||
2. `viewer` er en lenket medspiller (`round_participant.user_id ==
|
||
viewer`) → alltid tilgang til DEN runden.
|
||
3. `visibility_mode == 'public'` → alle, også anonyme (samme mønster som
|
||
turneringers offentlige side).
|
||
4. `visibility_mode == 'private'` → kun 1+2.
|
||
5. `visibility_mode == 'friends'` → krever innlogget bruker MED en
|
||
`accepted`-vennskapsrad til eieren OG minst én
|
||
`friend_categorization`-rad (`owner_user_id=eier, friend_user_id=
|
||
viewer`) hvor kategorien finnes i `round_visible_category` for akkurat
|
||
denne runden. Legg merke til retningen: det er EIERENS kategorisering
|
||
av VIEWER som brukes, ikke omvendt — riktig, siden det er eieren som
|
||
begrenser hvem som får se, basert på eierens egen gruppering.
|
||
|
||
**"Livescore"** — forstått som sanntidsoppdatering mens runden pågår,
|
||
samme idé som turnering sin `/t/[id]/live` (ADR-027). Gjenbruker samme
|
||
kringkastingsmønster (`app/realtime.py`, `broadcast_live_update`) — en
|
||
tilsvarende funksjon for runder, kringkastet ved hver hull-PATCH når
|
||
`visibility_mode != 'private'`, med et WS-endepunkt gated av samme
|
||
autorisasjonssjekk som over.
|
||
|
||
### Beslutning C — Tiered personsøk: delt mellom "finn venn" og "legg til medspiller"
|
||
|
||
Samme underliggende endepunkt brukes til BEGGE formål (finn en venn å
|
||
sende forespørsel til, OG søk opp en medspiller å legge til på en runde)
|
||
— begge er i essens "finn en person", kun hva som skjer ETTER valg
|
||
skiller dem. Foreslått: `GET /people/search?q=...`.
|
||
|
||
**Navnerekkefølge-uavhengig matching:** spørringen splittes i tokens
|
||
(mellomrom-separert). For HVER token må minst ett av `first_name`/
|
||
`last_name` matche (`ILIKE token||'%'`) — ALLE tokens må matche (AND på
|
||
tvers av tokens, OR på tvers av feltene per token). Dette gjør at "Erol
|
||
Haagenrud" og "Haagenrud Erol" gir identisk treff, uten noen spesiell
|
||
"gjett rekkefølgen"-logikk — det faller naturlig ut av at hvert ord kan
|
||
matche HVILKET SOM HELST av de to feltene.
|
||
|
||
**Tiered rangering, én spørring:** en beregnet prioritet per kandidat —
|
||
0 hvis venn (uavhengig av kategorisering — ALLE venner, ikke bare de i
|
||
en bestemt gruppe, siden dette er søk-for-å-legge-til, ikke visibility-
|
||
sjekken over), 1 hvis samme `home_club` som søkeren (nå en pålitelig
|
||
eksakt streng siden ADR-025-runden 2026-07-25 gjorde Hjemmeklubb til en
|
||
ekte dropdown-verdi i stedet for fritekst — retroaktivt en god
|
||
begrunnelse for den endringen), 2 hvis samme `country`, 3 ellers.
|
||
`ORDER BY prioritet, fornavn, etternavn LIMIT 20` gir venner-først uten
|
||
behov for separate spørringer per nivå.
|
||
|
||
**Personvernhensyn, bevisst innebygd i designet, ikke tilføyd i
|
||
etterkant:**
|
||
- Svaret inneholder KUN navn, avatar, hjemmeklubb — ALDRI e-post/mobil/
|
||
fødselsdato.
|
||
- Foreslått minimum 2 tegn i søket før noe returneres i det hele tatt
|
||
(hindrer triviell enumerering av alle brukere via ett enkelt
|
||
bokstavsøk) — MIN anbefaling, ikke bekreftet med bruker.
|
||
- Kun innloggede brukere kan søke (`get_current_user`, ikke anonymt).
|
||
|
||
### Beslutning D — Byggerekkefølge (foreslått, IKKE bekreftet)
|
||
|
||
Gitt omfanget (nytt vennekonsept + ny rundevisibilitet + nytt delt
|
||
søkeendepunkt + sanntidsutvidelse) foreslås tre uavhengig leverbare faser,
|
||
samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet:
|
||
|
||
1. **Venner-kjernen**: `friendship`+`friend_categorization`, forespørsel/
|
||
aksept/fjern-endepunkter, `/people/search`, en ny `/friends`-side
|
||
(søk, forespørsler, kategoriser). Leverbar og nyttig helt alene.
|
||
2. **Rundevisibilitet**: `round.visibility_mode`+`round_visible_category`,
|
||
valg ved rundestart, `can_view_round`-gatede lese-endepunkter for
|
||
ikke-eiere, sanntidsutvidelse for live-visning. Avhenger av fase 1.
|
||
3. **Ekte medspillere** (ikke bare gjester): utvid `POST .../participants`
|
||
til å godta et søkt `user_id` i tillegg til `guest_name`. Avhenger av
|
||
fase 1 (samme søk). **Avklart med bruker 2026-07-25: JA** — en
|
||
lagt-til ekte medspiller skal se runden i SIN EGEN "Egne runder"-liste,
|
||
ikke bare eieren.
|
||
**Konkret teknisk konsekvens, presisert her siden det ikke er
|
||
opplagt:** `list_rounds` filtrerer i dag KUN på `owner_user_id`, og
|
||
`RoundOut` sine `owner_holes_played`/`owner_total_score`/
|
||
`owner_score_to_par`-felt (lagt til 2026-07-25, se status) er alltid
|
||
utledet fra EIERENS `round_participant`-rad. For en medspiller som
|
||
ser runden i SIN liste må disse tallene i stedet vise DERES EGEN
|
||
score i runden, ikke eierens — feltene må derfor bli
|
||
"viewer-relative" (utledet fra HVILKEN SOM HELST deltaker-rad som
|
||
matcher innlogget bruker, enten `is_owner` eller lenket `user_id`),
|
||
ikke hardkodet til `is_owner=true`-raden. `list_rounds`-spørringen må
|
||
utvides til `WHERE owner_user_id = $1 OR id IN (SELECT round_id FROM
|
||
round_participant WHERE user_id = $1)`. Ingen endring i selve
|
||
eierskapet (`round.owner_user_id` er fortsatt entydig én person).
|
||
**Skrivetilgang avklart med bruker 2026-07-25: en medspiller skal
|
||
kunne registrere score for ALLE i flighten**, ikke bare sin egen rad
|
||
— samme skrivetilgang som eieren allerede har i dag (via
|
||
`_get_owned_round_or_404`), nå utvidet til å gjelde ENHVER lenket
|
||
ekte deltaker (`round_participant.user_id`), ikke kun eieren. Praktisk
|
||
presisering: dette gjelder KUN hull-registrering
|
||
(`PATCH .../participants/{id}/holes/{hole}`) og legg-til-deltaker —
|
||
sletting av selve runden og bane-/utslagsbytte (`DELETE /rounds/{id}`,
|
||
`PATCH /rounds/{id}` sine bane-felt) forblir eier-eksklusivt, siden
|
||
disse er destruktive/strukturelle handlinger uavhengig av hvem som
|
||
fører score. Autorisasjonssjekken for hull-PATCH blir dermed:
|
||
`viewer == owner_user_id OR viewer IN (SELECT user_id FROM
|
||
round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`.
|
||
|
||
**Status: 🔨 FASE 1 (venner-kjernen) BACKEND + FRONTEND BYGGET
|
||
2026-07-25.** Backend: migrasjon `025_friends.sql` + `app/routers/
|
||
friends.py` (`GET /people/search`, `POST /friends`, `POST /friends/{id}/
|
||
accept`, `DELETE /friends/{id}`, `GET /friends`, `PUT /friends/
|
||
{friend_user_id}/categories`) — 31 scratch-sjekker bestått, inkl.
|
||
presist bevist tiered rangering (venn→klubb→land→globalt) og at
|
||
kategorisering faktisk er PRIVAT (B ser aldri kategoriene A har satt B
|
||
i). `test_isolation.sql` fortsatt 12/12. Rullet ut mot ekte `teecup_db`/
|
||
`teecup_api` samme dag, bruker bekreftet eksplisitt.
|
||
Frontend: V0-zip (zip 19) integrert som `components/friends.tsx` + ny
|
||
rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo —
|
||
det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra
|
||
start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i
|
||
samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. Rullet
|
||
ut live 2026-07-25, bruker bekreftet eksplisitt.
|
||
|
||
**Fase 3 (ekte medspillere på en frittstående runde) BYGGET OG SCRATCH-
|
||
VERIFISERT 2026-07-26**, utløst av at brukeren rapporterte at "+ Gjest"-
|
||
skjemaet ikke søkte etter spillere i det hele tatt (rent tekstfelt,
|
||
`/people/search` var aldri koblet på). Bygget den delen av fase 3 som
|
||
faktisk var etterspurt: søk-og-legg-til en ekte bruker (gjenbruker
|
||
tiered `/people/search`, samme endepunkt som vennesøket) PLUSS den
|
||
tidligere avklarte "skriv for hele flighten"-regelen (bekreftet av
|
||
brukeren 2026-07-26 — "full tilgang nå", ikke bare rask utfylling).
|
||
**IKKE** bygget i denne runden: selve rundevisibilitet (fase 2,
|
||
offentlig/privat/delt-med-venner-i-grupper) — det er fortsatt et
|
||
separat, senere steg.
|
||
|
||
Konkret: `round_participant.user_id` (fantes i skjemaet siden ADR-033,
|
||
aldri eksponert via API) er nå skrivbar via `POST /rounds/{id}/
|
||
participants` (kjønn/HCP hentes automatisk fra den valgte personens
|
||
egen profil, ikke tastet manuelt). Ny migrasjon `027_round_participant_
|
||
user_unique.sql` (partiell unik indeks, hindrer dobbel-lenking). Rundens
|
||
tilgang delt i to nivåer: `_get_accessible_round_or_404` (eier ELLER en
|
||
lenket medspiller — lesing, hull-scoring for HELE flighten, fullføring)
|
||
vs. `_get_owned_round_or_404` (fortsatt strengt eier-only — rediger/
|
||
slett runde, legg til/fjern medspillere). `RoundOut` sine `owner_*`-
|
||
statistikkfelt omdøpt til `my_*` og gjort VIEWER-relative (regnes nå fra
|
||
den spørrende brukerens egen deltaker-rad, ikke alltid eierens — løser
|
||
et teknisk konsekvens-punkt notert allerede 2026-07-25 da "medspiller
|
||
ser runden i egen liste" ble bekreftet). `RoundParticipantOut` fikk et
|
||
nytt, alltid utfylt `display_name`-felt (levende oppslått, ikke
|
||
snapshot) — fanget OG fikset en reell latent bug i samme runde:
|
||
leaderboard-endepunktet (bygget 2026-07-26, samme dag) ville vist et
|
||
tomt navn for enhver lenket medspiller, siden dets `display_name`-
|
||
utledning den gang kun sjekket `is_owner`.
|
||
**Scratch-verifisert grundig (39/39 sjekker i to testløp):** søk-og-
|
||
legg-til (kjønn/HCP auto-fylt, avvist duplikat/selv/ukjent bruker/
|
||
ufullstendig profil), lenket medspiller kan se runden+registrere score
|
||
for BÅDE egen OG andres rad (whole-flight), men KAN IKKE forvalte runden
|
||
(rediger/slett/legge til/fjerne/endre stat_level — alle 403), lenket
|
||
medspiller KAN fullføre runden, `/rounds`-listen viser nå runden for en
|
||
lenket medspiller (med DERES egen fremdrift, ikke eierens), en helt
|
||
urelatert bruker fortsatt 403/ikke i listen, leaderboardets navn stemmer
|
||
for alle tre deltakertyper (eier/medspiller/gjest). `test_isolation.sql`
|
||
12/12 uendret. Ekte typesjekket produksjonsbuild av frontend (ny
|
||
søk-UI i `round-detail.tsx`, "Gjest" omdøpt til "Medspiller" gjennomgående,
|
||
samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/
|
||
`round-scorecard.tsx`, som hadde samme latente bug).
|
||
|
||
### Beslutning E — Kategori-match ved rundevisibilitet: MINST ÉN, ikke ALLE
|
||
|
||
Beslutning B punkt 5 (over) spesifiserte fra start "minst én
|
||
`friend_categorization`-rad ... hvor kategorien finnes i
|
||
`round_visible_category`". En senere presisering 2026-07-29 (kun
|
||
dokumentert i kode-kommentarer, aldri i denne ADR-en — selve
|
||
dokumentasjonshullet som gjorde denne avviket vanskelig å spore) strammet
|
||
dette til at ALLE en venns kategorier måtte være i rundens synlige sett,
|
||
ikke bare én.
|
||
|
||
**Reversert til original Beslutning B-regel 2026-08-08**, etter at
|
||
brukeren rapporterte at en runde delt med kategorien "Make" ikke ble
|
||
synlig for vedkommendes ektefelle. Årsak: ektefellen var i tillegg
|
||
kategorisert "Storfamilie" (Beslutning A tillater flere kategorier per
|
||
venn), og siden "Storfamilie" ikke var huket av på DENNE runden, blokkerte
|
||
den strenge ALLE-regelen hele synligheten -- til tross for at den
|
||
relevante kategorien ("Make") faktisk var valgt. I praksis gjorde dette
|
||
det nærmest umulig å dele pålitelig med noen som var tagget i mer enn én
|
||
kategori, uten å huke av samtlige av dem hver gang.
|
||
|
||
**Ny/gjeninnført regel:** en venn ser runden hvis MINST ÉN av kategoriene
|
||
eieren har satt dem i, er i rundens synlige sett -- flere kategorier på
|
||
samme venn er bare flere sjanser til å matche, aldri en ekstra
|
||
begrensning. En venn med INGEN kategorier vises fortsatt aldri (uendret).
|
||
|
||
Rettet i fire duplikate SQL-steder (`app/routers/rounds.py`:
|
||
`_can_view_round`, `_friends_who_can_see_round`,
|
||
`list_friends_on_course`; `app/routers/round_messages.py`: feed-
|
||
listingen) -- samme sted-for-sted-duplisering som opprinnelig omtalt i
|
||
Beslutning B, nå alle konsistent på MINST ÉN-varianten.
|
||
|
||
---
|
||
|
||
## ADR-037: Individuelle turneringer, flerrunde-turneringer — grunnstruktur
|
||
|
||
**Kontekst:** reist 2026-07-26, som følge av en avklaringsrunde om et
|
||
ønsket turnering-leaderboard (se FEATURE_BACKLOG.md sin "Utvidelse
|
||
2026-07-26"-seksjon for hele forhistorien). TeeCup skal etter hvert
|
||
kunne arrangere turneringer for INDIVIDUELLE spillere (f.eks.
|
||
«Københavner» — se FEATURE_BACKLOG.md sitt eget punkt om flere
|
||
turneringsformater), ikke bare dagens Ryder Cup-lagformat (ADR-011),
|
||
og disse skal kunne gå over FLERE RUNDER med sammenlagt resultat. Et
|
||
fremtidig Order of Merit (sesong-sammenlagt på tvers av flere
|
||
turneringer, brukerens eksempel: "klubbdager") er identifisert som et
|
||
beslektet, men separat, SISTE steg — se eget avsnitt nederst, ikke
|
||
designet i denne runden.
|
||
|
||
Denne ADR-en dekker KUN grunnstrukturen (datamodell-plassering,
|
||
turnering-type, flerrunde-støtte, poengmodell). Selve de fem konkrete
|
||
formatene (Københavner, High-low-high, Robbins, Try all,
|
||
Flaggturnering) fra FEATURE_BACKLOG.md designes hver for seg OVENPÅ
|
||
denne grunnstrukturen, ikke i denne runden.
|
||
|
||
**Viktig presisering underveis, som endrer en tidligere antakelse
|
||
(2026-07-25-runden om "flere flighter i én frittstående runde"):** i en
|
||
formell, org-arrangert individuell turnering er "flight" KUN en
|
||
tee-tid-/spilletempo-gruppering (som i dag for lag-turneringer) — IKKE
|
||
en leaderboard-grense. Leaderboardet spenner alltid HELE feltet,
|
||
uavhengig av hvem som spilte sammen. Dette er strukturelt ulikt den ad
|
||
hoc "flere flighter i en frittstående runde"-ideen (der leaderboardet
|
||
bevisst skal avgrenses til det man selv satte opp) — de to
|
||
"flight"-begrepene ligner i UI, men er IKKE samme konsept. Holdes
|
||
bevisst adskilt, ikke forent, selv om forrige runde antydet det motsatte.
|
||
|
||
### Beslutning A — Ny, parallell org-scopet datamodell (IKKE gjenbruk av `round`)
|
||
|
||
En individuell/flerrunde-turnering får sin EGEN, RLS-beskyttede
|
||
tabellstruktur under `organization_id` — IKKE en utvidelse av de
|
||
frittstående rundetabellene (`round`/`round_participant`/`round_hole`,
|
||
ADR-033).
|
||
|
||
**Begrunnelse:** ADR-033 Beslutning A var et BEVISST valg om at
|
||
frittstående runder er 100 % org-uavhengige (`plain_connection()`,
|
||
ingen RLS i det hele tatt — autorisasjon håndheves med
|
||
`WHERE owner_user_id = $1` i app-laget). Å gi `round` en valgfri
|
||
`organization_id`/`tournament_id` ville krevd HYBRID RLS (håndhevet kun
|
||
når organisasjon er satt) — en helt ny klasse sikkerhetslogikk som
|
||
IKKE finnes noe sted ellers i systemet i dag (`organization_id på ALLE
|
||
domenetabeller, håndhevet av RLS` er en ufravikelig invariant i
|
||
CLAUDE.md — en betinget/nullbar variant ville vært det FØRSTE
|
||
unntaket). Dette prosjektet har allerede én dokumentert hendelse
|
||
(RLS-tomstreng-bugen, se CHANGELOG.md 2026-07-16) som kom av
|
||
nettopp denne typen RLS-finesse — vi unngår bevisst å introdusere en
|
||
ny variant av samme risikoklasse. Noe skjema dupliseres (hull-for-hull-
|
||
registrering ligner mye på `round_hole`), men den delte regnelogikken
|
||
(`handicap_engine.py`) er allerede rammeverk-uavhengig og gjenbrukes
|
||
uendret uansett hvilken tabell dataene ligger i.
|
||
|
||
### Beslutning B — Samme `tournament`-tabell, ny type-diskriminator
|
||
|
||
`tournament` får en ny kolonne, f.eks. `format_type` (`'team'` |
|
||
`'individual'`, default `'team'` for bakoverkompatibilitet med alle
|
||
eksisterende rader).
|
||
|
||
**Begrunnelse:** en individuell turnering trenger fortsatt navn, status,
|
||
datoer, synlighet (ADR-018), invitasjonskode (ADR-020), org-eierskap —
|
||
ALT dette er allerede bygget og fungerer uendret på `tournament`-raden,
|
||
uavhengig av format. Å lage en helt ny toppnivå-entitet ville betydd å
|
||
gjenoppbygge synlighet/join-kode/landingssider fra bunnen for et andre
|
||
system — ren duplisering uten reell gevinst. Lag-tabellene
|
||
(`team`/`team_roster`) blir ganske enkelt ubrukte for
|
||
`format_type = 'individual'` — håndheves i app-laget (samme mønster som
|
||
ADR-011s to-lags-grense), ikke med en tung DB-constraint på tvers av
|
||
tabeller.
|
||
|
||
### Beslutning C — Flerrunde via en ny, økt-lignende tabell
|
||
|
||
Individuelle turneringer kan ha FLERE runder fra start, via en ny
|
||
`tournament_round`-tabell (org+tournament-scopet, samme rolle som
|
||
`session` har for lag-turneringer i dag — dato/bane/hullomfang per
|
||
runde). Sammenlagt resultat på tvers av rundene i turneringen summeres
|
||
ved lesing, SAMME "summer på ekte deltaker-id, aldri på en løs
|
||
side-label"-prinsipp som det eksisterende lag-leaderboardet allerede
|
||
bruker (`fetch_leaderboard`, `app/routers/tournaments.py`) for å unngå
|
||
å blande sammen feil rader.
|
||
|
||
**Bane-referanse:** `tournament_round.course_id` peker til den
|
||
EKSISTERENDE org-scopede `course`-tabellen (samme som `session` bruker
|
||
i dag) — INGEN snapshot-mekanisme som i frittstående runder
|
||
(ADR-033s `course_name_snapshot`). Organisasjonens egen banedata er
|
||
allerede stabil, admin-kontrollert data; snapshot-behovet i ADR-033 kom
|
||
av at frittstående runder leser LIVE fra en ekstern, ikke-org-kontrollert
|
||
kilde (teeoff) — samme begrunnelse gjelder ikke her.
|
||
|
||
**Deltaker-identitet:** en ny `tournament_participant`-tabell (org+
|
||
turnering-scopet, samme rolle som `team_roster` — refererer til
|
||
EKSISTERENDE `player`-tabellen, fryser `handicap_index_snapshot` ved
|
||
uttak, samme reproduserbarhetsprinsipp som ADR-007) — IKKE en ny,
|
||
frittstående gjeste-modell slik `round_participant` har. En turnering
|
||
har alltid en organisator som administrerer en kjent spillerpool
|
||
(samme som lag-turneringer i dag), ulikt en privatpersons frittstående
|
||
runde.
|
||
|
||
### Beslutning D — Rå slag lagres, poeng caches per format (samme mønster som match-play)
|
||
|
||
Hver `tournament_round`-deltaker sin hull-for-hull-registrering lagrer
|
||
RÅ BRUTTO SLAGTALL per hull (samme grunnform som `round_hole.score`) —
|
||
scorekortet trenger dette uansett, og det gir handlefrihet til å vise
|
||
flere ulike visninger (brutto/netto/stableford/Københavner-poeng) av
|
||
SAMME underliggende data.
|
||
|
||
I TILLEGG caches et FERDIG UTREGNET poengtall per format — samme
|
||
etablerte mønster som `match.status_text`/`points_side_a/b` i dag:
|
||
motoren (`handicap_engine.py`, ny funksjon per scoringsmetode) regner
|
||
poeng ved HVER hull-innsending, resultatet lagres i en egen kolonne
|
||
(f.eks. på en ny `tournament_round_score`-rad, én per deltaker per
|
||
runde) — rask leaderboard-lesing uten å måtte regne ut alt på nytt for
|
||
hele feltet ved hver visning, konsistent med hvordan matchstatus
|
||
allerede caches og regnes på nytt ved hver innsending
|
||
(`recompute_and_cache_match_state`).
|
||
|
||
`tournament.scoring_method` (eller ev. per `tournament_round`, hvis et
|
||
fremtidig format trenger å blande metoder — ikke avklart, default: fast
|
||
per turnering) avgjør HVILKEN motorfunksjon som brukes til å regne
|
||
poeng fra de rå slagene — ny CHECK-verdi + ny ren funksjon i
|
||
`handicap_engine.py` per format som legges til (`stroke_play_gross`,
|
||
`stroke_play_net`, `stableford`, `copenhagen_points`, ...). Hvert nytt
|
||
format fra FEATURE_BACKLOG.md sin liste blir dermed i hovedsak: én ny
|
||
motorfunksjon (testet isolert, samme "test i isolasjon FØR resten"-
|
||
prinsipp som ADR-005) + én ny CHECK-verdi, ikke en skjemaendring.
|
||
|
||
### Konsekvens — hva denne ADR-en IKKE avgjør ennå
|
||
|
||
- **De fem konkrete formatene** (Københavner, High-low-high, Robbins,
|
||
Try all, Flaggturnering) — hver trenger sin egen, mindre design-runde
|
||
oppå denne grunnstrukturen (poengformel, evt. spesielle
|
||
paringsregler for High-low-high/Robbins som IKKE er rent individuelle
|
||
poeng-per-hull).
|
||
- **Order of Merit** (sesong-sammenlagt på tvers av FLERE separate
|
||
turneringer). Bekreftet av brukeren som et beslektet, men SISTE steg
|
||
— forutsetter at individuelle turneringer med et poengresultat
|
||
finnes først. Krever sannsynligvis et helt nytt overordnet konsept
|
||
(en "sesong"/"serie", org-scopet, RLS som ellers) som grupperer flere
|
||
`tournament`-rader og akkumulerer poeng — IKKE designet i denne
|
||
runden.
|
||
- **Migrasjon/kode — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-30, IKKE ENNÅ
|
||
RULLET UT.** Migrasjon `040_individual_tournaments.sql` (alle fem nye
|
||
tabeller + `tournament.format_type`/`scoring_method`), nye motorfunksjoner
|
||
i `handicap_engine.py` (`stroke_play_gross_total`/`stroke_play_net_total`/
|
||
`stableford_points_for_hole`/`stableford_total`, 8 nye tester), og et
|
||
fullt CRUD-API (`app/routers/individual_tournaments.py`: runder/
|
||
turnering-deltakere/rundedeltakere/hull-for-hull-scoring/leaderboard).
|
||
Se FEATURE_BACKLOG.md ("Utvidelse 2026-07-26"-seksjonens oppdatering
|
||
2026-07-30) for full detalj om design, funn under bygging og
|
||
verifisering (52 API-sjekker + 63 motor-tester). Frontend er bevisst
|
||
IKKE bygget i denne runden — neste steg i samme "motor → skjema → API →
|
||
frontend"-rekkefølge som ADR-033/038/039.
|
||
|
||
---
|
||
|
||
## ADR-038: Faktisk (beregnet) HCP vs. manuelt satt HCP
|
||
|
||
**Kontekst:** reist 2026-07-28, rett etter en gjennomgang av frittstående
|
||
rundeføring (ADR-033) som avdekket et reelt, ubesluttet hull: hele
|
||
WHS-indeksmotoren (`handicap_index_from_differentials`/
|
||
`low_handicap_index`/`apply_index_caps` i `handicap_engine.py`, testet
|
||
41/41) kalles ALDRI fra noe API-endepunkt — `round_participant.
|
||
score_differential` regnes og lagres per runde, men `app_user.
|
||
handicap_index` endres kun manuelt (`PATCH /auth/profile`). Bekreftet
|
||
eksplisitt i `rounds.py` sin egen moduldoc som "eksplisitt uavklart punkt
|
||
i ADR-033".
|
||
|
||
Brukeren avklarte at TeeCup skal operere med TO tall, ikke ett:
|
||
|
||
### Beslutning A — To atskilte HCP-verdier
|
||
|
||
- **Manuelt satt HCP** (eksisterende `app_user.handicap_index`,
|
||
ADR-031) — UENDRET betydning og bruk: dette er tallet som brukes til
|
||
Course Handicap-beregning i enhver runde/deltaker med mindre
|
||
eksplisitt overstyrt per runde (allerede slik i dag via
|
||
`handicap_index_snapshot`). Redigeres fritt av brukeren selv, som i
|
||
dag.
|
||
- **Faktisk HCP** (ny `app_user.computed_handicap_index` +
|
||
`computed_handicap_index_updated_at`) — WHS Handicap Index (Rule 5.2),
|
||
regnet automatisk fra beste 8 av de ≤20 nyeste TELLENDE Score
|
||
Differential-ene, med den offisielle opptrappingstabellen for færre
|
||
enn 20 runder (`_INDEX_TABLE_UNDER_20`, allerede implementert og
|
||
testet — ingen ny motorkode trengs, kun at den faktisk kalles). Aldri
|
||
direkte redigerbar — kun avledet.
|
||
|
||
### Beslutning B — Uttrykkelig "overfør til manuelt HCP"-handling
|
||
|
||
Golferen får en egen, eksplisitt knapp ("Bruk som mitt HCP") i
|
||
`/account` som kopierer gjeldende `computed_handicap_index` inn i
|
||
`handicap_index` (samme skrivevei/historikk-logging som en vanlig
|
||
manuell PATCH). INGEN automatisk synkronisering noen vei — de to
|
||
tallene lever bevisst uavhengig av hverandre helt til brukeren selv
|
||
trykker knappen.
|
||
|
||
### Beslutning C — Eksplisitt eksklusjon per deltaker, kontrollert av HVER innlogget deltaker selv
|
||
|
||
Ny `round_participant.exclude_from_handicap boolean DEFAULT false` —
|
||
uavhengig av `counts_for_handicap` (som fortsatt er den AUTOMATISKE
|
||
WHS-kvalifiseringen, minimum spilte hull). Endelig medregning i faktisk
|
||
HCP krever BEGGE: `counts_for_handicap AND NOT exclude_from_handicap`.
|
||
|
||
**Avklart eksplisitt med bruker (AskUserQuestion), IKKE gjettet:** hver
|
||
INNLOGGET deltaker (eier ELLER en lenket medspiller, ADR-036 fase 3)
|
||
styrer SIN EGEN rads eksklusjon — ikke bare runde-eieren. Autorisasjon i
|
||
`PATCH .../participants/{id}` utvidet presist til akkurat dette ene
|
||
feltet: en ikke-eier kan KUN sende `exclude_from_handicap` (ethvert
|
||
annet felt i samme kall avvises 403) og KUN på sin egen rad (`rp.user_id
|
||
== requester`) — alle andre felt (tee/HCP/kjønn/navn/stat_level) forblir
|
||
strengt eier-only, uendret. Feltet er bevisst IKKE en del av
|
||
`rating_changed`-sperren i `update_participant` — kan endres uansett
|
||
fullført-status, siden det ikke påvirker AGS/differensial-matematikken
|
||
i seg selv, kun om resultatet TELLER i det HELE tatt.
|
||
|
||
### Beslutning D — Spilleform (slagspill/matchspill), selvdeklarert
|
||
|
||
Ny `round.play_format text DEFAULT 'stroke' CHECK (IN ('stroke',
|
||
'match'))`. Frittstående runder har i dag INGEN uavhengig måte å
|
||
"oppdage" matchspill på (ingen egen match-motor, ulikt turnering-siden)
|
||
— feltet er derfor selvdeklarert av brukeren ved oppsett (og fritt
|
||
redigerbart senere, som ren metadata, uendret av fullført-status).
|
||
|
||
**WHS-kilde lest og lagt til grunn** (`WHS_Rules_of_Handicapping_2024.
|
||
pdf`, Rule 3.3 "When a Hole is Started But Player Does Not Hole Out"):
|
||
matchspill-scorer ER teknisk et gyldig HCP-grunnlag under WHS (Rule
|
||
2.1a), MEN et hull som avgjøres/konsederes før utspilt krever en
|
||
subjektiv "most likely score" (avgrenset til netto dobbel bogey) — noe
|
||
TeeCups rene tallregistrering ikke har noen UI-vei til å representere
|
||
presist. Dette begrunner brukerens instinkt ("man bruker vanligvis ikke
|
||
matchspill til å beregne hcp") uten å hardkode et forbud: WHS tillater
|
||
det teknisk, men TeeCup kan ikke garantere et pålitelig grunnlag for
|
||
det i dag.
|
||
|
||
**Derfor: spør, ikke tving.** Når `play_format = 'match'` velges (ved
|
||
opprettelse, ELLER ved senere tillegg av en lenket medspiller), foreslår
|
||
frontend `exclude_from_handicap = true` som FORHÅNDSVALGT verdi med en
|
||
forklarende tekst — brukeren kan uansett overstyre til `false` (f.eks.
|
||
en fullstendig utspilt vennskapelig 18-hulls "matchspill" der alle hull
|
||
faktisk ble spilt ut). Ingen server-side tvang — `play_format` og
|
||
`exclude_from_handicap` er to uavhengige felt i skjemaet; kun
|
||
frontendens forhåndsutfylling kobler dem sammen.
|
||
|
||
### Beslutning E — Datagrunnlag: designet for BEGGE kilder, bygget for én
|
||
|
||
**Avklart eksplisitt med bruker:** "faktisk HCP" skal på sikt kunne
|
||
telle scorer fra BÅDE frittstående runder OG organisasjons-/
|
||
turneringsscoring, men v1 bygger KUN kilden for frittstående runder.
|
||
Løst med ett tynt, bevisst uabstrahert skjøtepunkt — IKKE en ny,
|
||
generell "scoring record"-tabell (ville vært for tidlig abstraksjon for
|
||
en kilde som ikke finnes ennå, se CLAUDE.md sin regel mot design for
|
||
hypotetiske fremtidige krav): en enkelt funksjon
|
||
`_gather_qualifying_differentials(conn, user_id)` i `rounds.py` henter
|
||
i dag KUN fra `round_participant JOIN round`, med en kommentar som
|
||
peker ut nøyaktig dette som skjøtepunktet for en fremtidig UNION mot
|
||
turnering-siden (som uansett trenger sin egen ADR-037-baserte
|
||
poeng-/differensial-motor FØRST, se ADR-037 Beslutning D).
|
||
|
||
### Beslutning F — Low Handicap Index / cap-historikk gjenbruker `handicap_history`, ikke en ny tabell
|
||
|
||
Rule 5.7 (Low Handicap Index, laveste indeks i 365 dager) og Rule 5.8
|
||
(soft/hard cap) krever en historikk av TIDLIGERE beregnede indeksverdier
|
||
å sammenligne mot — samme grunnform som den eksisterende
|
||
`handicap_history`-tabellen (ADR-031-oppfølging, migrasjon 018), men
|
||
den logger i dag KUN manuelle profilendringer. Løst med én ny kolonne
|
||
`handicap_history.source text DEFAULT 'manual' CHECK (IN ('manual',
|
||
'computed'))` fremfor en parallell tabell — samme tabell, to kilder,
|
||
`low_handicap_index()` filtrerer på `source = 'computed'`. Det aller
|
||
første beregnede indekstallet for en bruker har ingen tidligere
|
||
`computed`-historikk å låses mot — capping hoppes bevisst over da
|
||
(Rule 5.8 sitt "Low HI" er per definisjon udefinert før en første indeks
|
||
er etablert), ikke behandlet som en feil.
|
||
|
||
### Konsekvens — hva denne ADR-en IKKE dekker
|
||
|
||
- Turnering-/organisasjonsscoring teller fortsatt IKKE mot faktisk HCP
|
||
(Beslutning E) — venter på ADR-037s videre arbeid.
|
||
- Ingen automatisk periodisk "aging" av gamle differensialer utover de
|
||
20 nyeste (Rule 5.5) — løst implisitt ved at spørringen alltid henter
|
||
KUN de 20 nyeste, ikke en egen bakgrunnsjobb.
|
||
- Stableford-format for frittstående runder (se FEATURE_BACKLOG.md) er
|
||
fortsatt ikke bygget — påvirker ikke denne ADR-en, siden Score
|
||
Differential regnes fra Adjusted Gross Score uavhengig av
|
||
poengformat.
|
||
|
||
---
|
||
|
||
## ADR-039: Ekte spillformer for frittstående runder (match/skins/par-lag)
|
||
|
||
**Kontekst:** reist 2026-07-28, rett etter ADR-038: "Når en singlerunde
|
||
settes opp: Er dette en slagspillsrunde, en match mellom to spillere,
|
||
skins, eller en par- eller lag-konkurranse. Avhengig av svaret så må hcp
|
||
beregnes forskjellig, og også scorekortet vil se annerledes ut." Viktig
|
||
presisering: `round.play_format` (ADR-038) styrer i dag KUN et
|
||
HCP-eksklusjonsforslag — ikke selve scorings- eller scorekort-modellen.
|
||
Dette utvider `play_format` til å faktisk STYRE begge deler.
|
||
|
||
Fire load-bærende beslutninger avklart eksplisitt med bruker
|
||
(AskUserQuestion, to runder) før denne ADR-en ble skrevet.
|
||
|
||
### Beslutning A — To sider per runde, gjenbruker ADR-011s antakelse
|
||
|
||
Match/fourball/foursome/greensome/scramble trenger et «hvem spiller
|
||
mot hvem»-konsept `round_participant` ikke har i dag (en flat liste
|
||
uten gruppering). Løst med nøyaktig TO SIDER per runde (ny
|
||
`round_side`-tabell), håndhevet i app-laget — samme mønster som
|
||
ADR-011s to-lags-grense for organisasjons-turneringer, ikke en
|
||
DB-constraint. En side er 1 spiller (match) eller 2+ (fourball/
|
||
foursome/greensome/scramble_2/scramble_4). `round_participant.
|
||
round_side_id` (nullable — kun satt for to-sidede formater) knytter
|
||
deltakeren til sin side.
|
||
|
||
### Beslutning B — Alle fire par-/lag-underformater fra start, samme motor som turneringer
|
||
|
||
Bruker valgte bredt omfang: fourball, foursome, greensome OG begge
|
||
scramble-variantene (`scramble_2`/`scramble_4`) — ikke bare fourball
|
||
(som alene ville passet uendret inn i dagens skjema).
|
||
|
||
**Gjenbruker den EKSISTERENDE, testede match-play-motoren fullstendig
|
||
uendret** (`handicap_engine.py` sin `Format`-enum,
|
||
`AllowanceStrategy`-familien, `match_play_strokes`,
|
||
`allocate_over_played_holes`, `compute_match_state`, `HoleResult`) —
|
||
ingen ny motorkode for disse fire formatene. Selve API-laget
|
||
(`app/routers/rounds.py`) porterer det allerede bevisste,
|
||
produksjonskjørte mønsteret fra `app/handicap.py`/`app/routers/
|
||
scoring.py` (`compute_and_store_side_handicaps` →
|
||
`relative_strokes_for_match` → `_side_net`/`_compute_hole_results` →
|
||
`recompute_and_cache_match_state`), tilpasset `round`/`round_side` i
|
||
stedet for `match`/`team_side`. Samme `_SIDE_IS_UNIT`/`FORMAT_UNIT_SIZE`-
|
||
tabeller (hvor mange spillere som kreves per side før handicap kan
|
||
beregnes) gjenbrukes identisk.
|
||
|
||
### Beslutning C — Delt-ball-formater: `round_hole` gjenbruker `hole_score`s nullable-nøkkel-mønster
|
||
|
||
Foursome/greensome/scramble har ÉN kombinert score per SIDE per hull,
|
||
ikke én per spiller — akkurat samme situasjon som org-scopet
|
||
`hole_score` allerede løser (`match_participant_id` NULLABLE, delt-ball-
|
||
rader identifisert av `team_side` alene, to partielle unike indekser).
|
||
`round_hole.round_participant_id` gjøres NULLABLE, ny
|
||
`round_hole.round_side_id` lagt til, med samme
|
||
`(participant XOR side)`-CHECK og to partielle unike indekser som
|
||
originalen. For fourball/match (individuell ball) forblir `round_hole`
|
||
uendret, én rad per DELTAKER per hull, akkurat som i dag.
|
||
|
||
**Konsekvens for statistikk (ADR-033 Beslutning B):** de detaljerte
|
||
per-hull-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
|
||
ingen entydig mening for en DELT ball (hvem sitt slag var det?) — for
|
||
delt-ball-formater lagres derfor KUN selve slagtallet på siderraden,
|
||
ingen av de andre detalj-feltene (samme "et fast sett felt, ikke fri
|
||
logg"-prinsipp, bare at feltsettet er tynnere for denne raden).
|
||
|
||
### Beslutning D — Skins: konfigurerbar netto/brutto OG rullerer/deles, ny motorfunksjon
|
||
|
||
Ingen eksisterende motorstøtte for skins i det hele tatt (ikke et
|
||
turnering-format). Bruker valgte at DEN SOM SETTER OPP runden skal
|
||
kunne velge BEGGE akser uavhengig — ikke ett fastlåst standardvalg:
|
||
- `round.skins_scoring`: `'net'` (HCP-slag trekkes fra, WHS-vanlig) |
|
||
`'gross'`.
|
||
- `round.skins_tie_handling`: `'carry'` (uavgjort hulls "skin" ruller
|
||
til neste hull, vinneren der tar hele potten) | `'split'` (uavgjort
|
||
hulls skin deles likt mellom de tied spillerne i stedet).
|
||
|
||
Ny, ren, testbar motorfunksjon `compute_skins(scores_by_hole,
|
||
tie_handling)` i `handicap_engine.py` — tar allerede netto-ELLER-brutto-
|
||
avgjorte per-hull-scorer (kalleren bestemmer hvilket FØR kall, motoren
|
||
vet ikke selv om input er netto eller brutto), returnerer
|
||
`{participant_id: antall skins vunnet}` (float, siden 'split' kan gi
|
||
brøkdeler). Ingen sider trengs for skins — rent individuelt, som
|
||
slagspill.
|
||
|
||
### Beslutning E — HCP-telling per format
|
||
|
||
- **Slagspill, fourball, skins** (individuell ball — hver spiller har
|
||
en FULL, ekte brutto-score per hull): teller normalt mot faktisk HCP,
|
||
samme pipeline som i dag (Score Differential fra AGS). Bekreftet
|
||
eksplisitt av bruker for skins — "krever fullt utspilte hull (ingen
|
||
konsedering som i match) -- samme grunnlagsproblem som gjorde match
|
||
upålitelig gjelder ikke her."
|
||
- **Match** (uendret fra ADR-038): fortsatt "spør, anbefal
|
||
ekskludering" — Rule 3.3-problemet (konsederte hull) gjelder fortsatt
|
||
uendret.
|
||
- **Foursome/greensome/scramble** (delt ball — INGEN individuell score
|
||
finnes å telle): `counts_for_handicap` forblir usann for disse
|
||
deltakerne i disse rundene, alltid, uansett antall spilte hull.
|
||
Bekreftet eksplisitt av bruker som riktig løsning — matcher for
|
||
øvrig hvordan WHS i praksis også behandler delt-ball-formater (ikke
|
||
indeks-byggende på samme måte som individuell/fourball-spill).
|
||
|
||
### Beslutning F — Scorekort-presentasjon (frontend, ikke bygget i denne runden)
|
||
|
||
Notert for senere: match/fourball/foursome/greensome/scramble trenger
|
||
en løpende matchstatus-visning (gjenbruk av `describe()`-språket fra
|
||
`MatchState`, samme som `session-scorecard.tsx` allerede viser for
|
||
turnering-matcher) i stedet for rå slagsummer. Skins trenger en
|
||
skins-tavle (skins vunnet per spiller + hvilke hull som fortsatt er
|
||
"i potten"). Presist UI-design ikke gjort i denne runden — kun
|
||
backend/motor/skjema.
|
||
|
||
### Konsekvens — hva som bygges i DENNE runden vs. senere
|
||
|
||
Bruker valgte å gå rett til migrasjon + motor (ikke bare dokumentere).
|
||
Bygget: skjema (migrasjon 031), `compute_skins`-motorfunksjon + tester,
|
||
og backend-API for sider/delt-ball-scoring/format-resultat. Frontend
|
||
(nye skjermer for sideoppsett og de nye scorekort-presentasjonene,
|
||
Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
|
||
(backend/motor FØR frontend).
|
||
|
||
---
|
||
|
||
## ADR-040: «Det store grepet» — sammenhengende rundeoppsett + delt
|
||
Score/Scorekort/Leaderboard-navigasjon
|
||
|
||
**Kontekst:** reist 2026-08-01/02, som en direkte oppfølging av flere
|
||
visuelle redesign-runder (login-skjerm og dashbord, begge inspirert av et
|
||
eksternt design-verktøy, "Stitch"). Brukeren pekte på to strukturelle
|
||
problemer som selve fargeredesignet ikke løser: (1) rundeoppsettet er i
|
||
dag spredt over ETT langt skjema (`new-round.tsx`) OG en helt annen skjerm
|
||
(`round-detail.tsx`, etter at runden allerede er opprettet, for sider/
|
||
Money Ball-rekkefølge), (2) Score/Scorekort/Leaderboard for en frittstående
|
||
runde deler ingen fast navigasjon — hver side bygger sin egen fulle
|
||
side-chrome, ulikt Stitchs skisse.
|
||
|
||
**Beslutning A — rundeoppsettet blir én sammenhengende veiviser, 5 steg:**
|
||
Bane & tid → Spilleform (inkl. HCP-innstillinger) → Spillere → Lag/
|
||
rekkefølge (kun to-sidede formater/Money Ball) → Deling. Erstatter dagens
|
||
ett-langt-skjema-pluss-en-helt-annen-skjerm-mønster.
|
||
|
||
**Beslutning B — HCP-prosent er ALLTID justerbar, Match-HCP er en egen
|
||
bryter:** brukeren presiserte eksplisitt at prosenten (ADR-014s
|
||
`strategy`) aldri skal være låst til formatets standardverdi, og at
|
||
to-sidede formater i tillegg skal kunne skru `use_matchplay_handicap`
|
||
av/på — differensial-fordeling mellom sidene (laveste settes til 0
|
||
mottatte, resten mottar differansen fordelt fra laveste stroke index) i
|
||
stedet for ren brutto når det ikke er noen forskjell. **Funnet ved
|
||
verifisering, ikke antatt:** begge deler fantes allerede FERDIGBYGD i
|
||
motoren (`match_play_strokes()` i `handicap_engine.py`, ADR-014s "fire
|
||
brytere" i `app/handicap.py`) — kun brukt av org-scopede turneringer
|
||
(`session.allowance_override`). Frittstående runder (`round`-tabellen)
|
||
manglet bare selve kolonnen; ingen ny regnelogikk.
|
||
|
||
**Beslutning C — "Spillere og runde" (inkl. flighter) blir værende på
|
||
rundeoppsettsiden**, flytter IKKE inn i den nye delte fane-raden — dette
|
||
er forvaltning, ikke visning. Score/Scorekort/Leaderboard trenger i
|
||
stedet en kompakt, alltid tilgjengelig vei tilbake til oppsettsiden
|
||
(sammen med "Fullfør runde"/"Slett runde") — plassering (toppmeny vs.
|
||
footer vs. noe annet) er bevisst overlatt til V0 å foreslå ut fra en
|
||
presist beskrevet kontekst, ikke forhåndsbestemt her.
|
||
|
||
**Beslutning D — Scorekort og Statistikk slås sammen til ÉN side,**
|
||
statistikk RETT UNDER scorekortet — den delte fane-raden blir dermed tre
|
||
faner (Score / Scorekort / Leaderboard), ikke fire. `/my-rounds/[id]/
|
||
stats` forsvinner som egen rute, innholdet flytter inn i `/my-rounds/
|
||
[id]/scorecard`.
|
||
|
||
**Beslutning E — reelt, tidligere udokumentert funn: spillere i
|
||
scorekortet var ALDRI gruppert lagvis.** Brukeren rapporterte det
|
||
konkret (en ekte fourball-runde der Erol+Kåre var ett lag, Ellen
|
||
Anette+Christer det andre, men vist interleaved i rekkefølgen de ble
|
||
lagt til). Bekreftet i koden på BEGGE steder spillere vises for et
|
||
to-sidet format: `ScorecardGrid` (`round-detail.tsx`) og
|
||
`MatchScorecardGrid` (`round-scorecard.tsx`) sorterte ingen av dem på
|
||
`round_side_id` — ren innsettingsrekkefølge. Fikset med en liten,
|
||
presis sortering (stabil sort på side, side A samlet FØR side B) i
|
||
begge, IKKE en ny funksjon. Om celle-nivå-fargelegging (hvem som vant
|
||
hullet) alene er nok til å skille lagene visuelt, eller om det trengs
|
||
en tydeligere lag-blokk-markering, er et åpent spørsmål overlatt til V0
|
||
(samme "spør V0"-mønster som Beslutning C).
|
||
|
||
**Bygget og verifisert denne runden (2026-08-02):**
|
||
- Ny migrasjon `051_round_allowance_override.sql` — `round.allowance_
|
||
override jsonb`, rent additivt.
|
||
- `app/routers/rounds.py`: ny `_round_allowance_override()`-hjelpe-
|
||
funksjon (henter/parser friskt fra `round`-raden, samme mønster som
|
||
`matches.py`/`scoring.py` sin `session.allowance_override` — IKKE
|
||
cachet i selve config-objektet), koblet inn i `_recompute_side_
|
||
handicaps`/`_relative_strokes_for_round`. `RoundCreate`/`RoundUpdate`/
|
||
`RoundOut` fikk `allowance_override`. PATCH gjenberegner `playing_
|
||
handicap` for allerede tildelte sider ved endring (samme mønster som
|
||
`tournaments.py` sin `_recompute_session_matches`).
|
||
- Scratch-verifisert presist: 100%→50%-prosent ga `playing_handicap`
|
||
24/10 → 12/5 (nøyaktig som beregnet for hånd fra HCP 20,0/10,0 mot en
|
||
slope/rating-satt egendefinert bane), `use_matchplay_handicap:false`
|
||
ga rå 12/5 i stedet for differensial 7/0 (`match_play_strokes([12,5])`
|
||
→ laveste=5 til 0, differanse 7). Slagspill upåvirket (regresjon
|
||
bekreftet).
|
||
- Lag-grupperingsfiksen (Beslutning E) browserverifisert mot en fersk
|
||
scratch-fourball-runde som gjenskapte brukerens eget rapporterte
|
||
scenario nøyaktig (fire spillere lagt til i interleaved rekkefølge
|
||
A→Rødt, B→Blått, C→Rødt, D→Blått) — bekreftet visuelt i BÅDE
|
||
`ScorecardGrid` (live) og `MatchScorecardGrid` (post-runde) at Rødt
|
||
lag nå vises samlet, deretter Blått.
|
||
- Rullet ut mot ekte systemer 2026-08-02, bruker bekreftet eksplisitt:
|
||
migrasjon 051 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||
--build teecup_api teecup_frontend`. Begge containere boot-et rent,
|
||
`/health`/`/dashboard` → 200, anonym `PATCH /rounds/{ukjent-id}` ga
|
||
korrekt `401` (ikke en rå 404 — bekrefter ruten når FastAPI),
|
||
`teeoff.no` upåvirket.
|
||
|
||
**Steg 3 (V0-prompt 1, rundeoppsett-veiviseren) ✅ BYGGET, GRUNDIG
|
||
VERIFISERT OG LIVE (2026-08-02), samme dag:** V0-prompten (Beslutning A/B)
|
||
skrevet og kjørt av bruker (zip 25), `frontend/components/new-round.tsx`
|
||
skrevet fullstendig om — V0s 5-stegs veiviser-skall flettet med ekte
|
||
logikk: offisiell/egen bane-søk (inkl. nearby-geolokasjon), ekte egen-
|
||
bane-opprettelse (portert fra tidligere versjon), ekte `/people/search`,
|
||
ny `allowance_override`-konstruksjon i steg 2 (samme mønster som
|
||
`CreateSessionCard`s `buildAllowanceOverride`), og en helt ny
|
||
innsendingsorkestrering — runden (+ deltakere + sider/lineup) finnes
|
||
FØRST når HELE veiviseren er fullført (runde → sider → medspillere med
|
||
`round_side_id` satt direkte på opprettelse → eierens egen side-
|
||
tildeling via PATCH → Money Ball-rekkefølge via PATCH → naviger til
|
||
runden), ulikt den gamle "opprett runde først, legg til spillere
|
||
etterpå"-flyten.
|
||
**Reelle feil funnet og rettet under integrering:** V0s egen `Tee`-type
|
||
manglet kjønnsfelt (ville vist ufiltrerte utslag for både eier og
|
||
gjester) — lagt til `genders: ApiGender[]`, filtrert riktig per kjent
|
||
kjønn. To TS-feil (prop-spredning som overskrev `course`/`ownGender`
|
||
tilbake til `null`, og en `"x"`-kjønn-mismatch i tee-filtreringen).
|
||
**Bevisst, ærlig begrensning:** en spiller funnet via `/people/search`
|
||
har ukjent kjønn før de faktisk er lagt til runden (samme begrensning
|
||
som `PersonMatch`-modellen alltid har hatt) — utslagslisten for en slik
|
||
spiller er derfor UFILTRERT i veiviseren, med en forklarende tekst;
|
||
server-validering gir en klar feil ved et faktisk uforenlig valg.
|
||
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
|
||
ruter listet), og to komplette nettleser-gjennomkjøringer mot en fersk
|
||
scratch-backend — én Fourball-runde (egen bane, gjest, to sider
|
||
opprettet og tildelt interleaved, 90 % HCP-prosent) og én Slagspill-
|
||
runde (rene standardvalg). Bekreftet direkte mot databasen at
|
||
`allowance_override` ble bygget nøyaktig riktig i begge tilfeller
|
||
(`{"strategy":{"type":"per_player","percentage":0.9},...}` for
|
||
Fourball — riktig `per_player`, ikke `combined`, siden fourball ikke er
|
||
en side-enhet — og `null` for Slagspill, ingen unødvendig payload),
|
||
sidene/tildelingene stemte, og HCP ble beregnet korrekt fra ekte
|
||
profildata (kryssjekket mot hånd-regnet Course Handicap for begge
|
||
testspillerne).
|
||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon (ren frontend), `docker compose up -d --build teecup_frontend`
|
||
(gjenskapte også `teecup_api` som vanlig bivirkning, ingen backend-kode
|
||
rørt). Begge containere boot-et rent, `/health`/`/dashboard`/
|
||
`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||
|
||
**Steg 4-5 (V0-prompt 2, den delte Score/Scorekort/Leaderboard-fane-raden
|
||
+ integrering) ✅ BYGGET, GRUNDIG VERIFISERT OG LIVE (2026-08-02), samme
|
||
dag:** V0-prompten (Beslutning C/D) skrevet — bevisst SMALERE i omfang
|
||
enn prompt 1: kun den delte header/fane-raden, ikke en redesign av selve
|
||
innholdet på Scorekort-siden (den sammenslåingen er ren sammenstabling av
|
||
to allerede ferdige komponenter, løst direkte i kode uten V0). Kjørt av
|
||
bruker (zip 26) — leverte `components/round-header.tsx` (kontekstblokk
|
||
med tilbakeknapp/bane/tee/dato/tid/Matchspill-merke, tre-fanet
|
||
segmentert kontroll, og en "Administrer"-knapp som åpner en bunnsheet-
|
||
dialog med lenke til "Spillere og runde" + "Fullfør runde" + to-stegs
|
||
"Slett runde") — tatt inn UENDRET (ren presentasjon, ingen logikk å
|
||
bytte ut).
|
||
**Ekte datalag bygget rundt den:** ny `components/round-page-shell.tsx`
|
||
— henter kun de feltene headeren trenger via `/rounds/{id}`, bygger
|
||
`tabHrefs` til de tre ekte rutene, og implementerer `onFinishRound`/
|
||
`onDeleteRound` som egne, selvstendige handlinger (samme
|
||
`POST .../complete`/`DELETE ...`-endepunkter som `round-detail.tsx` sin
|
||
opprinnelige logikk, men uavhengig av den siden komponenten også må
|
||
fungere fra Scorekort- og Leaderboard-siden).
|
||
**Wiret inn i alle tre destinasjonene:** Score (`round-detail.tsx`sin
|
||
gamle egne header fjernet, erstattet med `RoundPageShell` — den
|
||
INTERNE "Score"/"Spillere og runde"-fanevekslingen beholdt UENDRET som
|
||
et bevisst lavrisiko-valg, nås nå i tillegg via headerens
|
||
"Administrer"-dialog med en ny `?tab=manage`-URL-parameter lest ved
|
||
mount), Scorekort (`round-scorecard.tsx` + `round-stats.tsx` slått
|
||
sammen til ÉN side via en ny `embedded`-prop på begge, statistikk RETT
|
||
UNDER scorekortet som spec-et i Beslutning D — gammel `/stats`-rute er
|
||
nå en ren redirect dit), Leaderboard.
|
||
**Reelle feil funnet og rettet under integrering:** to `<main>`-
|
||
landemerker på samme side etter sammenslåingen av Scorekort+Statistikk
|
||
(ugyldig HTML/tilgjengelighet) — rettet med en `ContentTag`-switch
|
||
(`div` når embedded, `main` ellers) i `round-stats.tsx`. Fjernet nå
|
||
overflødige kryss-lenker mellom de to sammenslåtte sidene, kollapset
|
||
"Se scorekort"/"Se full rundestatistikk" til én "Se scorekort og
|
||
statistikk"-knapp i `round-detail.tsx` sitt fullført-banner.
|
||
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
|
||
ruter listet), full nettleser-gjennomkjøring gjennom hele "Administrer"-
|
||
flyten — headeren konsistent på tvers av alle tre fanene, dialogens
|
||
lenke til "Spillere og runde" bekreftet å faktisk åpne riktig intern
|
||
fane via `?tab=manage`, ekte "Fullfør runde" (bekreftet `completed_at`
|
||
satt via et direkte API-kall etterpå) og ekte to-stegs "Slett runde"
|
||
(bekreftet navigerte korrekt til tom-tilstand på `/my-rounds`
|
||
etterpå). Traff en Turbopack-flakighet i selve scratch-dev-serveren
|
||
underveis (stuck spinner, ingen nettverkskall i det hele tatt) — løst
|
||
med en frisk containerstart, bekreftet IKKE en kodefeil (samme
|
||
kjente ustabilitetsklasse som tidligere dokumentert i dette
|
||
prosjektet, ikke noe nytt).
|
||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||
migrasjon (ren frontend), `docker compose up -d --build
|
||
teecup_frontend` (gjenskapte også `teecup_api` som vanlig bivirkning,
|
||
ingen backend-kode rørt). Begge containere boot-et rent,
|
||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||
|
||
**ADR-040 («det store grepet») er dermed HELT FERDIG, alle 5 steg,
|
||
backend + frontend, live:**
|
||
1. ~~Backend: `allowance_override`-migrasjon + kobling~~ ✅
|
||
2. ~~Kode-fiks: lag-sortering~~ ✅
|
||
3. ~~V0-prompt 1: den samlede rundeoppsett-veiviseren~~ ✅
|
||
4. ~~V0-prompt 2: den delte Score/Scorekort/Leaderboard-fane-raden~~ ✅
|
||
5. ~~Integrering + browserverifisering + utrulling~~ ✅
|
||
|
||
Bevisst utenfor omfang, egen fremtidig vurdering om ønskelig: den
|
||
INTERNE "Score"/"Spillere og runde"-fanevekslingen på selve Score-siden
|
||
ble bevisst beholdt (ikke fjernet/erstattet av `?tab=manage`-mekanismen
|
||
alene) som et lavrisiko-valg — kan forenkles videre senere om ønsket,
|
||
ikke noe brukeren har bedt om.
|
||
|
||
---
|
||
|
||
## ADR-041: Konkurranseklasser ("Damer fra 44, Herrer fra 50+")
|
||
|
||
**Kontekst:** Brukeren spurte om det var mulig å sette opp runder i en
|
||
turnering slik at f.eks. damer spiller fra utslag 44 mens herrer spiller
|
||
fra 50+. Undersøkelse viste at DELER allerede virket (hvert
|
||
`match_participant`/`round_participant` har alltid hatt sitt eget
|
||
`tee_id`, uavhengig av de andre, med server-side validering av at
|
||
utslaget har rating for spillerens kjønn, ADR-029) — men det var et
|
||
manuelt valg per spiller hver gang, ingen gjenbrukbar "klasse"-
|
||
gruppering. Ingen "klasse"/kategori-konsept for konkurranse fantes fra
|
||
før noe sted i skjemaet (kun den urelaterte venne-kategoriseringen,
|
||
ADR-036).
|
||
|
||
**Beslutning A — Klasser er fritt navngitte, ikke en fast taksonomi.**
|
||
Organisator oppretter klasser med eget navn (f.eks. "Damer", "Herrer A",
|
||
"Junior") og et VALGFRITT standardutslag — ikke en kjønns- eller
|
||
alders-spesifikk datamodell. Bekreftet eksplisitt med bruker
|
||
(`AskUserQuestion`) framfor en fast kjønn+alder-kombinasjon: dekker
|
||
kjønn/alder/HCP eller annet uten at systemet må forstå forskjellen,
|
||
samme fleksibilitets-prinsipp som `friend_categorization` (ADR-036).
|
||
|
||
**Beslutning B — Delt tabell, ikke duplisert per turneringstype.** Ny
|
||
`tournament_class`-tabell (migrasjon 053) FK'et til `tournament`, ikke
|
||
en utvidelse av selve `tournament`-raden — samme sidetabell-mønster som
|
||
`tournament_sponsor`. Siden BÅDE lagturneringer (ADR-011) og
|
||
individuelle turneringer (ADR-037) peker til samme delte `tournament`-
|
||
tabell (`format_type`-diskriminator), dekker ÉN tabell begge uten
|
||
duplisering. Klassetilhørighet lever på `team_roster.class_id`
|
||
(lagturneringer) og `tournament_participant.class_id` (individuelle
|
||
turneringer) — begge nullable, `ON DELETE SET NULL` (samme prinsipp som
|
||
`tournament_round_bbb_hole`s FK-er, migrasjon 044): sletter man en
|
||
klasse, forsvinner ikke roster-/deltaker-radene, de mister bare
|
||
merkelappen.
|
||
|
||
**Beslutning C — Standardutslag er en frontend-bekvemmelighet, ikke en
|
||
ny backend-mekanisme.** `tee_id` er ALLEREDE required og satt per
|
||
deltaker (ikke per økt/runde) ved både `POST .../matches/{id}/
|
||
participants` og `POST .../rounds/{id}/participants` — klassens
|
||
standardutslag forhåndsvelger bare dette feltet i UI-et (fortsatt fritt
|
||
overstyrbart), backend-kontrakten er uendret. Samme mønster som
|
||
`new-round.tsx`s eksisterende kjønnsfilter/-default for personlige
|
||
runder. Forhåndsvelgingen sjekker eksplisitt at utslaget faktisk finnes
|
||
på DEN aktuelle øktens/rundens bane før det brukes — en klasses
|
||
standardutslag kan tilhøre en annen bane enn den som er i bruk akkurat
|
||
da.
|
||
|
||
**Beslutning D — Egen resultatliste KUN i individuelle turneringer,
|
||
aldri i lagturneringer.** Den mest substansielle avklaringen
|
||
(`AskUserQuestion`, med begrunnelse presentert først): i lagturneringer
|
||
er poeng knyttet til hele KAMPER (lag mot lag, `match.points_side_a/b`),
|
||
ikke enkeltspillere — å dele opp dette per klasse gir ikke naturlig
|
||
mening, og brukeren bekreftet eksplisitt at klasse der KUN skal foreslå
|
||
utslag. Det finnes fra før et smalt, PER-ØKT individuelt leaderboard for
|
||
singles/fourball-slagspill (`fetch_individual_leaderboard`,
|
||
`tournaments.py`) — bevisst IKKE utvidet eller gjenbrukt av denne runden,
|
||
det er en annen, allerede eksisterende funksjon med annet formål
|
||
(sesjonsscoped, format-begrenset). I individuelle turneringer (flatt
|
||
felt, ADR-037) er splitting derimot en naturlig match: `individual_
|
||
leaderboard`-endepunktet fikk `class_id`/`class_name` lagt til i
|
||
SELECT+respons, men selve summerings-/sorteringslogikken er UENDRET —
|
||
frontend grupperer den allerede korrekt sorterte listen visuelt (stabil
|
||
gruppering bevarer riktig rangering per klasse), ingen ny motorlogikk i
|
||
`handicap_engine.py` trengtes (Copenhagen/BBB var allerede scope-
|
||
agnostiske på deltaker-mengde).
|
||
|
||
**Konsekvens:** `053_tournament_classes.sql`. Ny klasse-CRUD i
|
||
`tournaments.py` (delt, siden `tournament`-tabellen er delt). `Roster
|
||
EntryUpdate` (tidligere KUN `is_captain: bool` påkrevd) lagt om til
|
||
`exclude_unset`-PATCH-semantikk. Ny `PATCH .../participants/{id}` i
|
||
`individual_tournaments.py` (fantes ikke før). Frontend: "Klasser"-kort
|
||
i BÅDE `tournament-detail.tsx` og `individual-tournament-detail.tsx`,
|
||
utslag-forhåndsutfylling i `session-blind-draw.tsx`s `AddSlotForm` og
|
||
`individual-tournament-detail.tsx`s `AssignRoundParticipantControl`,
|
||
klasse-gruppert `LeaderboardTab` (individuelle turneringer). Se
|
||
CHANGELOG.md 2026-08-04 for full bygge-/verifiseringsdetalj (inkl. tall-
|
||
for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
|
||
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||
|
||
---
|
||
|
||
## ADR-042: Delt, plattform-omfattende bane-mal-bibliotek (`personal_course`)
|
||
|
||
**Kontekst:** Brukeren oppdaget at turneringsmodulens "opprett manuell
|
||
bane" i praksis var ubrukelig (kun et navnefelt, ingen vei til hull/
|
||
utslag), og ba om at BÅDE turneringsmodulen og single-runde-modulen skal
|
||
tilby "bruk en eksisterende bane (TeeOff eller andres custom-bane) som
|
||
mal" ved manuell baneoppretting. Et oppfølgingsspørsmål presiserte at
|
||
disse custom-banene "bør lagres, og de må være offentlige" — synlige og
|
||
gjenbrukbare av andre.
|
||
|
||
**Beslutning A — Gjenbruk `personal_course`, ikke en ny tabell.**
|
||
`personal_course` (020_personal_rounds.sql, opprinnelig bygget for
|
||
frittstående runder) er, til tross for navnet og til tross for at ingen
|
||
tidligere runde eksplisitt utnyttet det, ALLEREDE en global,
|
||
plattform-omfattende katalog — `GET /personal-courses` søker på tvers av
|
||
ALLE brukeres baner uten eier- eller organisasjonsfiltrering, siden
|
||
tabellen (bevisst, fra migrasjon 020) aldri har vært RLS-/org-scopet.
|
||
Fremfor å bygge en ny `community_course`-tabell (vurdert og forkastet)
|
||
gjenbrukes denne eksisterende, allerede-globale tabellen direkte som det
|
||
delte mal-biblioteket — eneste tilføyelse er én kolonne,
|
||
`forked_from_id` (migrasjon 054, selvreferende FK, `ON DELETE SET NULL`)
|
||
for proveniens/attribusjon. Dette er også den ENESTE farbare veien for
|
||
at single-runde-modulen (som ikke har noe organisasjons-begrep i det
|
||
hele tatt) kan dele samme bibliotek som org-turneringsmodulen.
|
||
|
||
**Beslutning B — "Offentlig" betyr hele plattformen, ikke bare egen
|
||
organisasjon.** Bekreftet eksplisitt med bruker (`AskUserQuestion`) etter
|
||
at jeg flagget spenningen: skal en publisert custom-bane være synlig for
|
||
ALLE organisasjoner + alle frittstående brukere, eller kun innad i én
|
||
organisasjon? Svaret var plattform-omfattende — organisasjonsgrensen
|
||
gjelder domenedata (turneringer, spillere, resultater), ikke dette
|
||
delte, lavsensitive bane-referansebiblioteket. En org-`course`-rad
|
||
(brukt til faktisk spill i en turnering) forblir like fullt org-scopet
|
||
og RLS-beskyttet som før — publisering til `personal_course` skjer som
|
||
en SEPARAT, samtidig innsetting i samme transaksjon (`courses.py`), ikke
|
||
en endring av `course`-tabellens egen skoping.
|
||
|
||
**Beslutning C — Eierskap håndheves i applikasjonslaget, ikke RLS.**
|
||
Siden `personal_course` bevisst aldri har vært org-scopet, finnes det
|
||
ingen `app.current_org`-kontekst å håndheve mot. De nye
|
||
endre-/dupliser-/slette-endepunktene (`rounds.py`) sjekker eksplisitt
|
||
`created_by_user_id == innlogget bruker` i handler-koden — samme
|
||
`plain_connection()`-uten-RLS-mønster som resten av `personal_course`/
|
||
`round`-familien allerede bruker (ADR-033 Beslutning A).
|
||
|
||
**Beslutning D — Rediger forker automatisk hvis du ikke eier raden;
|
||
Dupliser er en egen, eksplisitt handling.** `PATCH /personal-courses/{id}`:
|
||
eier den innloggede brukeren raden, oppdateres den i sted; eier
|
||
brukeren IKKE raden, opprettes automatisk en NY rad (eid av innlogget
|
||
bruker, `forked_from_id` satt til originalen) — originalen selv røres
|
||
aldri. Ett endepunkt dekker begge casene uten at frontend selv må
|
||
forgrene på eierskap. `POST .../duplicate` er en SEPARAT, eksplisitt
|
||
handling tilgjengelig uansett eierskap på kilden — dekker f.eks. å lage
|
||
en variant av DIN EGEN bane uten å miste originalen, noe fork-ved-
|
||
redigering alene ikke gjør (den trigges kun når raden IKKE er din).
|
||
Bekreftet eksplisitt med bruker at begge mekanismene skulle beholdes
|
||
side om side.
|
||
|
||
**Beslutning E — Mal-bruk er en engangs-kopi, ingen vedvarende kobling.**
|
||
Når en org-`course` opprettes med en `personal_course` (eller en
|
||
TeeOff-bane) som utgangspunkt, kopieres dataene inn i den nye raden ved
|
||
opprettelsestidspunktet — ingen fremmednøkkel eller synk-mekanisme
|
||
knytter dem sammen etterpå. Samme filosofi som offisiell TeeOff-import
|
||
allerede etablerte (ADR-019): en mal er et utgangspunkt å redigere fritt
|
||
fra, ikke en levende referanse. Konsekvens: sletting av en
|
||
`personal_course`-mal påvirker ALDRI en org-`course`-rad som tidligere
|
||
ble opprettet fra den.
|
||
|
||
**Konsekvens/bevisst utenfor omfang:** ingen moderasjon/vetting av
|
||
offentlige baner (tillitsbasert, matcher appens øvrige
|
||
lukkede-plattform-antagelser); ingen privat/kun-min-org-synlighet per
|
||
bane (alt publisert via denne veien er plattform-offentlig, ingen
|
||
per-rad-innstilling bygget). Se CHANGELOG.md 2026-08-04 for full
|
||
bygge-/verifiseringsdetalj (34/34 håndregnede API-sjekker, ekte
|
||
nettleser-verifisering på tvers av to brukere/to organisasjoner).
|
||
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||
|
||
---
|
||
|
||
## ADR-043: Order of Merit — sesong-sammenlagt rangering [OOM]
|
||
|
||
**Kontekst:** Brukeren ba om en vurdering av GolfBox sin "Order of
|
||
Merit"-funksjon (opplastet PDF) og om noe var verdt å adoptere. Svaret
|
||
var "ja, delvis": skillet mellom HVA som telles per turnering
|
||
(`result_type`) og HVORDAN det summeres over en sesong
|
||
(`aggregation_mode`) er et genuint godt, gjenbrukbart mønster —
|
||
GolfBox sin lag-kobling (strengmatching på lagnavn på tvers av
|
||
turneringer) ble eksplisitt flagget som noe å IKKE kopiere, siden
|
||
TeeCup allerede har ekte `player_id`-identitet på tvers av hele
|
||
organisasjonen.
|
||
|
||
**Beslutning A — Kun individuelle turneringer kan lenkes.** En OOM
|
||
(`order_of_merit`, migrasjon 055) lenker KUN `tournament.format_type =
|
||
'individual'`-turneringer (ADR-037) — ikke org-lagturneringer (ADR-011),
|
||
av samme grunn som konkurranseklassenes leaderboard-splitting (ADR-041):
|
||
poeng i en lagturnering er knyttet til hele kamper, ikke enkeltspillere.
|
||
|
||
**Beslutning B — To uavhengige akser, fem resultattyper.** `result_type`
|
||
(poeng-etter-plassering/Stableford-sum/brutto/netto/pengeliste) utleder
|
||
HVA som telles fra en lenket turnerings EGEN, allerede beregnede
|
||
leaderboard (gjenbruker `_compute_individual_standings`, faktorisert ut
|
||
av `individual_tournaments.py` sin `individual_leaderboard` 2026-08-04 —
|
||
ingen duplisering av posisjon-/til-par-beregningen).
|
||
`aggregation_mode` (sum/snitt/eclectic) avgjør HVORDAN dette summeres
|
||
over flere turneringer, uavhengig av `result_type`. Poeng/pengeliste
|
||
trenger en per-lenke konfigurasjon (`order_of_merit_link.points_table`/
|
||
`money_payout_table`, JSONB) siden ulike turneringer i samme OOM kan gi
|
||
ulik poengverdi for samme plassering.
|
||
|
||
**Beslutning C — "Lag" i en OOM er et sesong-par satt opp DIREKTE i
|
||
OOM-en, ikke hentet fra noen turnering.** Avklart eksplisitt med bruker
|
||
etter at jeg flagget en strukturell floke (individuelle turneringer har
|
||
per definisjon ingen lag, ADR-037): `order_of_merit_team`/
|
||
`_team_member` (migrasjon 055) er en REN gruppering av spillere oppå
|
||
deres allerede beregnede individuelle OOM-resultater — ingen
|
||
strengmatching, ingen ny turnering-struktur. Bevisst IKKE bygget i denne
|
||
runden (se FEATURE_BACKLOG.md).
|
||
|
||
**Beslutning D — `kind` og `result_type` låses etter opprettelse.**
|
||
Matcher GolfBox sin egen tilsvarende låsing, strukket til også å dekke
|
||
`result_type` her: å endre den i etterkant ville gjort eksisterende
|
||
lenkers poeng-/pengetabell-konfig meningsløs uten en egen
|
||
migreringssti. `PATCH`-endepunktet eksponerer bevisst ikke disse to
|
||
feltene i det hele tatt (ikke bare app-lags-validering) — opprett en ny
|
||
OOM i stedet.
|
||
|
||
**Beslutning E — "Regn ut ved lesing", ingen cache-tabell.** Samme
|
||
filosofi som Nassau/High-low-high/`individual_leaderboard` selv —
|
||
OOM-leaderboardet beregnes helt på nytt ved hvert `GET`-kall (henter
|
||
alle lenkede turneringers standings, utleder bidrag per spiller, filtrerer
|
||
på aldersgrense/minimum-resultater, aggregerer med
|
||
`order_of_merit_aggregate`, rangerer). Ingen ytelsesbekymring i praksis
|
||
gitt forventet skala (en sesong har typisk noen få til noen titalls
|
||
lenkede turneringer).
|
||
|
||
**Bevisst utenfor omfang v1 (se FEATURE_BACKLOG.md for full liste):**
|
||
Eclectic-aggregering (per-hull "drømmerunde" på tvers av lenkede
|
||
turneringer — krever egen datainnsamlingslogikk, ikke bare en ny
|
||
aggregeringsmodus); Lag-OOM sin faktiske leaderboard-beregning (CRUD-
|
||
skjema finnes fra migrasjon 055, men `GET .../leaderboard` avviser
|
||
eksplisitt `kind='team'` med en tydelig feilmelding inntil videre).
|
||
|
||
**Motor** (`handicap_engine.py`): `order_of_merit_points_for_position`
|
||
(uavgjort deler SAMME poengverdi ved sin felles plassering, ikke
|
||
gjennomsnitt) og `order_of_merit_aggregate` (sum/snitt, valgfri
|
||
behold-N-beste — "best" er alltid størst-er-bedre, kalleren negerer
|
||
brutto/netto-verdier før kall). 9 nye enhetstester, alle grønne.
|
||
|
||
**Frontend:** to nye sider (`/organizations/[id]/order-of-merit` liste
|
||
+ opprett, `.../[oomId]` detalj: lenkede turneringer → innstillinger →
|
||
resultatliste, i den rekkefølgen). Håndkodet (ikke V0 — ingen
|
||
V0-credits tilgjengelig denne runden), bevisst bygget til samme
|
||
visuelle presisjon som appens V0-eksporter (samme tokens/kort-mønster
|
||
som `org-members.tsx`, samme gull-ledertrøye-mønster som
|
||
`stroke-play-leaderboard.tsx`). Ny inngangslenke fra `org-members.tsx`.
|
||
|
||
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||
isolert scratch-MinIO + engangs API-/frontend-container): 63/63
|
||
håndregnede API-sjekker (fem resultattyper, uavgjort-håndtering,
|
||
behold-N-beste, minimum-resultater, aldersgrense, låst `kind`/
|
||
`result_type`), RLS-isolasjon eksplisitt testet på de fire nye
|
||
tabellene (kryss-org-lesing gir 0 rader, kryss-org-skriving blokkeres av
|
||
`WITH CHECK`), `test_isolation.sql` 12/12 uendret, ekte
|
||
nettleser-gjennomgang (liste, opprettelse, lenke turnering inkl.
|
||
poeng-tabell-skjemaet, innstillinger, gull-ledertrøye, ikke-kvalifisert-
|
||
seksjon). Full detalj i CHANGELOG.md 2026-08-04.
|
||
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||
|
||
---
|
||
|
||
## ADR-044: Kommentarer/bilder på frittstående runder + samlet feed
|
||
|
||
Reist av brukeren 2026-08-06: samme "Banter Board"-mulighet (bilde+tekst)
|
||
som org-turneringer allerede har (ADR-025), men for en enkelt, frittstående
|
||
runde — pluss en sentral feed-side som samler dette på tvers av runder.
|
||
Underveis avdekket research at rundevisibilitet (ADR-036 fase 2 —
|
||
`round.visibility_mode`/`round_visible_category`) allerede var bygget og
|
||
live siden 2026-07-28 (en eldre, aldri oppdatert CHANGELOG-seksjon sa
|
||
fortsatt "ikke bygget") — denne ADR-en gjenbruker den infrastrukturen
|
||
uendret, den handler kun om selve kommentarene og feed-aggregeringen.
|
||
|
||
**Beslutning A — Skriverett: kan-se-kan-poste.** Alle som kan SE en runde
|
||
(eier, lenket medspiller, `public`-synlig, eller riktig-kategorisert
|
||
`friends`-venn — samme `_can_view_round` som ADR-036) kan også POSTE et
|
||
bilde/kommentar til den. Samme "kan se = kan bidra"-modell som org-feeden,
|
||
IKKE begrenset til eier+medspillere. Avklart eksplisitt med bruker
|
||
(AskUserQuestion) — overstyrer et tidligere, snevrere "eier+medspillere"-
|
||
utkast notert i FEATURE_BACKLOG.md 2026-08-06.
|
||
|
||
**Beslutning B — Moderering: forfatter ELLER rundeeier.** Samme
|
||
"forfatter-eller-admin"-modell som org-feedens `delete_feed_message`
|
||
(ADR-025 Beslutning C) — rundens eier er her analogen til org-admin. Ulikt
|
||
lag-chattens strengere "kun forfatter, ingen unntak" (den er ekte privat,
|
||
en runde-kommentarseksjon åpen for tredjeparter trenger en reell
|
||
moderasjonsvei).
|
||
|
||
**Beslutning C — Egen `round_message`-tabell (migrasjon 058), ikke
|
||
gjenbruk av org sin `message`-tabell.** `round` har verken
|
||
`organization_id` eller RLS (ADR-033 Beslutning A) — en delt tabell ville
|
||
krevd enten en kunstig organisasjon rundt hver frittstående runde eller et
|
||
RLS-unntak. Samme feltform (forfatter-snapshot, valgfri tekst, valgfri
|
||
bilde-nøkkel) og samme MinIO+AVIF-pipeline (`app/storage.py`, ADR-018)
|
||
gjenbrukt uendret, egen `prefix="round_messages"`. Autorisasjon håndheves
|
||
utelukkende i app-laget (`app/routers/round_messages.py`,
|
||
`plain_connection()`), ikke database-policy — samme mønster som
|
||
`round`/`round_participant`.
|
||
|
||
**Beslutning D — Sanntid: ingen ny WebSocket-kanal.** Gjenbruker den
|
||
eksisterende `broadcast_round_update`/`/ws/rounds/{id}/live`-kringkastingen
|
||
(allerede trigget ved hver hull-endring) fremfor et nytt, payload-bærende
|
||
kanal-mønster (slik org-feeden bruker). `round-detail.tsx` sin eksisterende
|
||
WS-lytter fikk ett nytt tick-signal (`messagesRefreshTick`) som
|
||
`RoundMessages` reagerer på med en vanlig REST-refetch — ingen egen socket
|
||
for kommentarer. Bekreftet fungerende i ekte nettleser (se
|
||
CHANGELOG.md 2026-08-06) — MERK at dette KUN virker gjennom Caddy sin
|
||
dedikerte `handle /ws/* { reverse_proxy teecup_api:8000 }`-rute
|
||
(`/opt/teeoff/deploy/Caddyfile`), IKKE gjennom Next.js sitt eget
|
||
rewrite-lag (som ikke pålitelig proxyer WebSocket-oppgraderinger i
|
||
standalone-modus) — samme forutsetning som all annen runde-/turnering-
|
||
sanntid i appen.
|
||
|
||
**Beslutning E — Ny, dedikert `/my-feed`-side, IKKE en dashbord-seksjon.**
|
||
Avklart eksplisitt med bruker. Aggregerer på tvers av egne runder (uansett
|
||
`visibility_mode`) + venners synlige runder, modellert på
|
||
`list_friends_on_course` (`GET /friends/on-course`) sin reverserte retning
|
||
(viewer → venners synlige runder), men uten "spiller nå/siste 24t"-
|
||
filteret — en all-time, paginert (keyset, `?before=`) liste av
|
||
`round_message`-rader. Nås fra dashbordets "Venner på banen"-seksjon
|
||
(`SeeAllLink`) og en ny lenke i `/more`.
|
||
|
||
**KRITISK, funnet under full-stack-verifisering: rute-/rewrite-kollisjon,
|
||
samme klasse som `/rounds`→`/my-rounds` og `/friends`→`/my-friends`
|
||
(dokumentert i `frontend/next.config.mjs`).** Frontend-siden ble først lagt
|
||
på `/feed` — nøyaktig samme streng som det flate API-endepunktet `GET
|
||
/feed`. Uten en eksplisitt rewrite-regel vant Next.js sin egen side-rute
|
||
over ethvert forsøk på server-side proxy, så `fetch("/feed")` fra klienten
|
||
fikk appens egen HTML tilbake i stedet for JSON (stille feilet
|
||
JSON-parsing). Rettet ved (1) å flytte frontend-siden til `/my-feed`
|
||
(samme navnemønster som de tre presedensene over) OG (2) legge til en
|
||
manglende, eksplisitt `{ source: "/feed", destination: ... }`-regel i
|
||
`next.config.mjs` (det flate mønsteret, uten `:path*`, siden `/feed` ikke
|
||
har understier) — retting nummer to var nødvendig uavhengig av
|
||
sideflyttingen, siden regelen rett og slett manglet helt fra start.
|
||
|
||
**Scratch-verifisert grundig** (isolert scratch-DB/rolle/MinIO/API-/
|
||
frontend-container, PLUSS en minimal scratch-Caddy satt opp spesifikt for
|
||
å faktisk bevise WS-refetchen — ikke bare anta at den ville virke i
|
||
produksjon): 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen —
|
||
eier poster/sletter egen, medspiller poster, en annen ikke-eier kan IKKE
|
||
slette andres, eier KAN moderere andres, fremmed uten innsyn avvist på
|
||
GET+POST, `friends`-synlighet riktig inkludert/ekskludert per kategori,
|
||
feed-aggregering korrekt per bruker, paginering uten hopp/duplikat, anonym
|
||
`/feed`-tilgang korrekt 401). Ekte bildeopplasting bekreftet konvertert til
|
||
AVIF og lagret riktig. Ekte nettleser: kommentar+bilde postet og vist,
|
||
sletting fungerte, `/my-feed` viste riktig aggregert sett (egne + andres
|
||
offentlige runder), WS-refetch bekreftet fungerende GJENNOM Caddy (en
|
||
kommentar postet i én "økt" dukket opp automatisk i en åpen fane uten
|
||
interaksjon). Ekte typesjekket produksjonsbuild av frontend kjørt og
|
||
bekreftet (fanget rute-kollisjonen over ved kjøretid, ikke ved bygging —
|
||
typesjekk alene fanger ikke denne klassen feil). `test_isolation.sql`
|
||
uendret 12/12. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-/Caddy-
|
||
containere/images, V0-eksport-zip-er) ryddet opp fullstendig etter bruk.
|
||
|
||
**Frontend bygget via V0** (brukerens eksplisitte, stående arbeidsfordeling
|
||
— Claude skriver V0-prompten, bruker limer inn selv, Claude integrerer
|
||
eksporten): to prompter (kommentarseksjon + feed-side), begge levert med
|
||
eksakte data-kontrakter/design-tokens fra `DESIGN_SYSTEM.md` forhåndsutfylt
|
||
i prompten. Begge eksportene (zip 32/33) diffet ordrett mot hverandre og
|
||
mot ingenting-fra-før (nye filer) før integrering — ingen utilsiktet drift.
|
||
To små, bevisste integrasjonsjusteringer utover ren copy-inn: fjernet en
|
||
ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me` (samme
|
||
mønster som `round-detail.tsx`) i stedet for å kreve `currentUserId` som
|
||
ekstern prop, siden alle andre sider i appen er tynne wrappere som selv
|
||
henter egen brukeridentitet.
|
||
|
||
**Bevisst utenfor omfang:** egen payload-bærende WS-kanal for kommentarer,
|
||
redigering av innlegg (kun slett, samme som org-feeden), liker/reaksjoner,
|
||
varsler ved ny kommentar, sanntid på selve `/my-feed`-siden, inline
|
||
slett-knapp på `/my-feed` (kun fra runde-siden).
|
||
|
||
---
|
||
|
||
## ADR-045: Tilskuer-visning (`/watch/[id]`) — tre faner + "Spillere og runde", full paritet med eiersiden
|
||
|
||
Reist av brukeren 2026-08-06 (video-illustrasjon av bugen): en tilskuer som
|
||
klikker seg inn på en venns pågående runde via "Venner på banen" endte på
|
||
`/my-rounds/{id}` (eier-/medspiller-only, `_get_accessible_round_or_404`)
|
||
i stedet for `/watch/{id}` (den faktiske tredjepartsvisningen, ADR-036
|
||
fase 2) — rettet samme dag som en liten, isolert lenke-bug (se CHANGELOG
|
||
2026-08-06, punkt 26). I samme runde ba brukeren om at `/watch/[id]`
|
||
(fram til da ÉN enkelt side, kun kompakt status + leaderboard/matchstatus)
|
||
skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard) og
|
||
vise "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
|
||
flight-gruppering), uten eier-kun-handlingene (Rediger/Fullfør/Slett
|
||
runde, +Medspiller).
|
||
|
||
**Beslutning A — Full formatparitet via delt komponent + `publicMode`-
|
||
bryter, IKKE nye, forenklede tilskuer-komponenter.** `round-scorecard.tsx`
|
||
(`RoundScorecard`) og `round-leaderboard.tsx` (`RoundLeaderboard`) sin
|
||
faktiske rutenett-/tavle-rendring var ALLEREDE ren, skrivefri visning —
|
||
redigering skjer et helt annet sted (Score-fanens `ScoringWizard` i
|
||
`round-detail.tsx`). Det eneste som hindret gjenbruk for tilskuere var at
|
||
alle interne data-hentinger (flere hooks: `useFormatResult`,
|
||
`useFlightTeamStandings`, `useLeaderboardData`, pluss selve
|
||
`RoundScorecard`/`RoundLeaderboard` sine egne fetch-effekter) hardkodet
|
||
`/rounds/*`-prefikset og en autentisert WebSocket. Løsning: en ny,
|
||
valgfri `publicMode`-prop (default `false`, INGEN endring i eiersidens
|
||
oppførsel) som bytter alle interne URL-er til `/public/rounds/*` og
|
||
WS-kanalen til `/ws/public/rounds/{id}/live`. Dette gir ekte full
|
||
formatparitet (alle formater, ikke et utvalg) for null ekstra
|
||
vedlikeholdshold -- samme kode, to datakilder -- fremfor å bygge og
|
||
vedlikeholde en parallell, forenklet visning. Bekreftet eksplisitt med
|
||
bruker (AskUserQuestion) at full detalj var ønsket, ikke en forenklet
|
||
fellesvisning.
|
||
|
||
**Beslutning B — Ingen gjenbruk av `RoundHeader`/`RoundPageShell` for
|
||
selve fane-navigasjonen.** `RoundPageShell` gjør sitt eget autentiserte
|
||
`fetch('/rounds/{id}')`-kall og hardkoder `/my-rounds/{id}`-hrefs.
|
||
`RoundHeader` er i og for seg props-styrt, men "Administrer"-tannhjulet
|
||
render ALLTID og åpner `ManageRoundDialog` (Rediger/Fullfør/Slett-runde)
|
||
uansett om handlere er gitt inn — ingen eksisterende prop slår av selve
|
||
admin-seksjonen. Bygget i stedet en egen, liten `WatchTabs`-komponent
|
||
(`frontend/components/watch-tabs.tsx`) uten noen admin-vei i det hele
|
||
tatt -- ingen risiko for at en eier-handling vises ved et uhell, siden
|
||
koden rett og slett ikke inneholder den.
|
||
|
||
**Beslutning C — Ny, egen "Spillere og runde"-komponent
|
||
(`watch-players.tsx`), IKKE et forsøk på å gjøre `round-detail.tsx` sin
|
||
lokale `PlayerList`-funksjon gjenbrukbar via `canManage=false`.**
|
||
`PlayerList` er ikke eksportert, tett koblet til eier-autentiserte
|
||
handlere, og selv `canManage`-proppen dekker ikke alt konsekvent i dag
|
||
("Fullfør runde"-knappen der er kun gated på `!completed`, ikke
|
||
`isOwnerViewer` -- en eksisterende, urelatert kvalitetsbrist, ikke
|
||
arvet inn her). Den nye komponenten inneholder ingen +Medspiller-/
|
||
Rediger-/Fullfør-/Slett-kode i det hele tatt. Viser deltakerkort
|
||
(navn, "Eier"-merke -- bevisst INGEN "Deg"-merke, siden
|
||
`PublicRoundParticipantOut` ikke eksponerer `user_id` og det uansett
|
||
ikke gir mening i tilskuer-rammen), Utslag/HCP/Tildelte slag (allerede
|
||
offentlig eksponert data), "Så langt"-progresjon (fra
|
||
`/public/rounds/{id}/leaderboard`), og flight-gruppering.
|
||
|
||
**Beslutning D — Nytt offentlig endepunkt `GET /public/rounds/{id}/
|
||
flight-group`.** Speiler den eksisterende autentiserte `/rounds/{id}/
|
||
flight-group`, men med en ny `_viewable_flight_rows`-variant av
|
||
`_accessible_flight_rows` som bruker `_can_view_round` (eier/medspiller/
|
||
`public`/riktig-kategorisert `friends`-venn) per SØSKEN-flight uavhengig
|
||
-- en flight-gruppe kan ha søsken med ulik `visibility_mode`, og en
|
||
tilskuer skal kun se de søsknene de faktisk har innsyn i, akkurat som
|
||
selve anker-runden. Avklart eksplisitt med bruker (AskUserQuestion) at
|
||
flight-gruppering skulle bygges nå, ikke utsettes.
|
||
|
||
**Lukket, relatert gap:** den gamle `watch-round.tsx` sin lokale
|
||
`TWO_SIDED_FORMATS`-liste dekket kun 8 av 17 formater (manglet
|
||
`copenhagen/bbb/flag/shamble/money_ball/scramble_solo/scramble_solo_
|
||
match`, som falt til "Ingen live-visning tilgjengelig ennå"). Løst
|
||
automatisk av Beslutning A (samme `RoundLeaderboard`-komponent som
|
||
eiersiden, som allerede dekker alle formater) -- ikke en egen,
|
||
ekstra oppgave.
|
||
|
||
**Verifisert grundig, isolert scratch-miljø** (DB/rolle/MinIO/API-/
|
||
frontend-container, pluss en minimal scratch-Caddy for ekte WS-testing,
|
||
samme mønster som ADR-044): 6/6 håndregnede sjekker for det nye
|
||
flight-group-endepunktet (anonym ser kun offentlige søsken-flighter,
|
||
en venn med riktig kategori ser i tillegg `friends`-synlige, eieren ser
|
||
alle, en fremmed uten vennskap ser kun offentlige, en privat anker-runde
|
||
avvises helt for anonym). `tsc --noEmit` rent på hele frontend-prosjektet
|
||
etter refaktoren. Ekte nettleser: **regresjon FØRST** -- eiersidens
|
||
`/my-rounds/{id}/scorecard` og `/leaderboard` bekreftet uendret (samme
|
||
tall, samme layout) FØR noe nytt ble testet. Deretter tilskuer-sidene:
|
||
alle tre faner, "Spillere og runde" uten eier-knapper, ETT allerede-
|
||
dekket format (`stroke`) OG ETT tidligere udekket format (`flag`,
|
||
Flaggturnering) begge bekreftet å rendre korrekt gjennom
|
||
`/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte,
|
||
ikke bare i teorien. En privat rundes tilskuer-sider (alle tre) bekreftet
|
||
korrekt avvist for en anonym leser (egen isolert nettleser-kontekst,
|
||
ikke bare et API-kall) med riktig feilmelding og "Tilbake til
|
||
dashbordet"-lenke. Scratch-miljøet ryddet opp fullstendig etter bruk.
|
||
|
||
**Bevisst utenfor omfang:** enhver endring i selve eiersidens
|
||
`PlayerList`/`ManageRoundDialog`-logikk (den nevnte "Fullfør runde"-
|
||
gating-kvaliteten er en egen, urelatert observasjon).
|
||
|
||
---
|
||
|
||
## ADR-046: Emoji-reaksjoner + trådede kommentarer på innlegg
|
||
|
||
Reist av brukeren 2026-08-06, som del av samme melding som "nyeste først,
|
||
over alt": "man skal kunne like (eller bruke andre emojier) og kommentere
|
||
på innlegg." Avklart eksplisitt med bruker (AskUserQuestion, tre
|
||
spørsmål): (1) "innlegg" = BÅDE de eksisterende meldingene (rundekommentar/
|
||
lag-chat-melding/oppslagstavle-post) OG dashbord-feedens oppføringer, (2)
|
||
kommentarer skal være TRÅDET (svar på svar, flere nivåer), (3) reaksjoner
|
||
følger samme omfang som kommentarer.
|
||
|
||
**Nøkkelfunn fra research:** dashbord-feeden (`/my-feed`, ADR-044
|
||
Beslutning E) er IKKE en egen entitetstype ("runde fullført"-kort) — hver
|
||
feed-oppføring ER ganske enkelt én `round_message`-rad, aggregert på tvers
|
||
av runder. Å bygge reaksjoner/kommentarer på `round_message` dekker derfor
|
||
AUTOMATISK både rundens egen kommentarseksjon og feeden — ingen egen
|
||
tredje entitetstype trengtes. De reelle "innleggs"-typene var dermed kun
|
||
to underliggende tabeller: `round_message` (ADR-044, INGEN
|
||
`organization_id`/RLS) og `message` (ADR-025, `organization_id` NOT NULL
|
||
+ RLS, diskriminert på `scope` for lag-chat/oppslagstavle).
|
||
|
||
**Beslutning A — To parallelle tabellpar, IKKE én polymorf tabell.**
|
||
Migrasjon 059: `round_message_reaction`/`round_message_comment` (ingen
|
||
RLS, speiler `round_message` selv) og `message_reaction`/`message_comment`
|
||
(RLS'et via `app_current_org()`, speiler `message`). Samme presedens som
|
||
ADR-044 Beslutning C — en delt tabell på tvers av RLS/ikke-RLS-grensen
|
||
ville krevd enten en kunstig organisasjon eller et RLS-unntak, og
|
||
CLAUDE.md sin invariant ("organization_id på alle domenetabeller,
|
||
håndhevet av RLS") krever egen RLS-håndtering for org-siden uansett.
|
||
|
||
**Beslutning B — Reaksjoner: fast kuratert sett, én per bruker per
|
||
innlegg.** Seks emojier (👍 ❤️ 😂 😮 😢 🙏), validert i app-laget (400 ved
|
||
ukjent emoji) — IKKE et fritt emoji-utvalg. Bytte av emoji ERSTATTER
|
||
(UPSERT via `ON CONFLICT (post_id, user_id) DO UPDATE`), stables ikke —
|
||
matcher "like (eller bruke andre emojier)"-fraseringen (velg ÉN).
|
||
|
||
**Beslutning C — Kommentarer: flat lagring, kun tekst, kaskade-sletting.**
|
||
`parent_comment_id` selv-referanse, lagret FLATT — frontend bygger
|
||
tre-strukturen fra en kronologisk (eldst-først) liste (motsatt av resten
|
||
av appens "nyeste først"-strømmer, bevisst: en samtaletråd leses
|
||
top-til-bunn, ulikt en oppdateringsstrøm). Ingen bilde i selve
|
||
kommentaren (innlegget har allerede sitt eget). Sletting av en kommentar
|
||
med svar under KASKADE-SLETTER hele undertråden (FK `ON DELETE CASCADE`)
|
||
— ikke myk-slett-med-plassholder, samme "kun slett, aldri rediger"-
|
||
filosofi som `round_message`/`message` selv. Frontend viser en eksplisitt
|
||
advarsel ("Sletter du denne, forsvinner også svarene under.") før
|
||
bekreftet sletting av en kommentar med svar.
|
||
|
||
**Beslutning D — Autorisasjon speiler eksisterende regler per
|
||
innleggstype, ingen ny modell.** Hvem kan reagere/kommentere = hvem kan
|
||
POSTE selve innlegget (rundekommentar: `_can_view_round`/"kan se = kan
|
||
bidra"; lag-chat: rostret på laget; oppslagstavle: `_may_post_to_feed`).
|
||
Hvem kan slette en kommentar = samme regel som innleggstypen allerede
|
||
bruker for å slette selve innlegget (rundekommentar: forfatter ELLER
|
||
rundeeier; lag-chat: KUN forfatter, ingen unntak; oppslagstavle: forfatter
|
||
ELLER org-admin via `is_org_admin`). Egen reaksjon fjernes alltid fritt av
|
||
den som satte den.
|
||
|
||
**Beslutning E — Ingen ny WebSocket-payload-kanal.** Samme minimalt-
|
||
fotavtrykk-valg som ADR-044 Beslutning D: klienten refetcher reaksjons-/
|
||
kommentaroppsummeringen kun etter EGEN handling, ingen live-push til andre
|
||
samtidige seere i v1. **Viktig rettelse funnet under nettleserverifisering:**
|
||
de nye kommentar-endepunktene i `round_messages.py` kalte først
|
||
`broadcast_round_update()` (kopiert inn ved en inkonsekvens mot egen
|
||
plan) — dette trigget rundens EKSISTERENDE "noe endret seg"-WS-tick, som
|
||
`RoundMessages` reagerer på med en full refetch av HELE meldingslisten,
|
||
og med det kollapset enhver allerede-utvidet kommentartråd ANDRE STEDER
|
||
på siden ved hver eneste kommentarhandling. Fjernet fra de nye
|
||
kommentar-endepunktene (reaksjons-endepunktene hadde aldri kallet) —
|
||
oppdaget og rettet FØR utrulling, takket være ekte nettleserverifisering
|
||
i steget rett etterpå (ikke bare et API-testskript).
|
||
|
||
**Backend:** `app/routers/round_messages.py` fikk
|
||
`PUT/DELETE /rounds/{id}/messages/{id}/reaction` og
|
||
`GET/POST /rounds/{id}/messages/{id}/comments` +
|
||
`DELETE .../comments/{id}`, pluss `reactions`/`comment_count`-felt lagt
|
||
til på `RoundMessageOut` OG `FeedEntryOut` (grupperte batch-spørringer,
|
||
ingen N+1). `app/routers/messaging.py` fikk samme sett ×2 (lag-chat +
|
||
oppslagstavle), importerer `ALLOWED_REACTION_EMOJIS`/`ReactionSummary`/
|
||
`ReactionIn`/`CommentIn` fra `round_messages.py` for å unngå duplisert
|
||
typedefinisjon (bevisst IKKE en delt SQL-hjelpemodul på tvers av filene,
|
||
siden `plain_connection()` vs. `org_connection()` uansett gjør
|
||
spørringene ulike nok at abstraksjon over filgrensen ville vært kunstig).
|
||
|
||
**Frontend:** ny delt komponent `post-engagement.tsx` — reaksjonsrad
|
||
(kuratert sett, egen valgt fremhevet grønt) + trådet kommentarfelt (lazy-
|
||
lastet ved utvidelse, "Svar"-knapp per kommentar med innrykk per nivå,
|
||
kaskade-advarsel ved sletting), bygget via ÉN Claude-skrevet V0-prompt
|
||
(standard arbeidsfordeling) og integrert hånd-kodet i FIRE eksisterende
|
||
steder: `round-messages.tsx`, `feed.tsx` (`FeedCard`), `team-chat.tsx`,
|
||
`public-tournament.tsx` (`TournamentFeed`). To bevisste
|
||
integrasjonsjusteringer utover ren copy-inn: (1) byttet V0-eksportens
|
||
egendefinerte inline SVG-ikoner til `lucide-react` (`MessageSquare`/
|
||
`Reply`/`Trash2`) for å følge DESIGN_SYSTEM.md sin "ingen annen ikonpakke
|
||
blandet inn"-regel, (2) `feed.tsx` sin `FeedCard` var opprinnelig ÉN stor
|
||
`<Link>` som pakket inn HELE kortet — `PostEngagement` måtte flyttes UT av
|
||
selve lenken (til en søskenposisjon under) for å unngå ugyldig nøstet
|
||
interaktivt innhold og utilsiktet navigering ved klikk på en
|
||
reaksjonsknapp.
|
||
|
||
**Bevisst forenklet, ikke en bug:** `public-tournament.tsx` sin
|
||
`TournamentFeed` setter `canModerate={false}` alltid — den ekte "forfatter
|
||
ELLER org-admin"-regelen håndheves korrekt av BACKEND uansett (bekreftet i
|
||
autorisasjonsmatrisen under), men frontend mangler foreløpig et signal for
|
||
"er denne innloggede brukeren org-admin i akkurat denne organisasjonen" å
|
||
style et ekstra slett-ikon med for andres kommentarer. En org-admin som
|
||
vil moderere andres kommentar kan det (API-et tillater det), men ser ikke
|
||
en slett-knapp for det i UI-et ennå — en ren UI-fullstendighets-luke, ikke
|
||
et sikkerhetshull. Kan lukkes senere ved å tilføye ett felt (f.eks. i
|
||
tournament-info-oppslaget) uten skjemaendring.
|
||
|
||
**Scratch-verifisert grundig** (isolert scratch-DB/rolle fra
|
||
`teecup_db`-mal, egen API-container): full autorisasjonsmatrise for alle
|
||
tre innleggstyper (reager/bytt-reaksjon-som-upsert/fjern-reaksjon,
|
||
kommentér på toppnivå, svar på en kommentar, slett en kommentar med svar
|
||
under og bekreft kaskade, riktig avvisning per type sin faktiske
|
||
post-/slette-regel — lag-chat kun forfatter, oppslagstavle forfatter-
|
||
eller-org-admin via ekte org-medlemskap, rundekommentar forfatter-eller-
|
||
rundeeier). RLS-isolasjon for `message_reaction` bekreftet eksplisitt
|
||
(annen orgs `app.current_org` ser 0 rader). `/feed`-aggregeringen bekreftet
|
||
å vise samme reaksjons-/kommentardata som selve rundesiden (samme
|
||
`round_message.id` gjenbrukt). `test_isolation.sql` 12/12 uendret.
|
||
Ekte typesjekket produksjonsbuild av frontend kjørt og bekreftet (fanget
|
||
INGEN nye feil, ren build). **Ekte nettleserverifisering** (egen scratch-
|
||
Caddy for sesjon/WS): reagert, byttet reaksjon (upsert bekreftet visuelt),
|
||
postet trådet samtale (rot + svar, riktig innrykk), slettet en kommentar
|
||
med svar og bekreftet kaskaden fjernet begge fra UI-et, bekreftet
|
||
`feed.tsx` sin `PostEngagement` IKKE trigger navigering ved reaksjonsklikk
|
||
(Link-nøsting-fiksen virker), bekreftet `team-chat.tsx` og
|
||
`public-tournament.tsx` sin Oppslagstavle begge rendrer komponenten
|
||
korrekt uten konsollfeil. Fant og rettet WS-tick-regresjonen (Beslutning
|
||
E) i dette steget. Scratch-miljøet (DB/rolle/API-/frontend-/Caddy-
|
||
containere/images) ryddet opp fullstendig etter bruk.
|
||
|
||
**Bevisst utenfor omfang:** push-varsler ved ny kommentar/reaksjon,
|
||
sanntids-oppdatering hos ANDRE samtidige seere, fritt emoji-utvalg,
|
||
redigering av kommentarer, myk-slett-med-plassholder.
|
||
|
||
---
|
||
|
||
## ADR-047: Leaderboard — lukk gapet mot en konkurrentapp-referanse, uten å bygge om fra bunnen
|
||
|
||
Brukeren viste en video av GolfGameBook sitt leaderboard (trykk-for-å-
|
||
utvide rad → fullt hull-for-hull-scorekort inline + sosiale handlinger
|
||
per spiller) og ba om noe minst like bra i TeeCup — eksplisitt **IKKE**
|
||
en kopi av selve utseendet.
|
||
|
||
**Nøkkelfunn fra research, som endret omfanget vesentlig fra "bygg dette
|
||
fra bunnen":** det meste av kjernemekanikken referansen viser var
|
||
ALLEREDE bygget og live i `round-leaderboard.tsx`, fra en tidligere runde
|
||
(FEATURE_BACKLOG.md "Leaderboard for runder og turneringer", 2026-07-26)
|
||
som selv var inspirert av samme konkurrentapp — og allerede DA bevisst
|
||
designet med egen form+farge-språk (sirkel/firkant, grønn/oransje) i
|
||
stedet for å kopiere referansens fargebruk. `IndividualBoard` (dekker
|
||
`stroke/stableford/skins/flag/bbb/copenhagen`) hadde allerede utvidbare
|
||
rader (`IndividualRow`) med et fullt hull-for-hull-rutenett
|
||
(`HoleByHole`). Det som faktisk manglet: (1) ingen sosial/kommentar-
|
||
tilgang noe sted i leaderboardet, (2) ingen kompakt oppsummeringslinje
|
||
under rutenettet, (3) `TwoSidedBoard`/`HighLowBoard` (to-sidede formater)
|
||
hadde INGEN utvidelse i det hele tatt — kun en statisk hull-strip, (4)
|
||
`TeamFlightBoard` hadde en flat, ikke-utvidbar råscore-liste.
|
||
|
||
**Avklart med bruker (AskUserQuestion, to spørsmål) FØR bygging:**
|
||
1. **Sosial-kobling:** `post-engagement.tsx` (ADR-046) reagerer/
|
||
kommenterer på ETT spesifikt `round_message`-innlegg, ikke runden som
|
||
helhet — det finnes intet "innlegg per deltaker" i skjemaet (og
|
||
GolfGameBooks egen "Kommentar til spillefeeden"-knapp åpner uansett
|
||
samme delte feed uansett hvilken rad man trykker på). **Svar: delt
|
||
tråd, lenket fra hver rad** — ingen ny per-deltaker-entitet, ingen ny
|
||
migrasjon, ingen backend-endring i det hele tatt denne runden.
|
||
2. **To-sidede formater:** siden disse kun har 2 "sider" (ikke en
|
||
spillerliste), gir "én rad per spiller"-mønsteret ikke mening.
|
||
**Svar: ÉN utvidbar detaljseksjon for hele kampen**, ikke et
|
||
per-rad-mønster.
|
||
|
||
**Bygget, alt i `round-leaderboard.tsx` (+ to eksisterende filer fikk en
|
||
anker-id):**
|
||
- **`HoleByHole`** (IndividualBoard) fikk en kompakt oppsummeringslinje
|
||
(par-sum/totalscore/plassering, egen ordlyd — IKKE referansens) + en
|
||
"💬 Kommentarer"-tekstlenke.
|
||
- **`TeamFlightBoard`** sin flate råscore-liste konvertert til samme
|
||
utvidbare mønster via en ny `TeamMemberRow` (gjenbruker `HoleByHole`
|
||
uendret, uten rangeringskolonnen — et lags medlemmer rangeres ikke seg
|
||
imellom der).
|
||
- **`TwoSidedBoard`/`HighLowBoard`** fikk en ny, delt
|
||
`MatchDetailSection` — ÉN "Vis hull-for-hull med score"-knapp som,
|
||
lazy, monterer den nå eksporterte `MatchScorecardGrid`
|
||
(`round-scorecard.tsx`, samme ADR-045-presedens: eksporter en
|
||
allerede-godkjent, modul-privat komponent uendret, ren
|
||
synlighetsendring) + samme kommentar-lenke.
|
||
- **Type-utvidelse, ikke ny data:** `ApiRoundHeader`/`ApiRoundParticipant`
|
||
i round-leaderboard.tsx fikk flere felt lagt til (`tee_name_snapshot`,
|
||
`guest_name`, `display_name`, `is_owner`, `playing_handicap`) — alle
|
||
allerede returnert av samme `${apiPrefix}/{roundId}`-endepunkt
|
||
(bekreftet mot `RoundOut`/`PublicRoundOut` i `app/routers/rounds.py`),
|
||
bare ikke tidligere typet der. `guest_name` er valgfritt (fraværende i
|
||
`publicMode`, siden `PublicRoundParticipantOut` ikke har feltet) — en
|
||
ny `toGridRound()`-adapter defaulter det til `null` før
|
||
`MatchScorecardGrid` mates, trygt siden komponenten aldri leser feltet.
|
||
- **Anker-scroll:** `round-detail.tsx`/`watch-round.tsx` sin
|
||
kommentarseksjon fikk `id="kommentarer"`, PLUSS en liten
|
||
`useEffect(() => { if (round && hash === "#kommentarer")
|
||
scrollIntoView() }, [round])` i begge — nettleserens EGEN anker-scroll
|
||
(som skjer FØR async data er lastet) rakk ikke frem til et element som
|
||
ennå ikke fantes i DOM-en; funnet og rettet under nettleserverifisering.
|
||
|
||
**Ingen ny V0-runde, ingen backend-endring.** Alt er gjenbruk/eksport av
|
||
allerede-eksisterende, allerede godkjente komponenter/mønstre til nye
|
||
steder — "liten justering av eksisterende komponenter med allerede
|
||
etablerte mønstre"-unntaket fra den stående V0-arbeidsfordelingen.
|
||
|
||
**Bevisst forenklet, ikke en bug:** ingen kommentar-ANTALL-merke på selve
|
||
leaderboard-lenken (unngår et ekstra API-kall for kun et tall på en
|
||
lenke).
|
||
|
||
**Scratch-verifisert** (isolert DB fra `teecup_db`-mal, egen API-/
|
||
frontend-/Caddy-container): `tsc --noEmit` rent, ekte produksjonsbuild
|
||
kjørt og bekreftet ren. Ekte nettleser: **regresjon FØRST** —
|
||
`IndividualBoard`s eksisterende utvidelse bekreftet identisk for
|
||
slagspill-formatet (samme tall, samme rad-innhold) FØR noe nytt ble
|
||
testet. Deretter: ny oppsummeringslinje + kommentar-lenke bekreftet på
|
||
slagspill (inkl. anker-scroll faktisk fungerende, ikke bare landet på
|
||
riktig side), ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
|
||
bekreftet på et fourball-format med ekte 4-spiller-data (riktig
|
||
hull-for-hull-score, riktig løpende matchstatus "1 UP"/"AS"/"Dormie 1"),
|
||
samme fourball-format bekreftet identisk i `publicMode` (anonym
|
||
tilskuer-kontekst, egen isolert nettleser-kontekst — kommentar-lenken
|
||
riktig pekende til `/watch/{id}#kommentarer` i stedet for
|
||
`/my-rounds/...`). Ingen konsollfeil utover forventet 401 på
|
||
`/auth/me` for den anonyme leseren (allerede tolerert i koden).
|
||
|
||
**Ikke direkte nettleser-testet: `TeamFlightBoard` (shamble/money_ball)
|
||
og `HighLowBoard` (high_low_high)** — ingen eksisterende testdata for
|
||
disse formatene i scratch-DB-malen, og å håndkonstruere gyldige
|
||
runde+deltaker+hull-fixturer for dem fra bunnen ble vurdert som for
|
||
tidkrevende/feilutsatt for denne verifiseringsrunden. Vurdert lav risiko
|
||
likevel: `TeamFlightBoard`s nye `TeamMemberRow` gjenbruker `HoleByHole`
|
||
**uendret** (samme funksjon som allerede ble browser-testet via
|
||
`IndividualBoard`), og `HighLowBoard`s nye seksjon bruker **nøyaktig
|
||
samme** `MatchDetailSection`/`toGridRound`/`MatchScorecardGrid`-kobling
|
||
som allerede ble browser-testet via `TwoSidedBoard` — risikoflaten
|
||
(prop-tredding, type-adapter, komponent-montering) er identisk, ikke en
|
||
egen, utestet kodevei. Anbefales likevel spot-sjekket i ekte nettleser
|
||
neste gang en runde med disse formatene finnes eller opprettes.
|
||
|
||
**Merk, funnet under research, IKKE rettet i denne runden:** en
|
||
skrivefeil i eksisterende kode — `useLeaderboardData`s returnerte
|
||
variabel heter `viewerParticipantId` men inneholder faktisk den
|
||
INNLOGGEDE BRUKERENS `user_id` (fra `/auth/me`), ikke en
|
||
deltaker-id. Kun en forvirrende variabelnavngiving, ikke en funksjonell
|
||
feil (verdien er korrekt for det den faktisk brukes til alle steder,
|
||
inkludert det nye `MatchScorecardGrid`-kallet, som nettopp forventer
|
||
bruker-id under navnet `viewerId`) — la stå urørt, utenfor omfanget av
|
||
denne runden.
|
||
|
||
**Bevisst utenfor omfang:** `RoundLeaderboardMini` (topp-3-widgeten,
|
||
skal forbli et raskt overblikk), `ScrambleSoloResultView` (eget format,
|
||
lite utbredt), skudd-nivå-detalj (fairway/putts/GIR) utover det
|
||
`HoleByHole` allerede viser (den fulle scorekort-siden dekker det),
|
||
kommentar-antall-merke på leaderboard-lenken, et separat ett-trykks
|
||
"lik hele runden", "privat melding" (ingen DM-funksjon finnes).
|
||
|
||
---
|
||
|
||
## ADR-048: Slag-for-slag GPS-avstandsmåling
|
||
|
||
Reist av brukeren 2026-08-06: "Måle lenge på slag ... Fra der man er
|
||
ELLER fra et valgt punkt på et satellittfoto, til der man står ved siden
|
||
av ballen. Man skal også kunne si hvilken kølle man slo med. Dette kan
|
||
deles i feeden, eller beholdes i eget grensesnitt." Avklart eksplisitt med
|
||
bruker (AskUserQuestion, tre spørsmål, 2026-08-07): (1) v1 skal dekke
|
||
BÅDE individuelle runder OG lagformater (fourball/scramble/foursome m.fl.,
|
||
der hull henger av `round_side_id`), ikke bare individuell, (2)
|
||
delingsteksten skal være auto-generert MEN redigerbar, pluss et utsnitt
|
||
av satellittfotoet med slaget tegnet inn, (3) måling skal være tilgjengelig
|
||
både som en knapp i scoreførings-veiviseren OG som en alltid-synlig
|
||
merkelapp på hull-kortet (retroaktiv måling).
|
||
|
||
**Beslutning A — Kartleverandør: Mapbox GL JS, ikke Leaflet+Esri.** Esris
|
||
gratis satellittlag (den vanlige gratis Leaflet-kilden) forbyr eksplisitt
|
||
kommersiell bruk uten en separat, betalt ArcGIS-lisens — diskvalifiserende
|
||
gitt TeeCups kommersielle retning. Mapbox: 50 000 gratis kartlastninger/
|
||
mnd, ingen kortbinding for å starte, ~$5/1000 utover, eksplisitt tillatt
|
||
for kommersiell bruk.
|
||
|
||
**Beslutning B — Kostnadskontroll, tre tiltak.** (1) ETT kart-instans per
|
||
måle-økt (montert når "velg punkt på kart" åpnes, aldri remontert per
|
||
tap/pan — Mapbox fakturerer per initialisering, ikke per interaksjon).
|
||
(2) Kartet lastes KUN ved eksplisitt "velg punkt på kart" — velger
|
||
spilleren "min posisjon nå" for BEGGE punkt, initialiseres Mapbox GL JS
|
||
aldri (må verifiseres eksplisitt via nettverksfane under scratch-testing,
|
||
ikke bare visuelt, se verifiseringsseksjonen for ADR-048 i CHANGELOG.md
|
||
når den skrives). (3) Selve avstandsberegningen er ren klient-side
|
||
Haversine (`frontend/lib/geo.ts`), aldri et Mapbox-API-kall.
|
||
|
||
**Beslutning C — Ny `round_shot`-tabell (migrasjon 060), IKKE en
|
||
gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging som
|
||
hull-statistikk-DATAMODELL.** Dette er en separat, valgfri måle-funksjon;
|
||
`round_hole` sine faste felt (`club_off_tee`, `putts` osv.) er uberørt.
|
||
`round_shot` kjenner kun `round_hole_id` — IKKE `round_participant_id`/
|
||
`round_side_id` direkte — og arver dermed eierskap (individuell ELLER
|
||
lagformat) TRANSITIVT via `round_hole` sin eksisterende
|
||
`round_participant_id`/`round_side_id`-XOR (migrasjon 031). Dette dekker
|
||
begge hull-typer fra v1 uten egen XOR-logikk i den nye tabellen, via
|
||
speilede API-endepunkter
|
||
(`/participants/{id}/holes/{n}/shots` og `/sides/{id}/holes/{n}/shots`,
|
||
samme dobbelte mønster som `RoundHoleOut`/`RoundSideHoleOut` allerede
|
||
bruker for selve hull-scoringen). Samme non-RLS `plain_connection()`-
|
||
mønster som resten av det frittstående-runde-subsystemet (ADR-033
|
||
Beslutning A). `shot_number` er eksplisitt og hull-tolerant (ikke `ORDER
|
||
BY captured_at`) — unngår at GPS-tidsstempel-drift stille endrer
|
||
rekkefølgen; sletting midt i sekvensen etterlater et gap, ingen
|
||
omnummerering. Kun `UPDATE (shared_round_message_id)` er grantet (ett
|
||
smalt, eksplisitt unntak fra "slett og opprett på nytt, aldri rediger"-
|
||
prinsippet, se Beslutning E) — ellers ingen UPDATE, samme filosofi som
|
||
`round_message`.
|
||
|
||
**Beslutning D — Koordinater: rene `double precision` lat/lng-par, ikke
|
||
PostGIS.** Første funksjon i prosjektet som lagrer koordinater i det hele
|
||
tatt (60 migrasjoner uten presedens frem til nå). Volumet (maks noen
|
||
titalls rader per runde) og operasjonssettet (kun punkt-til-punkt
|
||
storsirkel-avstand, regnet klient-side) rettferdiggjør ikke en ny, tung
|
||
geo-avhengighet.
|
||
|
||
**Beslutning E — Deling gjenbruker `round_message` (ADR-044) uendret, via
|
||
en lenke.** Et nytt `POST /rounds/{id}/shots/{id}/share`-endepunkt
|
||
genererer satellitt-utsnittet SERVER-SIDE (Mapbox Static Images API, egen
|
||
HEMMELIG `TEECUP_MAPBOX_SECRET_TOKEN` — atskilt fra frontendens
|
||
OFFENTLIGE, URL-restriktere `NEXT_PUBLIC_MAPBOX_TOKEN` som Mapbox GL JS
|
||
bruker i nettleseren), laster bildet opp via eksisterende
|
||
`storage.upload_image()`, og setter INN en vanlig `round_message`-rad —
|
||
ingen ny feed-mekanisme. `shared_round_message_id` er en LENKE (ikke et
|
||
bool-flagg) slik at "delt" alltid reflekterer om meldingen faktisk
|
||
finnes (`ON DELETE SET NULL` hvis meldingen slettes separat i feeden).
|
||
Rekkefølgen er bevisst: slaget opprettes ALLTID først (uten deling);
|
||
deling er et frivillig, påfølgende steg — unngår at en feilet
|
||
slag-opprettelse kan etterlate en foreldreløs feed-melding. Mapbox-kallet
|
||
degraderer grasiøst til ren tekst uten satellittbilde ved feil/manglende
|
||
token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
|
||
delingen.
|
||
|
||
**Tillegg 2026-08-08 — sluttpunktet (ballen) kan nå OGSÅ velges på kart,
|
||
ikke bare GPS.** Brukeren, etter å ha brukt funksjonen i praksis: "Jeg må
|
||
også kunne velge på kartet hvor ballen ligger. Dessuten: Jeg vil gjerne se
|
||
kartet mens jeg går frem til ballen. Slagpunktet må være en del av det jeg
|
||
ser." Dette opphever den opprinnelige "sluttpunkt: alltid GPS, aldri
|
||
kart"-delen av flyt-beskrivelsen over (resten av ADR-048 står uendret) —
|
||
historikken beholdes over, ikke slettet, per CLAUDE.md-regelen om at en
|
||
endret beslutning får et tillegg, ikke en retusjert original.
|
||
|
||
- **end-steget** tilbyr nå samme valg som start-steget: "Jeg er ved ballen
|
||
nå" (GPS, uendret oppførsel) vs. "Vis kart mens jeg går" (nytt,
|
||
`MapPointPicker` med et `referencePoint`). Migrasjon `061` la til
|
||
`round_shot.end_method` (samme `gps`/`map_tap`-CHECK-mønster som
|
||
`start_method`, `DEFAULT 'gps'` siden ALLE slag før dette tillegget
|
||
faktisk ble målt med GPS for sluttpunktet).
|
||
- **`MapPointPicker` generalisert** til å ta et valgfritt `referencePoint`
|
||
(kun brukt på ball-steget): når satt, vises utslagspunktet som en fast,
|
||
ikke-flyttbar oransje markør ("Utslag"-merkelapp), og selve
|
||
punkt-markøren som plasseres ved tap er grønn i stedet for oransje —
|
||
samme fargekonvensjon som allerede fantes i delings-bildet
|
||
(`pin-s-a+ff5a1f` / `pin-s-b+2f7a3f`), nå ført konsekvent gjennom
|
||
UI-et også. Løser "slagpunktet må være en del av det jeg ser" konkret:
|
||
kartet henter brukerens live GPS-posisjon OG kjenner utslagspunktet, og
|
||
kjører `map.fitBounds([nåværende posisjon, utslagspunkt])` slik at begge
|
||
garantert er i bildet med det samme (ikke bare sentrert på det ene) —
|
||
faller tilbake til å sentrere på utslagspunktet alene (zoom 17) hvis GPS
|
||
feiler. Dekker samtidig "jeg vil se kartet mens jeg går fram til
|
||
ballen": brukeren kan la kartet stå åpent (satellittbildet, med
|
||
utslagsmarkøren som fast referansepunkt) mens de fysisk beveger seg, og
|
||
trykke der ballen faktisk ligger når de er fremme — i stedet for å måtte
|
||
vente på et GPS-fix. Fortsatt kun ETT kart-instans (Beslutning B
|
||
uendret): samme "mountes når steget åpnes, aldri på nytt per tap/pan"-
|
||
prinsipp gjelder nå BEGGE punkt-steg, ikke bare start.
|
||
- Scratch-verifisert (2026-08-08): migrasjon + CHECK-constraint
|
||
(ugyldig `end_method` avvist), API-nivå (`end_method` lagres/returneres
|
||
korrekt for begge verdier, default `gps` når feltet utelates), og full
|
||
nettleser-gjennomgang via Chrome DevTools MCP (GPS-fabrikkert
|
||
utslagspunkt → "Vis kart mens jeg går" → referansemarkør + fitBounds
|
||
bekreftet visuelt → tap plasserte grønn ballmarkør → lagret slag hadde
|
||
`start_method=gps, end_method=map_tap` i databasen, verifisert med
|
||
direkte SQL). Se CHANGELOG.md for full detalj.
|
||
|
||
**Tillegg 2026-08-08 (del 2) — ball-steget viser nå ALLTID kartet, med
|
||
løpende (sanntids) posisjon og avstand, og allerede målte slag viser nå
|
||
satellittfoto i listen.** Umiddelbart etter tillegget over, bruker: "Jeg
|
||
får ikke sett slagene jeg allerede har målt... jeg ønsker å kunne se på et
|
||
satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om jeg
|
||
velger 'min posisjon nå', så skal jeg se start og slutt på et
|
||
satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se lengden
|
||
så langt i sanntid mens jeg nærmer meg ballen." Dette endrer Beslutning B
|
||
sitt "kart lastes kun ved eksplisitt 'velg punkt på kart'"-prinsipp
|
||
BEVISST for ball-steget spesifikt (fortsatt uendret for start-steget):
|
||
|
||
- **Ball-steget slo sammen "GPS vs. kart"-valget til ÉN alltid-synlig
|
||
kart-visning.** Den forrige separate GPS-only-idle-skjermen (bare en
|
||
"Jeg er ved ballen nå"-knapp, ingen kart) og det egne `end_map`-steget
|
||
er fjernet -- `MapPointPicker` rendres nå direkte når "end"-steget nås,
|
||
alltid med `referencePoint=startPoint`. `MapPointPicker` viste
|
||
opprinnelig BEGGE bekreftelsesknappene når `referencePoint` var satt:
|
||
"Bekreft ballens posisjon" (trykket punkt, `end_method: "map_tap"`) og
|
||
"Jeg er ved ballen nå" (siste sporede posisjon, `end_method: "gps"`).
|
||
**Korrigert 2026-08-09** (samme dag, etter faktisk bruk): bruker,
|
||
"bekreft ballens posisjon er unødvendig" -- trykk-for-å-plassere-ballen
|
||
fjernet igjen. Ball-steget bekrefter nå UTELUKKENDE via sporet
|
||
GPS-posisjon; kartet der er rent informativt (referansepunkt +
|
||
sanntidsposisjon/-avstand), ikke lenger klikkbart for plassering.
|
||
Backendens `end_method`-kolonne/CHECK aksepterer fortsatt begge verdier
|
||
uendret (historiske `map_tap`-rader fra den korte perioden dette var
|
||
aktivt skal fortsatt leses korrekt) -- kun frontend-UI-et endret, ingen
|
||
migrasjon. Start-steget er UPÅVIRKET: trykk-for-å-plassere fungerer der
|
||
fortsatt akkurat som før.
|
||
- **Løpende posisjonssporing (`watchPosition`), kun på ball-steget.**
|
||
Dette er et bevisst, avgrenset unntak fra "ingen løpende watchPosition"
|
||
-- den opprinnelige begrunnelsen for det forbudet var KART-
|
||
lastnings-kostnad (Mapbox fakturerer initialisering), og `watchPosition`
|
||
i seg selv utløser ALDRI en ny kartinitialisering eller et nytt
|
||
Mapbox-API-kall -- det er ren nettleser-GPS som kun oppdaterer en
|
||
eksisterende markørs posisjon på det ALLEREDE lastede kartet. Samme
|
||
ETT-kart-instans-prinsipp står dermed fortsatt fast; kun selve
|
||
markør-/avstandsdataene oppdateres kontinuerlig. `watchPosition` ryddes
|
||
opp (`clearWatch`) når komponenten avmonteres.
|
||
- **Sanntids-avstand** vises som et stort tall nederst på kartet,
|
||
beregnet med `haversineMeters(referencePoint, livePosition)` (samme
|
||
rene funksjon som allerede fantes i `frontend/lib/geo.ts`, tidligere
|
||
brukt kun for forhåndsvisningen -- `shot-measurement-sheet.tsx` sin
|
||
egen duplikate Haversine-implementasjon fjernet til fordel for denne,
|
||
ren opprydding uten atferdsendring).
|
||
- **Kostnadskonsekvens, eksplisitt notert:** ball-steget laster nå
|
||
Mapbox-kartet for HVERT målt slag, uansett om brukeren til slutt
|
||
bekrefter via GPS eller kart-trykk (siden kartet uansett vises for at
|
||
brukeren skal se referansepunktet + sanntids-avstand mens de går).
|
||
Start-steget er UENDRET (kart lastes fortsatt kun ved eksplisitt "velg
|
||
punkt på kart") -- kun halvparten av et målt slags to punkter utløser nå
|
||
alltid en kartlastning. Vurdert og akseptert: 50 000 gratis
|
||
kartlastninger/mnd (Beslutning A) gir god margin for forventet volum.
|
||
- **Allerede målte slag i listen** (`round-detail.tsx`, `ShotList`) viser
|
||
nå et lite satellitt-thumbnail (160×160, samme offentlige, URL-
|
||
restrikterte token og samme `pin-s-a`/`pin-s-b`-fargekonvensjon som
|
||
forhåndsvisningen) bygget direkte fra `ShotRecord`s allerede-returnerte
|
||
`start_lat`/`start_lng`/`end_lat`/`end_lng` (utvidet fra kun
|
||
`id`/`club`/`distance_meters`/`shared_round_message_id`) -- løser "jeg
|
||
burde jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke
|
||
bare klubbe/avstand som tekst, men faktisk HVOR slaget ble slått. Ingen
|
||
backend-endring nødvendig -- `ShotOut` returnerte allerede alle fire
|
||
koordinatene, kun frontend-typen/rendringen manglet dem.
|
||
|
||
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
|
||
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
||
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
|
||
"eier ELLER lenket medspiller kan føre for hele flighten"-regel som
|
||
`update_hole`/side-PATCH allerede bruker). `_resolve_round_message_author_name`
|
||
flyttet fra `round_messages.py` til `rounds.py` (som allerede var den
|
||
importerte, ikke-importerende parten av de to filene) for å unngå en
|
||
sirkulær import da `share_shot` trengte å gjenbruke den uendret.
|
||
|
||
**Frontend:** `frontend/lib/geo.ts` (ren Haversine-funksjon, ingen
|
||
avhengigheter). Eksisterende kølle-plukker-mønster i `ScoringWizard`
|
||
(round-detail.tsx) trukket ut til en delt `ClubPicker`-komponent (ren
|
||
refaktor, bekreftet typesjekk uendret) slik at den nye måle-flyten kan
|
||
gjenbruke nøyaktig samme pill-knapp-interaksjon. `mapbox-gl` lagt til
|
||
(pakken har egne typedefinisjoner -- den separate `@types/mapbox-gl`-
|
||
pakken er en utdatert stub, bevisst IKKE installert), `NEXT_PUBLIC_MAPBOX_TOKEN` tredd gjennom
|
||
`frontend/Dockerfile` (build-tid, IKKE runtime — `NEXT_PUBLIC_`-variabler
|
||
bakes inn i klient-bundlen ved `next build`, samme fallgruve-mønster som
|
||
`TEECUP_API_ORIGIN` allerede dokumenterer i Dockerfile-kommentarene, men
|
||
speilet KUN i builder-steget her siden bruken er ren klient-side) og
|
||
`docker-compose.yml` sitt `teecup_frontend.build.args`. Selve
|
||
`ShotMeasurementSheet`-komponenten (alle steg + kart-plassholder) går via
|
||
én Claude-skrevet V0-prompt (standard arbeidsfordeling) — ekte Mapbox GL
|
||
JS-integrasjon i kart-steget kobles på hånd-kodet ETTER V0-eksporten, ikke
|
||
av V0 selv (V0-prompten ber eksplisitt om en visuell plassholder, ikke et
|
||
ekte kartbibliotek, for å unngå at V0 genererer en selvstendig
|
||
kart-komponent som remonterer per interaksjon og dermed bryter
|
||
Beslutning B).
|
||
|
||
**Bevisst utenfor omfang:** redigering av et allerede logget slag (slett
|
||
og mål på nytt i stedet), en historisk "vis alle slag på kart etter
|
||
runden"-visualisering.
|
||
|
||
## ADR-049: `/velkommen` — landingsside for innlogget-men-ikke-fullført-profil
|
||
|
||
Reist av brukeren 2026-08-09: "Skjemaet med personlig informasjon vises
|
||
for tidlig for ikke registrerte spillere etter at de logger seg inn...
|
||
Jeg tror det beste er at man kommer til en side med oppfordring til å
|
||
installere som app (som vanlig) og med deaktiverte knapper for runde,
|
||
turneringen og bli med med kode. Trykker man på noen av disse skal man
|
||
få beskjed om at personlig informasjon må fylles ut først, med lenke til
|
||
skjemaet. Under dette bør TeeCup presenteres." Bekreftet: ny, avgrenset
|
||
side (ikke en full erstatning av `/dashboard`, se drøftingen i chatten)
|
||
— dashbordet for FULLFØRT profil er uendret.
|
||
|
||
**Problemet:** en innlogget bruker med ufullstendig profil ble tidligere
|
||
sendt RETT til `/account` sitt påtvungne skjema ("Fullfør profilen din"),
|
||
uten noen kontekst om hva de nettopp logget seg inn på. Brukeren
|
||
observerte dette som en reell feil i praksis (antas: forvirring/frafall),
|
||
ikke bare en teoretisk innvending.
|
||
|
||
**Løsning: ny side `frontend/app/velkommen/page.tsx` +
|
||
`frontend/components/teecup/velkommen.tsx`,** satt inn i redirect-kjeden
|
||
mellom innlogging og `/account`:
|
||
- `frontend/app/page.tsx` og `frontend/app/logg-inn/page.tsx` sine
|
||
server-side redirects, OG `dashboard.tsx` sin klient-side
|
||
`profile_complete`-vakt (`loadMe()`), peker nå til `/velkommen` i
|
||
stedet for `/account` når profilen er ufullstendig. `/velkommen` selv
|
||
redirecter videre til `/dashboard` (komplett profil) eller `/logg-inn`
|
||
(ikke innlogget) — kan altså ikke nås "feil" via direkte URL.
|
||
- Siden viser, i rekkefølge: TeeCup-ordmerke + "Logg ut", en
|
||
personlig hilsen (`me.first_name ?? me.display_name`, samme
|
||
navneformat-regel som dashbordets hilsen, CLAUDE.md), den
|
||
eksisterende `InstallPrompt`-komponenten UENDRET (samme PWA-
|
||
oppfordring som dashbordet allerede bruker), en primær "Fullfør
|
||
profilen din"-CTA, TRE synlige men visuelt låste hurtighandlinger
|
||
("Ny runde"/"Ny turnering"/"Bli med med kode" — hengelås-ikon,
|
||
`aria-disabled`, IKKE HTML `disabled` siden de fortsatt skal være
|
||
klikkbare for å forklare hvorfor), og en presentasjon av TeeCup
|
||
(innhold hentet fra `teecup-beskrivelse.md`, skrevet om til kort
|
||
UI-tekst — ikke limt inn rått som markdown).
|
||
- Trykk på en låst handling viser en delt påminnelse
|
||
(`role="alert"`, skjermleser-varslet automatisk) med lenke videre til
|
||
`/account`, i stedet for å navigere — samme "forklar, ikke bare
|
||
blokkér"-prinsipp brukeren ba om.
|
||
- Bygget med samme "clubhouse"-palett-tokens (`--tee-strong`,
|
||
`--clubhouse-*`) og samme `TeeCupWordmark`/`InstallPrompt`-komponenter
|
||
som `/logg-inn` og det allerede reskinnede dashbordet (2026-08-08,
|
||
se CHANGELOG.md) — ikke funnet opp på nytt, gjenbruker den etablerte
|
||
retningen for akkurat denne delen av appen.
|
||
|
||
**Bevisst avgrenset omfang:** ingen ny server-side håndheving av
|
||
`profile_complete` er lagt til på andre sider (`/my-rounds`,
|
||
`/my-friends` osv. sjekker i dag kun innlogging, ikke profil-status —
|
||
en pre-eksisterende, ikke relatert inkonsistens, ikke rørt her). Kun de
|
||
tre inngangspunktene som faktisk styrte hvor en ufullstendig-profil-
|
||
bruker havnet (root, `/logg-inn`, dashbordets egen klient-vakt) er
|
||
endret.
|
||
|
||
## ADR-050: Sikkerhetsgjennomgang 2026-08-09 — rate limiting, sikkerhetshoder, inputvalidering
|
||
|
||
Brukeren ba om en full sikkerhetsgjennomgang ("sjekk alle felter som kan
|
||
fylles ut, og sjekk alle URL-er... lar siden/appen seg hacke?"). En
|
||
dedikert agent gjennomgikk autentisering, HELE autorisasjonslaget på
|
||
tvers av 16 routere, input-validering, filopplasting, CORS/nettverk og
|
||
hemmeligheter i git-historikken. Konklusjon: autorisasjons-/IDOR-laget er
|
||
uvanlig grundig og konsekvent (ingen bekreftet IDOR i noe skrive-
|
||
endepunkt) — de reelle funnene var manglende rate limiting (HØY),
|
||
manglende sikkerhetshoder (LAV-MIDDELS), inkonsekvent input-validering
|
||
(LAV), og én liten informasjonslekkasje i ett BBB-endepunkt
|
||
(informativt). Bruker: "tett sikkerhetshullene først."
|
||
|
||
**Beslutning A — Rate limiting, i minnet, ikke Redis (ennå).** Ny
|
||
`app/rate_limit.py`: en enkel fast-vindu-teller (`RateLimiter`) brukt
|
||
enten som FastAPI-dependency (IP-basert, via `client_ip()` som stoler på
|
||
`X-Forwarded-For` — trygt KUN fordi appen ikke er nåbar unntatt gjennom
|
||
Caddy, samme tillitsmodell som `should_use_secure_cookies()`) eller kalt
|
||
direkte med en egendefinert nøkkel. Lagt til på fem `app/routers/auth.py`
|
||
-endepunkter: `request-link`/`login-password`/`2fa/email/request`
|
||
(IP-basert, 5-10 forsøk/10 min — ressursen er ikke entydig knyttet til én
|
||
konto), og `2fa/verify`/`2fa/setup/confirm` (nøkkel = pending-/innlogget
|
||
bruker-ID, 8 forsøk/5 min — her ER ressursen én bestemt kontos kode, en
|
||
angriper med en gyldig pending-sesjon kunne ellers omgått IP-basert
|
||
begrensning ved å bytte IP). Trygt i minnet KUN fordi `teecup_api` kjører
|
||
som ÉN uvicorn-prosess (ingen `--workers`-flagg) — samme kjente
|
||
begrensning som andre in-memory-cacher i appen (se punkt 2 i listen
|
||
under). Ved fremtidig skalering til flere workers/containere MÅ dette
|
||
flyttes til Redis.
|
||
|
||
**Beslutning B — Sikkerhetshoder i Caddy, ikke Next.js-middleware.**
|
||
Lagt til i `/opt/teeoff/deploy/Caddyfile` sin `teecup.golf`-blokk (delt
|
||
fil med `teeoff.no`, KUN teecup.golf-blokken endret): `X-Frame-Options:
|
||
DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`,
|
||
`Strict-Transport-Security`, og en `Content-Security-Policy`. CSP-en er
|
||
bevisst IKKE en streng nonce-basert policy — appen bruker inline
|
||
`style`-attributter mange steder (React `style={{...}}`, hele
|
||
"clubhouse"-paletten) og Next.js sin hydrering kan trenge inline script,
|
||
så `'unsafe-inline'` er beholdt for script-src/style-src for å unngå å
|
||
knekke appen blindt. Verdien ligger i det som ER strammet inn:
|
||
`object-src`/`frame-ancestors`/`base-uri` blokkerer hele angrepsklasser,
|
||
og `connect-src`/`img-src` er begrenset til KUN Mapbox (slagmåling,
|
||
ADR-048) — en fremtidig XSS-bug kan ikke enkelt eksfiltrere data til en
|
||
vilkårlig tredjeparts-vert. Bekreftet i ekte nettleser mot produksjon:
|
||
ingen CSP-brudd i konsollen, et ekte Mapbox-kall (200 OK) fungerer
|
||
uendret.
|
||
|
||
**Reell driftsfallgruve funnet under utrulling, verdt å dokumentere:**
|
||
`docker exec teeoff_caddy caddy reload` (OG en direkte admin-API-`/load`
|
||
-PUT) rapporterte begge suksess uten feil, men endringen slo likevel
|
||
ALDRI igjennom — `md5sum` av filen inne i containeren avvek fra filen på
|
||
verten. Rotårsak: Docker sin bind-mount av EN ENKELT FIL (`./deploy/
|
||
Caddyfile:/etc/caddy/Caddyfile:ro`) er bundet til INODE-en filen hadde
|
||
ved containerens oppstart. Et skriveverktøy som lagrer atomisk (skriv til
|
||
midlertidig fil + `rename()`, vanlig og trygt mønster generelt) bytter ut
|
||
inode-en på verten — den kjørende containerens mount fortsetter da å
|
||
referere den GAMLE, nå frikoblede inode-en, usynlig for `reload`/admin-
|
||
API (som begge leser filen på nytt, men containerens FILSYSTEM-syn av
|
||
stien er allerede feil). Løsning: `docker restart teeoff_caddy` (ikke
|
||
bare `reload`) — dette er en STÅENDE fallgruve for enhver fremtidig
|
||
Caddyfile-endring, ikke unikt for denne runden. Kort, ufarlig avbrudd for
|
||
BEGGE sidene (teeoff.no og teecup.golf) ved restart, siden de deler
|
||
samme Caddy-container.
|
||
|
||
**Beslutning C — Inputvalidering, konsistens fremfor nye regler.** Ingen
|
||
nye valideringsprinsipper — kun manglende `max_length`/`ge`/`le` lagt til
|
||
der de manglet, etter EKSAKT samme mønster som allerede fantes andre
|
||
steder i samme fil (f.eks. `PlayerCreate.handicap_index` fikk samme
|
||
`ge=-10, le=54` som `auth.py` sin `ProfileUpdate.handicap_index` allerede
|
||
hadde). Én reell feil unngått underveis: `PersonalCourseTeeRatingIn.par`
|
||
ble først satt til `ge=3, le=6` (feilaktig antatt å være per-hull-par, som
|
||
`PersonalCourseHoleIn.par`) — DB-skjemaet (`020_personal_rounds.sql`,
|
||
ingen CHECK-constraint på den kolonnen) avslørte at det faktisk er
|
||
BANENS TOTALE par brukt i WHS-beregningen, rettet til `ge=54, le=90` før
|
||
utrulling. Endret: `rounds.py` (`PersonalCourseTeeRatingIn`/
|
||
`PersonalCourseTeeIn.name`/`PersonalCourseCreate.name`), `players.py`
|
||
(`PlayerCreate`/`PlayerUpdate`, begge — `handicap_index`/`mobile`/
|
||
`nickname`/`country`/`club`/`club_member_number`), `round_messages.py`
|
||
(`CommentIn.body`, delt/importert av `messaging.py` sine to bruksseder —
|
||
IKKE duplisert), samt tre `Form(default=None)`-felt (`round_messages.py`
|
||
+ to i `messaging.py`) som fikk `max_length=2000` (tekst) eller
|
||
`max_length=20` (invitasjonskode, som er 6 tegn generert, se
|
||
`tournaments.py._generate_join_code`).
|
||
|
||
**Beslutning D — BBB-endepunkt: autorisasjon flyttet FØR formatsjekk.**
|
||
`update_bbb_hole` i `rounds.py` kalte `_get_accessible_round_or_404`
|
||
ETTER å ha lest og validert `play_format`/`holes_planned` — en bruker
|
||
UTEN tilgang til en fremmed runde kunne dermed skille "runden finnes
|
||
ikke" (404) fra "runden finnes, feil format" (400) via feilmeldingen,
|
||
uten å oppnå noen skrivetilgang. Byttet rekkefølge: autorisasjon leses
|
||
FØRST, deretter format/hull-omfang. `round_row` kan ikke lenger være
|
||
`None` etter en vellykket autorisasjonssjekk, så den separate
|
||
null-sjekken er fjernet (var uansett aldri nåbar etter omorganiseringen).
|
||
|
||
**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten): rate
|
||
limiting bekreftet å faktisk utløse 429 ved riktig terskel (10./11. forsøk
|
||
på login-password, 6. forsøk på request-link), gyldig/ugyldig
|
||
input-validering bekreftet begge veier (HCP over 54 avvist, gyldig HCP
|
||
akseptert, for lang mobil avvist), BBB-endepunktet bekreftet å returnere
|
||
403 NOT_AUTHORIZED (ikke lenger 400 format-lekkasje) for en bruker uten
|
||
tilgang, OG fortsatt 200 OK for den faktiske eieren (regresjonssjekk).
|
||
`teecup_db`s ACL bekreftet uendret før/etter, scratch-ressurser ryddet
|
||
opp fullstendig. CSP verifisert i ekte nettleser mot produksjon (se
|
||
Beslutning B).
|
||
|
||
**Bevisst utenfor omfang denne runden** (nevnt av agenten, ikke fulgt
|
||
opp): full linje-for-linje-gjennomgang av `individual_tournaments.py`/
|
||
`tournaments.py`/`order_of_merit.py`/`courses.py` (kun stikkprøvd),
|
||
Web Push-abonnement-kapring-teori (praktisk risiko vurdert svært lav,
|
||
push-endepunkt-URL-er er ugjettbare), noen faktisk penetrasjonstest (kun
|
||
statisk kodegjennomgang).
|
||
|
||
## ADR-051: 13-årsgrense for kontoregistrering + presentasjonstekst-korrigeringer
|
||
|
||
Brukeren, 2026-08-10, etter å ha lest PDF-vedlegget "Barns personvern i
|
||
golfapp" (en Gemini-samtale om GDPR/personvernkrav for mindreårige i en
|
||
GPS+UGC-app): "Vi må gjøre noe med de som er barn. For det første:
|
||
Tillatt aldri de som er under 13 år å registrere seg i appen." Samtidig,
|
||
to relaterte presentasjonskorrigeringer: et annet PDF-vedlegg ("Installere
|
||
PWA fra Chrome på iPhone") viste at installasjonsteksten feilaktig
|
||
hevdet at PWA-installasjon KREVER Safari på iOS -- siden iOS 16.4 gjelder
|
||
ikke det lenger. Og: "Jeg tror ikke vi skal nevne golfklubber i
|
||
beskrivelsen av TeeCup," pluss et ønske om å fremheve at
|
||
turneringsadministrasjon/-presentasjon fungerer like godt på PC.
|
||
|
||
**Beslutning A — 13-årsgrense håndheves i `PATCH /auth/profile`, ikke i
|
||
et eget registreringssteg.** Siden `profile_complete` (fødselsdato er ett
|
||
av de obligatoriske feltene) allerede er den ENESTE veien forbi
|
||
`/velkommen`-sperren (ADR-049) og inn i resten av appen, er dette det
|
||
naturlige, allerede-eksisterende knutepunktet -- en under-13-åring kan
|
||
rett og slett aldri fullføre profilen sin, og kommer dermed aldri forbi
|
||
`/velkommen`s låste hurtighandlinger. Ingen ny tabell/kolonne, ingen nytt
|
||
flagg. Håndhevet SERVER-SIDE i `app/routers/auth.py` sin `update_profile`
|
||
(alder beregnet fra `body.birth_date`, avvist med `400 UNDER_MINIMUM_AGE`
|
||
hvis under 13 -- eksakt dagsgrense, ikke bare årstall), PLUSS
|
||
klientside i BEGGE skjemaer som setter fødselsdato
|
||
(`ProfileOnboarding` og `ProfileSection` i `account-settings.tsx`, delt
|
||
`computeAge()`-hjelpefunksjon) for umiddelbar tilbakemelding før
|
||
serveren i det hele tatt kontaktes.
|
||
|
||
**Bevisst IKKE utvidet til `player`-tabellen** (organisasjonens
|
||
spillerpool, `players.py`/`registration.py`). Dette er en annen
|
||
datamodell: en `player`-rad representerer en ROSTER-oppføring en
|
||
ARRANGØR/klubb legger inn om en person, IKKE nødvendigvis en person med
|
||
egen app-innlogging (`player.user_id` er nullable, akkurat denne
|
||
frikoblingen er selve poenget). Brukerens instruks var "de som er under
|
||
13 år å registrere seg" -- altså SELV opprette en app-konto, ikke at en
|
||
voksen arrangør registrerer et barn som deltaker i en turnering (det
|
||
siste er faktisk PDF-vedleggets EGEN anbefalte, tryggere løsning for
|
||
yngre spillere -- "sub-accounts" ført av en voksen, se PDF-ens punkt 2
|
||
under "Anbefalte løsninger"). Å blokkere `player`-tabellen ville brutt
|
||
akkurat den mekanismen.
|
||
|
||
**Beslutning B — PWA-installasjonstekst skiller nå Safari/Chrome på
|
||
iOS.** `install-prompt.tsx` sin `isIOS()`-sjekk fanger ALLE iOS-
|
||
nettlesere (deler WebKit-motor), men Del-ikonets PLASSERING er ulik
|
||
(Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet) --
|
||
generisk "i Safari"-tekst var dermed direkte feil for en Chrome-bruker,
|
||
ikke bare unøyaktig. Ny `iosBrowser()`-funksjon skiller på `CriOS`
|
||
(Chrome) i user agent-strengen (vanlig "Chrome"-sniffing ville feilaktig
|
||
også truffet Safari, som deler samme motor). Ukjente/andre iOS-
|
||
nettlesere får en nøytral "i nettleseren din"-tekst i stedet for å gjette.
|
||
|
||
**Beslutning C — presentasjonstekst korrigert i to filer.**
|
||
`teecup-beskrivelse.md` OG `velkommen.tsx` sin "Hva er TeeCup?"-seksjon
|
||
(sistnevnte var bevisst skrevet om fra førstnevnte, ikke limt inn rått,
|
||
se ADR-049) -- begge fikk "golfklubber" fjernet fra målgruppe-
|
||
beskrivelsen (både "hva det er i dag" og "hva det skal bli"-seksjonene
|
||
i beskrivelses-dokumentet), og et nytt punkt lagt til om at
|
||
turneringsadministrasjon/-presentasjon fungerer minst like godt på PC
|
||
som på telefon. "Klubbhus-stemning" (personlighet/tone-beskrivelsen,
|
||
navnet på selve designretningen) er UENDRET -- det er ikke en
|
||
målgruppe-påstand, kun en stemningsbeskrivelse.
|
||
|
||
**Scratch-verifisert** (tjueåttende scratch-miljø denne økten): eksakt
|
||
dagsgrense bekreftet med tre testtilfeller mot ekte API -- 10-åring
|
||
avvist (400 UNDER_MINIMUM_AGE), nøyaktig 13 år i dag akseptert (200,
|
||
`profile_complete: true`), én dag under 13 år avvist (400) -- samme
|
||
grensesnitt-endepunkt, kun fødselsdatoen endret mellom kallene. Bekreftet
|
||
i ekte nettleser: klientside-feilmeldingen vises umiddelbart med rød
|
||
kant ved en 2018-fødselsdato, "Fortsett"-knappen forblir deaktivert.
|
||
Presentasjonsteksten på `/velkommen` bekreftet uten "golfklubber" og med
|
||
det nye PC-punktet. `tsc --noEmit`/`py_compile` rene, ingen konsollfeil.
|
||
`teecup_db`s ACL bekreftet uendret, scratch-ressurser ryddet opp
|
||
fullstendig.
|
||
|
||
**Bevisst utenfor omfang denne runden** (drøftet med bruker, avtalt som
|
||
egen fremtidig runde): rapportering/blokkering/moderering av
|
||
brukergenerert innhold (bilder/kommentarer) og "privat som standard for
|
||
mindreårige"-personvern for GPS-/rundedeling -- begge reelle,
|
||
substansielle krav fra PDF-vedlegget for at appen skal godkjennes i App
|
||
Store/Google Play med UGC+GPS, men et eget, avgrenset prosjekt (admin-
|
||
panel, databasefiltrering, 24-timers responstid), ikke noe som hører
|
||
hjemme i samme runde som en enkel aldersgrense.
|
||
|
||
---
|
||
|
||
## ADR-052: Full clubhouse-overtagelse (designrunden) — 2026-08-10
|
||
|
||
Brukeren ba om "designrunden" og valgte, via `AskUserQuestion`, det
|
||
bredeste av tre foreslåtte omfang: **"Full overtagelse, alt på én
|
||
gang"** -- resten av appen (unntatt ny-runde-veiviserens egen `nr-*`-
|
||
palett, se ADR-048) skulle over på "clubhouse"-paletten
|
||
(ADR-047-oppfølgingen, hittil kun 7 filer: innlogging, dashbord,
|
||
bunnnav, velkommen) i én samlet runde, fremfor den tidligere planen om
|
||
gradvis retemaing skjerm for skjerm når de likevel røres.
|
||
|
||
**Beslutning A — CSS-variabel-remap i `globals.css`, IKKE per-fil
|
||
className-omskriving.** Utforsking (Explore-agent) bekreftet to
|
||
avgjørende fakta: (1) `components/ui/*` (button/card/badge/input osv.)
|
||
og samtlige ~90 gjenværende "Forest Green"-filer er RENE
|
||
token-konsumenter -- et grep etter hardkodede `bg-white`/`text-gray-*`/
|
||
`slate-*` utenfor de 7 clubhouse-filene ga null treff. (2) Clubhouse-
|
||
paletten var allerede definert som egne CSS-variabler
|
||
(`--tee-strong`/`--clubhouse-*`) additivt i samme fil. Konsekvens:
|
||
`:root`s shadcn-tokens (`--background`, `--card`, `--primary` osv.)
|
||
ble redefinert til å KOPIERE clubhouse sine verdier direkte, i stedet
|
||
for å endre className i 90 filer. `bg-primary`/`bg-card`/
|
||
`text-muted-foreground` osv. beholder navnene sine over hele appen --
|
||
det som endret seg er hvilken farge de peker på. Ingen av de ~90
|
||
filene ble rørt.
|
||
|
||
`--destructive`, `--brand-orange(-foreground)`, `--info(-foreground)`,
|
||
`--gold(-foreground)`, `--chart-1..6` er BEVISST uendret -- de er
|
||
distinkte semantiske signalfarger (feil/fare, sekundær-CTA, nøytral
|
||
info, personlig rekord, statistikk-skala), ikke del av
|
||
"nøytral/primær"-identiteten paletten styrer. `--brand-orange`
|
||
(opprinnelig `#ff5722`) er dessuten allerede nesten identisk med
|
||
clubhouse sin `--cup` (`#ff5427`) -- samme merkevarefamilie (ADR-009/
|
||
016), ingen synlig dissonans.
|
||
|
||
**Kontrast-korreksjon funnet FØR utrulling, ikke etterpå:** dagens
|
||
`--primary-foreground` var nesten-svart (riktig for den gamle LYSE
|
||
grønne primærfargen). Clubhouse sin `--tee-strong` (`#2f6b1e`) er en
|
||
MØRK, mettet grønn, brukt i eksisterende kode med `text-white`
|
||
(`velkommen.tsx`). `--primary-foreground` ble derfor satt til hvit ved
|
||
remap -- uten denne rettelsen ville teksten på hver primærknapp i hele
|
||
appen blitt ulesbar mørk-på-mørk umiddelbart ved deploy.
|
||
|
||
**Beslutning B — mørk variant designet av V0 på bestilling, ikke
|
||
oppfunnet av Claude.** Clubhouse hadde ingen mørk-modus-variant (kun
|
||
"fast lys-modus" på de 7 opprinnelige sidene). Bruker fikk valget
|
||
(`AskUserQuestion`) mellom "fjern mørk modus, hele appen blir fast
|
||
lys" og "lag et prompt til V0 om å designe en mørk variant" -- valgte
|
||
sistnevnte. Claude-forfattet V0-prompt ga V0 de ti låste lyse
|
||
clubhouse-verdiene som fasit-referanse og ba om et rent fargesvar
|
||
(ingen kode/komponenter) for et mørk-modus-motstykke, med eksplisitt
|
||
WCAG AA-kontrastkrav. V0 svarte (limt inn av bruker som en full
|
||
prosjekt-zip, `globals.css`-diffen inneholdt kun den nye `.dark`-
|
||
blokken) med: `--tee: #a3d165`, `--tee-strong: #3c8226`,
|
||
`--cup: #ff6a3d`, `--cup-strong: #ff8355`, `--clubhouse-bg: #131a0f`,
|
||
`--clubhouse-card: #1d2616`, `--clubhouse-ink: #eef3e6`,
|
||
`--clubhouse-muted: #a7b598`, `--clubhouse-border: #34402a`,
|
||
`--clubhouse-field: #26301d`. Claude verifiserte V0s egne oppgitte
|
||
kontrasttall med en uavhengig WCAG-beregning (ikke bare tillit til V0s
|
||
kommentar) -- alle stemte eksakt: ink/bg 15.7:1, muted/bg 8.2:1,
|
||
muted/card 7.2:1, hvit/tee-strong 4.76:1 (så vidt over AA-grensen på
|
||
4.5:1, verifisert med vilje siden det var det trangeste tallet).
|
||
|
||
**Beslutning C — ingen className-endring trengtes i de 7 opprinnelige
|
||
clubhouse-filene for mørk-modus-støtte.** Disse bruker eksplisitte
|
||
`bg-clubhouse-bg`/`text-tee-strong`-klasser (ikke de generiske
|
||
`bg-background`/`text-primary`-navnene). Opprinnelig plan (se
|
||
plan-fil) antok disse måtte skrives om for å følge mørk modus. Viste
|
||
seg unødvendig: siden Tailwinds `@theme inline` genererer disse
|
||
klassene som ren `var(--tee-strong)`-referanse, og en `.dark`-blokk
|
||
(+ tilsvarende `@media (prefers-color-scheme: dark)`-blokk) for de RÅ
|
||
`--tee`/`--cup`/`--clubhouse-*`-variablene ble lagt til samtidig (av
|
||
symmetri-/fullstendighetshensyn, se Beslutning A), arver disse 7
|
||
filene mørk modus automatisk via vanlig CSS-variabel-cascade -- helt
|
||
uavhengig av om de bruker `bg-clubhouse-bg` eller `bg-background` som
|
||
klassenavn. Ingen kode i disse 7 filene endret.
|
||
|
||
App-en har INGEN manuell mørk/lys-bryter (`app/layout.tsx` har ingen
|
||
`ThemeProvider`/`next-themes`, kun `@media (prefers-color-scheme:
|
||
dark)`) -- mørk modus er og forblir rent OS-styrt.
|
||
|
||
**Liten tilleggsrettelse i samme runde:** `app/layout.tsx`s
|
||
`themeColor`-metadata (nettleser-/statuslinje-tint, brukt av PWA-
|
||
installasjon) pekte fortsatt på de gamle fargene (`#8BC24A`/
|
||
`#1c261d`) -- oppdatert til de nye bakgrunnsfargene (`#f3f6ec`/
|
||
`#131a0f`), samme "rett opportunistisk opp når den likevel røres"-
|
||
prinsipp som tilgjengelighetsregelen i CLAUDE.md.
|
||
|
||
**Scratch-verifisert** (full stack: scratch-DB med alle 61
|
||
migrasjoner, `teecup_app_dr1`-rolle, hyphenert scratch-MinIO, scratch
|
||
API- og frontend-container, ekte seedet data via de faktiske API-
|
||
endepunktene -- personlig bane, runde, fem hullscore, IKKE rå SQL mot
|
||
domenetabellene). `tsc --noEmit` rent, ekte produksjonsbuild av
|
||
frontend rent (samme build som ville kjørt i `docker compose build`).
|
||
Browserverifisert i ekte nettleser (Chrome DevTools MCP,
|
||
`emulate colorScheme`) i BÅDE lys og mørk modus på: `/logg-inn` (alle
|
||
tilstander, uendret utseende), `/velkommen` (låst-profil-siden),
|
||
`/dashboard` (tom + med ekte seedet rundekort), `/my-rounds/[id]`
|
||
(scoreførings-hovedskjermen, appens største fil, 6726 linjer --
|
||
tidligere Forest Green, nå automatisk clubhouse via remap),
|
||
scorekort-tabellen (tett datagrid), `/account` (skjematung side).
|
||
Alle konsistente, god kontrast, ingen konsollfeil utover en harmløs
|
||
PWA-installasjons-infomelding. `/my-rounds/new` (ny-runde-veiviseren)
|
||
bekreftet visuelt UENDRET -- ingen smitte fra remappen inn i
|
||
`nr-*`-paletten. `BottomNav`s bevisste palett-nøytrale "flytende hvit
|
||
flate" (se CHANGELOG punkt 40) forblir hvit i mørk modus også -- dette
|
||
er et eksisterende, bevisst designvalg fra V0, ikke en regresjon fra
|
||
denne runden. `teecup_db`s ACL bekreftet uendret før/etter, alle
|
||
scratch-ressurser (DB, rolle, MinIO-volum, container-images) ryddet
|
||
opp fullstendig.
|
||
|
||
**Bevisst utenfor omfang:** dette er en FARGE-/identitetsovertagelse,
|
||
ikke en layout-/komponent-redesign av de ~90 filene -- de beholder sin
|
||
eksisterende struktur, kun ny fargeidentitet arvet automatisk. Egen,
|
||
fremtidig runde om noen av disse skjermene også trenger et
|
||
layoutløft, ikke besluttet eller påbegynt her.
|
||
|
||
---
|
||
|
||
## ADR-053: Ny-runde-veiviseren retemaet til clubhouse — 2026-08-10
|
||
|
||
Rett etter ADR-052 ba brukeren om at ny-runde-veiviseren (`nr-*`-
|
||
paletten, bevisst holdt utenfor ADR-052 som en tredje, uavhengig V0-
|
||
utforsking) også skulle få "det nye designet".
|
||
|
||
**Samme remap-prinsipp som ADR-052, samme fil.** De 16 `--nr-*`-
|
||
variablene i `globals.css` er den ENESTE kilden komponentene i
|
||
`components/ny-runde/*` leser (via rå `var(--nr-x)`-arbitrary-syntaks,
|
||
ikke `@theme inline`/Tailwind-klassenavn som resten av appen) -- et
|
||
grep bekreftet null hardkodet hex i alle 11 filer. Kun disse 16
|
||
variablenes VERDIER ble endret; ingen av de 11 filene rørt.
|
||
|
||
**Hvilke av de 16 som ble byttet til clubhouse, og hvilke som beholdt
|
||
sin opprinnelige, distinkte hue:** `--nr-bg/-surface/-surface-2/-ink/
|
||
-muted/-border/-accent/-accent-ink` byttet til de tilsvarende
|
||
clubhouse-verdiene (samme kilde som ADR-052, lys OG mørk). `--nr-danger
|
||
(-soft)`/`--nr-ok(-soft)` beholdt sin opprinnelige røde/grønne hue
|
||
UENDRET i lys modus -- samme prinsipp som `--destructive` i ADR-052 (en
|
||
distinkt semantisk signalfarge, ikke del av identiteten paletten
|
||
styrer). Disse fire fikk likevel NYE mørke varianter (`--nr-danger:
|
||
#ca655d`, `--nr-danger-soft: #382516`, `--nr-ok: #6da985`, `--nr-ok-
|
||
soft: #1d371f`) siden `.nr` aldri hadde noen mørk-modus-støtte i det
|
||
hele tatt før nå -- de opprinnelige lyse "soft"-bakgrunnene (pastellrosa/
|
||
-grønn ment for en hvit flate) ville sett ødelagt ut mot en mørk
|
||
overflate, så disse ble tilpasset mørk bakgrunn med samme
|
||
alpha-blande-teknikk som under.
|
||
|
||
**Fire variabler uten direkte clubhouse-kilde, avledet (ikke
|
||
oppfunnet fritt):** `--nr-faint`, `--nr-border-strong`, `--nr-accent-
|
||
soft`, `--nr-accent-ring` fantes ikke i clubhouse sitt sett på ti. Disse
|
||
ble alfa-blandet fra clubhouse sine allerede godkjente farger (f.eks.
|
||
`--nr-accent-soft` = clubhouse-bg blandet 14% med `tee`, samme visuelle
|
||
rolle som `bg-tee/10`-mønsteret allerede brukt andre steder i
|
||
clubhouse-filene). `--nr-faint` sin kontrast ble eksplisitt kalibrert
|
||
til å ALDRI havne under originalens egen kontrast (2.61:1 lys / mot
|
||
`--nr-bg`) -- endte på 3.28:1 lys / 4.64:1 mørk, komfortabelt over
|
||
originalen, ikke bare en tilfeldig fargeblanding.
|
||
|
||
**Scratch-verifisert** (eget scratch-miljø, samme fulle oppsett som
|
||
ADR-052 -- ny scratch-DB/rolle/MinIO/API/frontend-container).
|
||
`tsc --noEmit` rent. Browserverifisert i ekte nettleser i BÅDE lys og
|
||
mørk modus: bane-valg-steget, "Egen bane"-søket (øver `--nr-faint` i en
|
||
tom-tilstand-boks), og det tette hull-for-hull par/stroke-indeks-
|
||
skjemaet (18-raders tabell med dropdowns -- samme datatett-grid-test
|
||
som scorekortet i ADR-052). Alle konsistente, god kontrast. Ingen nye
|
||
konsollfeil (de tre "issue"-varslene som vises der -- manglende
|
||
`autocomplete`/`id`-attributter -- er uendret fra FØR denne runden,
|
||
altså forhåndseksisterende i selve V0-eksportens markup, ikke noe denne
|
||
CSS-only-endringen rørte eller kan ha forårsaket). Scratch-ressurser
|
||
ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
|
||
|
||
**Bevisst utenfor omfang:** samme som ADR-052 -- ren fargeovertagelse,
|
||
ingen layout-/strukturendring i veiviserens 11 filer.
|
||
|
||
---
|
||
|
||
## ADR-054: Kartretning ved slagmåling — bearing fra eget forrige slag, ikke green-koordinater — 2026-08-10
|
||
|
||
Brukeren spurte: ved lengdemåling av slag, kan kartet vises slik at "man
|
||
går oppover" (opp på skjermen = fremover på hullet) UTEN å registrere
|
||
green-koordinater (som verken TeeCup eller teeoff har noe sted, bekreftet
|
||
tidligere denne sesjonen)? Svaret var ja, og brukeren ba om at det bygges.
|
||
|
||
**Beslutning A — bearing regnet fra spillerens EGET forrige slag på
|
||
hullet, ikke fra en lagret hull-linje.** `round_shot` (ADR-048) lagrer
|
||
allerede start-/sluttkoordinater per slag, sortert på `shot_number`.
|
||
Ny `bearingDegrees(a, b)` i `frontend/lib/geo.ts` (standard forward-
|
||
azimuth-formel, ren funksjon, ingen Mapbox-avhengighet -- samme mønster
|
||
som `haversineMeters`) regner ut retningsvinkelen til FORRIGE slag på
|
||
hullet (siste element i den allerede hentede `shots`-listen i
|
||
`round-detail.tsx` sin `ShotMeasurementEntry`, som GET-endepunktet
|
||
allerede returnerer `ORDER BY shot_number`). Denne vinkelen sendes ned
|
||
som `initialBearing`-prop gjennom `ShotMeasurementSheet` til BEGGE
|
||
`MapPointPicker`-instansene (start-steget OG ball-steget), og settes som
|
||
`bearing` i selve Mapbox-konstruktøren.
|
||
|
||
**Beslutning B — statisk, ikke sanntids-rotasjon.** Bearingen settes ÉN
|
||
gang ved kart-initialisering, aldri oppdatert løpende mens brukeren går.
|
||
En kontinuerlig rotasjon etter live GPS-heading (`coords.heading` fra
|
||
`watchPosition`, som allerede kjører på ball-steget for sanntids-avstand)
|
||
ble vurdert og avvist -- GPS-heading er notorisk ustøyende ved lav
|
||
gangfart, og en urolig/hakkete kartrotasjon mens brukeren går ville vært
|
||
verre enn ingen rotasjon. Samme begrunnelse som ADR-048s eksisterende
|
||
"ett kart-instans, aldri re-initialisert"-prinsipp.
|
||
|
||
**Beslutning C -- fallback-rekkefølge for det FØRSTE slaget på et hull**
|
||
(ingen forrige slag å regne bearing fra ennå): (1) ett engangs-forsøk på
|
||
`coords.heading` fra det samme posisjonsoppslaget kartet uansett gjør for
|
||
sentrering (kun populert når enheten beveger seg, ofte `null` for et
|
||
enkeltstående oppslag -- akseptert, ikke en feiltilstand), (2) nord (0)
|
||
som endelig fallback. Ingen ny geolocation-spørring lagt til utover det
|
||
som allerede fantes.
|
||
|
||
Ingen migrasjon, ingen backend-endring -- rent klientside, tre filer
|
||
(`lib/geo.ts`, `components/shot/map-point-picker.tsx`,
|
||
`components/shot/shot-measurement-sheet.tsx`) pluss beregningen i
|
||
`components/round-detail.tsx` sin `ShotMeasurementEntry` (som allerede
|
||
har `shots`-listen fra sin eksisterende `useEffect`-henting).
|
||
|
||
**Verifisert i to lag:** (1) `bearingDegrees()` unit-verifisert
|
||
frittstående (Node, fire kjente himmelretninger fra ett origo-punkt --
|
||
nord/øst/sør/vest ga 0.0/90.0/180.0/270.0 eksakt). (2) Ende-til-ende i
|
||
ekte nettleser (eget scratch-miljø, samme fulle DB/rolle/MinIO/API/
|
||
frontend-oppsett som tidligere runder denne økten): seedet ett slag rett
|
||
ØST (bearing 90°) på hull 1 via de ekte API-endepunktene, midlertidig
|
||
konsollogg i `initMap()` bekreftet kartet fikk `bearing≈89.996`
|
||
(avrundingsdifferanse fra at 0.01°-lengdegrad-delta ikke er eksakt øst
|
||
på en kule -- korrekt) da måle-arket ble åpnet på hull 1. Hull 2 (ingen
|
||
tidligere slag) ga `bearing=0` (nord-fallback), som forventet. Debug-
|
||
loggen fjernet igjen etter verifisering, `tsc --noEmit` rent. Scratch-
|
||
ressurser ryddet opp fullstendig, `teecup_db`s ACL bekreftet uendret.
|
||
|
||
**Bevisst utenfor omfang:** sanntids-rotasjon under gange (se Beslutning
|
||
B), og en egen "nullstill til nord"-knapp i kart-UI-et (ikke bedt om,
|
||
lav verdi når bearingen uansett er statisk og sjelden feil).
|
||
|
||
---
|
||
|
||
## ADR-055: Nytt app-ikon (ball/pokal/tee), full-bleed, korrigert etter reell tegnefeil — 2026-08-10
|
||
|
||
Brukeren: dagens app-ikon var "fremdeles et gammelt utkast" til tross for
|
||
at et tidligere punkt (CHANGELOG, 2026-08-06) hevdet ikonene var "beskåret
|
||
fra den ekte TeeCup-logoen". Visuell inspeksjon av `icons/icon-512.png`,
|
||
`icon-maskable-512.png` og `apple-icon.png` (åpnet direkte i nettleser)
|
||
bekreftet brukerens vurdering: riktig motiv (golfball/pokal/tee), men en
|
||
grov beskjæring med enorm hvit padding rundt en liten sentrert grafikk --
|
||
nesten ulesbart ved favikon-størrelse (32×32), og for lite innhold i
|
||
sentrum til å tåle Androids sirkel-/dråpe-maskering.
|
||
|
||
**Beslutning A -- ny komposisjon via V0, samme motiv.** Claude skrev en
|
||
V0-prompt som IKKE ba om et nytt motiv, kun en ny KOMPOSISJON av det
|
||
eksisterende (bruker la ved den ekte kilde-SVG-en, `TeeCup-logo.svg`,
|
||
i samme V0-melding): full-bleed bakgrunn kant til kant (ingen
|
||
gjennomsiktig/hvit "luft"), motivet skalert til å fylle et sentrert
|
||
"safe zone"-område (~70-75%) som tåler både iOS- og Android-maskering,
|
||
ingen egen avrunding tegnet inn (plattformen masker selv), lesbart ned
|
||
til 32×32. V0 leverte ett master-SVG (1024×1024) med en mørkegrønn
|
||
full-bleed bakgrunn (`#14351c`) og motivet nestet i et sentrert
|
||
undervindu.
|
||
|
||
**Reelt funn under verifisering, IKKE en V0-feiltolkning.** Første
|
||
V0-svar hadde to synlige "hakk" skåret inn i pokal-kroppen ved
|
||
skulder-/hank-overgangen. Claude sammenlignet først mot feil kildefil
|
||
(en annen, rasterbasert `TeeCup-logo.svg` funnet i `Temp-uploads/` fra
|
||
tidligere i sesjonen) og konkluderte feilaktig at V0 måtte ha "tegnet på
|
||
nytt etter øyemål". Bruker delte deretter den FAKTISKE kilde-SVG-en
|
||
(`TeeCup-logo-kun.svg`, rene Inkscape-vektorbaner) direkte i chatten.
|
||
Rendret stort på hvit bakgrunn viste denne at hakkene faktisk stammer fra
|
||
en ekte, pre-eksisterende unøyaktighet i selve kildekunsten: to
|
||
overlappende oransje former (`path20`, fyll `#fd5524`, og `path21`, fyll
|
||
`#fe5d2c`) dekker ikke hverandre helt i to punkter. Usynlig på hvit
|
||
bakgrunn (de to oransjetonene ligner hverandre), men synlig som et hakk
|
||
på en sterkt kontrasterende mørk bakgrunn, siden bakgrunnsfargen lyser
|
||
gjennom gapet. V0s opprinnelige "tatt verbatim, ikke tegnet på nytt"
|
||
-påstand var altså korrekt -- Claudes første mistanke var feil.
|
||
|
||
**Beslutning B -- fikset ved å utvide, ikke omtegne.** Claude ga V0 en
|
||
presis, kildehenvisende rettemelding (identifiserte de to konkrete
|
||
path-fyllfargene og hvorfor gapet oppstår). V0s fiks: la til en synlig
|
||
`stroke` i samme farge som fyllet (`stroke="#fd5524" stroke-width="0.6"`)
|
||
på `path20`, som tetter gapet ved å utvide den underliggende formens
|
||
synlige kant, i stedet for å redigere selve bezier-kurvene. To små
|
||
håndtak-innvendige "glans"-former (opprinnelig nær-hvite, ville vist som
|
||
lyse flekker i håndtak-hullene mot mørk bakgrunn) fikk samtidig fyll
|
||
byttet til bakgrunnsfargen (`#14351c`) -- korrekt, siden de fungerer som
|
||
utsparinger, ikke skyggelegging, i denne fargekonteksten.
|
||
|
||
**Claude verifiserte fiksen visuelt** (stor rendring, samme
|
||
sammenligningsteknikk som avdekket problemet) før integrering --
|
||
hakkene bekreftet borte, ingen nye artefakter.
|
||
|
||
**Integrering:** ett 1024×1024 master-SVG er eneste kilde. Claude
|
||
rasterte selv alle nødvendige størrelser via ekte nettleser-rendring
|
||
(Chrome DevTools MCP, `devicePixelRatio=1` for eksakte piksler, bekreftet
|
||
med `file`-kommandoen etterpå) -- IKKE V0-genererte PNG-er, for å unngå
|
||
enhver eksport-unøyaktighet: `icons/icon-192.png`, `icons/icon-512.png`,
|
||
`icons/icon-maskable-512.png` (samme kilde som icon-512 -- V0s
|
||
komposisjon var allerede safe-zone-riktig, ingen egen beskjæring
|
||
trengtes), `apple-icon.png` (180×180), `icon-light-32x32.png` og
|
||
`icon-dark-32x32.png` (samme bilde for begge -- det nye ikonet har sin
|
||
egen faste mørkegrønne bakgrunn og trenger ikke lenger to
|
||
tema-varianter, ulikt en eventuell fremtidig gjennomsiktig/hvit
|
||
favikon-variant). `public/icon.svg` (SVG-favikon-fallback) erstattet med
|
||
master-SVG-et selv.
|
||
|
||
`app/manifest.ts` sin `theme_color`/`background_color` sto fortsatt på
|
||
de gamle Forest Green-fargene (`#8BC24A`/`#ffffff`) -- oppdatert til
|
||
clubhouse (ADR-052): `theme_color: "#2f6b1e"` (primær merkevarefarge,
|
||
Android UI-tinting), `background_color: "#f3f6ec"` (lys bakgrunn,
|
||
PWA-splashskjerm).
|
||
|
||
**Scratch-verifisert:** `tsc --noEmit` rent. Egen frontend-only
|
||
scratch-container (ingen DB/API-endring i denne runden) -- ekte
|
||
produksjonsbuild, `GET /manifest.webmanifest` bekreftet nye farger,
|
||
`icon.svg`/`icons/icon-192.png`/`icon-light-32x32.png` bekreftet 200 OG
|
||
visuelt korrekte i ekte nettleser. Scratch-ressurser ryddet opp
|
||
fullstendig.
|
||
|
||
**Bevisst utenfor omfang:** `teecup-wordmark.svg` (det fulle
|
||
ikon+tekst-ordmerket, brukt andre steder) er IKKE endret -- kun de rene
|
||
app-ikon-filene.
|
||
|
||
---
|
||
|
||
## ADR-056: Reell WHS-svakhet + reell rundevisnings-bug — funnet via ekstern kodegjennomgang, 2026-08-10
|
||
|
||
Brukeren delte en ekstern vurdering av appen (parafrasert): to "harde
|
||
kjerner" -- WHS-beregningen og live-scoring/samtidighet -- er der en
|
||
vibe-kodet app typisk svikter, og fortjener ekte enhetstester mot kjente
|
||
fasitcaser, ikke selvbekreftende tester. To parallelle Explore-agenter
|
||
gransket hver sin kjerne. WHS-agentens funn ga direkte opphav til
|
||
Beslutning A under. Samtidighets-agentens funn (last-write-wins er et
|
||
BEVISST, allerede dokumentert valg i ADR-028 -- ikke en overraskelse)
|
||
ga IKKE grunnlag for umiddelbar koding, kun en åpen designbeslutning
|
||
brukeren må ta først (se "Åpent, ikke besluttet" nederst).
|
||
|
||
**Beslutning A -- WHS Rule 3.1b sitt >54/4+-slag-unntak implementert og
|
||
fasit-testet.** Agentens revisjon fant at `handicap_engine.py` sin NDB-
|
||
cap (`max_hole_score_for_handicap`) manglet et reelt, publisert WHS-
|
||
unntak: ved banehandicap over 54 OG 4 eller flere mottatte slag på ett
|
||
hull, er maks hullscore par+5 (IKKE den vanlige par+2+mottatte slag).
|
||
Claude verifiserte dette FØR koding direkte mot kildePDF-en (`WHS_Rules_
|
||
of_Handicapping_2024.pdf`, side 37, `pdftotext`-søk, ikke agentens
|
||
gjenfortelling): "Where a Course Handicap is calculated at more than 54
|
||
and a player receives 4 or more strokes on a hole, the maximum hole
|
||
score is par + 5 for handicap purposes."
|
||
|
||
Lagt til som et nytt, valgfritt `course_handicap`-parameter (standard
|
||
`None`) på `max_hole_score_for_handicap` og `adjusted_gross_score` --
|
||
bakoverkompatibelt (uendret oppførsel for ethvert kall som ikke sender
|
||
det). To produksjonskallsteder i `rounds.py` (`update_hole` sin
|
||
"plukket opp"-gren, og rundefullførings-differensial-beregningen)
|
||
oppdatert til å sende med den allerede tilgjengelige
|
||
`course_handicap_snapshot`.
|
||
|
||
Fire nye, presist begrunnede tester lagt til i `test_handicap_engine.py`
|
||
(grensetilfellene eksplisitt: eksakt 54 -- IKKE "mer enn 54", eksakt 3
|
||
slag -- IKKE "4 eller flere", samt end-til-ende via
|
||
`adjusted_gross_score`). 117/117 tester grønne (opp fra 115). Scratch-
|
||
verifisert også via ekte API-kall (deltaker med banehandicap 63, hull
|
||
med 4 mottatte slag, "plukket opp"-endepunktet returnerte 9 som
|
||
forventet, ikke det gamle 10).
|
||
|
||
**Beslutning B -- `round.tee_name_snapshot` synkes nå med eierens eget
|
||
utslagsbytte.** Egen, urelatert bug rapportert av bruker samme økt (se
|
||
skjermdump): rundens toppheader (delt på tvers av Score/Scorekort/
|
||
Leaderboard) viste et gammelt utslag ("50") selv etter at brukeren
|
||
hadde endret utslaget til "55" -- bekreftet i `round_participant` (begge
|
||
rader riktig "55") vs. `round.tee_name_snapshot` (fortsatt "50"). Root
|
||
cause: `PATCH /rounds/{round_id}/participants/{participant_id}`
|
||
(`update_participant`, brukt ved enkelt-deltaker-utslagsbytte) skrev
|
||
KUN til `round_participant`, aldri til `round` -- ulikt den separate
|
||
hele-bane-bytte-grenen i `update_round`, som alltid har oppdatert begge
|
||
sammen. De to skrive-stiene var ikke holdt i synk.
|
||
|
||
Fiks: når denne PATCH-en endrer `tee_name` for EIERENS EGEN
|
||
deltaker-rad (`current["user_id"] == current["round_owner_user_id"]`,
|
||
ikke en medspillers/gjests -- ulike deltakere kan bevisst spille ulike
|
||
utslag, se `ParticipantCreate.tee_name`), oppdateres
|
||
`round.tee_name_snapshot` til samme verdi i samme kall -- speiler
|
||
nøyaktig hvordan `create_round`/`_create_participant` allerede holder
|
||
disse to i synk ved selve opprettelsen.
|
||
|
||
Scratch-verifisert: eierens eget utslagsbytte oppdaterte
|
||
`round.tee_name_snapshot` korrekt; et påfølgende utslagsbytte for en
|
||
GJEST lot `round.tee_name_snapshot` stå urørt (bekrefter at kun
|
||
eierens egen rad trigger synken, som tiltenkt).
|
||
|
||
**Ikke rettet i denne runden (venter på egen brukerbekreftelse, se
|
||
CHANGELOG):** den konkrete, allerede-live runden brukeren viste
|
||
skjermdump av (`round.tee_name_snapshot` fortsatt "50" i ekte
|
||
`teecup_db` for akkurat den runden) -- kodefiksen over hindrer nye
|
||
tilfeller, men retter ikke historisk feil data. Krever en engangs
|
||
`UPDATE`-setning mot ekte `teecup_db`, vist og bekreftet separat per
|
||
CLAUDE.md.
|
||
|
||
**Besluttet samme økt:** samtidighets-/konflikt-halvparten av den
|
||
eksterne vurderingen krevde en designbeslutning før noe kunne bygges --
|
||
bruker fikk valget mellom "enkel versjonssjekk + tydelig feilmelding",
|
||
"feltvis i stedet for radvis overskriving", og "ikke nå" via
|
||
`AskUserQuestion`, valgte det anbefalte første alternativet. Bygget
|
||
samme økt, se ADR-057.
|
||
|
||
---
|
||
|
||
## ADR-057: Optimistisk versjonssjekk for samtidig hull-redigering — 2026-08-10
|
||
|
||
Direkte oppfølging av ADR-056: bruker valgte "enkel versjonssjekk +
|
||
tydelig feilmelding" for `update_hole`/`update_side_hole` sin
|
||
last-write-wins-oppførsel (ADR-028, bevisst den gangen, men brukeren
|
||
ønsket det endret nå).
|
||
|
||
**Beslutning A -- én ny `version`-kolonne på `round_hole` (migrasjon
|
||
062), dekker begge eierskapstyper.** `round_hole` er allerede delt
|
||
mellom deltaker- og side-eide hull (XOR-constraint) -- én kolonne holder
|
||
for begge `update_hole`/`update_side_hole`. `DEFAULT 1`, økes med 1 for
|
||
hver skrivning. Klienten sender `expected_version` tilbake (valgfritt --
|
||
`None`/utelatt hopper over sjekken, samme bakoverkompatible mønster som
|
||
resten av appens valgfrie felt).
|
||
|
||
**Beslutning B -- atomisk sjekk i selve UPDATE-en, ikke en separat
|
||
"les så skriv".** `WHERE ... AND ($N::int IS NULL OR version = $N::int)`
|
||
i samme setning som selve skrivningen -- ingen TOCTOU-vindu mellom sjekk
|
||
og skrivning. Et `None`-resultat skilles fra "hullet finnes ikke" via
|
||
enten en allerede-utført eksistenssjekk lenger opp i funksjonen
|
||
(`update_hole`, som uansett må slå opp par/stroke-index før en
|
||
"plukket opp"-beregning) eller en egen fallback-eksistenssjekk
|
||
(`update_side_hole`, som ikke hadde noen forhåndssjekk fra før) -- aldri
|
||
tvetydig hvilken feil som returneres.
|
||
|
||
**Beslutning C -- offline-køen kjeder versjonen fremover innad i én
|
||
flush-runde.** Reelt funn under design (før noe ble bygget, ikke en bug
|
||
funnet i ettertid): uten dette ville en spillers EGNE påfølgende
|
||
offline-redigeringer av samme hull avvist HVERANDRE som falske
|
||
konflikter, siden lokal state (og dermed enqueue-tidspunktets
|
||
`expected_version`) aldri oppdateres mellom to sekvensielle
|
||
avspillinger i samme batch. `flushQueue` (`offline-queue.ts`) holder nå
|
||
et lite `Map<url, siste kjente versjon>` gjennom hele flush-runden og
|
||
overstyrer `expected_version` på påfølgende oppføringer til samme URL
|
||
med den nyeste kjente verdien.
|
||
|
||
**Reelt UX-hull funnet UNDER scratch-verifisering, rettet før
|
||
utrulling.** Første implementasjon gjenbrukte komponentens
|
||
eksisterende `error`-tilstand for konfliktmeldingen. Viste seg (kun ved
|
||
ekte to-enhets-test i nettleser, ikke synlig fra kode-lesing alene) at
|
||
`error` er en FATAL, hele-siden-erstattende tilstand (brukt for f.eks.
|
||
"runden finnes ikke") -- en forbigående hull-konflikt tok dermed over
|
||
HELE skjermen og tvang brukeren til å navigere bort, i stedet for en
|
||
liten varsel. Rettet med en egen, dismissbar `conflictNotice`-tilstand
|
||
(banner øverst i siden, "Skjul"-knapp, resten av UI-et forblir fullt
|
||
brukbart) -- gjenbrukt også for den eksisterende (fra før denne runden)
|
||
kø-synk-feilmeldingen, som hadde nøyaktig samme fullskjerm-problem.
|
||
|
||
**Scratch-verifisert i tre lag, alle i ekte nettleser/API, ingen
|
||
enhetstester på TS-siden (ingen testinfrastruktur for det i
|
||
prosjektet ennå):**
|
||
1. Backend, `update_hole` OG `update_side_hole` hver for seg (ekte
|
||
API-kall): enhet A skriver først (lykkes, versjon 1→2), enhet B med
|
||
samme (nå utdaterte) `expected_version` avvist med 409 og eksakt
|
||
forventet feiltekst, A sin verdi bekreftet fortsatt lagret (ikke B
|
||
sin avviste), B henter ny versjon og lykkes på nytt forsøk. Bekreftet
|
||
at PATCH uten `expected_version` fortsatt fungerer (bakoverkompatibelt).
|
||
`update_side_hole` i tillegg bekreftet at et ikke-eksisterende hull
|
||
fortsatt gir 404, ikke feilaktig 409.
|
||
2. To ISOLERTE nettleser-kontekster (`isolatedContext`, egne
|
||
informasjonskapsel-rom -- ekte to-enheter-simulering, ikke bare to
|
||
faner som deler økt) som samme flight-medlem: enhet B med en allerede
|
||
åpen, utdatert veiviser fikk 409 ved innsending, veiviseren viste
|
||
automatisk den ferske (A sin) verdien i stedet for B sitt avviste
|
||
forsøk, banneret vist og dismissbart, RESTEN AV SIDEN forble
|
||
fullt brukbar (dette avdekket UX-hullet over).
|
||
3. Offline-kø-kjeding: ekte nettverks-emulering (DevTools "Offline"),
|
||
to påfølgende redigeringer av SAMME hull mens frakoblet, tilbake på
|
||
nett -- begge synkroniserte korrekt (endte på siste verdi), INGEN
|
||
falsk konflikt seg imellom, "venter på synk"-indikatoren forsvant helt.
|
||
|
||
`tsc --noEmit` og `py_compile` rene gjennom hele runden. Scratch-
|
||
ressurser ryddet opp fullstendig hver gang, `teecup_db`s ACL bekreftet
|
||
uendret.
|
||
|
||
**Bevisst utenfor omfang:** ORG-TURNERINGENES match-scoring
|
||
(`session-scorecard.tsx`, `/orgs/{id}/matches/{id}/hole-scores`) er et
|
||
HELT separat system (annen tabell, annen router) med trolig samme
|
||
last-write-wins-egenskap -- IKKE undersøkt eller endret denne runden,
|
||
siden verken den opprinnelige eksterne vurderingen eller brukerens
|
||
oppfølging nevnte det spesifikt. Egen, fremtidig vurdering om det
|
||
trengs der også.
|
||
|
||
---
|
||
|
||
## ADR-058: Automatisert backend-testinfrastruktur — RLS, versjonssjekk, aldersgrense — 2026-08-10
|
||
|
||
Direkte oppfølging av investor-statusrapporten (kodebasert revisjon,
|
||
samme dag): "nesten ingen automatisert testdekning utenfor HCP-motoren"
|
||
pekt ut som det klart største enkeltfunnet. Bruker ba om å ta tak i
|
||
akkurat dette, med to avklarte avgrensninger: (1) usikkert om Forgejo
|
||
Actions faktisk har en registrert runner for dette repoet på
|
||
forgejo.jegvil.no — bekreftet KUN at instansen har `has_actions: true`
|
||
for repoet (anonymt API-kall), ikke at en runner faktisk plukker opp
|
||
jobber. CI-workflow-fil er derfor BEVISST utenfor denne runden — egen,
|
||
senere runde, verifisert empirisk ved faktisk push. (2) Frontend-
|
||
testoppsett (Vitest/Playwright) valgt bort denne runden — kun backend.
|
||
|
||
**Beslutning A — ekte integrasjonstester mot en automatisert scratch-
|
||
database, ikke mot mocks.** Samme prinsipp som all manuell scratch-
|
||
verifisering i prosjektet til nå (se CLAUDE.md), men automatisert i
|
||
`scripts/run_backend_tests.sh`: oppretter en midlertidig database +
|
||
en midlertidig rolle i den SAMME `teeoff_db`-Postgres-containeren,
|
||
kjører ALLE 62 migrasjonene i rekkefølge, kjører testene, dropper
|
||
database + rolle igjen. Rører aldri ekte `teecup_db` — bekreftet
|
||
eksplisitt etter hver kjøring (ACL uendret, ingen gjenglemte
|
||
scratch-ressurser).
|
||
|
||
Migrasjon 002 hardkoder BÅDE rollenavnet `teecup_app` (cluster-globalt
|
||
navn, delt med den ekte rollen) OG databasenavnet `teecup_db` i én
|
||
GRANT-setning. Begge patches med `sed` (ordgrense-presist, unngår å
|
||
treffe `teecup_app_password`/`teecup_app_exists`-variabelnavnene i
|
||
samme fil) til scratch-spesifikke navn før migrasjonen kjøres — samme
|
||
sed-patch-mønster som er brukt manuelt i alle tidligere scratch-runder
|
||
i prosjektet, nå skriptet.
|
||
|
||
**Beslutning B — testene kaller de FAKTISKE router-/auth-funksjonene
|
||
direkte (`app.routers.rounds.update_hole`, `app.auth.get_authorized_org`,
|
||
`app.routers.auth.update_profile`), ikke en gjenimplementering av
|
||
logikken i SQL.** FastAPI sin `Depends()`-injeksjon trengs ikke når
|
||
funksjonen kalles direkte i Python — de resolverte verdiene (en
|
||
`CurrentUser`, en allerede-verifisert `organization_id`) sendes rett
|
||
inn som vanlige argumenter. Dette betyr testene kjører gjennom SAMME
|
||
kodesti som produksjon (samme SQL, samme feilhåndtering, samme
|
||
`app_error`) — IKKE en parallell test-bare implementasjon som kunne
|
||
drevet fra virkeligheten uten å bli fanget opp.
|
||
|
||
Testene kjører i en dedikert `Dockerfile.test`-container (pytest +
|
||
pytest-asyncio, IKKE del av prod-imaget) tilkoblet samme
|
||
`teeoff_default`-Docker-nettverk som `teecup_api` selv bruker i
|
||
produksjon — nødvendig fordi Postgres-containeren ikke har noen
|
||
host-publisert port i dette miljøet, kun cluster-intern DNS
|
||
(`teeoff_db:5432`, nøyaktig slik den ekte appen kobler til).
|
||
`lifespan`/MinIO-oppstart (`app.main`) er BEVISST unngått — testene
|
||
importerer routerne direkte i stedet for å boote hele ASGI-appen, så
|
||
ingen ekte MinIO-bucket berøres av denne runden.
|
||
|
||
**Beslutning C — tre dekningsområder, valgt etter risiko, ikke
|
||
fullstendighet.** 17 tester totalt:
|
||
- `tests/test_rls_isolation.py` (4 tester) — automatiserer
|
||
`test_isolation.sql`s manuelle sjekker: cross-org SELECT lekker
|
||
aldri rader (selv uten eksplisitt WHERE i spørringen), cross-org
|
||
UPDATE påvirker 0 rader, INGEN org-kontekst satt gir TOMT resultat
|
||
(ikke "alt" — regresjonsvern for migrasjon 005s NULL-guard-fiks),
|
||
og `get_authorized_org` avviser et faktisk ikke-medlem FØR noen
|
||
org-scopet spørring i det hele tatt kjøres.
|
||
- `tests/test_concurrency_version_check.py` (6 tester) — ADR-057s
|
||
versjonssjekk, nå automatisert: vellykket skrivning inkrementerer
|
||
versjon, en utdatert `expected_version` gir 409 STALE_VERSION,
|
||
`expected_version=None` hopper bevisst over sjekken
|
||
(bakoverkompatibilitet), et ikke-eksisterende hull gir 404 (ikke
|
||
409) — alt dekket for BÅDE `update_hole` (deltaker-eide hull) og
|
||
`update_side_hole` (side-eide hull, egen 404-vs-409-logikk lagt til
|
||
i samme runde som versjonskolonnen).
|
||
- `tests/test_auth_age_gate.py` (5 tester) — 13-årsgrensen på selve
|
||
dagsgrensen (eksakt 13 år i dag tillatt, én dag for tidlig avvist),
|
||
godt under grensen avvist, andre feltoppdateringer uten fødselsdato
|
||
utløser ikke sjekken, og en avvist oppdatering skriver INGENTING
|
||
til databasen (ingen delvis skrivning før feilen kastes).
|
||
|
||
**Verifisert:** alle 17 nye tester + de eksisterende 117 HCP-testene
|
||
grønne. Ekte `teecup_db` sitt rolleoppsett (`teecup_app`:
|
||
`NOSUPERUSER`/`NOBYPASSRLS`) og radantall i sentrale tabeller bekreftet
|
||
uendret etter kjøring. Ingen deploy denne runden — dette er ren
|
||
testinfrastruktur, ingen produksjonskode endret.
|
||
|
||
**Bevisst utenfor omfang, egne fremtidige runder:** frontend-tester, og
|
||
dekning av flere router-funksjoner enn de tre høyest-risiko-områdene
|
||
over (f.eks. øvrige `rounds.py`-endepunkter,
|
||
`individual_tournaments.py`, org-turnering-match-scoring sin
|
||
`scoring.py` — sistnevnte spesielt interessant siden ADR-057
|
||
dokumenterte AT den mangler samtidighetsvern, men det er foreløpig
|
||
udekket av en test som beviser det empirisk). CI-workflow-filen ble
|
||
løst SAMME dag — se ADR-059.
|
||
|
||
---
|
||
|
||
## ADR-059: Selvhostet Forgejo Actions-runner — 2026-08-10
|
||
|
||
Direkte oppfølging av ADR-058: `.forgejo/workflows/backend-tests.yml`
|
||
ble lagt til og pushet som en EMPIRISK test av om
|
||
forgejo.jegvil.no faktisk hadde en aktiv runner for dette repoet
|
||
(instansen svarte `has_actions: true` på et anonymt API-kall, men det
|
||
beviste ikke at noe faktisk plukker opp jobber). Resultat: jobben la
|
||
seg i kø som "Waiting" og ble stående uendret i over et minutt — ingen
|
||
runner fantes. Bruker ba om at det rettes.
|
||
|
||
**Beslutning A — selvhostet `act_runner` PÅ DENNE serveren, ikke en
|
||
ekstern/skyhostet runner.** `run_backend_tests.sh` er avhengig av
|
||
`docker exec teeoff_db` og `teeoff_default`-nettverket, som kun
|
||
eksisterer på denne verten. En runner et annet sted ville uansett
|
||
ikke kunnet kjøre testene slik de er skrevet i dag.
|
||
|
||
**Beslutning B — Docker-utenfor-Docker (DooD) via
|
||
`docker_host: automount` i runner-konfigen, ikke Docker-i-Docker.**
|
||
Jobb-containeren får HOST-ens `docker.sock` bind-montert inn (samme
|
||
mønster som runneren selv bruker for å starte jobb-containere i
|
||
utgangspunktet), slik at `scripts/run_backend_tests.sh` sine egne
|
||
`docker build`/`docker run`/`docker exec`-kall oppretter ekte
|
||
søsken-containere på host-nivå — ikke en nøstet, isolert
|
||
docker-daemon som ikke ville sett `teeoff_db` i det hele tatt.
|
||
Jobb-image: `catthehacker/ubuntu:act-latest` (de facto standardimage
|
||
for act/act_runner — har docker-CLI, git, bash forhåndsinstallert,
|
||
unngår `apt-get install` i hvert eneste kjør).
|
||
|
||
**Beslutning C — registrerings-tokenet ble ALDRI limt inn i chatten.**
|
||
Bruker hentet det selv fra Forgejo sitt UI (repo → Settings → Actions
|
||
→ Runners) og la det i en midlertidig fil med `chmod 600` på serveren;
|
||
Claude leste filen direkte via shell, registrerte runneren
|
||
(`forgejo-runner register --no-interactive`), og makulerte
|
||
tokenfilen umiddelbart etterpå (`shred -u`). Selve
|
||
runner-legitimasjonen som oppsto (`.forgejo-runner/.runner` — en ekte,
|
||
langlevd hemmelighet, ikke det korte engangs-registreringstokenet) er
|
||
gitignored, `chmod 600`, aldri committet — samme disiplin som
|
||
`.env`.
|
||
|
||
**Beslutning D — kjøres via `docker-compose.yml`, ikke en løs
|
||
`docker run`.** Startet først manuelt for å bevise at det fungerte
|
||
(ekte push → "Waiting" → runner registrert → jobb plukket opp →
|
||
"Success", verifisert i Forgejo sitt UI), deretter flyttet inn som en
|
||
egen `teecup-forgejo-runner`-tjeneste i `docker-compose.yml` for
|
||
samme drift-disiplin som resten av stacken (`restart: unless-stopped`,
|
||
dokumentert, ikke en engangs-kommando ingen husker senere).
|
||
`group_add: "112"` (docker-gruppens GID på DENNE verten) er
|
||
nødvendig fordi runner-imagets prosess kjører som ikke-root — uten
|
||
den kan den ikke lese den mountede sokkelen selv om den er tilgjengelig.
|
||
Vertsspesifikk verdi, sjekk på nytt om dette noensinne flyttes.
|
||
|
||
**Sikkerhetsnotat, bevisst akseptert:** tilgang til `docker.sock`
|
||
tilsvarer root-ekvivalent kontroll over VERTEN (enhver som kan
|
||
opprette en container kan mounte hva som helst fra filsystemet).
|
||
Dette er en reell heving av angrepsflaten sammenlignet med resten av
|
||
stacken, som ingen andre containere her har. Akseptert fordi (a) det
|
||
er en direkte konsekvens av arkitekturen `run_backend_tests.sh` allerede
|
||
hadde (samme prinsipp, bare automatisert fremfor kjørt manuelt fra en
|
||
already-priviligert shell), og (b) runneren kjører KUN denne ene
|
||
repoens CI, på en enkeltpersons egen server — ikke en delt/flerbruker-
|
||
instans.
|
||
|
||
**Verifisert:** ekte push → `.forgejo/workflows/backend-tests.yml`
|
||
kjørte, viste "Success" i Forgejo sitt UI (grønn hake, samme run som
|
||
sto "Waiting" rett før runneren startet). `teecup_db`s rolleoppsett og
|
||
fravær av gjenglemte scratch-databaser bekreftet uendret etter kjøring.
|
||
|
||
---
|
||
|
||
## ADR-060: Optimistisk versjonssjekk for org-turneringers match-scoring — 2026-08-10
|
||
|
||
Fortsettelse av robusthetslinjen fra ADR-057: bruker ba eksplisitt om å
|
||
tette det siste kjente, dokumenterte hullet — org-turneringenes
|
||
match-scoring (`hole_score`/`match_hole_result`,
|
||
`app/routers/scoring.py`, `session-scorecard.tsx`) hadde INGEN
|
||
samtidighetsvern, ren `INSERT ... ON CONFLICT DO UPDATE` siste-skriving-
|
||
vinner, dokumentert som bevisst utenfor omfang i både ADR-057 og
|
||
ADR-058.
|
||
|
||
**Beslutning A — samme `version`-kolonne-mønster, men UPSERT i stedet
|
||
for UPDATE.** Migrasjon 063 legger `version integer NOT NULL DEFAULT 1`
|
||
til BÅDE `hole_score` og `match_hole_result` (to separate tabeller, to
|
||
separate scoring-modi — `stroke` vs. `hole_result` — se
|
||
`session.scoring_mode`). Ulikt round_hole (som alltid har en
|
||
forhåndseksisterende rad å oppdatere) skriver disse to endepunktene
|
||
via `INSERT ... ON CONFLICT DO UPDATE`, siden FØRSTE registrering av et
|
||
hull ikke har noen eksisterende rad. Postgres sin
|
||
`DO UPDATE SET ... WHERE <betingelse>` løser dette elegant: betingelsen
|
||
sjekkes KUN på selve UPDATE-grenen (konflikt-tilfellet) og ignoreres
|
||
fullstendig på ren INSERT — en helt ny rad tar alltid INSERT-veien og
|
||
bryr seg aldri om `expected_version`, uansett hva klienten sendte.
|
||
Konsekvens: `row is None` etter denne UPSERT-en betyr ALLTID en ekte
|
||
versjonskonflikt, ALDRI "finnes ikke" — INSERT-grenen dekker
|
||
"finnes ikke"-tilfellet transparent. Enklere enn round_hole/
|
||
`update_side_hole`, som trengte en egen eksistenssjekk for å skille de
|
||
to.
|
||
|
||
**Beslutning B — offline-kø-kjedingen (`offline-queue.ts`) måtte
|
||
generaliseres, ikke bare gjenbrukes.** Reelt funn UNDER design, før
|
||
noe ble bygget: ADR-057s versjonskjeding nøklet på `url` alene, riktig
|
||
for round_hole (én URL per hull). Men `/hole-scores`/`/hole-results`
|
||
bruker SAMME URL for ALLE hull i en match — uten en mer presis nøkkel
|
||
ville versjonen returnert fra hull 3 sin skriving blitt brukt som
|
||
"forventet versjon" for en påfølgende, urelatert skriving til hull 7 i
|
||
samme flush-runde, verre enn ingen kjeding i det hele tatt. Løst med et
|
||
nytt, valgfritt `resourceKey`-felt på `QueueEntry`
|
||
(default = `url`, så round_hole sin eksisterende bruk er 100 %
|
||
bakoverkompatibel, uendret oppførsel) — `session-scorecard.tsx` sender
|
||
nå en egen nøkkel per hull+side+enhet (`strokeKey`/`resultKey`).
|
||
|
||
**Beslutning C — autorisasjon i testene, ikke i produksjonskoden.**
|
||
`submit_hole_score`/`submit_hole_result` krever at brukeren enten ER en
|
||
faktisk match-deltaker (via `player`/`team_roster`/`match_participant`-
|
||
kjeden) ELLER org-eier/admin (`is_org_admin`-fallback,
|
||
`team_authz.py`). Testene bruker bevisst org-eier-fallbacken for de
|
||
fleste tilfellene (langt billigere fixture-oppsett), men bygger den
|
||
FULLE deltaker-kjeden for individuell-ball-testen spesifikt, siden
|
||
`match_participant_id`-oppslaget i `submit_hole_score` er uavhengig av
|
||
org-admin-status og krever en ekte rad.
|
||
|
||
**Verifisert:** 6 nye pytest-integrasjonstester
|
||
(`tests/test_scoring_concurrency.py`) — delt-ball OG individuell-ball-
|
||
grenen i `submit_hole_score` hver for seg, `submit_hole_result`, alle
|
||
med vellykket skrivning/versjonsøkning, 409 STALE_VERSION ved konflikt,
|
||
og bekreftet at en avvist skrivning faktisk IKKE ble lagret. Alle 23
|
||
backend-tester (17 fra før + 6 nye) grønne i samme kjøring via CI-
|
||
runneren fra ADR-059. `tsc --noEmit` rent.
|
||
|
||
**Bevisst utenfor omfang:** ingen browser-basert to-enhets-verifisering
|
||
denne runden (ulikt ADR-057) — pytest-dekningen ble vurdert som
|
||
tilstrekkelig gitt at testene kaller de faktiske router-funksjonene
|
||
direkte, og frontend-siden (`session-scorecard.tsx`) følger nøyaktig
|
||
samme, allerede browser-verifiserte mønster som `round-detail.tsx` fikk
|
||
i forrige runde.
|
||
|
||
**Rullet ut 2026-08-10**, bruker bekreftet eksplisitt — migrasjon 063
|
||
kjørt mot ekte `teecup_db`, `teecup_api`/`teecup_frontend` bygget/
|
||
restartet rent, ingen konsollfeil. CI plukket opp pushen automatisk og
|
||
kjørte grønt (samme runner som ADR-059).
|
||
|
||
---
|
||
|
||
## ADR-061: Frontend-testinfrastruktur (Vitest) — 2026-08-10
|
||
|
||
Direkte fortsettelse av ADR-058/060, som begge eksplisitt navnga
|
||
frontend-tester som bevisst utenfor omfang. Bruker ba om å ta tak i det
|
||
som neste steg i robusthetslinjen, nå som backend-testriggen og CI-
|
||
runneren stod klare.
|
||
|
||
**Beslutning A — Vitest, ren logikk-testing (`lib/**`), ingen
|
||
komponenttester ennå.** Node-miljø, ingen jsdom/@testing-library i
|
||
denne runden — det finnes ingen komponenttester å kjøre ennå, og
|
||
`node`-miljø er raskere/enklere når det ikke trengs. Tre moduler valgt
|
||
etter samme risikobaserte prinsipp som backend-rundene:
|
||
- `lib/geo.ts` (`haversineMeters`/`bearingDegrees`) — ekte GPS-
|
||
avstandsmåling (ADR-048), fasit-forankret mot kjente geodetiske
|
||
verdier (Oslo–Bergen luftlinje, 1 breddegrad ved ekvator ≈ 111,2 km),
|
||
ikke bare selv-konsistente tall.
|
||
- `lib/offline-queue.ts` — regresjonsvern for `resourceKey`-fiksen fra
|
||
ADR-060 selv: egen test beviser eksplisitt at to ulike ressurser som
|
||
deler samme URL IKKE lenger blander sammen versjonsnummer, og at
|
||
round_hole sin eksisterende bruk (ingen `resourceKey` satt) fortsatt
|
||
faller tilbake til url-basert nøkling uendret. `fake-indexeddb` gir en
|
||
ekte IndexedDB-implementasjon i test-miljøet — ikke en mocket
|
||
tilnærming til køen.
|
||
- `lib/ny-runde/formats.ts` — fullstendighets-/konsistenssjekker på
|
||
tvers av format-listene (`FORMAT_TO_API`, `FORMAT_MAP`, `API_TO_FORMAT`,
|
||
`TWO_SIDED` m.fl.), direkte motivert av et tidligere kjent
|
||
ømtåelig-punkt (separate `play_format`-gatingsett i flere
|
||
frontend-filer, lett å glemme å oppdatere ett sted når et nytt
|
||
format legges til). Testene fanger spesifikt opp det TypeScript IKKE
|
||
gjør automatisk: `FORMAT_MAP` bygges med en `as`-cast som omgår
|
||
fullstendighetssjekk, og en vanlig array som `FORMATS` har ingen
|
||
kompilator-garanti for å dekke alle `FormatId`-verdier.
|
||
|
||
**Beslutning B — pnpm, ikke npm.** Prosjektet bruker pnpm
|
||
(`pnpm-lock.yaml`, Dockerfilens `corepack enable && pnpm install`) —
|
||
et første forsøk med `npm install` feilet med en intern npm/arborist-
|
||
krasj nettopp FORDI `node_modules` allerede var strukturert som et
|
||
pnpm-lagret (`.pnpm/`-innhold), ikke fordi noe var korrupt. Ingen skade
|
||
skjedd (bekreftet med `pnpm install --frozen-lockfile`, som validerte
|
||
hele treet på nytt), men en påminnelse om å sjekke lockfile-typen FØR
|
||
man antar npm er riktig verktøy.
|
||
|
||
**Reelt funn, rettet i samme runde:** `frontend/pnpm-workspace.yaml` var
|
||
ignorert i `frontend/.gitignore` (arvet fra den opprinnelige V0-sandbox-
|
||
malen, der fila kun inneholdt V0-interne sandbox-detaljer). Filen fikk
|
||
nå reelt, deploy-relevant innhold (`allowBuilds: sharp: true`, fra
|
||
`pnpm approve-builds` — uten den feiler `pnpm install` i et rent
|
||
miljø/CI med `[ERR_PNPM_IGNORED_BUILDS]`). Ignore-regelen fjernet,
|
||
fila sporet i git — ellers ville CI feilet på akkurat denne
|
||
installasjonen hver eneste gang, usynlig lokalt siden node_modules
|
||
allerede var godkjent på denne serveren fra før.
|
||
|
||
**Beslutning C — egen, lettvekts CI-workflow, ikke gjenbruk av backend-
|
||
sin.** `.forgejo/workflows/frontend-tests.yml` trigger kun på
|
||
`frontend/lib/**`/`package.json`/lockfile-endringer, bruker
|
||
`actions/setup-node@v4` (Node 22, matcher Dockerfilen) + `corepack
|
||
enable` + `pnpm install --frozen-lockfile` + `pnpm test` — INGEN
|
||
Docker-utenfor-Docker, ingen `docker.sock`-tilgang nødvendig, siden
|
||
denne suiten ikke rører database eller containere i det hele tatt.
|
||
Enklere og tryggere blast-radius enn backend-workflowen.
|
||
|
||
**Verifisert:** 30 tester, 3 filer, alle grønne lokalt
|
||
(`./node_modules/.bin/vitest run`, og via `pnpm test` etter
|
||
`allowBuilds`-fiksen). Ingen produksjonskode endret -- ren
|
||
testinfrastruktur.
|
||
|
||
**Bevisst utenfor omfang:** komponenttester (jsdom/@testing-library),
|
||
og en tilsvarende fullstendighetssjekk PÅ TVERS AV `round-detail.tsx`/
|
||
`scorecard.tsx`/`leaderboard.tsx` sine egne, separate
|
||
`play_format`-gatingsett (kjent ømtålelig punkt, men disse er store
|
||
komponentfiler som ville krevd en helt annen testtilnærming enn ren
|
||
lib-import — egen, fremtidig vurdering).
|
||
|
||
---
|
||
|
||
## ADR-062: Åtte brukerrapporterte UX-funn — "Ny runde" og score-registrering — 2026-08-11
|
||
|
||
Bruker sendte åtte konkrete, skjermbilde-dokumenterte problemer fra
|
||
faktisk bruk (dashbord, ny-runde-veiviseren, score-registrering, feed).
|
||
Delt i to spor etter samme prinsipp som tidligere runder: reelle
|
||
logikkfeil rettet direkte, reelt interaksjonsdesign sendt via V0.
|
||
|
||
**Direkte rettet, ingen V0 (4 av 8):**
|
||
1. **`round-card.tsx`: "Hull"-cellen viste alltid PLANLAGT antall hull
|
||
(`holes_planned`), aldri antall FAKTISK spilt** — en runde markert
|
||
"Fullført" etter f.eks. 9 av 18 hull viste fortsatt "18". Data fantes
|
||
allerede (`my_holes_played` sendes for alle runder, uansett status);
|
||
selve "X/Y hull spilt"-indikatoren var bare feilaktig gatet til
|
||
`status === "active"`. Rettet til `played < round.holes` (viser
|
||
avviket uansett status, skjuler seg selv når spilt == planlagt).
|
||
2. **Putt-avstand-knappenes etiketter** (`round-detail.tsx`) —
|
||
fem av seks brukte "<Xm"-mønster, den sjette ("8m+") brøt mønsteret
|
||
med et "+"-suffiks og ble dermed lett oversett. Etikettene endret til
|
||
konsekvent "X-Ym"-format (kun `label`, ikke `value`/`PuttBucket`-
|
||
kontrakten — ingen backend-endring). Bekreftet via git-historikk at
|
||
dette var Claude-forfattet, ikke V0 (brukeren spurte eksplisitt).
|
||
3. **"Se feed" på dashbordet** — for anonymt/uspesifikt. Endret til "Se
|
||
venneaktivitet", knyttet direkte til seksjonens egen kontekst
|
||
("Venner på banen").
|
||
4. **@-tagging av medspillere i feeden** — vurdert, IKKE bygget denne
|
||
runden (egen, betydelig funksjon: datamodell + søk + rendering).
|
||
Anbefalt regel hvis/når den bygges: treff begrenset til faktiske
|
||
relasjoner (medspillere/venner, ikke hele brukerbasen — personvern),
|
||
og en ekte søkbar nedtrekksliste ved "@" fremfor fritekst-tolkning
|
||
(upålitelig med flere like navn).
|
||
|
||
**Sendt via V0, mottatt som "tee-cup (9).zip", diffet mot live-treet FØR
|
||
noe ble tatt inn (samme rutine som alltid — kun `delivery/`-mappen var
|
||
den faktiske leveransen, resten var V0s egen sandbox-scaffolding, ikke
|
||
tatt inn):**
|
||
|
||
5. **Ny-runde steg 2: "Antall hull" og "Avanserte handicap-
|
||
innstillinger" flyttet FØR det store 18-knappers formatrutenettet**
|
||
(var sist, lett oversett bak noe man må skrolle forbi). Ren
|
||
rekkefølge-endring, ingen ny funksjonalitet.
|
||
6. **Score-registreringens "detaljer"-steg delt i to synlige seksjoner**
|
||
("Retning" / "Detaljer per hull") med overskrift+divider, og en ny
|
||
`ScrollFade`-hjelpekomponent (bunn-fade + nedoverpil, ResizeObserver-
|
||
drevet, skjuler seg selv ved bunn) som løser at brukere ikke visste
|
||
de måtte skrolle for å finne Chip/Bunker/Straffeslag/Anywayslag.
|
||
7. **Anywayslag endret fra tall-rutenett til +/−-stepper** — samme
|
||
inndatamønster som Chip/Bunker/Straffeslag nå (alle fire er
|
||
konseptuelt samme type data, brøt tidligere mønster uten grunn).
|
||
8. **Netto par-markør på Slag-knappene**: en liten oransje prikk (med
|
||
kontrastring, synlig i BÅDE valgt og uvalgt tilstand) på knappen som
|
||
tilsvarer spillerens personlige netto par (rå par + `strokesReceived`,
|
||
allerede tilgjengelig i komponenten), pluss en tekstlig
|
||
"Ditt netto par (N)"-forklaring over selve rutenettet (tilgjengelighet
|
||
— ikke avhengig av fargesyn alene). `aria-label` utvidet tilsvarende.
|
||
9. **Putter-velgeren fikk egen visuell identitet** (oransje venstre-
|
||
aksentkant + flagg-ikon på tittelraden) for å skille den fra Slag-
|
||
velgeren, som ellers er nøyaktig samme rutenett-komponent. Selve
|
||
tallknappenes etablerte grønne "valgt"-stil er UENDRET.
|
||
|
||
**Verifisering:** `tsc --noEmit` rent på alle endringer. Full scratch-
|
||
stack bygget (scratch-DB med alle 63 migrasjoner, egen scratch-MinIO,
|
||
scratch-API- og scratch-frontend-container, ekte produksjonsbuild av
|
||
frontend — ikke bare `tsc`). Ekte bruker opprettet og logget inn via
|
||
magic-link-bypass (`DEV_LOG_MAGIC_LINKS`), egen bane opprettet via ekte
|
||
API-kall (`POST /personal-courses`) med HCP 24 satt på spilleren for å
|
||
garantere `strokes_received > 0` på hull 1 — nødvendig for faktisk å se
|
||
netto-par-markøren i bruk, ikke bare anta at koden er riktig.
|
||
Browserverifisert (Chrome DevTools MCP, mobil viewport 390×844) i BÅDE
|
||
lys og mørk modus: ny-runde steg 2 sin nye rekkefølge, seksjonsdeling og
|
||
scroll-hint (bekreftet `opacity-100`→`opacity-0` ved faktisk scroll til
|
||
bunn, ikke bare visuell antagelse), netto-par-prikk (både alene og
|
||
samtidig med "valgt"-tilstand), Putter-aksent, de nye "X-Ym"-putt-
|
||
avstand-etikettene. Ingen konsollfeil. Scratch-miljøet ryddet opp
|
||
fullstendig (containere, images, database, rolle), `teecup_db`s
|
||
rolleoppsett bekreftet uendret.
|
||
|
||
**Oppfølging 2026-08-11 — punkt 5 (ChoiceRow) IKKE løst, brukeren
|
||
rapporterte tilbake:** "Avstand første putt" sine seks alternativer
|
||
viste seg fortsatt som 5 knapper på én rad + 1 alene, ikke ryddig 3+3
|
||
som antatt. Root cause: `ChoiceRow` (den delte komponenten, IKKE
|
||
`NumberPicker`) brukte `flex flex-wrap` + `flex-1` per knapp, som pakker
|
||
så mange knapper som teksten tillater per rad i stedet for et fast
|
||
antall — tilfeldigvis akkurat 5+1 for disse seks korte etikettene.
|
||
Rettet til `grid grid-cols-3` (samme mønster som `NumberPicker` allerede
|
||
brukte). Alle FIRE andre `ChoiceRow`-bruk i filen (Kjønn,
|
||
Statistikk-nivå × 2) har nøyaktig 3 valg fra før — visuelt uendret for
|
||
dem, kun 6-alternativs-tilfellet endrer seg. Browserverifisert lys+mørk.
|
||
Committet (`17e4793`), deployet 2026-08-11 sammen med tagging-runden
|
||
(se ADR-063).
|
||
|
||
**Oppfølging 2026-08-11 — punkt 6 (ScrollFade) hadde TO reelle feil,
|
||
brukeren rapporterte via skjermbilde av Retning-steget:** "Den
|
||
sprettende ned-pilen fungerer ikke. Den ligger OPPÅ en annen nedpil."
|
||
|
||
1. `ResizeObserver` observerte scroll-BEHOLDEREN (`el`), ikke
|
||
innholdet. Beholderens boks-størrelse er fast (`flex-1`, bundet av
|
||
veiviserens layout) og endrer seg ALDRI når man bytter steg —
|
||
observeren fyrte derfor aldri når et steg med reelt overflow ble
|
||
vist, og `hasMore` ble stående på sin opprinnelige (ofte `false`)
|
||
verdi. Hintet virket rett og slett ikke der det trengtes, nøyaktig
|
||
det brukeren rapporterte. Rettet ved å observere en egen
|
||
`contentRef`-div rundt `children` i stedet for beholderen selv.
|
||
2. Fade-/piloverlayet er absolutt posisjonert over de siste 64px av
|
||
scroll-området uten noen garanti om at ekte innhold ikke havner
|
||
der. På Retning-steget (med fullt kølle-utvalg satt på testbrukeren
|
||
for å reprodusere brukerens eksakte rutenett) landet nettopp
|
||
"Kort"-knappens eget `ArrowDown`-ikon i den sonen — to piler oppå
|
||
hverandre, akkurat som beskrevet. Rettet med en usynlig 64px-buffer
|
||
(`SCROLL_FADE_HEIGHT_PX`) etter innholdet; `hasMore`-utregningen
|
||
trekker fra samme høyde så bufferen selv aldri gir et falskt
|
||
positivt hint på kort innhold.
|
||
|
||
Browserverifisert (samme reproduksjon: full kølle-liste for å tvinge
|
||
frem overflow): hintet vises korrekt (`opacity: 1`) når steget faktisk
|
||
overflower, forsvinner korrekt (`opacity: 0`) ved reell bunn, "Kort"
|
||
fullt synlig og klar av overlay-sonen ved skrolling, lys+mørk, ingen
|
||
konsollfeil. `tsc --noEmit` rent, 45/45 vitest grønt. Committet
|
||
(`ee91d03`).
|
||
|
||
---
|
||
|
||
## ADR-063: @-tagging av medspillere/venner i runde-feeden — 2026-08-11
|
||
|
||
Reist av bruker som en av åtte UX-funn (ADR-062, punkt 7), bevisst
|
||
utsatt der og bakt inn i FEATURE_BACKLOG.md som egen sak. Bruker ba om å
|
||
sette den i gang som egen runde.
|
||
|
||
**Beslutning A — kun FAKTISKE relasjoner er taggbare, aldri fritekstsøk
|
||
i hele brukerbasen** (personvernbegrunnelsen fra FEATURE_BACKLOG-notatet
|
||
fulgt direkte). `_taggable_candidates()` (ny hjelpefunksjon,
|
||
`round_messages.py`) returnerer UNION av rundens lenkede deltakere og
|
||
avsenderens egne venner (`friendship`, status='accepted', begge
|
||
retninger) — ALDRI et generelt personsøk. Håndhevet BÅDE i det nye
|
||
søkeendepunktet (`GET /rounds/{id}/taggable-people`) OG server-side ved
|
||
innsending (klientens forslag stoles aldri blindt på).
|
||
|
||
**Beslutning B — offset-basert tag-span, ikke innebygd tekst-syntaks.**
|
||
`round_message_tag`/`round_message_comment_tag` (migrasjon 064) lagrer
|
||
`start_index`/`end_index` inn i `body`-teksten SLIK DEN BLE SKREVET, ikke
|
||
en `@[navn](id)`-markdown-lignende syntaks i selve teksten. Trygt fordi
|
||
verken `round_message` eller `round_message_comment` NOENSINNE kan
|
||
redigeres i etterkant (kun slettes, se 058/059) — offsettene kan derfor
|
||
aldri gå ut av synk med teksten. Den viste teksten forblir alltid
|
||
NØYAKTIG det avsenderen skrev; kun lenkemålet (bruker-ID) løses opp mot
|
||
NÅVÆRENDE navn ved lesing, ikke frosset — en tag skal peke på personen,
|
||
ikke et navn-øyeblikksbilde (bevisst forskjell fra `author_display_name`,
|
||
som ER frosset).
|
||
|
||
**Beslutning C — ugyldige tags forkastes stille, hele innlegget avvises
|
||
aldri.** En tag mot noen som ikke lenger er taggbar (f.eks. sluttet å
|
||
være medspiller mellom autocomplete og innsending), eller med en offset
|
||
utenfor tekstens lengde, filtreres bare bort (`_validate_and_prepare_tags`)
|
||
— resten av innlegget/kommentaren postes uendret. Unngår at en
|
||
kapp-løpstilstand på klientsiden blokkerer en ellers gyldig melding.
|
||
|
||
**Beslutning D — varsling gjenbruker eksisterende `type="round"`,
|
||
ingen ny notification-kategori.** `notification.type` er begrenset til
|
||
fire faste verdier (`friend`/`tournament`/`round`/`result`, migrasjon
|
||
026/033, koblet til brukerens egne e-post-preferanser per kategori) — en
|
||
ny femte kategori ville krevd UI-endring i varslingsinnstillingene også,
|
||
utenfor denne rundens omfang. `"{fullt navn} tagget deg i et innlegg."`
|
||
sendes til hver gyldig tagget person (aldri til seg selv), samme
|
||
`create_notification()`-vei (in-app + push + evt. e-post) som alt annet
|
||
rundevarsel.
|
||
|
||
**To parallelle tabeller, ikke én delt polymorf** (`round_message_tag`/
|
||
`round_message_comment_tag`) — samme konvensjon som
|
||
`round_message_reaction` vs. `message_reaction` (migrasjon 059).
|
||
|
||
**Verifisert:** 8 nye pytest-tester (`tests/test_message_tags.py`) —
|
||
kandidatlisten inkluderer venn og rundedeltaker, ekskluderer fremmede og
|
||
seg selv, søkefiltrering, gyldig tag på både innlegg og kommentar (med
|
||
varsel bekreftet skrevet til `notification`-tabellen), ugyldig tag
|
||
(fremmed / utenfor tekstlengde / seg selv) forkastes stille UTEN å
|
||
avvise selve meldingen. Alle 31 backend-tester (23 fra før + 8 nye)
|
||
grønne i samme kjøring. `teecup_db` bekreftet uendret.
|
||
|
||
**Frontend — oppdatering samme dag:** opprinnelig sendt som eget
|
||
V0-prompt (se over), men bruker gikk tom for V0-credits og ba Claude
|
||
implementere promptet selv i stedet. `lib/mentions.ts` (ren offset-/
|
||
diff-logikk, 15 enhetstester) + `components/mention-input.tsx`
|
||
(`MentionTextarea`-autocomplete + `TaggedText`-rendring), koblet inn i
|
||
`round-messages.tsx` (innlegg), `post-engagement.tsx` (kommentarer —
|
||
delt av flere meldingssystemer, `roundId`/`tags` gjort valgfrie for å
|
||
ikke bryte de org-scopede callerne) og `feed.tsx` (aggregert
|
||
`/my-feed`-visning — der backendens `GET /feed` manglet `tags` ble
|
||
også lagt til her, oppdaget under dette arbeidet). Scratch-verifisert
|
||
i nettleser (autocomplete, tagging i innlegg og kommentar, rendring som
|
||
lenke både i rundevisning og i feed, varsel bekreftet, personvern-
|
||
scoping bekreftet i UI også, lys+mørk). `tsc --noEmit` rent, 45/45
|
||
vitest grønt. Se CHANGELOG.md.
|
||
|
||
Org-turneringenes tilsvarende "Banter Board" (`message`/
|
||
`message_comment`) fikk IKKE samme funksjon — helt separat system,
|
||
ikke nevnt i den opprinnelige forespørselen, fortsatt bevisst utenfor
|
||
omfang.
|
||
|
||
---
|
||
|
||
## ADR-064: GolfAPI.io som tredje banekilde — avstand til mål (rangefinder) — 2026-08-12
|
||
|
||
Brukeren fikk et GolfAPI.io-token (20 API-kall) og ba om avstandsmåling
|
||
til grønn (front/midt/bak) og hindringer — notert som en kjent, ikke-
|
||
designet ambisjon siden 2026-07-22 (se FEATURE_BACKLOG.md), atskilt fra
|
||
den allerede byggede slag-for-slag GPS-avstandsmålingen (ADR-048, som
|
||
måler AD HOC-punkter brukeren selv velger, ikke faste banepunkter).
|
||
Startbane: **Tjøme Golfklubb** — bekreftet av bruker at den IKKE finnes
|
||
i TeeOff, som gjør dette til det generelle "bane utenfor TeeOffs
|
||
dekning"-tilfellet FEATURE_BACKLOG.md (tillegg 2026-08-03) allerede
|
||
pekte ut som naturlig neste steg, ikke et spesialtilfelle.
|
||
|
||
**Live validert FØR noe ble bygget** (ekte GolfAPI-kall, ikke antatt fra
|
||
research alene): søk (`GET /clubs?name=Tjøme`), fullt scorekort
|
||
(`GET /courses/{id}` — par/SI per hull, 4 tees med per-hull-lengde i
|
||
meter, course rating/slope inkl. front9/back9), og koordinater
|
||
(`GET /coordinates/{id}` — 167 punkter for Tjøme: grønn front/midt/bak
|
||
per hull, bunkere, vann, avstandsmarkører, tee-punkter). Kostnad
|
||
bekreftet: søk 0,1 kall, full henting (course + coordinates) 2 kall —
|
||
matcher brukerens egen "20 kall ≈ 10 baner"-anslag.
|
||
|
||
**Beslutning A — ett delt, globalt cache-lag (`golfapi_course`/
|
||
`_hole`/`_tee`/`_coordinate`, migrasjon 065), ALDRI live oppslag.**
|
||
Motsatt av teeoffs policy (fritt, offentlig API, live oppslag er
|
||
kostnadsfritt) — GolfAPI-kall koster ekte, sterkt begrenset budsjett.
|
||
GolfAPIs avtale tillater eksplisitt permanent caching ("no need to call
|
||
the API to fetch the same course multiple times"), så cachen hentes KUN
|
||
én gang NOENSINNE per fysisk bane, uansett hvor mange organisasjoner/
|
||
brukere som senere kobler til den samme banen — håndhevet av én sentral
|
||
chokepoint-funksjon, `golfapi_cache.get_or_fetch_golfapi_course()`, som
|
||
ALL import-kode må gå via (aldri et direkte `golfapi_client`-kall fra et
|
||
endepunkt). Speiler ADR-019s "import ved eksplisitt valg, ikke live
|
||
oppslag"-filosofi, men med en enda strengere begrunnelse (penger, ikke
|
||
bare transaksjonssikkerhet/oppetid).
|
||
|
||
**Beslutning B — kopier inn i BEGGE eksisterende banemodeller, ikke en
|
||
tredje.** Bekreftet med bruker (AskUserQuestion): GolfAPI-importerte
|
||
baner skal kunne brukes i BÅDE org-turneringer OG frittstående runder.
|
||
- `course` (org-scopet): `course_source`-enum utvidet med
|
||
`'international'`; `external_course_ref` lagrer GolfAPIs `courseID`
|
||
direkte (samme kolonne teeoff allerede bruker til
|
||
`facility_slug:course_id`). Nye endepunkt `international-search`/
|
||
`international-import` i `courses.py`, speiler `official-search`/
|
||
`official-import` (ADR-019) strukturelt og validerings-messig
|
||
("fail loudly" på ufullstendige data, aldri en delvis import).
|
||
- `personal_course` (global, ADR-042): ny nullable
|
||
`external_golfapi_course_id`-kolonne + delvis unik indeks (samme
|
||
mønster som `course`s migrasjon 010). Speilende endepunkter i
|
||
`rounds.py`. `round.course_source` trenger INGEN endring — en
|
||
GolfAPI-importert bane blir en helt vanlig `personal_course`-rad
|
||
(`course_source='custom'`), kun med proveniens-kolonnen satt.
|
||
- Begge kopier-inn-stegene leser FRA det samme delte cache-laget —
|
||
importeres Tjøme via en turnering OG senere via en frittstående runde,
|
||
koster det GolfAPI 0 ekstra kall andre gang, uansett rekkefølge.
|
||
|
||
**Beslutning C — koordinatene kopieres ALDRI inn per import.** Ulikt
|
||
par/rating (som MÅ fryses per import for reproduserbare HCP-resultater,
|
||
ADR-007/ADR-019s prinsipp), er koordinatene ikke en del av HCP-
|
||
beregningen i det hele tatt. Rangefinder-oppslag (nytt endepunkt
|
||
`GET /rounds/{id}/holes/{n}/target-points`) leser derfor alltid DIREKTE
|
||
fra den delte `golfapi_course_coordinate`-cachen via banens lagrede
|
||
GolfAPI-courseID — én fysisk bane har ett koordinatsett, uansett hvor
|
||
mange ganger banen er importert til ulike organisasjoner/brukere.
|
||
Endepunktet returnerer RÅ punkter (lat/lng), ALDRI en ferdigregnet
|
||
avstand — en forhåndsberegnet avstand ville vært utdatert i det
|
||
øyeblikket spilleren beveger seg. Klienten Haversine-regner selv
|
||
(`frontend/lib/geo.ts`, samme rene funksjon som ADR-048 allerede bygget)
|
||
mot spillerens EGEN, ferske GPS-posisjon.
|
||
|
||
**Beslutning D — v1-visning: ren tall-/tekstvisning, intet kart.**
|
||
Bekreftet med bruker (AskUserQuestion) — "142 m front / 151 m midt /
|
||
163 m bak" pluss evt. nærmeste hindring, ingen Mapbox-kartlasting i det
|
||
hele tatt. Null ekstra driftskostnad utover selve GolfAPI-importen,
|
||
matcher Claude sin egen tidligere loggførte anbefaling
|
||
(FEATURE_BACKLOG.md, 2026-08-06) og ADR-048s etablerte kostnadskontroll-
|
||
filosofi (kart lastes kun når det er strengt nødvendig). Brukeren
|
||
foreslo selv en fremtidig **freemium-idé** (gratis = tall, betalt =
|
||
interaktivt satellittkart/"Plays Like"-vind-/høydejustering/flyover,
|
||
etter mønster fra etablerte konkurrenter som Hole19/18Birdies) —
|
||
BEVISST NOTERT, IKKE BYGGET: appen har ingen betalingsinfrastruktur i
|
||
dag, og v1-beslutningen (tall, ikke kart) er uansett riktig uavhengig av
|
||
om et fremtidig betalt lag legges til senere.
|
||
|
||
**Beslutning E — datamodell for koordinatene speiler GolfAPIs egen POI-
|
||
struktur, men normalisert til lesbar tekst.** `poi_type` (grønn/bunker/
|
||
vann/trær/avstandsmarkør/dogleg/vei/tee-punkt), `location`
|
||
(front/midt/bak — IKKE navngitt "green_location": Tjømes bunkere hadde
|
||
også front/bak-punkter, feltet er generisk for ethvert POI, ikke kun
|
||
grønn) og `side_fairway` (venstre/senter/høyre) er tekst+CHECK, ikke
|
||
egne enum-typer — samme "kan utvides uten `ALTER TYPE`"-begrunnelse som
|
||
`session.format` allerede bruker (001).
|
||
|
||
**Budsjettdisiplin:** import er ALLTID en eksplisitt, synlig
|
||
brukerhandling (et "Hent bane fra GolfAPI"-søk + valg), ALDRI
|
||
automatisk/implisitt — med kun ~15-16 kall igjen etter denne rundens
|
||
utvikling+verifisering (2,3 til research/validering + 2 til det siste
|
||
bevisste, ekte E2E-kallet), er dette en reell begrensning appen må
|
||
respektere fremover, ikke en teoretisk bekymring.
|
||
|
||
**Bevisst utenfor denne runden:** rangefinder-VISNINGEN i selve
|
||
scorekortet (`round-detail.tsx` sin hull-header har allerede reservert
|
||
plass ved GIR-merket siden 2026-07-25) er ikke koblet inn ennå — venter
|
||
på en Claude-skrevet V0-prompt (samme arbeidsdeling som ADR-048s
|
||
`ShotMeasurementSheet`), ikke bygget håndkodet. Org-siden fikk full
|
||
import-støtte (`international-search`/`-import`), men INGEN tilsvarende
|
||
rangefinder-visning i turneringens scoreførings-UI ennå — ingen
|
||
reservert plass finnes der i dag, egen, senere vurdering når/hvis
|
||
etterspurt. GolfAPI som generell fallback for ALLE norske søk (ikke
|
||
bare når TeeOff mangler banen) er en bevisst IKKE-endring — Tjøme er
|
||
eksempelet som utløste dette, ikke en politikkendring for Norge
|
||
generelt.
|
||
|
||
**Verifisert:** migrasjon 065 kjørt mot en automatisert scratch-database
|
||
(`scripts/run_backend_tests.sh`-mønsteret) — alle 31 eksisterende
|
||
backend-tester fortsatt grønne (ingen regresjon). Egen scratch-runde
|
||
(fixture-basert, monkeypatchet `golfapi_client` med de FAKTISKE Tjøme-
|
||
svarene fra research-fasen, for å unngå å bruke flere av de knappe
|
||
API-kallene under iterasjon) bekreftet: cache-chokepoktet henter GolfAPI
|
||
nøyaktig én gang og ALDRI på nytt ved gjentatte kall (idempotens), 18
|
||
hull + 4 tees + 167 koordinater cachet korrekt, org-import oppretter en
|
||
`course`-rad med `source='international'` + riktig `hole`/`tee_rating`-
|
||
antall (18 hull, 8 tee_rating-rader — 4 tees × 2 kjønn), duplikat-import
|
||
avvist av unik indeks i BEGGE modeller (`course_org_external_ref_unique`
|
||
fra migrasjon 010, ny `personal_course_golfapi_ref_unique`), og
|
||
target-points-oppslaget returnerer korrekte front/midt/bak-punkter for
|
||
et gitt hull. **Ett bevisst, siste ekte kall** mot GolfAPI (course +
|
||
coordinates, 2 kall) bekreftet at hele kjeden — `golfapi_client.py`s
|
||
faktiske HTTP-kall inkludert, ikke bare cache-logikken — fungerer mot
|
||
den virkelige tjenesten, ikke bare mot fixtures. `teecup_db` bekreftet
|
||
uendret gjennom hele verifiseringen (`\l`-sjekk før/etter, scratch-
|
||
database+rolle droppet). Frontend: `tsc --noEmit` rent, 45/45 vitest.
|
||
**Tillegg samme dag — rangefinder-visningen koblet inn og rullet ut.**
|
||
Brukeren kjørte V0-prompten (zip 10), resultatet
|
||
(`components/target-distance.tsx`) matchet spesifikasjonen nøyaktig.
|
||
Claude koblet den inn via en ny `components/hole-target-distance.tsx`
|
||
(GPS/fetch/Haversine-logikken, samme `watchPosition`-mønster som
|
||
ADR-048), montert i `ScoringWizard` sin hull-header
|
||
(`round-detail.tsx`). Fant og fikset en reell driftsfeil under
|
||
utrullingen: `TEECUP_GOLFAPI_TOKEN` var satt i `.env`, men
|
||
`docker-compose.yml` sin `teecup_api`-tjeneste videreførte den aldri
|
||
til containeren. Full-stack scratch-verifisert (ekte innlogging, ekte
|
||
import via det virkelige endepunktet -- cache-treff, 0 nye API-kall --
|
||
ekte runde, nettleserverifisert lys+mørk med geolocation-emulering:
|
||
front/midt/bak-avstander og nærmeste hindring rendret korrekt, ingen
|
||
konsollfeil). **Rullet ut 2026-08-12**, bruker bekreftet. Migrasjon 065
|
||
kjørt mot ekte `teecup_db`, `docker compose build && up -d` kjørt (to
|
||
ganger denne dagen -- brukeren stanset bevisst mellom første og andre
|
||
gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full
|
||
verifiseringsdetalj.
|
||
|
||
**Tillegg 2026-08-13 — den primære "Ny runde"-veiviseren manglet
|
||
GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.** Brukeren
|
||
spurte hvor, nøyaktig, funksjonen kunne sees — det viste seg at
|
||
2026-08-12-rundens søk-UI kun var koblet inn i "endre bane"-skjemaet
|
||
(`round-detail.tsx`), ikke i den faktiske `/my-rounds/new`-veiviseren
|
||
(`components/ny-runde/`, en helt separat komponenttre). Lagt til der.
|
||
Under verifisering av DENNE tilkoblingen (ikke forrige runde, som kun
|
||
reelt HTTP-testet POST-import, aldri GET-søket) ble en ekte 500-feil
|
||
funnet: `GET /personal-courses/{personal_course_id}` (eksisterende,
|
||
generisk) var registrert FØR den nye, mer spesifikke
|
||
`GET /personal-courses/international-search` — FastAPI matcher ruter i
|
||
registreringsrekkefølge, så den generiske ruten fanget grådig opp
|
||
"international-search" som en ugyldig UUID. Lærdom: en ny literal-path-
|
||
rute som deler prefiks med en eksisterende parameterisert rute MÅ
|
||
registreres FØR den, ikke etter — nå kommentert direkte i koden
|
||
(`rounds.py`) for å unngå gjentakelse. Org-siden (`courses.py`) hadde
|
||
aldri dette problemet (ingen bar `/courses/{course_id}`-rute der).
|
||
Full scratch-verifisert på nytt (ekte klikk-gjennom i selve "Ny
|
||
runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle
|
||
treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg)
|
||
for full detalj.
|
||
|
||
**Tillegg 2026-08-13 — to nye baner (Larvik/Seasidebanen, Nesbyen/
|
||
Nesfjellet) + en reell GolfAPI-datakvalitetsbug funnet under importen.**
|
||
Nesbyen har `hasGPS=0` hos GolfAPI (ingen koordinater), så koordinatene
|
||
ble registrert MANUELT av bruker (124 punkter, feltbefart) og satt inn
|
||
direkte i den samme delte `golfapi_course_coordinate`-cachen som
|
||
Beslutning C beskriver — skjemaet er kilde-agnostisk, rangefinderen
|
||
fungerer identisk uansett om et punkt kom fra API-et eller ble hånd-
|
||
registrert. To nye `poi_type`-verdier lagt til (migrasjon 067): `rock`
|
||
(fjellknaus — regnes som hinder) og `layup` (til fairway — kun
|
||
informativt, ikke et hinder).
|
||
|
||
Under selve importen krasjet koden to ganger på et reelt GolfAPI-funn:
|
||
tjenesten returnerer noen ganger en TOM STRENG (`""`) i stedet for
|
||
`null`/fraværende felt for `courseRatingMen`/`slopeMen`/
|
||
`courseRatingWomen`/`slopeWomen` — ikke bare for kvinner, som ADR-019s
|
||
tilsvarende teeoff-antakelse forutsatte, Nesbyen manglet `slopeMen`
|
||
også. Fikset på tre nivåer: (1) `golfapi_cache.py` normaliserer nå
|
||
`""` → `None` for alle fire ratingfelt FØR insert, (2)
|
||
`golfapi_course_tee.course_rating_men`/`slope_men` gjort nullable
|
||
(migrasjon 068 — var feilaktig `NOT NULL`), (3) begge import-
|
||
endepunktene (`courses.py`s `international-import`, `rounds.py`s
|
||
personal-course-ekvivalent) bygger nå rating per utslag defensivt —
|
||
hopper over et utslag HELT (ingen `tee`/`personal_course_tee`-rad) hvis
|
||
verken herre- eller dame-rating er brukbar, og avviser HELE importen
|
||
med `EXTERNAL_DATA_INCOMPLETE` FØR transaksjonen starter hvis
|
||
bokstavelig talt intet utslag har noen brukbar rating (fail-fast,
|
||
samme ADR-019 Beslutning C-filosofi). Nesbyens 6 utslag hadde faktisk
|
||
INGEN rating i det hele tatt hos GolfAPI (ikke bare tomme strenger på
|
||
enkeltfelt) — brukeren oppga alle 6 utslags course rating/slope
|
||
(herre+dame, ett utslag uten damerating) manuelt fra klubbens eget
|
||
scorekort, satt inn direkte i `golfapi_course_tee`-cachen før import.
|
||
|
||
**Verifisert:** `python3 -m py_compile` rent på alle tre endrede
|
||
Python-filer, full `./scripts/run_backend_tests.sh` (31/31, migrasjon
|
||
068 bekreftet anvendbar). Selve importen kjørt direkte mot ekte
|
||
`teecup_db` via et engangsskript i `teecup_api`-containeren (speiler
|
||
`import_international_personal_course`s kopier-inn-logikk nøyaktig) —
|
||
etterpå bekreftet med en read-only spørring: Larvik (18 hull, 6 utslag,
|
||
11 tee-ratinger, 95 koordinater) og Nesbyen (18 hull, 6 utslag, 11
|
||
tee-ratinger, 124 koordinater), begge eid av Erol Haagenrud, `golfapi_
|
||
course.has_gps`/`num_coordinates` korrigert for Nesbyen. **Rullet ut
|
||
2026-08-13** — migrasjon 067+068 kjørt mot ekte `teecup_db`,
|
||
`teecup_api` bygget/omstartet, ren oppstartslogg.
|
||
|
||
Disse må avklares før eller under de relevante fasene:
|
||
|
||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
||
`JWT_SECRET`. TeeCup bør bruke separate, uavhengige secrets. *(Sikkerhet)*
|
||
2. **Cache over flere prosesser:** In-memory `dict`-cache på `app.state` deles ikke
|
||
mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)*
|
||
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
|
||
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
|
||
4. **Scramble-grensesnitt — ✅ HELT FERDIG 2026-07-29.** Arkitekturen skal
|
||
ta høyde for formatet; eksakt UI-løsning spesifiseres senere.
|
||
**Utvidet 2026-07-25, DELVIS AVKLART OG BYGGET 2026-07-28:** brukeren
|
||
ba om statistikk over hvor mange utslag hver spiller har hatt (dvs.
|
||
hvor mange ganger spillerens drive ble valgt). Bygget for FRITTSTÅENDE
|
||
RUNDER (ADR-039 sitt delt-ball-format, `round_hole.selected_
|
||
participant_id`, migrasjon 036) — se FEATURE_BACKLOG.md ("Scramble:
|
||
statistikk over utslag brukt per spiller") for full detalj.
|
||
**Fullført 2026-07-29:** samme funksjon bygget for org-scopede
|
||
turnering-scramble/-greensome også (`hole_score.selected_
|
||
participant_id`, migrasjon 039) — samme mønster, se CHANGELOG.md
|
||
2026-07-29 for full detalj (validering, delt `submitStroke`-utvidelse,
|
||
ny `SelectedDriverSummary` i `session-scorecard.tsx`). Begge domener
|
||
dekket, ingen kjent gjenstående forskjell mellom scramble og greensome.
|
||
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
|
||
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
|
||
på at f.eks. et foursome ikke får per-spiller-scorer.
|
||
6. **Individuelle turneringer, flerrunde-turneringer og Order of Merit**
|
||
(reist 2026-07-26). **Grunnstruktur for de to første AVKLART samme
|
||
dag, se ADR-037:** ny, parallell org-scopet datamodell (IKKE en
|
||
utvidelse av ADR-033s `round`-tabeller), samme `tournament`-tabell
|
||
med ny `format_type`-diskriminator, flerrunde via ny
|
||
`tournament_round`-tabell, rå slag lagres + poeng caches per format
|
||
(samme mønster som match-play). **Fortsatt åpent:** de fem konkrete
|
||
formatenes egne poengregler (Københavner m.fl.), og Order of Merit
|
||
(sesong-sammenlagt på tvers av flere turneringer) — bekreftet som et
|
||
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
|
||
skrevet — ADR-037 er ren struktur-beslutning.
|
||
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
|
||
par-lag-konkurranse, reist 2026-07-28) — **AVKLART, BYGGET (backend +
|
||
frontend) OG LIVE SAMME DAG, se ADR-039.** `round.play_format` styrer
|
||
nå faktisk både HCP-beregning (via en portert versjon av turnering-
|
||
motoren) og et `format-result`-lese-endepunkt for scorekort-
|
||
presentasjon, med tilhørende UI for sideoppsett/skins-konfig/
|
||
matchstatus-visning — se FEATURE_BACKLOG.md/CLAUDE.md for full detalj
|
||
og etterfølgende samme-dags oppfølgingsrunder. "Flere flighter i én
|
||
frittstående runde" (egen seksjon i FEATURE_BACKLOG.md) forblir et
|
||
adskilt, ikke-relatert spørsmål (grupperer RUNDER, ikke deltakere
|
||
innad i én runde).
|
||
|
||
---
|
||
|
||
## ADR-065: Per-deltaker-fullføring av frittstående runder + scorekort på e-post — 2026-08-13
|
||
|
||
Brukerens konkrete scenario: fire spillere sammen i én runde, to går 18
|
||
hull, én går 9, én må avslutte etter 14. Med dagens modell
|
||
(`round.completed_at`, rundeomfattende) kan runden ikke fullføres for
|
||
de ferdige spillerne uten samtidig å låse scoreføring for de som
|
||
fortsatt spiller (`readOnly = completed` var alt-eller-ingenting) — og
|
||
ingen kunne få scorekortet sitt på e-post før HELE runden var ferdig.
|
||
|
||
Bekreftet med bruker (AskUserQuestion) og ett tillegg i etterkant:
|
||
1. **Rundens egen fullføring forblir MANUELL** — eieren trykker
|
||
fortsatt "Fullfør runde" til slutt (HCP-beregning/varsler skjer
|
||
fortsatt der, uendret). Per-deltaker-fullføring er KUN en nagging-
|
||
brems + trigger for e-post, IKKE en auto-cascade til hele rundens
|
||
fullføring.
|
||
2. **Scorekort på e-post sendes UMIDDELBART** når en spiller selv blir
|
||
ferdig, ikke samlet ved rundefullføring — mest nyttig nettopp for
|
||
spilleren som går av tidlig.
|
||
3. **Tillegg:** må også kunne sende scorekort til en enkelt spiller i
|
||
ETTERTID, lenge etter at runden allerede er fullført (glemt/tapt
|
||
e-post, ønske om å sende på nytt) — et eget on-demand-endepunkt,
|
||
ikke bundet til selve fullførings-transisjonen.
|
||
|
||
**Bevisst utenfor omfang:** delt-ball-formater (foursome/scramble/
|
||
greensome, som fullfører per SIDE via `round_side`, ikke per deltaker)
|
||
— brukerens eksempel er individuell slagspill. Strukturelt symmetrisk
|
||
follow-up når/hvis etterspurt.
|
||
|
||
**Datamodell (migrasjon 066):** `round_participant.completed_at
|
||
timestamptz`, ingen CHECK mot `round.completed_at` — applikasjonslaget
|
||
garanterer rekkefølgen (deltaker-fullføring skjer alltid FØR/uavhengig
|
||
av rundens egen, aldri etter at runden er låst).
|
||
|
||
**Tilgang — `completed` er et FLIGHT-STYRT felt, ikke eier-/selv-
|
||
only.** Speiler `update_hole`s eksisterende begrunnelse ("en lenket
|
||
medspiller kan registrere score for HELE flighten") — enhver med
|
||
tilgang til runden (eier ELLER lenket medspiller) kan avslutte/
|
||
gjenåpne EN HVILKEN SOM HELST deltaker, akkurat som de allerede kan
|
||
føre score for en hvilken som helst deltaker. Ny
|
||
`_FLIGHT_MANAGED_PARTICIPANT_FIELDS = {"completed"}`-konstant i
|
||
`rounds.py` hopper eksplisitt over både eier-only-sjekken og
|
||
selv-only-sjekken KUN for dette feltet — uendret for alle andre felt i
|
||
samme kall. Avvist med `ALREADY_COMPLETED` (409) hvis `round.
|
||
completed_at` allerede er satt — ingen åpning/lukking av
|
||
enkeltdeltakere etter at hele runden er låst.
|
||
|
||
**E-post — én delt funksjon, to mottakertyper.**
|
||
`_send_participant_round_summary(conn, round_id, participant_id,
|
||
round_label) -> bool` erstatter den gamle gjeste-only
|
||
`_send_guest_round_summaries` — slår nå opp BÅDE gjest (`guest_email`)
|
||
OG lenket konto (`app_user.email` via LEFT JOIN), returnerer om en
|
||
adresse fantes. Kun den FØRSTE `null → true`-transisjonen av
|
||
`completed_at` trigger e-post (idempotent — re-sending `completed:
|
||
true` når allerede sann, eller `completed: false`, sender aldri på
|
||
nytt automatisk). `complete_round` har en fallback-løkke: enhver
|
||
deltaker som fortsatt er `completed_at IS NULL` når hele runden
|
||
fullføres, får det satt nå OG e-post sendt der også — dekker det
|
||
vanlige tilfellet (ingen trykket "avslutt" underveis) uten
|
||
dobbel-sending for de som allerede var individuelt avsluttet.
|
||
`app/email.py`s `send_round_summary_email` fikk `is_linked_account`/
|
||
`round_id`-parametre: lenkede mottakere får en direkte
|
||
`/my-rounds/{round_id}`-lenke (ingen magic-link, de har allerede
|
||
konto) og hilsen med `app_user.first_name` (navneformat-regelen i
|
||
CLAUDE.md), gjester beholder den opprinnelige magic-link-CTA-en.
|
||
|
||
**Nytt on-demand-endepunkt** (tillegg 3 over): `POST /rounds/{round_
|
||
id}/participants/{participant_id}/send-scorecard` — fungerer UANSETT
|
||
fullføringsstatus, også lenge etter at runden er fullført. Samme
|
||
flight-styrte tilgang som `completed`-feltet, men bevisst INGEN
|
||
`ALREADY_COMPLETED`-sperre (leser og sender kun, muterer aldri
|
||
databasetilstand). 204 ved suksess, `NO_EMAIL_ADDRESS` (400) hvis
|
||
deltakeren ikke har noen registrert adresse.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh`. Egen scratch-database: 4-spiller
|
||
frittstående slagspill-runde (blanding lenket bruker + gjest med
|
||
e-post), ulikt antall hull spilt, avsluttet to deltakere manuelt via
|
||
PATCH — bekreftet e-post trigges umiddelbart med riktig mottaker-
|
||
variant (gjest vs. lenket, ulik lenke/hilsen), `ALREADY_COMPLETED` ved
|
||
forsøk etter rundefullføring, `complete_round` sender KUN til
|
||
ikke-allerede-avsluttede deltakere, en lenket medspiller (ikke eier)
|
||
kan avslutte en ANNEN deltaker (flight-styrt tilgang bekreftet), angre
|
||
(`completed: false`) fungerer før rundens egen fullføring, og det nye
|
||
on-demand-endepunktet fungerer også lenge etter rundefullføring.
|
||
Deployet til ekte `teecup_db`/`teecup_api` sammen med migrasjon 067
|
||
(GolfAPI-tillegg, se ADR-064-tillegg over), 2026-08-13.
|
||
|
||
**Tillegg samme dag — frontend-UI bygget og rullet ut.** `Player.
|
||
completed` lagt til (avledet fra `ApiParticipant.completed`), delt
|
||
`ParticipantCompletionActions`-komponent gjenbrukt i BÅDE
|
||
`PlayerHoleCards` (Score-fanen) og `PlayerList` ("Spillere og
|
||
runde"-fanen) -- "Avslutt for [navn]" (skjult når runden selv er
|
||
fullført, siden backend uansett avviser da), "Ferdig ✓ (N hull)" +
|
||
"Angre" (kun når ikke rundefullført), "Send scorekort" (alltid synlig,
|
||
uansett fullføringsstatus -- dekker "send til enkeltspiller i etterkant,
|
||
lenge etter fullføring"-tillegget). `allHolesEnteredForEveryone` og
|
||
hurtig-hopp-stripens "registrert"-sjekk teller nå en `completed`-spiller
|
||
som ferdig uavhengig av faktisk hull-dekning. `advanceWizardPlayer`
|
||
hopper over fullførte spillere i samlebånds-kjeden. Et ikke-spilt hull
|
||
for en fullført spiller viser nå "Ferdig" i stedet for "Registrer" +
|
||
den stiplede pluss-boksen.
|
||
|
||
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest. Ekte nettleser-
|
||
verifisering (ekte innlogging inkl. reell TOTP-2FA-kode generert fra
|
||
kontoens lagrede secret, ekte runde opprettet på Tjøme -- cachet bane,
|
||
0 nye GolfAPI-kall -- med to deltakere): "Avslutt for X"/"Send
|
||
scorekort" synlig på begge kort i begge faner, avslutning av gjesten
|
||
trigget e-post umiddelbart (ingen feil i `teecup_api`-loggen, SMTP
|
||
konfigurert), UI oppdaterte seg til "Ferdig ✓"-merke + "Angre"-lenke +
|
||
"Ferdig"-tekst i score-kolonnen, "Send scorekort" for eieren (lenket
|
||
konto, andre kodesti enn gjesten) ga "Sendt ✓"-tilbakemelding, "Angre"
|
||
tilbakestilte korrekt og var persistent etter fane-bytte. Bekreftet
|
||
visuelt lys+mørk. Ingen konsollfeil gjennom hele forløpet. Testrunden
|
||
slettet etter verifisering (`DELETE /rounds/{id}`, bekreftet fjernet fra
|
||
`teecup_db`). **Rullet ut 2026-08-13** -- ren frontend-endring (ingen ny
|
||
migrasjon), `docker compose build teecup_frontend && up -d
|
||
teecup_frontend`.
|
||
|
||
**Tillegg samme dag — "fullt scorekort" i e-posten, pluss dato/klokkeslett/
|
||
spilletid i hilsenen.** Bruker, rett etter forrige tillegg: e-posten burde
|
||
sende DET FULLE scorekortet ("all statistikk for runden"), ikke bare
|
||
slag/netto -- og hilsen-linjen burde vise dato, klokkeslett og hvor lang
|
||
tid runden tok, med eksempel: "Her er scorekortet ditt fra Tjøme
|
||
Golfklubb – Bane, i dag klokken 08:40 (du spilte 18 hull på 3t 42min,
|
||
sammen med 3 andre):". Et FØRSTE forsøk bygde en enkel vertikal
|
||
hull-per-rad-tabell (kolonner Hcp/Par/Slag/Netto/Poeng/Fw/Putt/GIR/
|
||
Innspill/Chip/Bunker/Straff, én rad per hull) -- bruker så testresultatet
|
||
og ba i stedet om at e-posten skal SE UT SOM selve Scorekort-siden i
|
||
appen (sendte skjermbilde av `round-scorecard.tsx`), ikke bare inneholde
|
||
de samme tallene i et annet oppsett. Hele HTML-tabellen ble derfor bygget
|
||
om fra bunnen -- se "faktisk skipet design" under, den vertikale
|
||
varianten eksisterer ikke lenger i koden.
|
||
|
||
**Faktisk skipet design (HTML-delen av `send_round_summary_email`,
|
||
email.py):** hull som KOLONNER (ikke rader), i to 9-hulls-blokker
|
||
("Ut"/"Inn", eller "Tot" for en 9-hullsrunde) -- porterer
|
||
`ScoreBlock`-komponenten i frontend/components/round-scorecard.tsx
|
||
rad-for-rad til ren HTML/inline-CSS: mørk hull-nummer-header, en grønn
|
||
Ut/Inn-oppsummeringskolonne, fargede score-merker (sirkel/firkant,
|
||
fylt/omrisset etter eagle/birdie/par/bogey/double -- samme
|
||
`_classify()`-terskel som frontend sin `classify()`), og glyffer for
|
||
Fw/Innspill (◎ traff, ↖/↗/↑/↓/←/→ retning, farget grønt/oransje) og GIR
|
||
(✓). Rad-etikettene ble kortet ned samme dag etter et oppfølgingsbilde
|
||
av selve e-posten (`Score`/`Netto`/`Poeng` → `Scr`/`Net`/`Pnt` -- tok for
|
||
mye bredde i en allerede trang 9-kolonners tabell). Under blokkene: fire
|
||
"total tiles" (Par/Score/Til par/Poeng), identisk med `TotalTile`-raden
|
||
nederst på selve Scorekort-siden. Radene graderer seg fortsatt -- en rad
|
||
(Netto/Poeng/Fw/Putt/GIR/Innspill/Chip/Bnk/Str/Any) tas kun med når
|
||
MINST ett hull faktisk har data for den, samme show*-mønster som
|
||
round-scorecard.tsx. Tekst-fallbacken (for klienter uten HTML-støtte)
|
||
beholdt sin egen, enklere hull-per-linje-form (samme feltsett, men kan
|
||
ikke meningsfullt gjengi et 2D-rutenett i ren tekst) -- ett bevisst,
|
||
akseptert avvik mellom de to variantene, resten av innholdet er identisk.
|
||
`RoundSummaryHole` (email.py) utvidet med `stroke_index`/`picked_up`/
|
||
`points` (Stableford, portert fra frontend sin `stablefordPoints`)/
|
||
`putts`/`tee_shot_result`/`approach_result`/`chip_count`/
|
||
`bunker_shot_count`/`penalty_strokes`/`anyway_strokes`.
|
||
|
||
**To reelle bugs funnet UNDER ende-til-ende-verifisering mot en ekte,
|
||
tidligere fullført runde (ikke bare isolert forhåndsvisning):**
|
||
1. `round.played_at` er KUN en `date`-kolonne -- ingen klokkeslett lagret
|
||
i det hele tatt. "Utslagstid"-feltet i ny-runde-veiviseren fanges opp
|
||
av wizard-state, men sendes faktisk ALDRI til backend
|
||
(`wizard-context.tsx` sender kun `played_at: s.date`) -- en egen,
|
||
allerede eksisterende feil, ikke rettet her (FEATURE_BACKLOG.md-verdig
|
||
funn, utenfor denne rundens omfang). Løsning: brukte `round.
|
||
started_at` (timestamptz, satt når runden faktisk startes) som
|
||
klokkeslett i stedet, med graceful fallback (kun dato, ingen
|
||
klokkeslett-ledd) når `started_at` mangler. Nytt `start_hole`-
|
||
parameter styrer Ut/Inn-rekkefølgen etter SPILLEREKKEFØLGEN (samme
|
||
sirkulære `holeOrder`-logikk som round-scorecard.tsx), ikke rå
|
||
hole_number.
|
||
2. Samme root cause forplantet seg til spilletid-utregningen:
|
||
`duration_start` falt opprinnelig tilbake til `round.played_at` (en
|
||
`date`) når `started_at` manglet -- ville krasjet med
|
||
`TypeError` ved subtraksjon mot en ekte `datetime`. Fjernet fallbacken
|
||
helt (ingen varighet vises hvis runden aldri ble "startet", i stedet
|
||
for å blande typer). Spilletiden bruker `round_participant.
|
||
completed_at` (DENNE spillerens egen sluttid) minus `round.
|
||
started_at`, med fallback til `round.completed_at` for historiske
|
||
runder fullført FØR migrasjon 066 (ingen individuell completed_at satt
|
||
den gang) -- verifisert direkte mot en ekte, gammel runde (started_at
|
||
06:30 UTC, completed_at 10:46 UTC → riktig utledet 4t16min).
|
||
|
||
**Verifisert:** `python3 -m py_compile` rent, 31/31 backend-tester ved
|
||
hver iterasjon. Isolert forhåndsvisning (monkeypatchet `_send_sync` for å
|
||
fange opp HTML-en uten å faktisk sende, 18 hull med data for ALLE
|
||
graderte felt inkl. eagle/dobbel bogey/plukket opp, fairway/GIR/chip/
|
||
bunker/straff/innspill i alle retninger) nettleser-rendret og
|
||
skjermbilde-sammenlignet direkte mot brukerens eget skjermbilde av
|
||
Scorekort-siden -- bekreftet visuelt samsvar (samme blokk-oppsett,
|
||
farger, glyffer, tiles). **Ekte ende-til-ende-kall** (`POST .../
|
||
send-scorecard`) mot en reell, tidligere fullført runde i produksjon,
|
||
gjentatt etter hver av de to bug-fiksene og etter etikett-forkortelsen
|
||
-- endte med 204 og ren `teecup_api`-logg. **Rullet ut 2026-08-14**
|
||
(flere delrunder samme kveld/natt) -- ren backend-endring (ingen ny
|
||
migrasjon), `docker compose build teecup_api && up -d teecup_api` hver
|
||
gang.
|
||
|
||
---
|
||
|
||
## ADR-066: Flaggturnering -- GPS-flaggplanting + runde 2+-scoreføring ("Del A") — 2026-08-14
|
||
|
||
Bruker ba om en tredelt utvidelse av Flaggturnering: (A) spilleren
|
||
"planter flagget" via GPS der slagbudsjettet tar slutt, (B)
|
||
turneringsledelsen kan slå av/på en kartoversikt over ALLE plantede
|
||
flagg (satellittfoto), synlig for spillere og tilskuere, (C) nytt format
|
||
Eclectic (brutto/netto/Stableford, beste resultat per hull på tvers av
|
||
en turnerings runder). **Kun Del A er bygget i denne runden** -- Del
|
||
B/C er fullt spesifisert i en godkjent plan, men ingen kode skrevet
|
||
ennå.
|
||
|
||
Bekreftet med bruker (AskUserQuestion, to runder):
|
||
1. Flagg-GPS bygges for BEGGE hjem -- frittstående runder OG
|
||
org-individuelle turneringer (Flaggturnering finnes i begge, selv om
|
||
nærmeste presedens -- ADR-048 slag-for-slag-GPS -- kun fantes for
|
||
frittstående runder).
|
||
2. **Full runde 2-scoreføring**, ikke bare en visuell markering --
|
||
spilleren som fullfører 18 hull MED slag igjen fortsetter reelt inn i
|
||
"runde 2" på samme play_order.
|
||
3. **Selvkorrigerende, interaktiv flyt** (brukerens eget forslag,
|
||
foretrukket fremfor mitt opprinnelige forslag om en passiv
|
||
påminnelse): spilleren trykker "Plant flagget" når som helst; appen
|
||
spør "Hullet du ut på hull N?" (N = første hull uten registrert score
|
||
i spillerekkefølgen). JA → flagget var prematurt, spilleren må føre
|
||
inn scoren for hull N og trykke "Plant flagget" på nytt fra neste
|
||
utslag (ingen innsending skjer). NEI → dette ER flagg-punktet, GPS
|
||
fanges, og appen spør om spilleren landet på green (i så fall:
|
||
avstand i meter+cm, som direkte påvirker resultatlisten -- jo nærmere
|
||
koppen, jo lengre har spilleren kommet blant andre som gikk tom på
|
||
samme hull). **Backend validerer UANSETT server-side** mot faktisk
|
||
registrert scoredata ("stol aldri blindt på klienten") -- klientflyten
|
||
er en UX-guide, ikke autoritativ.
|
||
|
||
**Datamodell -- to nye, HELT ISOLERTE tabellpar** (migrasjon 069
|
||
frittstående, 070 org, se filenes egne headerkommentarer for full
|
||
kolonneliste): `round_participant_flag_plant`/`tournament_round_
|
||
participant_flag_plant` (posisjon der spilleren gikk tom for slag, UNIQUE
|
||
på deltaker-id -- re-planting er slett+opprett-på-nytt, samme prinsipp
|
||
som `round_shot`) og `round_hole_flag_overflow`/`tournament_round_hole_
|
||
flag_overflow` (scoreføring for runde 2+). **Bevisst IKKE en `lap`-
|
||
kolonne på selve `round_hole`/`tournament_round_hole`** -- de tabellene
|
||
er delt av samtlige ni eksisterende formater og lest av et stort antall
|
||
spørringer (scorekort/statistikk/HCP-differensial) som alle antar ≤18
|
||
rader per deltaker; en delt utvidelse hadde krevd en revisjon av HVER
|
||
eksisterende spørring for å unngå at runde-2-rader lekker inn uventet
|
||
andre steder. En helt egen tabell holder all ny risiko innenfor selve
|
||
flagg-funksjonen. Org-tabellene er RLS-beskyttet, samme
|
||
`org_isolation`-mønster som migrasjon 040; frittstående-tabellene har
|
||
ingen RLS (samme `plain_connection()`-presedens som resten av
|
||
frittstående-rundesystemet).
|
||
|
||
**Motor:** ny, ren `flag_lap_and_hole(holes_completed, play_order) ->
|
||
(lap, hole_number)`-hjelpefunksjon i `handicap_engine.py`, rett etter
|
||
eksisterende `flag_result()`/`FlagResult`. Selve `flag_result()` viste
|
||
seg IKKE å trenge endring -- kalleren konkatenerer bare lap 1s fulle
|
||
gross_strokes-sekvens med lap 2s (kun hvis lap 1 var fullført) til én
|
||
flat liste før kallet; `holes_completed` blir naturlig en telling som
|
||
kan overstige 18. 5 nye tester i `test_handicap_engine.py` (budsjett
|
||
brukt opp midt i lap 2, budsjett akkurat nok til begge laps uten
|
||
plantbart punkt, lap/hull-oversettelse ved ikke-standard start_hole,
|
||
m.fl.) -- full suite 122/122.
|
||
|
||
**API (speilende ruter i `rounds.py`/`individual_tournaments.py`):**
|
||
`POST/GET/DELETE .../flag-plant`, `GET/PUT .../flag-overflow/{lap}/
|
||
holes/{hole_number}`. Server beregner FAKTISK "hull i gang" fra
|
||
eksisterende scoredata (`_flag_current_position`/`_flag_current_
|
||
position_org`) og avviser (`VALIDATION_FAILED`, 400) ethvert
|
||
plant-/overflow-forsøk som ikke stemmer -- selv om frontend allerede
|
||
guider brukeren dit. **To ulike autorisasjonsmodeller bevisst
|
||
beholdt, ikke slått sammen:** frittstående runder er flight-styrt
|
||
(eier ELLER lenket medspiller kan handle for enhver deltaker, samme som
|
||
`update_hole`); org-turneringer er selv-only (`user_is_own_tournament_
|
||
participant`, samme som den filens `update_hole`).
|
||
|
||
**Frontend -- ny UI-overflate, bygget via V0 (standingregel i
|
||
CLAUDE.md/minne).** Skrev en detaljert V0-prompt for `FlagPlantSheet`
|
||
(GPS auto-fanget → "Hullet du ut?" Ja/Nei → evt. "Landet du på green?"
|
||
+ meter/cm → bekreftelse), sendt til bruker. Bygde parallelt en tydelig
|
||
MERKET midlertidig håndkodet versjon (samme props-kontrakt) for å kunne
|
||
verifisere hele backend+integrasjonen uten å vente. **V0-eksporten (zip
|
||
11) kom tilbake samme dag** og erstattet den midlertidige versjonen i
|
||
BÅDE `round-detail.tsx` (`FlagPlantSheet`) og
|
||
`individual-tournament-detail.tsx` (`FlagPlantSheetOrg`, egen kopi per
|
||
filens konvensjon -- fikk i tillegg `expectedLap`-prop lagt til
|
||
kallstedet, som den midlertidige org-varianten manglet). Selvstendig
|
||
GPS-håndtering (ingen kartkomponent -- det er "Del B"), server-avvisning
|
||
vist som vedvarende inline-feil (ikke en forsvinnende toast).
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (46/46, inkl. 15 nye flagg-plant-
|
||
tester i to nye testfiler). Egen scratch-database + scratch
|
||
`teecup_api`-container (port 18000) + lokal `next dev` (port 13000):
|
||
ende-til-ende-flyt for BEGGE hjem, inkl. server-side avvisning av et
|
||
for-tidlig plantingsforsøk og full runde 2-scoreføring, geolocation
|
||
emulert via Chrome DevTools MCP (måtte omgå at `emulate()` kun setter
|
||
koordinater, ikke selve Permissions API-tilstanden -- løst med
|
||
`navigate_page({type:"reload", initScript: ...})` som overstyrer
|
||
`navigator.geolocation` direkte). Lys+mørk bekreftet, lap 2 sin
|
||
`(runde {lap})`-visuelle markering bekreftet live. Etter V0-swap:
|
||
`tsc --noEmit` rent, 45/45 vitest, begge filer. Scratch-stacken revet
|
||
ned igjen etterpå -- ingen av dette har rørt ekte `teecup_db`.
|
||
|
||
**Rullet ut 2026-08-14** -- migrasjon 069/070 kjørt mot ekte `teecup_db`
|
||
som `teeoff_admin` (`teecup_app` mangler bevisst `CREATE`-rett på schema
|
||
public, se ADR-002-begrunnelsen i `002_roles_and_grants.sql` -- migrasjoner
|
||
kjøres alltid av admin-rollen, aldri av runtime-rollen), etter eksplisitt
|
||
bekreftelse fra bruker. Alle fire tabeller verifisert direkte i skjemaet
|
||
etterpå (kolonner/constraints/RLS-policy som forventet), deretter
|
||
`docker compose build teecup_api teecup_frontend && up -d` -- begge
|
||
containere startet rent, ingen feil i logg. Del B (kartoversikt +
|
||
synlighetsbryter) og Del C (Eclectic) gjenstår, egne
|
||
scratch-verifiserings-/bekreftelsesrunder per CLAUDE.md/plan-disiplinen.
|
||
|
||
---
|
||
|
||
## ADR-067: Flaggturnering -- kartoversikt over plantede flagg + synlighetsbryter ("Del B") — 2026-08-14
|
||
|
||
Andre del av den tredelte Flaggturnering-utvidelsen (se ADR-066 for Del A
|
||
og full kontekst). Turneringsledelsen (frittstående: eier: org:
|
||
ethvert medlem) kan slå av/på en satellittkartoversikt som viser ALLE
|
||
deltakeres plantede flagg samtidig -- standard AV, samme "skjult til
|
||
avslørt"-filosofi som `visibility_mode`/blind draw. En spiller ser
|
||
ALLTID sitt eget flagg uansett bryter-status; det er kun ANDRES flagg
|
||
bryteren styrer.
|
||
|
||
**Avklart med bruker underveis (AskUserQuestion):** org-individuelle
|
||
turneringer har INGEN offentlig tilskuer-side i det hele tatt ennå
|
||
(`/t/[id]/live` er bygget utelukkende for lagturneringer -- team_a/
|
||
team_b/matcher, ingen `format_type`-gren for individuelt). Å bygge en
|
||
slik side fra bunnen for å kunne vise kartet der også ville vært en HELT
|
||
EGEN, mye større oppgave enn selve kartfunksjonen. Bruker valgte
|
||
(anbefalt alternativ) å AVGRENSE Del B sin org-side til org-medlemmer/
|
||
deltakere (samme tilgangsnivå som det eksisterende `flag-result`-
|
||
endepunktet) -- INGEN ny offentlig spectator-infrastruktur bygget her.
|
||
Frittstående runder FÅR full tilskuerstøtte, siden `/watch/[id]` og
|
||
`/public/rounds/*`-mønsteret allerede finnes og fungerer likt innlogget
|
||
som anonymt.
|
||
|
||
**Datamodell (migrasjon 071):** to enkle boolean-kolonner, `round.
|
||
flag_map_visible` og `tournament.flag_map_visible`, begge `NOT NULL
|
||
DEFAULT false`. Ingen nye tabeller -- gjenbruker flagg-plant-tabellene
|
||
fra migrasjon 069/070 (ADR-066) direkte, kun ETT nytt felt per hjem for
|
||
selve synlighets-bryteren.
|
||
|
||
**API:** ingen nye skrive-endepunkter for selve bryteren -- kun nye
|
||
felt på de allerede eksisterende PATCH-modellene (`RoundUpdate.
|
||
flag_map_visible`, eier-only via `_get_owned_round_or_404`;
|
||
`TournamentUpdate.flag_map_visible`, ethvert org-medlem via
|
||
`get_authorized_org` -- begge gjenbruker sine filers eksisterende PATCH-
|
||
handlere UENDRET, siden begge allerede var generiske "kun de feltene som
|
||
faktisk sendes" whitelist-drevne). Nytt LESE-endepunkt per hjem: `GET
|
||
/rounds/{id}/flag-map` (delt `_build_flag_map()`-hjelper, gjenbrukt av
|
||
BÅDE den autentiserte ruten og `GET /public/rounds/{id}/flag-map` for
|
||
tilskuere -- samme datauttrekk, ulik tilgangssjekk via
|
||
`_get_accessible_round_or_404` vs. `_get_viewable_round_or_404`) og `GET
|
||
/orgs/{id}/tournaments/{id}/rounds/{id}/flag-map` (kun autentisert,
|
||
`get_authorized_org`, se avgrensningen over). Responsen har alltid
|
||
`visible_to_all` + `flags`-listen -- ALLE deltakeres flagg når bryteren
|
||
er PÅ, ELLERS kun requesterens eget (0 eller 1 rad).
|
||
|
||
**Frontend -- nok en ny UI-overflate via V0.** Skrev en detaljert prompt
|
||
for `flag-map-overview.tsx` (FØRSTE flerpunkts-Mapbox-kart i appen --
|
||
alt annet Mapbox-innhold viser ett punkt om gangen), sendt til bruker.
|
||
Bygde parallelt en tydelig MERKET midlertidig håndkodet versjon (samme
|
||
props-kontrakt: `flags: FlagMapEntry[]`, `visibleToAll: boolean`, ren
|
||
UI uten egne nettverkskall) for å kunne verifisere hele funksjonen uten
|
||
å vente. Wired inn via tre tynne, selvstendige "seksjon"-komponenter
|
||
(egen fetch + `next/dynamic(..., {ssr:false})`-lasting av selve kartet,
|
||
samme mønster som `NassauPanel`): `FlagMapSection`
|
||
(`round-detail.tsx`, Administrer-fanen), `FlagMapSectionOrg`
|
||
(`individual-tournament-detail.tsx`, Score-fanen) og `WatchFlagMap`
|
||
(`watch-round.tsx`, tilskuer-siden -- viser ingenting i det hele tatt
|
||
når bryteren er av OG tilskueren ikke har noe eget flagg, i stedet for
|
||
en tom/forvirrende boks). Kartet skiller lap 1 fra lap 2+ med BÅDE farge
|
||
OG en tekst-badge ("R{lap}") på selve markøren, aldri farge alene
|
||
(fargeblindhet-hensynet i CLAUDE.md sin tilgjengelighetsregel), pluss en
|
||
alltid synlig tekst-legende.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (50/50, 4 nye flag-map-tester i de
|
||
samme to testfilene som ADR-066 -- "kun eget flagg vises når bryteren er
|
||
av"/"alle flagg vises når den er på", begge hjem). `tsc --noEmit` rent +
|
||
45/45 vitest etter frontend-endringene. Egen scratch-database + scratch
|
||
`teecup_api`-container (port 18000, kodendringer live-mountet inn i
|
||
containeren for å unngå gjenoppbygging ved hver iterasjon) + lokal `next
|
||
dev` (port 13000): sådde to komplette scenarioer direkte via
|
||
`tests/conftest.py` sine hjelpefunksjoner (ikke gjenimplementert) --
|
||
frittstående runde og org-turnering, hver med to spillere og ett flagg
|
||
plantet hver (én lap 1, én lap 2 med `on_green`+avstand). Bekreftet i
|
||
ekte nettleser: bryteren av → kun eget flagg + "Kun ditt eget flagg er
|
||
synlig"-banner; bryteren på → begge flagg, korrekt lap 2-merking, popup
|
||
med spillernavn/hull/runde/avstand ved trykk; samme for org-siden;
|
||
tilskuer-siden (`/watch/[id]`) viser begge flagg når bryteren er på,
|
||
uten å kreve innlogging. Lys+mørk bekreftet. Mapbox sin URL-restrikterte
|
||
offentlige token krevde samme `Referer`-header-omgåelse via Chrome
|
||
DevTools MCP som tidligere kartverifiseringer i denne loggen (se punkt
|
||
51 i CHANGELOG.md). Scratch-stacken (database, rolle, container, MinIO-
|
||
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
|
||
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.
|
||
|
||
**Rullet ut 2026-08-14** -- migrasjon 071 kjørt mot ekte `teecup_db`
|
||
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker. Begge nye
|
||
kolonner verifisert direkte i skjemaet. `docker compose build teecup_api
|
||
teecup_frontend && up -d` -- begge containere startet rent. Del C
|
||
(Eclectic) gjenstår.
|
||
|
||
---
|
||
|
||
## ADR-068: Eclectic-format for org-individuelle turneringer ("Del C") — 2026-08-14
|
||
|
||
Tredje og siste del av den tredelte utvidelsen (se ADR-066/067 for Del
|
||
A/B -- Del C er ikke Flaggturnering-relatert i seg selv, men bedt om i
|
||
samme runde). Eclectic er en "drømmerunde": spillerens BESTE resultat
|
||
per hullnummer på tvers av ALLE turneringens runder, summert til én
|
||
total. Tre varianter (brutto/netto/Stableford), samme mønster som
|
||
`stroke_gross`/`stroke_net`/`stableford` sin eksisterende oppsplitting.
|
||
|
||
**Fail-loudly samme-bane-krav:** Eclectic krever at ALLE turneringens
|
||
runder spilles på samme bane -- ellers betyr ikke "hull 5" det samme på
|
||
tvers av rundene (ulik par/stroke index/fysisk hull), og "beste resultat
|
||
per hull" blir meningsløst å sammenligne. Håndhevet ved rundeopprettelse
|
||
(`create_round`, individual_tournaments.py): avviser med
|
||
`VALIDATION_FAILED` hvis turneringens `scoring_method` starter med
|
||
`eclectic_` og den nye rundens `course_id` avviker fra en allerede
|
||
eksisterende runde. Samme ADR-019-filosofi som resten av appen (avvis
|
||
tydelig, ikke stille feil).
|
||
|
||
**Datamodell (migrasjon 072):** INGEN nye tabeller/kolonner -- kun tre
|
||
nye verdier i `tournament_scoring_method_check` (samme drop+recreate-
|
||
mønster som 042/044/046). Eclectic gjenbruker eksisterende
|
||
`tournament_round_hole` (per-hull bruttoslag, allerede lagret av alle
|
||
formater) og `tournament_round_participant.course_handicap` (allerede
|
||
beregnet for alle scoring_method-verdier) -- alt regnes ut VED LESING,
|
||
samme "regn ut ved lesing"-filosofi som resten av individuelle
|
||
turneringers leaderboard (Beslutning C).
|
||
|
||
**Motor:** ny, ren `eclectic_best_per_hole(values_by_hole, result_type)`
|
||
i `handicap_engine.py` -- for hvert hullnummer, plukker BESTE verdi
|
||
(lavest for brutto/netto, høyest for Stableford) blant alle registrerte
|
||
runde-verdier for det hullet, uavhengig av hvilken runde den kom fra.
|
||
Sporer også HVILKEN runde (via en kaller-tildelt `round_index`) hvert
|
||
plukk kom fra -- brukt til frontend sin "beste-kilde"-visning. Hull uten
|
||
noen registrering utelates (ikke tellet som 0). 5 nye tester, inkl. ett
|
||
scenario der beste resultat for ulike hull kommer fra ULIKE runder
|
||
(beviser at plukkingen faktisk skjer per hull, ikke "velg beste hele
|
||
runde") -- full motor-suite 127/127.
|
||
|
||
**API-integrasjon -- BEVISST avvik fra opprinnelig plan.** Planen
|
||
foreslo opprinnelig et helt nytt leseendepunkt for Eclectic. Etter å ha
|
||
studert `_compute_individual_standings` (den delte funksjonen bak
|
||
`individual_leaderboard`, allerede brukt av `stroke_gross`/`stroke_net`/
|
||
`stableford`/`copenhagen`/`bingo_bango_bongo`) viste det seg klart
|
||
renere å legge Eclectic til som EN NY GREN i den samme funksjonen --
|
||
`LeaderboardEntry` fikk `eclectic_total`/`eclectic_holes`-felt (alltid
|
||
`None`/tom for andre scoring_method-verdier), og en ny
|
||
`_attach_eclectic_totals()`-hjelper populerer dem. INGEN nytt
|
||
endepunkt -- frontend bruker det samme `GET .../individual-leaderboard`
|
||
den allerede kaller for alle andre formater. 5 nye pytest-tester
|
||
(gross/net/stableford-utregning, samme-bane-avvisning, bekreftet at
|
||
IKKE-eclectic-turneringer fortsatt tillater ulike baner) -- full
|
||
backend-suite 55/55.
|
||
|
||
**Frontend (individual-tournament-detail.tsx, håndkodet -- filens egen
|
||
uttalte konvensjon, se filens header-kommentar).** `SCORING_METHOD_
|
||
LABELS` fikk de tre nye verdiene. `LeaderboardTab` sin flate liste
|
||
(samme gren som Københavner/BBB bruker, ikke Augusta-resultattavlen som
|
||
er forbeholdt de tre opprinnelige slagspill-variantene) fikk en
|
||
utvidbar "vis hull-for-hull"-rad per deltaker -- ny `EclecticHoleTable`-
|
||
komponent viser hull/par/verdi/"Fra runde N" for nøyaktig de hullene som
|
||
faktisk teller, beviser visuelt for spilleren at totalen er satt sammen
|
||
på tvers av runder.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (55/55). `.venv/bin/python
|
||
test_handicap_engine.py` (127/127, standalone-selvsjekk uten pytest).
|
||
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||
`teecup_api`-container (port 18001, live-mountet kode) + lokal `next
|
||
dev` (port 13001): sådde én turnering med to runder på SAMME bane, én
|
||
deltaker med hull 1 = 4/7 slag og hull 2 = 6/3 slag på tvers av de to
|
||
rundene -- forhåndsberegnet forventet total (4+3=7) stemte nøyaktig med
|
||
det viste leaderboardet, og "vis hull-for-hull" viste riktig kilde-runde
|
||
per hull. Bekreftet i nettleser: samme-bane-avvisningen ga en synlig,
|
||
tydelig feilmelding ved forsøk på å opprette en runde på en annen bane.
|
||
Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned etterpå --
|
||
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||
|
||
**Samme økt, tre små ikke-relaterte UI-rettelser** (brukerrapportert via
|
||
skjermbilder, rettet opportunistisk før Del C): avstandsindikatoren
|
||
(`target-distance.tsx`) brukte "grønn" og "Midt" i stedet for de riktige
|
||
golf-uttrykkene "green"/"senter" -- rettet, samt fjernet "Oppdateres
|
||
live"-badgen (og det nå ubrukte `isLive`-sporet i
|
||
`hole-target-distance.tsx`) helt, etter brukerønske. "Ny runde"-
|
||
veiviserens "Antall hull"-bryter (`Segmented`-primitiven i
|
||
`components/ny-runde/primitives.tsx`) hadde en avvikende valgt-stil
|
||
(hvit/skygge) sammenlignet med resten av samme skjerm sine knappegrupper
|
||
(`ChoiceCard`/`ToggleButton`, grønn aksent-stil) -- rettet til samme
|
||
`border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]`-mønster,
|
||
gjelder alle `Segmented`-instanser appen-vidt (delt primitiv).
|
||
|
||
**Rullet ut 2026-08-14** -- migrasjon 072 kjørt mot ekte `teecup_db`
|
||
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
|
||
`docker compose build teecup_api teecup_frontend && up -d` -- begge
|
||
containere startet rent (ruller også ut de tre UI-rettelsene). Dette
|
||
var siste del av den tredelte Flaggturnering-utvidelsen -- Del A/B/C
|
||
alle bygget OG rullet ut.
|
||
|
||
---
|
||
|
||
## ADR-069: Offline-utvidelse -- kommentar-/bildeposting i rundefeeden (ADR-028-tillegg) — 2026-08-14
|
||
|
||
Foranledning: bruker spurte om installasjonsbanneret sin påstand
|
||
"virker delvis uten nett" faktisk stemte. Undersøkelse bekreftet at den
|
||
GJORDE det (ekte IndexedDB-skrivekø for hull-scoreføring, service
|
||
worker-cache for allerede besøkte sider) -- men bruker ønsket å gjøre
|
||
"delvis" til mer fullstendig. Presentert tre konkrete utvidelsesretninger
|
||
(AskUserQuestion); bruker valgte "Utvid offline-skriving til mer enn
|
||
scoreføring (kommentarer/bilder)" -- IKKE de to andre alternativene
|
||
(proaktiv full-runde-precaching ved åpning, eller et generelt app-skall
|
||
for en aldri-besøkt kaldstart).
|
||
|
||
**Datamodell (migrasjon 073) -- idempotens, IKKE en ny funksjon i seg
|
||
selv.** Hull-score-køen (ADR-028) er trygg å gjenta fordi PATCH +
|
||
`expected_version` konvergerer uansett hvor mange ganger den sendes.
|
||
`POST /rounds/{id}/messages` er IKKE naturlig idempotent -- hvert kall
|
||
oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
|
||
flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
|
||
samme kommentar to ganger. Løsning: `round_message` fikk en nullbar
|
||
`client_message_id uuid`-kolonne + en delvis unik indeks
|
||
`(round_id, client_message_id) WHERE client_message_id IS NOT NULL`.
|
||
Klienten genererer denne (`crypto.randomUUID()`) FØR første forsøk
|
||
(både det direkte online-forsøket og et evt. køet gjenforsøk sender
|
||
samme id) -- et gjentatt kall med samme id returnerer den allerede
|
||
opprettede meldingen (`post_round_message`, round_messages.py) i stedet
|
||
for å opprette en duplikat. 3 nye backend-tester (repetert id → samme
|
||
melding, ulike id-er → separate meldinger, manglende id → uendret
|
||
oppførsel for bakoverkompatible kallere) -- full backend-suite 58/58.
|
||
|
||
**offline-queue.ts utvidet til multipart, ikke bare JSON.** Køen
|
||
(opprinnelig kun `Content-Type: application/json`) fikk et nytt
|
||
`isMultipart`-flagg på `QueueEntry`. Når satt, er `body` et FLATT
|
||
objekt av string/Blob-felt i stedet for en JSON-serialiserbar verdi --
|
||
IndexedDB sin structured-clone-algoritme lagrer `Blob`/`File`-verdier
|
||
NATIVT, ingen egen blob-lagringsstruktur trengs. `flushQueue` bygger da
|
||
`FormData` i stedet for å JSON-serialisere. Uendret oppførsel for
|
||
eksisterende (hull-score-)oppføringer uten flagget.
|
||
|
||
**Frontend (round-messages.tsx) -- SAMME mønster som round-detail.tsx
|
||
sin hull-score-kø, men et EGET, isolert kø-navnerom.** `matchId:
|
||
\`${roundId}:messages\`` (ikke bare `roundId`) -- forhindrer at denne
|
||
komponentens kø noensinne blandes med round-detail.tsx sin egen
|
||
hull-score-kø i samme IndexedDB-database, selv om begge er montert på
|
||
samme side samtidig. Egne `isOnline`/`pendingCount`/`syncingMessages`-
|
||
tilstander, egne online/offline-lyttere, egen mount-tids-sjekk for
|
||
gjenværende kø fra en tidligere økt -- fullstendig frikoblet fra
|
||
round-detail.tsx sin tilsvarende logikk. Et lokalt "optimistisk kort"
|
||
(egen `PendingRoundMessage`-type, ALDRI blandet inn i den ekte
|
||
`messages`-listen) vises øverst med "Venter på synk" + klokke-ikon
|
||
inntil en vellykket synk trigger en full `load()` som erstatter det med
|
||
serverens fasit (fanger bl.a. opp tagger forkastet server-side).
|
||
Bilde-forhåndsvisningen bruker sin EGEN, dedikerte `URL.createObjectURL`
|
||
(ikke composerens `imagePreview`, som revokes med det samme av
|
||
`clearSelectedImage()`).
|
||
|
||
**Bevisst UTENFOR omfang:** kommentar-på-kommentar (`round_message_
|
||
comment`, `PostEngagement`-tråden) fikk IKKE samme idempotens/kø-støtte
|
||
-- kun rundefeedens hovedinnlegg (tekst+bilde), som var det brukeren
|
||
faktisk pekte på. Å gjenopprette et optimistisk kort etter en FULL
|
||
nettleser-lukking+gjenåpning mens offline er bevisst IKKE forsøkt
|
||
(bilde-forhåndsvisningens object-URL overlever uansett ikke en reload)
|
||
-- en køet skriving flushes likevel korrekt automatisk når nettet er
|
||
tilbake, den vises bare ikke optimistisk i mellomtiden. Denne
|
||
avgrensningen er testet eksplisitt (se under) og fungerer som forventet.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (58/58, migrasjon 073 inkludert).
|
||
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||
`teecup_api`-container (port 18002, live-mountet kode) + lokal `next
|
||
dev` (port 13002), ekte nettverk-frakoblet-emulering (Chrome DevTools
|
||
MCP sin `networkConditions: "Offline"`, IKKE bare CDP-nivå -- bekreftet
|
||
at appens egen `navigator.onLine`-avledede tilstand faktisk reagerte):
|
||
(1) tekst-only kommentar postet offline → optimistisk kort med
|
||
"Venter på synk" → satt online igjen → automatisk synk, banner
|
||
forsvant, EKTE melding med reaksjoner/slett-knapp dukket opp, bekreftet
|
||
NØYAKTIG 1 rad i databasen (ingen duplikat); (2) samme med bilde
|
||
(ekte opplastet PNG via Chrome DevTools sin `upload_file`) -- optimistisk
|
||
bilde-forhåndsvisning vist offline, ekte AVIF-konvertert MinIO-bilde
|
||
etter synk; (3) reload-mens-offline-scenario -- postet en tredje
|
||
kommentar offline, lastet siden PÅ NYTT mens fortsatt offline (simulerer
|
||
at appen lukkes/gjenåpnes), satt online igjen -- den køede skrivingen
|
||
fra FØR reload ble automatisk funnet og synket ved mount, bekreftet
|
||
NØYAKTIG 3 rader totalt (ingen duplikat på tvers av reload). Lys+mørk
|
||
bekreftet. Scratch-stacken (database, rolle, container, MinIO-
|
||
scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
|
||
`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.
|
||
|
||
**Rullet ut 2026-08-14** -- migrasjon 073 kjørt mot ekte `teecup_db`
|
||
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
|
||
`docker compose build teecup_api teecup_frontend && up -d` -- begge
|
||
containere startet rent.
|
||
|
||
---
|
||
|
||
## ADR-070: Utvidet spillerskjema i "Deltakere" -- for-/etternavn, betalt, kommentar + bla i eksisterende (2026-08-14)
|
||
|
||
Foranledning: bruker viste to skjermbilder av "Deltakere"-flyten i en
|
||
org-individuell turnering (`individual-tournament-detail.tsx`). Søkefeltet
|
||
tilbød KUN "Opprett ny spiller: «Erik»" -- ingen måte å velge en allerede
|
||
eksisterende spiller i org-poolen. Brukeren ba samtidig om at midlertidige/
|
||
nyopprettede spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato,
|
||
E-postadresse, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp,
|
||
Sjekkboks for betalt, Kommentar.
|
||
|
||
**Rotårsak for "kan ikke velge eksisterende": ikke en datalastings-bug.**
|
||
`AddParticipantControl` sin `matches`-liste ble KUN utledet når søkefeltet
|
||
hadde en ikke-tom, substring-matchende query -- det fantes ingen måte å
|
||
bla i poolen uten allerede å kjenne et treffende navn. Fikset ved å legge
|
||
til et `available`-memo (`pool` minus allerede lagt-til deltakere) som
|
||
`matches` faller tilbake til når søket er tomt, i stedet for å returnere
|
||
`[]`. `MAX_VISIBLE_MATCHES` (8, opp fra hardkodet 6) begrenser visningen.
|
||
|
||
**7 av 11 ønskede felt fantes allerede i `player`-skjemaet** (migrasjon
|
||
007: `birth_date`, `email`, `club_member_number`, `club`, `country`,
|
||
`handicap_index`, `gender`) -- bare ikke eksponert i DENNE UI-flyten.
|
||
`first_name`, `last_name`, `paid`, `comment` manglet reelt -- migrasjon
|
||
074 legger dem til på `player`, med samme for-/etternavn-splitt-mønster
|
||
som migrasjon 052 (`round_participant.guest_first_name/guest_last_name`):
|
||
`display_name` beholdes UENDRET som det faktisk viste navnet alle andre
|
||
steder i appen leser (leaderboards, matcher, rostring) -- `first_name`/
|
||
`last_name` er en NY, valgfri kilde ved siden av, ikke en erstatning.
|
||
Backfill for eksisterende spillere bruker samme "første ord = fornavn,
|
||
resten = etternavn"-heuristikk som migrasjon 052 sin backfill.
|
||
|
||
**Bevisst avvik fra det etablerte "backend synker display_name"-mønsteret:**
|
||
migrasjon 052/015 lot backend beregne/synke et avledet visningsnavn fra
|
||
for-/etternavn ved skriving. Her lot jeg i stedet FRONTEND beregne
|
||
`display_name = \`${firstName} ${lastName}\`.trim()` før sending, som et
|
||
helt normalt felt sammen med de nye `first_name`/`last_name`-feltene --
|
||
for å unngå å røre `create_player`/`update_player` sin eksisterende
|
||
kontrakt (generisk, whitelist-drevet PATCH via Pydantic sin
|
||
`exclude_unset`, brukt av mengder av eksisterende kallere). `paid`/
|
||
`comment` er ren registreringsadministrasjon, ingen kobling til noe
|
||
eksisterende.
|
||
|
||
**Frontend:** `AddParticipantControl` fikk et progressivt avslørt
|
||
skjema -- Fornavn/Etternavn/Kjønn/Hcp alltid synlig, resten bak en
|
||
"+ Flere detaljer"-knapp (Fødselsdato, E-post, Hjemmeklubb,
|
||
Medlemsnummer, Land, Betalt-avkrysning, Kommentar). `splitName()`-
|
||
hjelpefunksjon forhåndsutfyller Fornavn/Etternavn fra søkefeltets
|
||
frittekst når "Opprett ny spiller"-knappen trykkes (samme heuristikk som
|
||
backfillen).
|
||
|
||
**Fant og fikset underveis (selvfunnet under nettleserverifisering, ikke
|
||
brukerrapportert):** tom-tilstand-teksten når `available.length === 0`
|
||
sa "Ingen eksisterende spillere i organisasjonen ennå" uansett årsak --
|
||
misvisende når poolen faktisk HAR spillere, de er bare allerede lagt til
|
||
denne turneringen. Splittet i to riktige meldinger (`pool.length === 0`
|
||
vs. alle allerede lagt til), pluss en tredje ny melding for "søkte, null
|
||
substring-treff blant eksisterende" (viste tidligere ingenting).
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert).
|
||
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||
`teecup_api`-container (live-mountet kode) + lokal `next dev`: opprettet
|
||
en eksisterende spiller i poolen ("Kari Nordmann") og bekreftet den nå
|
||
dukker opp direkte ved åpning av "Legg til deltaker" med tomt søkefelt
|
||
(fikset bug), opprettet en ny spiller med alle 11 felt utfylt via det
|
||
utvidede skjemaet og bekreftet samtlige lagret korrekt i databasen.
|
||
Lys+mørk bekreftet, inkl. det utvidede skjemaet med avkrysningsboks.
|
||
|
||
**Verifiseringsfallgruve, ikke en app-bug:** Chrome DevTools MCP sin
|
||
`fill`-verktøy satte visuelt riktig verdi på et natively multi-segment
|
||
`<input type="date">`, men trigget ikke Reacts `onChange` pålitelig --
|
||
`birth_date` kom tilbake `null` fra API-et til tross for at feltet viste
|
||
riktig dato rett før innsending. Bekreftet ved å gjenta med
|
||
tastatur-drevet `press_key` inn i dato-feltets spinbuttons i stedet --
|
||
lagret korrekt. Ingen kodeendring nødvendig; kun automatiserings-
|
||
verktøyets begrensning med native dato-inputs, ikke en reell
|
||
applikasjonsbug.
|
||
|
||
**Tillegg samme dag:** bruker ba om samme fiks i lag-turneringers
|
||
roster-tillegg (`tournament-detail.tsx`, `AddPlayerControl`) etter å ha
|
||
fått påpekt den strukturelt identiske begrensningen der. Samme
|
||
"available"-fallback-mønster (utledet av `matches`, ikke en egen
|
||
tilstand) lagt til -- spillere allerede rostret på det ANDRE laget
|
||
forblir i listen, nedgradert/deaktivert med "allerede på {lag}", kun
|
||
spillere på DETTE laget ekskluderes fra `matches`. Tom-tilstand-teksten
|
||
splittet på samme måte (pool tomt / alle allerede lagt til / søk uten
|
||
treff). Ingen migrasjon -- ren frontend-endring. `tsc --noEmit` rent +
|
||
45/45 vitest. Egen scratch-database + scratch `teecup_api`-container
|
||
(port 18004, live-mountet kode) + lokal `next dev` (port 13004): to
|
||
lag, tre poolspillere (én allerede rostret på det andre laget) --
|
||
bekreftet at kontrollen viser alle tre direkte ved tomt søk (riktig
|
||
nedgradert for den som er opptatt), søk smalner riktig inn, og
|
||
"Legg til"-flyten for en eksisterende spiller fungerer uendret. Lys+mørk
|
||
bekreftet. Scratch-stacken revet ned -- ekte `teecup_db`/`teecup_api`/
|
||
`teecup_frontend` urørt (ingen migrasjon å rulle ut for dette tillegget).
|
||
|
||
**Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db`
|
||
som `teeoff_admin` (4 eksisterende spillere backfillet), etter
|
||
eksplisitt bekreftelse fra bruker. `docker compose build teecup_api
|
||
teecup_frontend && up -d` -- begge containere startet rent.
|
||
|
||
---
|
||
|
||
## ADR-071: Full statistikkdybde i org-turneringers hull-scoring, paritet med frittstående runder ("Steg 1" av spillerens per-hull-historikk) (2026-08-15)
|
||
|
||
Foranledning: bruker ba om å kunne se full statistikk basert på ALLE
|
||
tidligere ganger en spiller har spilt et gitt hull. Undersøkelse avdekket
|
||
at `tournament_round_hole` (org-turneringer) aldri har hatt noe utover
|
||
`gross_strokes` siden den ble opprettet (migrasjon 040) -- ingen putts/
|
||
utslag-/innspillretning/bunker-/straffeslag-detalj, og ingen UI for det i
|
||
`individual-tournament-detail.tsx` sin `HoleGrid`. Historikk-funksjonen
|
||
kan derfor kun bli "full" for turneringssiden hvis den datadybden bygges
|
||
FØRST. Bekreftet med bruker (AskUserQuestion): begge datakilder skal
|
||
telle med, med full dybde i begge -- valgte det tyngste alternativet
|
||
fremfor "kun frittstående" eller "turnering kun med slagtall".
|
||
|
||
**Migrasjon 075**: `tournament_round_hole` fikk nøyaktig samme nye felt
|
||
som `round_hole` allerede har (`putts, club_off_tee, tee_shot_result,
|
||
approach_result, chip_count, bunker_shot_count, penalty_strokes,
|
||
anyway_strokes, first_putt_distance_bucket`), pluss `version` (ny
|
||
optimistisk-lås-kolonne for denne tabellen -- endepunktet var til nå rent
|
||
siste-skriver-vinner, ulikt `round_hole` sitt ADR-057/migrasjon-062-
|
||
mønster). `tournament_participant` fikk `stat_level` (samme tre verdier
|
||
som `round_participant.stat_level`) -- lagt PÅ TOURNAMENT-NIVÅ (ikke per
|
||
runde), fordi en deltaker normalt spiller flere runder i samme turnering
|
||
og ikke skal måtte velge nivå på nytt hver gang. Trygg default
|
||
(`strokes_only`) -- ingen eksisterende turnering endrer oppførsel før
|
||
noen eksplisitt hever nivået.
|
||
|
||
**Backend** (`individual_tournaments.py`): `HoleUpdate`/`RoundHoleOut`
|
||
utvidet feltnavn-for-feltnavn etter `rounds.py` sin ekvivalent.
|
||
`update_hole` sin `INSERT ... ON CONFLICT DO UPDATE` fikk en
|
||
`WHERE (expected_version IS NULL OR tournament_round_hole.version =
|
||
expected_version)`-betingelse på DO UPDATE-grenen -- MERK at dette er en
|
||
reell forskjell fra `round_hole` sitt mønster: der finnes raden alltid
|
||
fra rundestart (ren `UPDATE`), her opprettes raden først ved FØRSTE
|
||
score, så INSERT-grenen må være ubetinget (en fersk innsending skal
|
||
aldri kunne 409-blokkeres av en `expected_version` som ikke gir mening
|
||
ennå). `TournamentParticipantCreate/Update/Out` + `_TOURNAMENT_
|
||
PARTICIPANT_COLUMNS` fikk `stat_level` som ren whitelist-tilføyelse
|
||
(samme mønster som migrasjon 074 sin `paid`/`comment`, ingen
|
||
handler-logikk-endring). `RoundParticipantOut` fikk `stat_level` speilet
|
||
inn (join via `tournament_participant`) slik at frontend vet hvilken
|
||
UI-vei å ta per deltaker.
|
||
|
||
**Frontend**: `NumberPicker`/`ChoiceRow`/`DirectionCross`/`Stepper`/
|
||
`WizardSection` (+ `golfTermForScore`-hjelperen) trukket UT av
|
||
`round-detail.tsx` til en ny delt fil `hole-stat-inputs.tsx` -- ren
|
||
mekanisk utrekking (ingen atferdsendring for `ScoringWizard`, bekreftet
|
||
med full `tsc`/vitest etterpå), nødvendig fordi begge scoringsflytene nå
|
||
trenger identiske trykkbaserte inputs (ALDRI dropdowns i selve
|
||
hull-registreringen -- egen, bevisst UI-regel for denne typen felt,
|
||
IKKE en generell "ingen select-elementer i appen"-regel: `class_id`/nye
|
||
`stat_level`-velgerne i organisatorens deltakerliste er vanlige
|
||
`<select>`, samme presedens som eksisterende klasse-velger der).
|
||
|
||
Nytt `HoleStatsSheet` i `individual-tournament-detail.tsx` -- ETT
|
||
skjermbilde (IKKE en flerstegs-veiviser som `ScoringWizard`, siden
|
||
`HoleGrid` allerede har valgt ETT hull for ÉN allerede valgt deltaker,
|
||
ikke en hel spillerekkefølge å bla gjennom). `HoleGrid` sin egen
|
||
`editingHole`/`draft`-tilstand (enkelt inline-tallfelt) er BEVISST
|
||
UENDRET for `stat_level="strokes_only"`-deltakere -- kun `stat_level !=
|
||
"strokes_only"` åpner det nye sheet-et. `stat_level` redigeres i
|
||
"Deltakere"-listen under "Oppsett" (ny `<select>` ved siden av den
|
||
eksisterende klasse-velgeren).
|
||
|
||
**Bevisst avgrenset:** `ownBagClubs` sendes tom (`[]`) til
|
||
`HoleStatsSheet` -- "din egen kølle-bag" krever å vite om scoreren ER
|
||
spilleren selv (`player.user_id` mot innlogget bruker), som
|
||
`individual-tournament-detail.tsx` ikke allerede henter noe sted.
|
||
`ClubPicker` fungerer fint uten (faller tilbake til fritekst) -- en
|
||
convenience-nicety utelatt, ikke en mangel.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (64/64, 6 nye tester i
|
||
`test_tournament_hole_stats.py`: versjonsøkning, 409 ved stale version,
|
||
`expected_version=None` omgår sjekken, FØRSTE skriving lykkes uansett
|
||
feil `expected_version` (INSERT-grenen ubetinget), full feltrundtur,
|
||
`stat_level`-default+eksplisitt-verdi ved opprettelse). `tsc --noEmit`
|
||
rent + 45/45 vitest. Egen scratch-database + scratch `teecup_api`
|
||
(port 18005, live-montert kode) + lokal `next dev` (port 13005): to
|
||
deltakere (`strokes_only` og `full`) i samme runde -- bekreftet
|
||
`HoleStatsSheet` åpner og lagrer alle felt korrekt for `full`-deltakeren
|
||
(inkl. gjenåpning med forhåndsutfylte verdier), OG at `strokes_only`-
|
||
deltakerens opprinnelige inline-tallfelt fungerer helt uendret (ingen
|
||
regresjon). Lys+mørk bekreftet, inkl. retning/detaljer-seksjonene.
|
||
Scratch-stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||
`teecup_frontend` urørt.
|
||
|
||
**Neste steg (Steg 2, egen ADR):** selve historikk-aggregeringen på
|
||
tvers av alle spilte runder/turneringer -- denne ADR-en leverer kun
|
||
datadybden Steg 2 er avhengig av.
|
||
|
||
**Rullet ut 2026-08-15** -- migrasjon 075 kjørt mot ekte `teecup_db` som
|
||
`teeoff_admin`, etter eksplisitt bekreftelse fra bruker. `docker compose
|
||
build teecup_api teecup_frontend && up -d` -- begge containere startet
|
||
rent.
|
||
|
||
---
|
||
|
||
## ADR-072: Spillerens per-hull-historikk på tvers av alle runder/turneringer ("Steg 2") (2026-08-15)
|
||
|
||
Foranledning: se ADR-071 -- dette er selve historikk-funksjonen bruker ba
|
||
om, bygget oppå statistikkdybde-pariteten ADR-071 leverte. Bekreftet med
|
||
bruker (AskUserQuestion): custom/håndlagde baner ekskluderes helt (ingen
|
||
pålitelig felles identitet); ordinære (teeoff-/GolfAPI-importerte) baner
|
||
telles med fra BÅDE frittstående runder og org-turneringer, slått sammen
|
||
til én historikk når det faktisk er samme fysiske bane.
|
||
|
||
**Ny delt modul `app/hole_history.py`** (mønster: `app/blind_draw.py`/
|
||
`app/team_authz.py`, ikke db-fri som `handicap_engine.py` siden dette er
|
||
datainnhenting, ikke handicap-regler). Kjernen er bane-broen mellom to
|
||
helt forskjellige identitetssystemer:
|
||
- Teeoff: `round.teeoff_facility_slug`+`teeoff_course_id` (frittstående)
|
||
`<->` `course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"`
|
||
når `course.source='official'` (org-turnering) -- eksakt samme
|
||
streng-format som `import_official_course()` i `courses.py` allerede
|
||
bygger den med, verifisert mot kilden før bruk.
|
||
- GolfAPI: `personal_course.external_golfapi_course_id` `<->`
|
||
`course.external_course_ref` når `course.source='international'` --
|
||
begge lagrer samme rå GolfAPI-ID, uten prefiks.
|
||
- Custom (`round.course_source='custom'` med `personal_course.external_
|
||
golfapi_course_id IS NULL`, eller `course.source='custom'`) gir
|
||
bevisst `None` fra begge resolve-funksjonene -- kalleren skjuler da
|
||
historikk-panelet stille, ingen feilmelding.
|
||
|
||
`fetch_personal_hole_history` går via `round_participant.user_id`
|
||
(samme presedens som `/rounds/stats/summary`). `fetch_tournament_hole_
|
||
history` må derimot håndtere at spilleren kan ha spilt turnering i
|
||
FLERE organisasjoner -- `tournament_round_hole`/`course`/`tournament_
|
||
participant` er alle org-scopet/RLS-beskyttet, så ett enkelt `org_
|
||
connection()`-kall kan aldri dekke alle. Løst med SAMME N+1-per-org-
|
||
mønster som `/auth/me` allerede bruker: `player_organizations_for_user()`
|
||
(migrasjon 015, en smal `SECURITY DEFINER`-bro som kun eksponerer en
|
||
org-ID-liste) gir org-listen trygt via `plain_connection()`, deretter ett
|
||
`org_connection(org_id)`-kall per org. Ingen superuser-/bypass-RLS-
|
||
snarvei (ufravikelig regel, CLAUDE.md).
|
||
|
||
GIR-/fairway-formlene speiler den ETABLERTE `score - putts <= par - 2`
|
||
(rounds.py/round-stats.tsx), IKKE skjema-kommentarens avvikende formel
|
||
(samme presisering som ADR-071). `summarize_hole_history()` er en ren
|
||
funksjon (ingen db) -- regner GIR%/fairway%/snitt-putter KUN over
|
||
instanser som faktisk har putts/utslagsretning registrert (en
|
||
`strokes_only`-historisk rad bidrar til snitt-slag, men trekkes ikke inn
|
||
i GIR%-nevneren), sorterer mest-nylig-først med udaterte turneringsrunder
|
||
(`tournament_round.scheduled_at` er nullbar) sist.
|
||
|
||
**To tynne endepunkt**, ett i hver router, begge kaller inn i samme
|
||
`hole_history_for_user()` og returnerer SAMME kombinerte historikk
|
||
uansett hvilken side spørringen kom fra:
|
||
- `GET /rounds/{id}/participants/{pid}/holes/{n}/history` (`rounds.py`)
|
||
- `GET /orgs/{org}/tournaments/{tid}/rounds/{rid}/participants/{pid}/holes/{n}/history`
|
||
(`individual_tournaments.py`)
|
||
|
||
Historikken er for DELTAKEREN (`participant_id`), ikke nødvendigvis den
|
||
innloggede brukeren -- en lenket medspiller kan se en annens historikk
|
||
mens de fører score for flighten, samme tilgang som selve scoringen.
|
||
Gjester (`round_participant.user_id`/`player.user_id` er `NULL`) gir
|
||
`None` fra endepunktet -- samme "skjul panelet stille"-kontrakt som en
|
||
custom bane, ingen feil.
|
||
|
||
**Frontend**: nytt `HoleHistoryPanel` i den delte `hole-stat-inputs.tsx`
|
||
(egen unntak fra filens ellers rene "ingen datahenting"-regel, siden
|
||
begge scoringsflytene trenger nøyaktig samme henting/visning). Henter
|
||
selv (`GET .../history`), viser INGENTING når responsen er `null`
|
||
(laster ELLER ingen historikk -- ingen synlig forskjell, unngår et
|
||
flimrende spinner-perifert-panel). Ekspanderbar: lukket viser ett
|
||
sammendrag ("Spilt N ganger før (X frittstående, Y turnering) -- snitt A
|
||
slag (+/-par), GIR B%, C putter i snitt"), åpen viser hver enkeltinstans
|
||
(dato, kilde, resultat). Lagt til i `ScoringWizard` sitt strokes-steg
|
||
(`round-detail.tsx`) og i det nye `HoleStatsSheet` (`individual-
|
||
tournament-detail.tsx`, `ADR-071`) -- samme komponent, ulik URL.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (73/73 -- 12 nye tester i
|
||
`test_hole_history.py`: 6 rene enhetstester av `summarize_hole_history`
|
||
(GIR%/fairway%/snitt-eksklusjon, sortering), pluss integrasjonstester som
|
||
BEVISER selve bane-broen -- samme spiller, samme teeoff-bane spilt både
|
||
frittstående OG i en turnering i en ANNEN organisasjon enn spilleren
|
||
selv eier noe i, kombinert historikk viser begge; en custom personal_
|
||
course (ingen GolfAPI-kobling) resolve-r til `None`; en gjeste-
|
||
turneringsdeltaker (`player.user_id IS NULL`) gir `None`-historikk).
|
||
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||
`teecup_api`-container (port 18006, live-montert kode) + lokal `next
|
||
dev` (port 13006): samme spiller, samme teeoff-bane (facility/course-ID
|
||
matchende), én frittstående runde-hull + én turneringsrunde-hull i en
|
||
ANNEN org -- bekreftet at BEGGE kontekster (ScoringWizard OG
|
||
HoleStatsSheet) viser identisk kombinert historikk ("Spilt 2 ganger før
|
||
(1 frittstående, 1 turnering) -- snitt 4.5 slag (+0.5 til par), GIR 50%,
|
||
1.5 putter i snitt" -- håndregnet og bekreftet korrekt), ekspandert
|
||
instansliste viser begge kildene med riktig dato/kilde-merking (udatert
|
||
turneringsrunde viste korrekt "Ukjent dato"), og et upsilt hull viste
|
||
INGEN panel (stille skjult, ikke en feil). Lys+mørk bekreftet. Scratch-
|
||
stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
|
||
`teecup_frontend` urørt.
|
||
|
||
**Ingen migrasjon** -- rent lesefunksjon oppå migrasjon 075 sitt skjema.
|
||
|
||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||
bruker. Begge containere startet rent.
|
||
|
||
---
|
||
|
||
## ADR-073: Regresjon i "Ny runde"-veiviseren -- eierens statistikknivå (og utslag ved endring) ble ikke lagret (2026-08-15)
|
||
|
||
Foranledning: bruker rapporterte at de "til stadighet må gå tilbake å
|
||
sette utslagssted og statistikktype på nytt før runden begynner", og at
|
||
"det må ha blitt borte i prosessen, for det fungerte tidligere" --
|
||
vedla en skjermopptak-video (`Temp-uploads/Video_2026-08-15_070115.mp4`,
|
||
lest via `ffmpeg` hentet på farten som npm-pakke `ffmpeg-static`, siden
|
||
ingen video-verktøy fantes fra før og `apt`/`pip` krevde sudo).
|
||
|
||
**Videoen viste presist to symptomer for RUNDE-EIEREN** (frittstående
|
||
runde, `ny-runde/`-veiviseren): (1) endres utslag i "Bane & tid"-
|
||
bekreftelsesskjermen ETTER at banen først ble valgt, viser steg 3 sitt
|
||
spillerkort fortsatt det GAMLE utslaget -- (2) "Statistikk for deg
|
||
selv"-valget i steg 2 ("Spilleform") hadde INGEN effekt på den faktiske
|
||
innsendingen -- runden ble alltid opprettet med `strokes_only` ("Kun
|
||
slag") uansett hva som ble valgt der, bekreftet ved å åpne "Rediger" på
|
||
eget spillerkort i steg 3 rett før innsending.
|
||
|
||
**Rotårsak (`wizard-context.tsx`/`step1-course-time.tsx`/`step2-
|
||
format.tsx`):** eierens spillerobjekt i `state.players[]` har SINE EGNE
|
||
`teeId`/`statLevel`-felt, atskilt fra de øverste `state.teeId`/`state.
|
||
statLevel`-feltene steg 1/steg 2 faktisk redigerer. Disse ble tidligere
|
||
KUN synkronisert i ett øyeblikk -- da banen først velges (`pickCourse()`
|
||
i `step1-course-time.tsx`, som satte begge samtidig). Endres utslag
|
||
IGJEN i bekreftelsesskjermen (`Fields()`, linje 746, `onChange={(e) =>
|
||
patch({ teeId: ... })}`), oppdateres kun det øverste feltet -- for
|
||
utslag var dette rent KOSMETISK (selve `submit()` i `wizard-context.tsx`
|
||
leser `state.teeId` direkte for eieren ved selve rundeopprettelsen, se
|
||
linje 307/311), men for statistikknivå var det en REELL datafeil:
|
||
`submit()` sin `stat_level`-verdi for eieren leses fra `owner?.
|
||
statLevel` (spillerobjektet, linje 317) -- IKKE fra `state.statLevel`.
|
||
`owner.statLevel` settes KUN til `"score"` hardkodet i `loadMe()`
|
||
(linje 217) når `/auth/me` laster, og synkroniseres ALDRI fra `state.
|
||
statLevel` når brukeren faktisk gjør et valg i steg 2 -- "Statistikk for
|
||
deg selv"-seksjonen påvirket dermed KUN default-verdien for NYE
|
||
medspillere lagt til senere (`defaultStat`-proppen til `AddPlayerPanel`,
|
||
uendret/fortsatt riktig), aldri eierens eget valg.
|
||
|
||
**Ikke en ny mangel -- en bekreftet regresjon:** kommentaren over
|
||
`pickCourse()` sin manuelle engangs-synk siterte eksplisitt at den
|
||
"matcher eksakt" den opprinnelige (nå oppdelte) `new-round.tsx` sin
|
||
`chooseCourse()`-logikk for UTSLAG -- selve engang-synk-mønsteret er
|
||
altså gammelt og forsettlig replikert ved en tidligere refaktorering til
|
||
dagens `ny-runde/`-mappestruktur. Ingenting tilsvarende fantes noensinne
|
||
for STATISTIKKNIVÅ i denne koden -- det er uklart om en eldre
|
||
enkeltfil-versjon av veiviseren hadde `owner.statLevel` lest direkte fra
|
||
`state.statLevel` uten et eget spillerfelt (brukerens "det fungerte
|
||
tidligere" tyder på det), men konklusjonen er uansett den samme: i
|
||
DAGENS kode har "Statistikk for deg selv" vært virkningsløs for eieren
|
||
siden splittingen til separate steg-komponenter, sannsynligvis
|
||
usett fordi standardverdien (`strokes_only`) er nettopp det de fleste
|
||
runder uansett bruker.
|
||
|
||
**Fiks:** i stedet for punktvise manuelle synkroniseringer, en ENKELT
|
||
`useEffect` i `WizardProvider` (`wizard-context.tsx`) som holder
|
||
`players[owner].teeId`/`statLevel` KONTINUERLIG i synk med `state.teeId`/
|
||
`state.statLevel`, uansett hvor mange ganger disse endres eller fra
|
||
hvilket steg. Kjører kun en faktisk `patch()` når verdiene faktisk
|
||
avviker (unngår unødvendige re-renders/uendelig løkke). Den nå
|
||
overflødige manuelle engangs-synken i `pickCourse()` fjernet -- ett
|
||
sted for denne logikken fremfor to som lett driver fra hverandre igjen.
|
||
Endrer brukeren stat-nivået sitt EKSPLISITT i steg 3 sitt eget
|
||
spillerkort (i stedet for steg 2), respekteres det inntil de ev. går
|
||
TILBAKE til steg 2 og endrer det der igjen (samme presedens-rekkefølge
|
||
som "siste redigerte felt vinner", ingen ny brukerforvirring innført).
|
||
|
||
**Verifisert:** `tsc --noEmit` rent + 45/45 vitest (ingen eksisterende
|
||
test dekket denne regresjonen -- INGEN ny automatisert test lagt til
|
||
heller, siden `ny-runde/`-veiviseren ikke har noen eksisterende
|
||
test-infrastruktur i dette repoet; browserverifisering ansett
|
||
tilstrekkelig for en ren frontend-tilstandssynk-fiks). Egen
|
||
scratch-database + scratch `teecup_api`-container (port 18007,
|
||
live-montert kode) + lokal `next dev` (port 13007), MED en egen bane
|
||
opprettet med to utslag (for å kunne reprodusere "bytt utslag etter
|
||
førstevalg") -- gjenskapte videoens eksakte scenario (banevalg → bytt
|
||
utslag fra standard "32" til "50" i bekreftelsesskjermen → "All
|
||
statistikk" i steg 2 → steg 3 sitt spillerkort viste nå KORREKT "Utslag:
|
||
50" og "Alt" (uten regresjonens stale "32"/"Kun slag") → fullført runde
|
||
→ bekreftet direkte i databasen at BÅDE `round.tee_name_snapshot='50'`
|
||
OG `round_participant.stat_level='full'` faktisk ble lagret → åpnet
|
||
scoreregistrering for hull 1, bekreftet at veiviseren auto-hopper videre
|
||
til Putter-steget (kun mulig for `strokes_and_putts`/`full`, aldri for
|
||
`strokes_only`) -- beviser fiksen virker HELE VEIEN til faktisk lagret
|
||
data, ikke bare i visningen. Lys+mørk bekreftet for selve
|
||
"Ny runde"-veiviseren (steg 1-3). Scratch-stacken fullstendig revet ned
|
||
-- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||
|
||
**Ingen migrasjon** -- ren frontend-tilstandssynk-fiks.
|
||
|
||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
|
||
Containeren startet rent.
|
||
|
||
---
|
||
|
||
## ADR-074: Order of Merit -- lag-leaderboard + eclectic-total-bug fikset ("Steg 0+1" av gjenstår-listen) (2026-08-15)
|
||
|
||
Foranledning: bruker ba om å gå videre med de to store gjenstående OOM-
|
||
punktene fra ADR-043 (spiller-OOM, 2026-08-04): lag-OOM sin faktiske
|
||
leaderboard-beregning (skjema fantes, `GET .../leaderboard` avviste
|
||
eksplisitt for `kind='team'`), og eclectic-aggregering på tvers av
|
||
lenkede turneringer. Utvidet til tre punkter da FEATURE_BACKLOG.md sin
|
||
"gjenstår"-liste viste seg å ha et tredje, ikke opprinnelig nevnt punkt
|
||
(offentlig/delt visning) -- bekreftet med bruker at alle tre tas i denne
|
||
runden (se ADR-075/076 for de to andre).
|
||
|
||
**Bifunn tatt FØRST, uavhengig av resten:** `_contribution_for_entry`
|
||
(brukt av BÅDE eksisterende sum/snitt-OOM-er og den nye eclectic-
|
||
aggregeringen) leste alltid `entry.gross_total`/`net_total`/
|
||
`stableford_total` for en lenket turnering -- ALDRI `entry.
|
||
eclectic_total`, selv når den lenkede turneringens `scoring_method` var
|
||
`eclectic_gross`/`net`/`stableford` (ADR-068, bygget 2026-08-14, EFTER
|
||
OOM-en selv 2026-08-04 -- aldri revidert siden). Konsekvens: en klubb som
|
||
lenket en eclectic-scoret turnering inn i en stableford/brutto/netto-OOM
|
||
fikk stille FEIL tall -- spillerens rå slagsum på tvers av rundene (et
|
||
tall som ikke engang avgjorde DEN turneringens egen vinner), i stedet
|
||
for dens faktiske "drømmerunde"-resultat. Fikset: `_contribution_for_
|
||
entry` tar nå også imot turneringens `scoring_method` (allerede
|
||
tilgjengelig fra `_compute_individual_standings` sin returnerte
|
||
metadata, kun forkastet før) og leser `eclectic_total` når relevant.
|
||
|
||
**Lag-OOM sin leaderboard** (ADR-043 Beslutning C, aldri bygget):
|
||
- Ny CRUD (`order_of_merit_team`/`_team_member`, tabellene fantes
|
||
allerede fra migrasjon 055 -- INGEN ny migrasjon i dette steget):
|
||
`POST/GET/DELETE .../order-of-merits/{id}/teams`,
|
||
`POST/DELETE .../teams/{id}/members`. Samme mønster som
|
||
`order_of_merit_link`-CRUD-en (eksisterer-sjekk + `translate_db_
|
||
errors()`). `player_id`-FK-en i skjemaet er IKKE org-scopet (migrasjon
|
||
055-avgjørelse) -- egen eksplisitt eksistenssjekk lagt til i
|
||
`add_order_of_merit_team_member` for å ikke stole blindt på klienten.
|
||
- Leaderboard-grenen for `kind='team'` erstatter den tidligere eksplisitte
|
||
avvisningen: for hvert lag, regn ut HVERT medlems allerede-etablerte
|
||
individuelle OOM-sluttresultat (samme `_player_eligibility`+
|
||
`_aggregate_value`-par som spillernivået, faktorisert ut og delt), slå
|
||
deretter disse medlemsverdiene sammen med SAMME `order_of_merit_
|
||
aggregate`-kall -- gjenbrukt EN GANG TIL på lagnivå, med OOM-ens
|
||
ALLEREDE LAGREDE `aggregation_mode`/`count_best_n`. **Bevisst
|
||
designvalg, ingen ny konfig**: ETT lagret aggregeringsvalg styrer
|
||
BEGGE nivåer -- `count_best_n=1` betyr dermed BÅDE "hver spillers
|
||
beste 1 av N turneringer" OG "lagets beste 1 av M medlemmer" samtidig
|
||
(bekreftet i test: `test_team_leaderboard_best_n_applies_at_both_
|
||
levels`, ikke en overraskelse men en direkte konsekvens av
|
||
gjenbruket). `aggregation_mode='eclectic'` er allerede sperret for
|
||
`kind='team'` ved opprettelse (eksisterende sjekk, uendret) -- lagnivået
|
||
trenger derfor aldri håndtere eclectic.
|
||
- `OrderOfMeritLeaderboardEntry` sitt `player_id`/`player_name`-par
|
||
gjenbrukes UENDRET for lagnivået (bærer der lagets EGEN id/navn) --
|
||
ingen ny modell, kalleren vet allerede OOM-ens `kind`.
|
||
|
||
**Frontend**: `order-of-merit-detail.tsx` sin `kind === "team"`-
|
||
placeholder ("Lag-resultatliste er ikke bygget ennå") fjernet helt --
|
||
`LeaderboardTable` var allerede generisk nok til å vise lag-resultater
|
||
uendret (leser kun `player_id`/`player_name`/`value`/`position`, ingen
|
||
spiller-spesifikk logikk å fjerne). Ny "Lag"-seksjon (kun for
|
||
`kind='team'`) mellom Innstillinger og Resultatliste -- samme liste+
|
||
skjema-mønster som Lenkede turneringer-seksjonen: opprett/slett lag,
|
||
legg til/fjern medlem fra org-ens spillerpool (enkelt `<select>`, samme
|
||
presedens som andre administrasjons-sammenhenger i appen -- IKKE i strid
|
||
med "ingen dropdowns"-regelen, som gjelder scoring-registrering
|
||
spesifikt, se ADR-071).
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (77/77 -- 5 nye tester i
|
||
`test_order_of_merit.py`: eclectic-total-bug-regresjon, lag-leaderboard
|
||
summerer medlemmers individuelle OOM-totaler korrekt (håndregnet: 162 +
|
||
198 = 360), `count_best_n` sin to-nivås-anvendelse bevist eksplisitt,
|
||
full lag-CRUD + `kind='player'`-guard). `tsc --noEmit` rent + 45/45
|
||
vitest. Egen scratch-database + scratch `teecup_api`-container (port
|
||
18008, live-montert kode) + lokal `next dev` (port 13008): opprettet en
|
||
ekte lag-OOM i nettleseren, lenket to turneringer, opprettet ett lag,
|
||
la til to medlemmer ETT OM GANGEN -- bekreftet leaderboardet oppdaterte
|
||
seg LIVE til riktig verdi ved hver endring (162 → 360 ved andre medlem,
|
||
tilbake til 162 ved fjerning), gull-fremhevet førsteplass-rad vist
|
||
korrekt. Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned --
|
||
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||
|
||
**Ingen migrasjon** -- ren kode-endring, tabellene fantes fra migrasjon
|
||
055.
|
||
|
||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||
bruker. Begge containere startet rent.
|
||
|
||
---
|
||
|
||
## ADR-075: Order of Merit -- eclectic-aggregering på tvers av lenkede turneringer ("Steg 2" av gjenstår-listen) (2026-08-15)
|
||
|
||
Foranledning: andre av de tre gjenstående OOM-punktene fra ADR-043, se
|
||
ADR-074 sin innledning for hele bakgrunnen/bekreftelsen. Eclectic-
|
||
aggregering ("sesong-drømmerunde": beste resultat PER HULL på tvers av
|
||
ALLE lenkede turneringer, kun `result_type IN ('stableford','gross',
|
||
'net')`) var forberedt i skjemaet (`aggregation_mode='eclectic'` fantes
|
||
som verdi) men eksplisitt avvist i `order_of_merit_leaderboard` ("ikke
|
||
støttet ennå").
|
||
|
||
**Samme-bane-håndheving** (app-laget, ingen migrasjon): eclectic-
|
||
aggregering på tvers av turneringer er meningsløs hvis turneringene ikke
|
||
er spilt på samme bane (ulike hull-oppsett/par/stroke index). Håndhevet
|
||
på TO steder, samme "fail loudly"-filosofi som ADR-068/ADR-019
|
||
Beslutning C:
|
||
- `link_tournament_to_order_of_merit`: avviser lenking av en turnering
|
||
hvis OOM-en allerede er `aggregation_mode='eclectic'` OG den nye
|
||
turneringens bane avviker fra en allerede lenket turnerings bane.
|
||
- `update_order_of_merit`: avviser å endre `aggregation_mode` TIL
|
||
`'eclectic'` hvis turneringer med ulik bane allerede er lenket.
|
||
|
||
Ny `_validate_same_course_for_eclectic()` + `_course_id_for_tournament()`
|
||
(samme `LIMIT 1`-antakelse på `tournament_round.course_id` som
|
||
`_attach_eclectic_totals` allerede gjorde -- én bane per turnering,
|
||
udiskutert forutsetning i hele kodebasen).
|
||
|
||
**Datainnsamling** (ny `_compute_eclectic_oom_values`, gjenbruker
|
||
`eclectic_best_per_hole()` fra `handicap_engine.py` HELT UENDRET --
|
||
motoren viste seg, som antatt i planen, kildeuavhengig nok til å
|
||
fungere identisk for "flere runder i én turnering" (ADR-068) og "flere
|
||
turneringer i én OOM" (dette steget), kun datainnsamlingen foran den er
|
||
forskjellig): tilpasset variant av `_attach_eclectic_totals` sin
|
||
`tournament_round_hole`-spørring, filtrert på `tr.tournament_id =
|
||
ANY($linked_ids)` i stedet for én turnering. Nøkkelforskjell fra
|
||
innad-i-turnering-varianten: keyed på `player_id` (ikke
|
||
`tournament_participant_id`, som er ulik per turnering for samme
|
||
spiller), og kilde-indeksen er sammensatt av (turnering-id, runde-
|
||
sekvens) siden `sequence` kun er unik INNENFOR én turnering. Returnerer
|
||
også `tournament_count_by_player` -- antall DISTINKTE turneringer som
|
||
faktisk bidro minst ett hull til spillerens sluttsum, brukt som
|
||
`results_available` for `min_results_required`-sjekken (rå `len(values)`
|
||
ville alltid vært 1 i eclectic-modus, meningsløst som eligibility-mål).
|
||
|
||
`_player_eligibility` sin signatur endret fra å ta `raw_values: list`
|
||
til å ta `results_available: int` direkte -- frikobler "hvor mange
|
||
resultater talte" fra "de faktiske verdiene", nødvendig fordi eclectic-
|
||
modusens tall ikke lenger kommer fra `len(raw_values)`.
|
||
|
||
**Leaderboard**: `order_of_merit_leaderboard` sin tidligere eksplisitte
|
||
avvisning for `aggregation_mode == 'eclectic'` erstattet med faktisk
|
||
beregning -- kun for `kind='player'` (kind='team' + eclectic er allerede
|
||
sperret ved opprettelse, uendret fra ADR-043).
|
||
|
||
**Frontend**: "Eclectic"-valget lagt til begge steder
|
||
`aggregation_mode` settes, gated identisk til (og speilende) server-
|
||
sidens regel (`kind==='player' && result_type IN
|
||
('stableford','gross','net')`) slik at UI-et aldri tilbyr et valg
|
||
serveren uansett ville avvist:
|
||
- `order-of-merit-list.tsx` (opprettelsesskjema): ny `eclecticAllowed()`-
|
||
helper, tredje `SegmentButton` vist betinget, PLUSS auto-reset til
|
||
`'sum'` hvis brukeren endrer `kind`/`resultType` ETTER å ha valgt
|
||
Eclectic slik at den ikke lenger er gyldig (kind/resultType er fritt
|
||
redigerbare før opprettelse, i motsetning til detail-siden under).
|
||
- `order-of-merit-detail.tsx` (innstillinger, eksisterende OOM): samme
|
||
`eclecticAllowed()`-mønster, men INGEN reset-logikk trengs -- `kind`/
|
||
`result_type` er låst etter opprettelse her (vist eksplisitt i UI-en,
|
||
se ADR-074), så gatingen er ren visning av et fast faktum. Hjelpetekst
|
||
lagt til under Eclectic-valget som forklarer samme-bane-kravet.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (80/80 -- 3 nye tester i
|
||
`test_order_of_merit.py`: cross-tournament eclectic velger faktisk
|
||
beste-per-hull på TVERS av turneringer, ikke bare beste turnering eller
|
||
snitt (hånd-utregnet: par 1-9 fra turnering A + par 10-18 fra turnering
|
||
B = 72, `results_available == 2`), avvisning ved lenking av turnering på
|
||
annen bane, avvisning ved modus-bytte til eclectic med allerede ulike
|
||
baner lenket). `tsc --noEmit` rent + 45/45 vitest.
|
||
|
||
Egen scratch-database + scratch `teecup_api`-container (port 18102,
|
||
live-montert kode, `TEECUP_DEV_LOG_MAGIC_LINKS=true` for å logge inn
|
||
uten SMTP) + lokal `next dev` (port 13102): seedet to turneringer på
|
||
samme bane (par hull 1-9/bogey 10-18 i A, omvendt i B) + én tredje
|
||
turnering på en ANNEN bane, opprettet en eclectic-OOM, lenket A+B --
|
||
leaderboard viste 72 (bekreftet også direkte mot API før nettleser-
|
||
verifisering). Innstillinger-seksjonen viste "Eclectic" korrekt valgt
|
||
med hjelpeteksten synlig. Forsøk på å lenke turnering C (annen bane) ga
|
||
en tydelig, lesbar feilmelding i UI-et: "Eclectic-aggregering krever at
|
||
alle lenkede turneringer spilles på samme bane." Opprettelsesskjemaets
|
||
gating verifisert direkte i nettleseren: Eclectic-knappen dukker opp for
|
||
`Spiller`+`Stableford-sum`/`Bruttoscore-sum`/`Nettoscore-sum`, forsvinner
|
||
og aggregering nullstilles automatisk til `Sum` når resultattype endres
|
||
til `Poeng etter plassering` ELLER type endres til `Lag`. Lys+mørk
|
||
bekreftet (skjermbilde av Innstillinger-seksjonen i mørk modus). Scratch-
|
||
stacken fullstendig revet ned (database, rolle, container, MinIO-bøtte,
|
||
`next dev`) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||
|
||
**Ingen migrasjon** -- ren kode-endring, `aggregation_mode='eclectic'`
|
||
fantes som gyldig verdi fra migrasjon 055.
|
||
|
||
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
|
||
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
|
||
bruker. Begge containere startet rent.
|
||
|
||
---
|
||
|
||
## ADR-076: Order of Merit -- offentlig/delt visning ("Steg 3" av gjenstår-listen, sluttfører ADR-043) (2026-08-15)
|
||
|
||
Foranledning: siste av de tre gjenstående OOM-punktene fra ADR-043/074/
|
||
075, se ADR-074 sin innledning for hele bakgrunnen/bekreftelsen.
|
||
`order_of_merit.public_visible` fantes som redigerbart felt helt fra
|
||
migrasjon 055 (2026-08-04), men ingen uautentisert side leste det --
|
||
feltet stod ubrukt i praksis frem til nå.
|
||
|
||
**Migrasjon 076** (ny SECURITY DEFINER-bro, fjerde i rekken etter
|
||
`public_org_by_slug` 009/`public_tournament_by_code` 011/`link_player_
|
||
by_email` 008): `public_order_of_merit_by_id(p_id uuid) RETURNS uuid`
|
||
(organization_id). Samme NULL-for-begge-tilfeller-mønster som de tre
|
||
andre broene -- returnerer NULL for BÅDE "finnes ikke" OG "finnes, men
|
||
`public_visible=false`", slik at et 404-svar aldri røper at en gitt
|
||
id faktisk eksisterer (anti-enumerering). `GRANT EXECUTE ... TO
|
||
teecup_app`.
|
||
|
||
**Backend**: ny `public_router` (prefix `/public/order-of-merits`) i
|
||
`order_of_merit.py` selv -- IKKE lagt til `registration.py` (som
|
||
rommer resten av `/public/...`-endepunktene) fordi den gjenbruker
|
||
`order_of_merit_leaderboard()` direkte i stedet for å duplisere hele
|
||
beregningen for en uautentisert variant. Samme triks som testsuiten
|
||
allerede brukte: kaller router-funksjonen med `organization_id` som et
|
||
vanlig Python-argument, utenom FastAPI sin `Depends`-injeksjon (gyldig
|
||
fordi `get_authorized_org` kun er en `Depends`-default, ikke en
|
||
signatur-begrensning). `GET /public/order-of-merits/{oom_id}` slår opp
|
||
org via broen, henter OOM-metadata (`name`/`kind`/`result_type`/
|
||
`aggregation_mode`) fra `org_connection`, kaller leaderboard-funksjonen,
|
||
returnerer alt samlet. Registrert i `main.py` som
|
||
`order_of_merit.public_router`.
|
||
|
||
**Frontend**: ny `app/order-of-merit/[id]/page.tsx` (server component,
|
||
`generateMetadata` mot den nye public-ruten -- OG-tags/tittel satt til
|
||
OOM-ens faktiske navn) + `components/public-order-of-merit.tsx`
|
||
(`"use client"`, modellert direkte på `public-club.tsx` sitt hente-/
|
||
laste-/feil-tilstand-mønster -- samme banner-stil, samme loading-
|
||
spinner/feil-tilstand). `LeaderboardTable` inni denne komponenten er en
|
||
BEVISST DUPLIKAT av `order-of-merit-detail.tsx` sin (gull-fremhevet
|
||
førsteplass, "ikke kvalifisert"-seksjon) -- i tråd med hvordan list/
|
||
detail-komponentene i dette området allerede holder egne lokale kopier
|
||
av samme visuelle mønster, ikke en delt komponent.
|
||
|
||
"Del offentlig lenke"-knapp lagt til i `order-of-merit-detail.tsx` sine
|
||
Innstillinger, rett under "Offentlig synlig"-bryteren -- samme kopier-
|
||
til-utklippstavle-mønster som `JoinCodeChip` i `individual-tournament-
|
||
detail.tsx`/`tournament-detail.tsx` (klikk kopierer, viser "Lenke
|
||
kopiert" i 1,5 sekund). Gated på `oom.public_visible` (den FAKTISK
|
||
LAGREDE verdien, ikke bryterens lokale, kanskje-ulagrede tilstand) --
|
||
viser i stedet en hjelpetekst ("Lenken blir aktiv når du lagrer.") hvis
|
||
brukeren har slått på bryteren men ikke lagret ennå, slik at knappen
|
||
aldri tilbyr en lenke som faktisk ville gitt 404.
|
||
|
||
**Verifisert:** `python3 -m py_compile` + full
|
||
`./scripts/run_backend_tests.sh` (83/83 -- migrasjon 076 kjørt som del
|
||
av den vanlige scratch-migreringsløypen, 3 nye tester: 404 for
|
||
ikke-eksisterende id, 404 for en PRIVAT OOM -- eksplisitt bevist samme
|
||
statuskode som ikke-eksisterende, altså reell anti-enumerering, ikke
|
||
bare en påstand -- og 200 med korrekt navn/kind/result_type/leaderboard
|
||
for en offentlig OOM). `tsc --noEmit` rent + 45/45 vitest.
|
||
|
||
Egen scratch-database (migrasjonene kjørt fra bunnen av, inkl. den nye
|
||
076) + scratch `teecup_api` (port 18103, live-montert kode) + lokal
|
||
`next dev` (port 13103): seedet en offentlig OOM og en privat OOM i
|
||
samme org. Offentlig side nådd i en EGEN, isolert nettleser-kontekst
|
||
UTEN innlogget økt/session-cookie (reelt inkognito-scenario, ikke bare
|
||
"ingen linke til innlogging i UI-et") -- viste navn, type/resultattype,
|
||
og korrekt resultatliste (72/90 for to spillere, håndregnet).
|
||
`generateMetadata` bekreftet virket server-side (fanetittel "Offentlig
|
||
sesong-OOM | TeeCup" satt FØR klient-JS kjørte). Privat OOM i samme
|
||
inkognito-kontekst ga identisk "Order of Merit finnes ikke"-feilside som
|
||
en helt oppdiktet id -- ingen synlig forskjell. "Del offentlig lenke"
|
||
verifisert i den autentiserte innstillinger-seksjonen: knapp med riktig
|
||
url-konstruksjon bekreftet (`window.location.origin +
|
||
/order-of-merit/{id}`, samme url faktisk besøkt og fungerte i
|
||
inkognito-konteksten over). Slo av "Offentlig synlig" og lagret --
|
||
knappen forsvant fra UI-et OG endepunktet ga 404 umiddelbart etterpå,
|
||
bekreftet direkte mot API-et. Lys+mørk bekreftet. Scratch-stacken
|
||
fullstendig revet ned (database, rolle, container, MinIO-bøtte, begge
|
||
`next dev`-instanser) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend`
|
||
urørt.
|
||
|
||
**Migrasjon 076 vist og bekreftet av bruker FØR kjøring mot ekte
|
||
`teecup_db`** (kun additiv -- én ny funksjon, ingen skjemaendring,
|
||
ingen datamigrering).
|
||
|
||
**Rullet ut 2026-08-15** -- migrasjon 076 kjørt mot ekte `teecup_db`
|
||
etter eksplisitt bekreftelse fra bruker (`CREATE FUNCTION`/`GRANT`,
|
||
verifisert med `\df` + et direkte NULL-for-ukjent-id-kall FØR kode-
|
||
utrullingen), deretter `docker compose build teecup_api teecup_frontend
|
||
&& up -d`. Begge containere startet rent. Etter utrulling: `GET
|
||
/public/order-of-merits/<ukjent-id>` bekreftet 404 og `GET
|
||
/order-of-merit/<ukjent-id>` bekreftet 200 (feil-tilstanden i selve
|
||
siden) direkte mot `teecup.golf`.
|
||
|
||
Med dette er alle tre punktene fra ADR-043 sin opprinnelige
|
||
"gjenstår"-liste fullført: lag-OOM (ADR-074), eclectic på tvers av
|
||
turneringer (ADR-075), offentlig visning (dette dokumentet).
|
||
|
||
---
|
||
|
||
## ADR-077: Tilbake-navigering til en tidligere spiller i scoringsveiviseren ("Steg 1" av tre forbedringer i score-registreringen) (2026-08-16)
|
||
|
||
Foranledning: bruker observerte at når man fører score for flere spillere
|
||
etter hverandre i samlebånd-flyten (`ScoringWizard`, frittstående runder)
|
||
og har trykket "Neste" videre forbi en spiller, er det umulig å rette opp
|
||
en feilregistrering hos den spilleren INNE i selve veiviser-flyten — man
|
||
må fullføre veiviseren helt til slutten, tilbake på Score-fanen lete opp
|
||
riktig spillers kort for det aktuelle hullet, og åpne det på nytt. Første
|
||
av tre planlagte forbedringer (se `~/.claude/plans/joyful-wishing-crab.md`
|
||
for hele planen) — kun denne er round-only: org-turneringer har ingen
|
||
tilsvarende kjede-flyt (`HoleGrid` velger ett hull+én spiller om gangen),
|
||
og ADR-065 (per-deltaker-fullføring) er eksplisitt bygget kun for
|
||
frittstående runder.
|
||
|
||
**Løsning**: kontekst-raden i `ScoringWizard` (alle spillerne, aktiv
|
||
fremhevet) var BEVISST ikke-interaktiv ved bygging (2026-07-26-kommentaren:
|
||
"bytte spiller midt i et steg ville latt uferdige felt henge løst") —
|
||
denne antakelsen viste seg foreldet: `updateWizardStat` har siden lagret
|
||
hvert felt for seg MED EN GANG (PATCH per felt, ikke batch-lagring), så
|
||
det er trygt å bytte spiller når som helst uten datatap. Raden er nå
|
||
interaktiv: hver spiller-chip er en knapp, trykk på en ANNEN spiller enn
|
||
den aktive (inkludert allerede "Ferdig"-markerte -- bekreftet at backend
|
||
ikke har noen hard sperre mot å redigere en fullført spillers data, kun
|
||
runde-nivå `readOnly` blokkerer) bytter veiviseren dit via en ny
|
||
`switchWizardPlayer()`-funksjon i `RoundDetail` (samme mål-oppløsning som
|
||
`openPlayerOrTeamSideEntry`, inkl. scramble-solo-lag-siden-avviket, men
|
||
uten å røre `currentHole`). Steget nullstilles automatisk til "Slag" for
|
||
den nye spilleren via den allerede eksisterende `useEffect(() =>
|
||
setStepIndex(0), [player.id])`. "Ferdig"-markerte spillere vises med en
|
||
tekst-etikett ("· Ferdig") i raden, ikke bare farge (tilgjengelighetsregel
|
||
-- aldri fargekoding alene).
|
||
|
||
Ingen ny fil, ingen V0 (gjør et eksisterende, bevisst
|
||
ikke-interaktivt element interaktivt -- ingen ny visuell flate).
|
||
|
||
**Verifisert:** `tsc --noEmit` rent + 45/45 vitest. Egen scratch-database
|
||
+ scratch `teecup_api` (port 18104, live-montert kode,
|
||
`TEECUP_DEV_LOG_MAGIC_LINKS=true`) + lokal `next dev` (port 13104): runde
|
||
med tre spillere. Registrerte Par for spiller 1, byttet midt i steget til
|
||
spiller 2 (Bogey) via kontekst-raden -- spiller 1 sin allerede lagrede
|
||
score forble urørt. Byttet tilbake til spiller 1 FRA MIDT I spiller 2 sin
|
||
flyt, rettet scoren til Birdie -- lagret korrekt, spiller 2 sin Bogey
|
||
urørt. Trykket "Avslutt for Erol Scratch" (ADR-065), åpnet spiller 2 sin
|
||
veiviser på nytt, bekreftet at spiller 1 nå vises som "Erol Scratch ·
|
||
Ferdig" i kontekst-raden OG fortsatt er trykkbar -- åpnet ham, viste
|
||
korrekt lagret Birdie (ikke nullstilt), ingen sperre. Lys+mørk bekreftet.
|
||
Scratch-stacken fullstendig revet ned (database, rolle, container,
|
||
MinIO-bøtte, `next dev`) -- ekte `teecup_db`/`teecup_api`/
|
||
`teecup_frontend` urørt.
|
||
|
||
**Ingen migrasjon** -- ren frontend-endring.
|
||
|
||
**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
|
||
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
|
||
Containeren startet rent.
|
||
|
||
---
|
||
|
||
## ADR-078: Chip/Bunker/Straffeslag/Anywayslag som egen skjerm ("Steg 2" av tre forbedringer i score-registreringen) (2026-08-16)
|
||
|
||
Foranledning: bruker observerte at Chip/Bunker/Straffeslag/Anywayslag ble
|
||
vist klemt inn under retningsvalget for utslag/innspill i begge
|
||
scoringsflytene -- ikke godt nok, burde være en egen skjerm. Bekreftet
|
||
(AskUserQuestion) at dette skal gjelde BEGGE flyter -- frittstående runder
|
||
OG org-turneringer -- "opplevelsen for brukeren skal være lik uansett".
|
||
|
||
**Strukturell splitt, begge flyter:**
|
||
- Frittstående runder (`ScoringWizard`, `round-detail.tsx`): `WizardStep`
|
||
utvidet fra `"strokes" | "putts" | "puttDistance" | "details"` til
|
||
`"strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"` --
|
||
"details" splittet i to egne steg. GIR-auto-inferensen (2026-07-30)
|
||
flyttet fra å trigge på `step !== "details"` til `step !== "direction"`.
|
||
- Org-turneringer (`HoleStatsSheet`, `individual-tournament-detail.tsx`):
|
||
bygget om fra ÉN lang scrollbar skjerm til en LITEN intern steg-flyt
|
||
(samme stegrekkefølge som over), med samme auto-hopp og
|
||
GIR-auto-inferens som runde-siden. **Bevisst avgrensning**: lagringen
|
||
er UENDRET -- fortsatt ÉN batched `save()`-kall ved siste steg (nå
|
||
trigget fra "Lagre"-knappen i stedet for en alltid-synlig knapp), IKKE
|
||
konvertert til runde-sidens PATCH-per-felt-modell. De to flytene får nå
|
||
samme STRUKTUR/interaksjon, ikke delt kode eller delt lagrings-
|
||
mekanikk -- unngår å risikere en fungerende, annerledes arkitektur som
|
||
ikke var en del av forespørselen.
|
||
|
||
**Visuell polering (V0-designet)**: siden det gjenstående "Detaljer per
|
||
hull"-steget nå har en hel skjerm for seg selv, ble den tidligere
|
||
1-kolonne-stabelen (mye tomrom under fire små tellere) erstattet med et
|
||
FAST 2x2-rutenett av teller-FLISER -- egne kort med etikett + stort
|
||
sentrert tall + -/+ nederst. Interaksjonskontrakten er uendret (minus
|
||
disabled på 0, pluss disabled på `max`, alle trykkflater >=44px), kun det
|
||
visuelle uttrykket er nytt. `Stepper`-komponenten (delt av begge flyter
|
||
via `hole-stat-inputs.tsx`) oppdatert i selve komponenten -- automatisk
|
||
identisk i begge flyter uten duplisert arbeid. `grid-cols-2` (ALDRI
|
||
`grid-cols-3`) fordi seksjonen er fast `max-w-sm` (384px): to fliser gir
|
||
~184px hver, nok til flisens to 44px-knapper side om side.
|
||
|
||
V0-prompt skrevet av Claude (se `feedback_frontend_via_v0`), kjørt av
|
||
bruker, eksport (`tee-cup (12).zip`) integrert -- KUN den relevante
|
||
`Stepper`-omdesignet + grid-endringen ble portert inn (eksporten var en
|
||
full prosjekt-snapshot fra en delt V0-arbeidsflate, inkludert en eldre,
|
||
FØR-splitt kopi av hele `round-detail.tsx` -- ingen fullstendig
|
||
filerstatning, kun det faktisk endrede uttrykket for Stepper/rutenett
|
||
overført til den ekte, allerede steg-splittede filen). Zip slettet fra
|
||
`Temp-uploads/` etter integrering.
|
||
|
||
**Verifisert:** `tsc --noEmit` rent + 45/45 vitest (begge ganger --
|
||
strukturell splitt, og etter V0-integrering). Egen scratch-database +
|
||
scratch `teecup_api` + lokal `next dev`, to runder (port 18105/13105 for
|
||
strukturell verifisering, port 18106/13106 for V0-integrering): begge
|
||
flyter testet ende-til-ende gjennom ALLE steg (strokes → putts →
|
||
puttDistance → direction → holeDetails), GIR-auto-inferens bekreftet
|
||
fortsatt virker (trigget riktig på "direction"-steget), lagring bekreftet
|
||
i begge flyter (round-siden PATCH-per-felt, turnering-siden batched
|
||
"Lagre"), gjenåpning av et allerede utfylt hull viste korrekt lagrede
|
||
verdier. 2x2-rutenettet med teller-flisene bekreftet identisk i begge
|
||
flyter, interaksjon (øke/redusere, disabled ved 0/max) bekreftet
|
||
fungerende. Lys+mørk bekreftet i begge runder. Scratch-stackene
|
||
fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/`teecup_frontend`
|
||
urørt.
|
||
|
||
**Ingen migrasjon** -- ren frontend-endring.
|
||
|
||
**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
|
||
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
|
||
Containeren startet rent.
|
||
|
||
---
|
||
|
||
## ADR-079: Personlig hull-historikk ut av slagvinduet + full historikk med grafer ("Steg 3" av tre forbedringer i score-registreringen) (2026-08-16)
|
||
|
||
Foranledning: bruker observerte at personlig hull-historikk ble vist INNI
|
||
selve slagvinduet/-arket (forstyrret registreringen), og at klikk kun ga
|
||
en kort oppsummering -- ikke full historikk. Ba om at den flyttes til
|
||
"vinduet før, f.eks. før 'Avslutt før...'", og at klikk skal vise FULL
|
||
historikk inkl. grafer. Bekreftet (AskUserQuestion) at "grafer" betyr det
|
||
ETABLERTE stolpe-mønsteret appen allerede bruker (`CategoryBar`/
|
||
`DeviationBar` i `round-stats.tsx`), ikke et nytt graf-bibliotek -- og at
|
||
begge scoringsflyter skal dekkes.
|
||
|
||
**Ingen backend-endring** -- `app/hole_history.py` sin `HoleHistoryOut`
|
||
(bygget i ADR-072) returnerte allerede alt som trengs (aggregert
|
||
oppsummering + full per-instans-liste); rent frontend-arbeid.
|
||
|
||
**Flytting, begge flyter:**
|
||
- Frittstående runder: `HoleHistoryPanel` fjernet fra `ScoringWizard` sitt
|
||
`"strokes"`-steg, lagt til i `PlayerHoleCards` sitt spillerkort (RETT
|
||
FØR `ParticipantCompletionActions`, altså nøyaktig "vinduet før 'Avslutt
|
||
for...'" brukeren pekte på).
|
||
- Org-turneringer: fjernet fra `HoleStatsSheet` sitt `"strokes"`-steg,
|
||
lagt til som et NYTT FØRSTE steg (`"overview"`) i steg-flyten ADR-078
|
||
bygde -- ingen ekstern "kort-liste"-skjerm finnes der slik runde-siden
|
||
har (`HoleGrid` er et tett rutenett). For å unngå en ekstra obligatorisk
|
||
trykk-runde for HVER hull-registrering (18 hull × N spillere) hopper
|
||
dette steget automatisk videre av seg selv når spilleren ikke har noen
|
||
historikk å vise -- kun synlig når det faktisk er noe der, verifisert
|
||
eksplisitt (hull uten historikk hoppet rett til "Slag", hull MED
|
||
historikk viste steget og ventet på trykk).
|
||
|
||
**Klikk-oppførsel endret**: `HoleHistoryPanel` ekspanderte tidligere en
|
||
enkel liste inline ved klikk -- åpner nå i stedet den nye fulle
|
||
historikk-skjermen (`onOpenFull`-prop). Chevron endret fra ned (ekspander)
|
||
til høyre (naviger videre), samme visuelle konvensjon som resten av appen
|
||
bruker for "gå til en annen skjerm" vs. "utvid her".
|
||
|
||
**Ny delt komponent `components/hole-history-detail.tsx`
|
||
(`HoleHistoryDetail`)** -- fullskjerm (`fixed inset-0 z-50`, samme
|
||
konvensjon som `ScoringWizard`/`HoleStatsSheet`), samme `historyUrl`-
|
||
parametermønster som panelet selv brukte. Viser: fire stat-fliser (ganger
|
||
spilt/snitt slag/beste/dårligste), snitt-til-par som en fremhevet stor
|
||
DeviationBar-lignende tall-boks, GIR%/fairway%, en SCORE-FORDELING som
|
||
fargede `CategoryBar`-stolper (EKSAKT samme 6-kategori-inndeling og
|
||
`--chart-1..6`-fargelogikk som `round-stats.tsx`, egen lokal kopi -- samme
|
||
"ikke delt kode på tvers av filer"-mønster som resten av dette området),
|
||
og den detaljerte per-instans-listen under (dato/kilde/score/putter).
|
||
|
||
**Bevisst avvik fra planen: INGEN V0-runde for denne skjermen.** Planen
|
||
forutså en V0-prompt for denne, men i motsetning til ADR-078 sin
|
||
holeDetails-skjerm (som IKKE hadde noe visuelt forbilde) ba brukeren
|
||
EKSPLISITT om å gjenbruke et NAVNGITT, allerede eksisterende mønster
|
||
(`CategoryBar`/`DeviationBar`) -- selve designbeslutningen var dermed
|
||
allerede tatt og bekreftet av brukeren, ikke noe som gjensto å utforske
|
||
via V0. Bygget direkte, med samme presisjon som om mønsteret var hentet
|
||
fra en V0-eksport.
|
||
|
||
**Verifisert:** `tsc --noEmit` rent + 45/45 vitest. Egen scratch-database
|
||
+ scratch `teecup_api` (port 18107) + lokal `next dev` (port 13107): to
|
||
frittstående runder på SAMME bane-nøkkel (hull 1 scoret Par i den ene,
|
||
Birdie i den andre) + en org-turnering på en bane med MATCHENDE
|
||
`external_course_ref`-bro (`app/hole_history.py` sin teeoff-bro-streng),
|
||
for å bevise kombinert historikk på tvers av BEGGE kontekster i
|
||
`"overview"`-steget. Bekreftet: historikk-panelet vises korrekt plassert
|
||
FØR "Avslutt for {navn}" i `PlayerHoleCards` (runde-siden), IKKE lenger
|
||
synlig inni selve slagvinduet. Klikk åpnet full historikk-skjerm med
|
||
korrekt aggregerte tall (snitt 3,5 slag, −0,50 til par, GIR 100%) OG en
|
||
korrekt score-fordelingsgraf (Birdie 50%/Par 50%, riktige farger). Samme
|
||
skjerm bekreftet identisk fra org-turnering-siden sitt `"overview"`-steg,
|
||
med kombinert historikk fra begge runde-typer. Hull UTEN historikk
|
||
bekreftet hoppet automatisk forbi `"overview"`-steget til "Slag" uten
|
||
synlig flimmer. Lys+mørk bekreftet på hele historikk-skjermen (fliser,
|
||
stolpediagram, liste). Scratch-stacken fullstendig revet ned -- ekte
|
||
`teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||
|
||
Med dette er alle tre forbedringene i score-registreringen brukeren ba om
|
||
etter Order of Merit fullført: tilbake-navigering (ADR-077), egen skjerm
|
||
for hull-detaljer (ADR-078), personlig historikk relokert + full historikk
|
||
med grafer (dette dokumentet).
|
||
|
||
**Ingen migrasjon** -- ren frontend-endring.
|
||
|
||
**Rullet ut**: ikke ennå -- venter på bekreftelse fra bruker.
|
||
|
||
---
|
||
|
||
## 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
|