Før bakgrunnsfjerning

This commit is contained in:
Erol Haagenrud 2026-08-02 16:34:54 +02:00
parent 3da637b7a2
commit 5e66ce63f5
28 changed files with 3206 additions and 1468 deletions

View file

@ -392,7 +392,9 @@
"Bash(rm -f /opt/teecup/v0-zip22-login-*.png /opt/teecup/v0-integrated-*.png *)",
"Bash(curl -s -o /dev/null -w \"friends/on-course anonym: %{http_code}\\\\n\" https://teecup.teeoff.no/friends/on-course)",
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch >/dev/null 2>&1 *)",
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)"
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
"Bash(echo \"tsc exit: $?\")",
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -3611,6 +3611,207 @@ Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
---
## 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.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

213
CLAUDE.md
View file

@ -5952,6 +5952,211 @@ Ferdig og verifisert:
teecup_api`-kjøring, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket.
- **Login-skjermen redesignet ("Forest Green", inspirert av et eksternt
design-verktøy kalt "Stitch"), BYGGET OG LIVE (2026-08-01/02):**
brukeren ba eksplisitt om et V0-prompt basert på Stitchs skisse, kjørte
det selv, sendte zip-eksporten tilbake. `frontend/components/login-
form.tsx` skrevet fullstendig om — V0s nye visuelle skall (låst
fargepalett `const C = {...}`, WCAG-kontrast verifisert presist, ikke
anslått, for hvert fargevalg) flettet med 100 % ekte logikk fra den
gamle komponenten uendret: `sendLink()` (`POST /auth/request-link`),
passord-innlogging (`POST /auth/login-password`), invitasjonskode-
oppslag (`GET /public/tournaments/by-code/{code}` + navigasjon).
`TwoFactorVerifyForm`/`TwoFactorSetupForm` (ADR-021) beholdt HELT
uendret, kun rendret inni det nye kortskallet. **Reell bug funnet under
integrering, browserverifisert:** `app/page.tsx` hadde sin egen
`<Wordmark/>`+tagline-header OVER selve `LoginForm`, som nå OGSÅ hadde
fått sin egen header inni det nye kortet — duplikat. Fjernet den ytre
headeren + det nå ubrukte `Wordmark`-importet fra `page.tsx`.
**Rullet ut live**, bruker bekreftet eksplisitt ("Rull ut live. Jeg har
comitted til git og kan reversere om nødvendig."): ren frontend-endring,
ingen migrasjon.
- **Dashbordet redesignet i samme visuelle retning, BYGGET OG LIVE
(2026-08-01/02), to runder samme dag:** første runde (V0-eksport)
skrev om `dashboard.tsx` fra bunnen — flettet ekte ADR-035-datalag
(organisasjoner/turneringer/runder/HCP-historikk/venner/varsler) med
V0s nye visuelle seksjoner, la til en NY «Venner på banen»-seksjon
(bekreftet av bruker at den skal ligge RETT UNDER hurtighandlingene,
jf. Stitchs egen skisse — jeg hadde ikke selv sjekket dette FØR
brukeren spurte eksplisitt, rettet før prompten ble sendt).
**Ny backend-endepunkt bygget for dette:** `GET /friends/on-course`
(`app/routers/rounds.py`, ny `FriendOnCourseEntry`-modell) — venner med
en PÅGÅENDE runde, eller en fullført innen siste 24 timer (bekreftet
av bruker), filtrert gjennom SAMME synlighets-SQL som `_can_view_
round` sin venner-gren (ADR-036 fase 2) — ingen egen, parallell
synlighetsregel.
**Andre runde, presise fargekorreksjoner mot Stitchs FAKTISKE
skjermbilde** (ikke bare beskrivelse — brukeren lastet opp det ekte
bildet etter at jeg først måtte innrømme jeg hadde slettet den
opprinnelige zip-en og derfor sammenlignet blindt): pikselverdier
hentet presist via PIL i en engangs Docker-container (Stitchs egen
knappegrønn viste seg å være `#2d950c`, kontrast mot hvit tekst kun
3,88:1 — FEILER WCAG AA, flagget proaktivt FØR den ble brukt).
`const C` fikk `primary:"#1f6b08"` (samme fargefamilie, kontrast
~6,6:1, verifisert med samme relative-luminans-formel), `primaryInk:
"#ffffff"`, `borderSoft:"#e4ebe3"`, ny `AVATAR_HUES`-array (pastell-
par til venne-avatarer). Header endret fra glassmorfisme til flat
opak bakgrunn, flagg-ikonet fikk lys grønn bakgrunn i stedet for
oransje, `QuickAction`/`ShortcutButton` og `LiveFriends`-kortene
fargekorrigert tilsvarende.
**Ny fast bunn-fanerad** (`BottomTabBar`, eksportert fra
`dashboard.tsx`, brukt av flere sider): fem faner (Hjem/Runder/
Turneringer/Profil/Mer). **Reell rute-kollisjon funnet og rettet FØR
utrulling** (samme klasse feil som tidligere i prosjektet, `/rounds`
vs. API-prefikset) — V0s genererte `href="/rounds"`/`href="/tournaments"`
pekte begge feil (ingen slik side finnes) — rettet til `/my-rounds` og
et ankerpunkt `/dashboard#kommende-turneringer` (ingen egen turnering-
liste-side finnes). Ny, tidligere ikke-eksisterende `/more`-side bygget
(`components/more-menu.tsx`, `app/more/page.tsx`) som femte fanes reelle
mål — samler Konto/Venner/Varsler/organisasjoner ett sted, ekte
`handleLogout()`. `<main>`-padding økt (`pb-24`) for å ikke overlappe
den nye faste bunnraden.
`install-prompt.tsx` fikk kun ny JSX (mørk grønn gradient-banner),
100 % av den ekte iOS/Android-deteksjons-/`beforeinstallprompt`-/
14-dagers-utsettelseslogikken uendret.
**Rullet ut live**, bruker bekreftet eksplisitt: ingen migrasjon for
visuelle deler, ny `/friends/on-course`-ruten dekket av samme
`docker compose up -d --build teecup_api teecup_frontend`. Begge
containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket.
- **«Det store grepet»: sammenhengende rundeoppsett + delt Score/
Scorekort/Leaderboard-navigasjon — steg 1+2 av 5 BYGGET, SCRATCH-/
BROWSERVERIFISERT OG LIVE (2026-08-02), se ADR-040 for full
beslutningslogg:** direkte oppfølging av login-/dashbord-redesignet —
brukeren pekte på to strukturelle hull utover selve fargespråket
(rundeoppsettet spredt over to skjermer, Score/Scorekort/Leaderboard
deler ingen fast navigasjon). Fem beslutninger bekreftet (ADR-040
Beslutning A-E), plan skrevet som en levende Artifact-skisse før
bygging.
**Steg 1 (backend, HCP-prosent alltid justerbar + Match-HCP-bryter):**
ny migrasjon `051_round_allowance_override.sql` (`round.allowance_
override jsonb`), koblet inn i `_recompute_side_handicaps`/`_relative_
strokes_for_round`/`POST`+`PATCH /rounds` (`app/routers/rounds.py`).
Gjenbruker ADR-014s allerede ferdigbygde motor uendret (`match_play_
strokes()`, `parse_allowance_config`) — frittstående runder manglet
bare selve kolonnen.
**Steg 2 (ren kode-fiks, ikke V0): spillere i scorekortet var ALDRI
gruppert lagvis** — reelt, tidligere udokumentert funn rapportert av
bruker med en konkret lenke (fourball-runde der to kjente lagkamerater
ble vist interleaved). Bekreftet i koden: `ScorecardGrid` (`round-
detail.tsx`) og `MatchScorecardGrid` (`round-scorecard.tsx`) sorterte
ingen av dem på `round_side_id`. Fikset med stabil sortering (side A
samlet, deretter side B) i begge.
**Scratch-verifisert presist:** 100 %→50 %-prosent ga `playing_
handicap` 24/10 → 12/5 (nøyaktig som beregnet for hånd), `use_
matchplay_handicap:false` ga rå 12/5 i stedet for differensial 7/0.
Lag-grupperingsfiksen browserverifisert mot en fersk scratch-fourball-
runde som gjenskapte brukerens eget scenario nøyaktig (interleaved
tilføyelsesrekkefølge A→Rødt, B→Blått, C→Rødt, D→Blått) — bekreftet
visuelt i BEGGE visningene at lagene nå vises samlet.
**Rullet ut mot ekte systemer 2026-08-02**, bruker bekreftet eksplisitt
(plan vist FØR migrasjonen): 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), `teeoff.no`
upåvirket.
**Gjenstår (steg 3-5 av 5, ingen V0-prompt skrevet/sendt ennå):**
V0-prompt 1 (den samlede rundeoppsett-veiviseren), V0-prompt 2 (den
delte fane-raden + spørsmålet om lag-markering utover sortering),
deretter integrering/browserverifisering/utrulling av begge. Se
FEATURE_BACKLOG.md for detaljert arbeidsnotat.
- **«Det store grepet»: steg 3 av 5 (V0-prompt 1, rundeoppsett-
veiviseren) BYGGET, GRUNDIG VERIFISERT OG LIVE (2026-08-02), samme
dag:** V0-prompten (Beslutning A/B) skrevet, 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`, og en helt
ny innsendingsorkestrering — runden+deltakere+sider/lineup finnes
FØRST når hele veiviseren er fullført, ulikt den gamle "opprett runden
først"-flyten).
**Reelle feil funnet og rettet under integrering:** V0s egen
`Tee`-type manglet kjønnsfelt (ville vist ufiltrerte utslag) — lagt til
`genders`, filtrert riktig. To TS-feil (prop-spredning som overskrev
`course`/`ownGender` tilbake til `null`, en `"x"`-kjønn-mismatch i
tee-filtreringen).
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
ruter listet), to komplette nettleser-gjennomkjøringer mot en fersk
scratch-backend (én Fourball-runde med egen bane/gjest/to sider/90 %
HCP-prosent, én Slagspill-runde med rene standardvalg) — bekreftet
direkte mot databasen at `allowance_override` ble bygget nøyaktig
riktig i begge tilfeller (`per_player` for Fourball siden det ikke er
en side-enhet, `null` for Slagspill), sidene/tildelingene stemte, og
HCP ble beregnet korrekt fra ekte profildata.
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_frontend` (gjenskapte
også `teecup_api` som vanlig bivirkning). Begge containere boot-et
rent, `/health`/`/dashboard`/`/my-rounds/new` → 200, `teeoff.no`
upåvirket. Full detalj i ARCHITECTURE_DECISIONS.md (ADR-040).
- **«Det store grepet»: steg 4-5 av 5 (V0-prompt 2, den delte Score/
Scorekort/Leaderboard-fane-raden + integrering) BYGGET, GRUNDIG
VERIFISERT OG LIVE (2026-08-02), samme dag — ADR-040 dermed HELT
FERDIG:** V0-prompten (Beslutning C/D) bevisst smalere i omfang enn
prompt 1 — kun selve header/fane-chrome-en, ikke en redesign av
Scorekort-sidens innhold (den sammenslåingen med Statistikk er ren
kode-sammenstabling, løst direkte uten V0). Kjørt av bruker (zip 26)
— ny `components/round-header.tsx` (tatt inn uendret: kontekstblokk
+ tre-fanet segmentert kontroll + en "Administrer"-knapp som åpner en
bunnsheet-dialog med "Spillere og runde"-lenke + "Fullfør runde" +
to-stegs "Slett runde").
**Ekte datalag:** ny `components/round-page-shell.tsx` — henter
rundedata for headeren, implementerer `onFinishRound`/`onDeleteRound`
som egne, selvstendige kall (samme `POST .../complete`/`DELETE ...`
som før, men uavhengig siden komponenten deles av alle tre sidene).
**Wiret inn:** Score (`round-detail.tsx` sin gamle egne header
fjernet, erstattet med `RoundPageShell` — den interne "Score"/
"Spillere og runde"-fanevekslingen bevisst BEHOLDT som lavrisiko-valg,
nås nå i tillegg via headerens dialog med en ny `?tab=manage`-URL-
parameter), Scorekort (`round-scorecard.tsx`+`round-stats.tsx` slått
sammen til ÉN side via en ny `embedded`-prop på begge -- statistikk
RETT UNDER scorekortet, gammel `/stats`-rute er nå en redirect dit),
Leaderboard.
**Reelt funn og fiks under integrering:** to `<main>`-landemerker på
samme side etter sammenslåingen (ugyldig HTML/a11y) -- rettet med en
`ContentTag`-switch (`div` når embedded) i `round-stats.tsx`. Fjernet
nå overflødige kryss-lenker, kollapset "Se scorekort"/"Se full
rundestatistikk" til én knapp i `round-detail.tsx` sitt fullført-
banner.
**Verifisert grundig i isolert scratch:** full produksjonsbuild, full
nettleser-gjennomkjøring av hele "Administrer"-flyten -- headeren
konsistent på tvers av alle tre fanene, dialogens lenke til "Spillere
og runde" bekreftet å faktisk åpne riktig intern fane, ekte "Fullfør
runde" (bekreftet `completed_at` satt via API etterpå) og ekte
to-stegs "Slett runde" (bekreftet navigerte korrekt til tom-tilstand
etterpå). Traff en Turbopack-flakighet i scratch-dev-serveren
underveis (stuck spinner) -- løst med frisk containerstart, bekreftet
IKKE en kodefeil.
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_frontend` (gjenskapte
også `teecup_api` som vanlig bivirkning). Begge containere boot-et
rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
**Bevisst IKKE dekket:** Beslutning E sitt spørsmål om lag-
gruppering trenger mer enn celle-fargelegging (sortering i seg selv
ble fikset i steg 2) -- prompt 2 ble bevisst avgrenset til kun
header-chrome-en, dette spørsmålet er derfor fortsatt åpent, ikke
stilt til V0 ennå. Se FEATURE_BACKLOG.md.
- **Rundeoppsett-veiviseren: dato/klokkeslett forhåndsutfylt, LIVE
(2026-08-02), samme dag:** brukeren ba om at dagens dato og gjeldende
klokkeslett skal være default når man setter opp en runde, i stedet
for tomme felt. `frontend/components/new-round.tsx` sitt Steg 1 fikk
to nye lokale hjelpefunksjoner (`todayIso()` -- lokal tidssone, ikke
UTC, portert fra en tidligere versjon av filen, samme presisjonshensyn
som `<input type="date">` alltid har krevd i dette prosjektet;
`nowTimeString()`) — `date`/`time`-state initialiseres nå med disse i
stedet for tomme strenger (dato respekterer fortsatt `prefillPlayedAt`
fra "legg til en flight til"-flyten der den er satt). Browserverifisert
i isolert scratch: begge feltene viste korrekt dagens dato/klokkeslett
ved første besøk til Steg 1s felt-visning. Rullet ut live, ingen
migrasjon, kun `docker compose up -d --build teecup_frontend`, `/health`/
`/my-rounds/new` → 200, `teeoff.no` upåvirket.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
@ -6214,3 +6419,11 @@ Neste steg:
poeng). Backend er fullt funksjonelt og testet, men ubrukelig fra
selve appen inntil dette bygges — samme lagdelings-rekkefølge
(motor→skjema→API→frontend) som ADR-037/038/039.
15. **Ferdig, kun for historikk:** «Det store grepet» (ADR-040) — alle
5 steg (backend/allowance_override, lag-sortering, rundeoppsett-
veiviseren, delt Score/Scorekort/Leaderboard-fane-rad, integrering)
BYGGET, VERIFISERT OG LIVE 2026-08-02, se status over. **Ett bevisst
utsatt, fortsatt åpent spørsmål:** trenger lag-grupperingen i
scorekortet mer enn celle-fargelegging for å skille lagene tydelig
nok (utover selve sorteringen, som allerede er fikset) — ikke stilt
til V0 ennå, egen liten vurdering om ønskelig. Se FEATURE_BACKLOG.md.

View file

@ -50,18 +50,28 @@ ellers virker helt av seg selv så lenge token-klassene brukes.
| Token | Lys modus | Rolle |
|---|---|---|
| `background` / `foreground` | nesten hvit / mørk grønn-grå | sideflate / brødtekst |
| `card` / `card-foreground` | hvit / mørk grønn-grå | kort, paneler, seksjoner |
| `background` / `foreground` | nesten hvit / mørk grå | sideflate / brødtekst |
| `card` / `card-foreground` | hvit / mørk grå | kort, paneler, seksjoner |
| `primary` / `primary-foreground` | `oklch(0.7512 0.1613 130.33)` (grønn) / mørk | hovedhandling, valgt tilstand, "under par"/positiv |
| `secondary` / `secondary-foreground` | lys grønn-tint | sekundær vekt, sjeldent brukt alene |
| `muted` / `muted-foreground` | lys grå-grønn / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
| `accent` / `accent-foreground` | lys grønn-tint | hover-tilstand på nøytrale flater |
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |
| `muted` / `muted-foreground` | lys grå / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
| `accent` / `accent-foreground` | lys grå | hover-tilstand på nøytrale flater |
| `destructive` | rød-oransje | slett/fare-handlinger |
| `border` / `input` | lys grå-grønn | kant på kort, felt, delelinjer |
| `border` / `input` | lys grå | kant på kort, felt, delelinjer |
| `ring` | samme som `primary` | fokus-ring (tastatur-tilgjengelighet) |
| `brand-orange` / `brand-orange-foreground` | `oklch(0.6792 0.2128 36.53)` | sekundærfarge — "over par"/advarsel, IKKE feil (det er `destructive`) |
| `chart-1``chart-6` | grønn→blå→gul→oransje→rød-skala | statistikk-/fordelingsvisualiseringer (ADR-033), ALDRI brukt for kategoriske UI-tilstander |
**2026-08-02:** alle nøytrale tokens (`background`/`muted`/`accent`/
`border`/`secondary` m.fl.) hadde tidligere en svak grønn hue (130-145)
iblandet selv når de skulle være "nøytral grå" — dette ga et vedvarende,
utilsiktet grønt skjær på nesten hver bakgrunn, hover-tilstand og kant i
hele appen (brukeren opplevde appen som "stuck i det lysegrønne
utseendet"). Rettet til ekte nøytral grå (`chroma 0`) på tvers av lys/
mørk/media-query-blokkene. `primary` er UENDRET og fortsatt den eneste
tokenen som skal lese som grønn — reservert til valgt/aktiv tilstand og
primærhandling, se semantikk-listen under.
**Farge-semantikk, konsekvent på tvers av hele appen:**
- `primary` (grønn) = valgt/aktiv, positivt resultat (under par, ledende,
bekreftet), primærhandling.

View file

@ -3825,6 +3825,63 @@ beregnet og vist, banneret erstattet av det eksisterende
felles `docker compose up -d --build teecup_frontend`), bruker bekreftet
eksplisitt.
## Notat 2026-08-01/02: «Det store grepet» — sammenhengende rundeoppsett + delt Score/Scorekort/Leaderboard-navigasjon — ✅ HELT FERDIG (alle 5 steg), se ADR-040
Full beslutningslogg i ARCHITECTURE_DECISIONS.md (ADR-040) — dette notatet
er kun arbeidssporet/gjenstående plan, ikke beslutningene selv.
**Bakgrunn:** oppstod som en direkte oppfølging av login-/dashbord-
redesign-rundene (inspirert av et eksternt design-verktøy, "Stitch").
Brukeren pekte på to strukturelle hull utover selve fargespråket:
rundeoppsettet er spredt over to helt separate skjermer, og Score/
Scorekort/Leaderboard for en frittstående runde deler ingen fast
navigasjon.
**Fem load-bærende beslutninger bekreftet av bruker** (se ADR-040 for full
tekst): (A) rundeoppsettet blir én 5-stegs veiviser, (B) HCP-prosent alltid
justerbar + egen Match-HCP-bryter (`use_matchplay_handicap`), (C)
"Spillere og runde" (inkl. flighter) blir værende på oppsettsiden, ikke i
den nye fane-raden — plassering av vei-tilbake/fullfør/slett overlatt til
V0, (D) Scorekort+Statistikk slås sammen til ÉN side (statistikk under
scorekortet) — fane-raden blir 3 faner, ikke 4, (E) et reelt, tidligere
udokumentert funn: spillere i scorekortet var ALDRI gruppert lagvis (ren
innsettingsrekkefølge) — fikset med stabil sortering på `round_side_id`.
**Status per 2026-08-02:**
1. ✅ Backend: migrasjon `051_round_allowance_override.sql` +
`_round_allowance_override()`-kobling i `app/routers/rounds.py`.
Scratch- og produksjonsverifisert (se ADR-040 for tallene).
2. ✅ Kode-fiks: lag-sortering i `ScorecardGrid` (`round-detail.tsx`) og
`MatchScorecardGrid` (`round-scorecard.tsx`). Browserverifisert mot
brukerens eget rapporterte bug-scenario (interleaved lag-tilføyelse).
3. ✅ **V0-prompt 1 BYGGET, VERIFISERT OG LIVE (2026-08-02):**
`frontend/components/new-round.tsx` er nå den samlede 5-stegs
veiviseren (zip 25 fra V0, flettet med ekte logikk — bane-søk/
-opprettelse, `/people/search`, `allowance_override`-konstruksjon,
full innsendingsorkestrering runde→sider→medspillere→tildelinger).
Full detalj (inkl. de to reelle feilene funnet under integrering) i
ARCHITECTURE_DECISIONS.md (ADR-040).
4. ✅ **V0-prompt 2 BYGGET, VERIFISERT OG LIVE (2026-08-02):** ny delt
header/fane-rad (`components/round-header.tsx`, tatt inn uendret fra
V0 zip 26, presentasjon-only) + ny `components/round-page-shell.tsx`
(ekte datalag/handlinger) — koblet inn på alle tre destinasjonene.
Scorekort+Statistikk slått sammen til ÉN side (ren kode-sammenstabling
av to eksisterende komponenter, ikke sendt til V0 — bevisst utenfor
prompt 2s omfang). "Spillere og runde"/Fullfør/Slett nås via en
"Administrer"-dialog i headeren. Full detalj (inkl. den ene reelle
feilen funnet — to `<main>`-landemerker på samme side) i
ARCHITECTURE_DECISIONS.md (ADR-040).
5. ✅ Integrering + browserverifisering + utrulling — ferdig samme dag.
**Bevisst IKKE dekket av V0-prompt 2, fortsatt et åpent spørsmål:**
Beslutning E sitt spørsmål om lag-grupperingen (sortering fikset i
steg 2) trenger mer enn celle-fargelegging for å skille lagene tydelig
nok i scorekortet — prompt 2 ble bevisst smalere i omfang enn først
planlagt (kun selve header/fane-chrome-en), så dette spørsmålet er
IKKE stilt til V0 ennå. Egen, senere, liten vurdering om ønskelig.
---
## Bevisst endret fra opprinnelige (Gemini-)råd
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte

View file

@ -55,23 +55,28 @@
:root {
color-scheme: light;
--background: oklch(0.985 0.005 130);
--foreground: oklch(0.24 0.02 145);
/* Nøytrale flater er EKTE nøytral grå (chroma 0) -- før 2026-08-02 hadde
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
grønt skjær nesten hver bakgrunn/hover/kant i hele appen. `primary`
er fortsatt den bevisste, mettede merkevare-grønnen -- kun den skal
lese som grønn. */
--background: oklch(0.985 0 0);
--foreground: oklch(0.24 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.24 0.02 145);
--card-foreground: oklch(0.24 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.24 0.02 145);
--popover-foreground: oklch(0.24 0 0);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.22 0.04 140);
--secondary: oklch(0.96 0.02 130);
--secondary-foreground: oklch(0.3 0.03 140);
--muted: oklch(0.96 0.01 130);
--muted-foreground: oklch(0.52 0.02 140);
--accent: oklch(0.94 0.05 130);
--accent-foreground: oklch(0.3 0.04 140);
--primary-foreground: oklch(0.22 0 0);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.3 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.52 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.3 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.9 0.015 130);
--input: oklch(0.9 0.015 130);
--border: oklch(0.9 0 0);
--input: oklch(0.9 0 0);
--ring: oklch(0.7512 0.1613 130.33);
--brand-orange: oklch(0.6792 0.2128 36.53);
--brand-orange-foreground: oklch(0.99 0 0);
@ -104,20 +109,20 @@
.dark {
color-scheme: dark;
--background: oklch(0.19 0.02 150);
--foreground: oklch(0.96 0.01 130);
--card: oklch(0.24 0.02 150);
--card-foreground: oklch(0.96 0.01 130);
--popover: oklch(0.24 0.02 150);
--popover-foreground: oklch(0.96 0.01 130);
--background: oklch(0.19 0 0);
--foreground: oklch(0.96 0 0);
--card: oklch(0.24 0 0);
--card-foreground: oklch(0.96 0 0);
--popover: oklch(0.24 0 0);
--popover-foreground: oklch(0.96 0 0);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.2 0.04 140);
--secondary: oklch(0.28 0.02 150);
--secondary-foreground: oklch(0.96 0.01 130);
--muted: oklch(0.28 0.02 150);
--muted-foreground: oklch(0.7 0.02 135);
--accent: oklch(0.32 0.04 140);
--accent-foreground: oklch(0.96 0.01 130);
--primary-foreground: oklch(0.2 0 0);
--secondary: oklch(0.28 0 0);
--secondary-foreground: oklch(0.96 0 0);
--muted: oklch(0.28 0 0);
--muted-foreground: oklch(0.7 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(0.96 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
@ -148,20 +153,20 @@
@media (prefers-color-scheme: dark) {
:root:not(.light) {
color-scheme: dark;
--background: oklch(0.19 0.02 150);
--foreground: oklch(0.96 0.01 130);
--card: oklch(0.24 0.02 150);
--card-foreground: oklch(0.96 0.01 130);
--popover: oklch(0.24 0.02 150);
--popover-foreground: oklch(0.96 0.01 130);
--background: oklch(0.19 0 0);
--foreground: oklch(0.96 0 0);
--card: oklch(0.24 0 0);
--card-foreground: oklch(0.96 0 0);
--popover: oklch(0.24 0 0);
--popover-foreground: oklch(0.96 0 0);
--primary: oklch(0.7512 0.1613 130.33);
--primary-foreground: oklch(0.2 0.04 140);
--secondary: oklch(0.28 0.02 150);
--secondary-foreground: oklch(0.96 0.01 130);
--muted: oklch(0.28 0.02 150);
--muted-foreground: oklch(0.7 0.02 135);
--accent: oklch(0.32 0.04 140);
--accent-foreground: oklch(0.96 0.01 130);
--primary-foreground: oklch(0.2 0 0);
--secondary: oklch(0.28 0 0);
--secondary-foreground: oklch(0.96 0 0);
--muted: oklch(0.28 0 0);
--muted-foreground: oklch(0.7 0 0);
--accent: oklch(0.32 0 0);
--accent-foreground: oklch(0.96 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);

View file

@ -1,3 +1,4 @@
import { RoundPageShell } from "@/components/round-page-shell"
import { RoundLeaderboard } from "@/components/round-leaderboard"
export default async function RoundLeaderboardPage({
@ -6,5 +7,9 @@ export default async function RoundLeaderboardPage({
params: Promise<{ id: string }>
}) {
const { id } = await params
return <RoundLeaderboard roundId={id} />
return (
<RoundPageShell roundId={id} activeTab="leaderboard">
<RoundLeaderboard roundId={id} embedded />
</RoundPageShell>
)
}

View file

@ -1,6 +1,20 @@
import { Suspense } from "react"
import { RoundDetail } from "@/components/round-detail"
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <RoundDetail roundId={id} />
return (
<Suspense
fallback={
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
}
>
<RoundDetail roundId={id} />
</Suspense>
)
}

View file

@ -1,10 +1,20 @@
import { RoundPageShell } from "@/components/round-page-shell"
import { RoundScorecard } from "@/components/round-scorecard"
import { RoundStats } from "@/components/round-stats"
// Scorekort + Statistikk slått sammen til ÉN side (ADR-040 Beslutning D,
// "det store grepet") -- statistikk RETT UNDER scorekortet, delt header/
// fane-rad via RoundPageShell.
export default async function RoundScorecardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <RoundScorecard roundId={id} />
return (
<RoundPageShell roundId={id} activeTab="scorecard">
<RoundScorecard roundId={id} embedded />
<RoundStats roundId={id} embedded />
</RoundPageShell>
)
}

View file

@ -1,10 +1,14 @@
import { RoundStats } from "@/components/round-stats"
import { redirect } from "next/navigation"
// Slått sammen med scorekortet (ADR-040 Beslutning D, "det store grepet",
// 2026-08-02) -- statistikk vises nå RETT UNDER scorekortet på ÉN side.
// Redirect bevarer eventuelle eksisterende lenker/bokmerker til denne
// stien.
export default async function RoundStatsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <RoundStats roundId={id} />
redirect(`/my-rounds/${id}/scorecard`)
}

View file

@ -286,8 +286,8 @@ export function AccountSettings() {
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
</div>
@ -630,8 +630,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<User aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<User aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
</div>
@ -1019,8 +1019,8 @@ function EmailSection({ email }: { email: string }) {
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">E-post</h2>
</div>
@ -1138,8 +1138,8 @@ function SecondaryEmailSection({
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
</div>
@ -1271,8 +1271,8 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Lock aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Lock aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Passord</h2>
</div>
@ -1382,8 +1382,8 @@ function PushNotificationSection() {
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<BellRing aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<BellRing aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Push-varsler denne enheten</h2>
</div>
@ -1466,8 +1466,8 @@ function NotificationEmailPrefsSection() {
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Bell aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Bell aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Varsler e-post</h2>
</div>

View file

@ -189,8 +189,8 @@ function LoadingState() {
function EmptyState() {
return (
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<MapPin aria-hidden="true" className="size-8 text-primary" />
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
<MapPin aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen runder funnet</h2>

View file

@ -65,7 +65,7 @@ function Avatar({ name, url, size = "large" }: { name: string; url: string | nul
return (
<span
aria-hidden="true"
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-primary/15 font-bold text-foreground")}
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-muted font-bold text-foreground")}
>
{initials(name)}
</span>

View file

@ -576,8 +576,8 @@ function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onC
{friends.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<Users aria-hidden="true" className="size-7 text-primary" />
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Users aria-hidden="true" className="size-7 text-muted-foreground" />
</span>
<div className="flex flex-col gap-1.5">
<p className="text-lg font-bold text-foreground">Ingen venner ennå</p>
@ -647,8 +647,8 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
{categories.length > 0 && (
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
{categories.map((code) => (
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-3 py-1 text-sm font-semibold text-foreground">
<Tag aria-hidden="true" className="size-3.5 text-primary" />
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-sm font-semibold text-foreground">
<Tag aria-hidden="true" className="size-3.5 text-muted-foreground" />
{categoryLabel(code)}
</li>
))}
@ -732,7 +732,7 @@ function Avatar({ name }: { name: string }) {
return (
<span
aria-hidden="true"
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/15 text-base font-bold text-foreground"
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-muted text-base font-bold text-foreground"
>
{initials(name)}
</span>

File diff suppressed because it is too large Load diff

View file

@ -199,8 +199,8 @@ function LoadingState() {
function EmptyState() {
return (
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<ClipboardList aria-hidden="true" className="size-8 text-primary" />
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
<ClipboardList aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>

View file

@ -246,8 +246,8 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:bg-accent/40"
>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Trophy aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
</div>
@ -524,8 +524,8 @@ function StatusArea({
return (
<section className="grid gap-3 sm:grid-cols-2" aria-label="Status">
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Calendar aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
<Calendar aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@ -538,8 +538,8 @@ function StatusArea({
</div>
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Users aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">

View file

@ -11,13 +11,13 @@
import type React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { RoundPageShell } from "@/components/round-page-shell"
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CalendarDays,
Check,
ChevronDown,
Clock,
@ -25,7 +25,6 @@ import {
ChevronLeft,
ChevronRight,
Flag,
MapPin,
Minus,
Plus,
RefreshCw,
@ -153,13 +152,6 @@ function uiGenderToApi(g: Gender): ApiGender {
return g === "male" ? "m" : g === "female" ? "f" : "x"
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function formatDate(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return dateFormatter.format(parsed)
}
// --- API-typer ---------------------------------------------------------------
type ApiParticipant = {
@ -302,12 +294,6 @@ const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "mon
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
// lagret varighet, kun utledet ved visning.
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
function formatTime(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return timeFormatter.format(parsed)
}
function formatDuration(startedAt: string, completedAt: string): string | null {
const start = new Date(startedAt).getTime()
const end = new Date(completedAt).getTime()
@ -407,6 +393,13 @@ function playerLabel(p: ApiParticipant): string {
export function RoundDetail({ roundId }: { roundId: string }) {
const router = useRouter()
// Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
// ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
// siden -- lest KUN ved mount (bevisst ikke reaktiv på senere endringer,
// matcher den eksisterende lokale pill-vekslingen som fortsatt fungerer
// uendret for navigering INNAD på siden).
const searchParams = useSearchParams()
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
@ -443,7 +436,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
// fleste trenger under selve spillingen.
const [pageTab, setPageTab] = useState<"score" | "manage">("score")
const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false)
@ -1138,53 +1131,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
{/* Sticky header */}
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Link
href="/my-rounds"
aria-label="Tilbake til egne runder"
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
{round.name?.trim() && (
<span className="flex items-center gap-1.5 truncate">
<MapPin aria-hidden="true" className="size-4 shrink-0" />
{round.course_name_snapshot}
</span>
)}
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
{round.tee_name_snapshot}
</span>
<span className="flex items-center gap-1.5 tabular-nums">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
{formatDate(round.played_at)}
</span>
{round.started_at && (
<span className="flex items-center gap-1.5 tabular-nums">
<Clock aria-hidden="true" className="size-4 shrink-0" />
{formatTime(round.started_at)}
</span>
)}
{round.play_format === "match" && (
<Badge variant="outline" className="shrink-0">
Matchspill
</Badge>
)}
</div>
</div>
</div>
</header>
<RoundPageShell roundId={roundId} activeTab="score">
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="mb-4 text-base font-medium text-destructive">
@ -1555,7 +1502,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
strokesReceived={wizardApiHole?.strokes_received ?? null}
/>
)}
</div>
</RoundPageShell>
)
}
@ -2008,22 +1955,15 @@ function CompletedBanner({
)
})}
</ul>
<div className="flex flex-col gap-2 sm:flex-row">
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
<Link
href={`/my-rounds/${roundId}/stats`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Se full rundestatistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</div>
{/* Scorekort og statistikk slått sammen til ÉN side (ADR-040
Beslutning D) -- ett felles knapp i stedet for to separate. */}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort og statistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</div>
)
}
@ -3603,7 +3543,7 @@ function AddParticipantForm({
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground"
>
{person.first_name[0]}
{person.last_name[0]}

View file

@ -0,0 +1,347 @@
"use client"
// Delt header + fane-rad for Score/Scorekort/Leaderboard (ADR-040
// Beslutning C/D, "det store grepet"). Presentasjon fra V0 (zip 26),
// tatt inn UENDRET (rent visuelt/strukturelt, ingen ekte logikk å
// bytte ut -- selve rundedataen og handlingene wires inn av
// `round-page-shell.tsx`, som eier ekte henting/POST/DELETE).
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import Link from "next/link"
import {
ArrowLeft,
CalendarDays,
CheckCircle2,
Clock,
Flag,
MapPin,
Settings2,
Trash2,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
export type RoundTab = "score" | "scorecard" | "leaderboard"
export type RoundHeaderInfo = {
/** Course name — always known. Shown as heading unless a customName exists. */
courseName: string
/** Optional custom round name; when set it becomes the heading and the
* course name drops to a secondary line. */
customName?: string
teeName: string
date: string
/** Optional tee time, e.g. "09:20". */
teeTime?: string
/** Head-to-head match-play round → shows the outlined "Matchspill" badge. */
isMatchPlay?: boolean
}
type RoundHeaderProps = {
activeTab: RoundTab
round: RoundHeaderInfo
/** Where the back button navigates (out of the round entirely). */
backHref?: string
/** Per-tab destinations. When omitted, tabs render as inert buttons
* (useful for isolated previews). */
tabHrefs?: Record<RoundTab, string>
/** Destination for the round-management screen ("Spillere og runde"). */
manageHref?: string
/** Mock admin actions. */
onFinishRound?: () => void
onDeleteRound?: () => void
className?: string
}
const TABS: { key: RoundTab; label: string }[] = [
{ key: "score", label: "Score" },
{ key: "scorecard", label: "Scorekort" },
{ key: "leaderboard", label: "Leaderboard" },
]
// --- Shared header ---------------------------------------------------------
export function RoundHeader({
activeTab,
round,
backHref = "/rounds",
tabHrefs,
manageHref = "#",
onFinishRound,
onDeleteRound,
className,
}: RoundHeaderProps) {
const [manageOpen, setManageOpen] = useState(false)
const heading = round.customName ?? round.courseName
// Only show the course name as a secondary line when a custom name replaced it.
const showCourseLine = Boolean(round.customName)
return (
<header
className={cn(
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
className,
)}
>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-3 sm:px-5 sm:py-4">
{/* Row 1: back + identity + manage */}
<div className="flex items-start gap-3">
<Link
href={backHref}
aria-label="Tilbake til egne runder"
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="flex min-w-0 items-center gap-2 text-lg font-extrabold leading-tight tracking-tight text-foreground text-balance sm:text-xl">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="min-w-0 break-words">{heading}</span>
</h1>
{round.isMatchPlay && (
<span className="inline-flex shrink-0 items-center rounded-full border border-primary/50 px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-primary">
Matchspill
</span>
)}
</div>
{/* Secondary line(s): course name (if custom), then tee / date / time */}
<div className="flex flex-col gap-0.5">
{showCourseLine && (
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<MapPin aria-hidden="true" className="size-4 shrink-0" />
<span className="break-words">{round.courseName}</span>
</span>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium text-muted-foreground">
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
{round.teeName}
</span>
<span className="flex items-center gap-1.5 tabular-nums">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
{round.date}
</span>
{round.teeTime && (
<span className="flex items-center gap-1.5 tabular-nums">
<Clock aria-hidden="true" className="size-4 shrink-0" />
{round.teeTime}
</span>
)}
</div>
</div>
</div>
{/* Quiet, labeled management affordance — secondary to the tabs. */}
<button
type="button"
onClick={() => setManageOpen(true)}
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-xl border border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Settings2 aria-hidden="true" className="size-4" />
<span className="hidden sm:inline">Administrer</span>
<span className="sr-only sm:hidden">Administrer runde</span>
</button>
</div>
{/* Row 2: three-tab segmented control */}
<nav aria-label="Rundevisning">
<div className="flex items-center gap-1 rounded-full border border-border bg-muted/60 p-1">
{TABS.map((tab) => {
const active = tab.key === activeTab
const shared =
"flex min-h-11 flex-1 items-center justify-center rounded-full px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-base"
const activeCls = "bg-primary text-primary-foreground shadow-sm"
const inactiveCls =
"text-muted-foreground hover:text-foreground hover:bg-background/60"
if (tabHrefs) {
return (
<Link
key={tab.key}
href={tabHrefs[tab.key]}
aria-current={active ? "page" : undefined}
className={cn(shared, active ? activeCls : inactiveCls)}
>
{tab.label}
</Link>
)
}
return (
<button
key={tab.key}
type="button"
aria-current={active ? "page" : undefined}
className={cn(shared, active ? activeCls : inactiveCls)}
>
{tab.label}
</button>
)
})}
</div>
</nav>
</div>
{manageOpen && (
<ManageRoundDialog
manageHref={manageHref}
onClose={() => setManageOpen(false)}
onFinishRound={onFinishRound}
onDeleteRound={onDeleteRound}
/>
)}
</header>
)
}
// --- Management dialog -----------------------------------------------------
function ManageRoundDialog({
manageHref,
onClose,
onFinishRound,
onDeleteRound,
}: {
manageHref: string
onClose: () => void
onFinishRound?: () => void
onDeleteRound?: () => void
}) {
const closeRef = useRef<HTMLButtonElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const [mounted, setMounted] = useState(false)
// Escape to close + focus the close button on open + lock scroll.
useEffect(() => {
setMounted(true)
closeRef.current?.focus()
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", onKey)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.removeEventListener("keydown", onKey)
document.body.style.overflow = prevOverflow
}
}, [onClose])
if (!mounted) return null
// Portal to <body> so the fixed overlay isn't trapped by the header's
// backdrop-filter containing block.
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<div
className="absolute inset-0 bg-foreground/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="manage-round-title"
className="relative z-10 flex w-full max-w-md flex-col gap-5 rounded-t-3xl border border-border bg-card p-5 shadow-xl sm:rounded-3xl sm:p-6"
>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2
id="manage-round-title"
className="text-xl font-extrabold tracking-tight text-foreground"
>
Administrer runde
</h2>
<p className="text-sm font-medium text-muted-foreground text-pretty">
Spillere, lag og administrasjon for denne runden.
</p>
</div>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="Lukk"
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
</div>
{/* Reach the roster / team setup screen */}
<Link
href={manageHref}
className="flex min-h-14 items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<Users aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="text-base font-bold text-foreground">Spillere og runde</span>
<span className="text-sm font-medium text-muted-foreground">
Legg til, fjern eller rediger spillere og lag
</span>
</span>
</Link>
{/* Administrative actions */}
<div className="flex flex-col gap-2">
<Button
onClick={() => {
onFinishRound?.()
onClose()
}}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<CheckCircle2 aria-hidden="true" className="size-5" />
Fullfør runde
</Button>
{confirmDelete ? (
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-foreground text-pretty">
Slette runden permanent? Dette kan ikke angres.
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setConfirmDelete(false)}
className="min-h-11 flex-1 justify-center rounded-xl font-bold"
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={() => {
onDeleteRound?.()
onClose()
}}
className="min-h-11 flex-1 justify-center gap-1.5 rounded-xl font-bold"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett runde
</Button>
</div>
</div>
) : (
<Button
variant="destructive"
onClick={() => setConfirmDelete(true)}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<Trash2 aria-hidden="true" className="size-5" />
Slett runde
</Button>
)}
</div>
</div>
</div>,
document.body,
)
}

View file

@ -589,7 +589,16 @@ const MODE_MISSING_LABEL: Record<Mode, string> = {
points: "poengsum",
}
export function RoundLeaderboard({ roundId }: { roundId: string }) {
export function RoundLeaderboard({
roundId,
embedded = false,
}: {
roundId: string
// ADR-040 ("det store grepet") -- dropper denne komponentens egen
// sticky header når den rendres under den delte `RoundHeader`
// (via `RoundPageShell`) i stedet for standalone.
embedded?: boolean
}) {
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
const [mode, setMode] = useState<Mode>("gross")
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
@ -621,18 +630,20 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Leaderboard
</Link>
</div>
</header>
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Leaderboard
</Link>
</div>
</header>
)}
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">

View file

@ -0,0 +1,93 @@
"use client"
// Delt datalag for RoundHeader (ADR-040 Beslutning C/D) -- brukt av alle
// tre faneskjermene (Score/Scorekort/Leaderboard). Henter kun de feltene
// selve headeren trenger (ikke hele RoundOut) -- de tre sidene under
// gjør fortsatt sin egen, uavhengige, mer komplette henting, akkurat som
// før denne runden. onFinishRound/onDeleteRound er egne, selvstendige
// implementasjoner (ikke delt med round-detail.tsx sine interne
// finishRound/deleteRound) -- samme to ekte endepunkter
// (`POST .../complete`, `DELETE ...`), men denne komponenten må fungere
// uavhengig av round-detail.tsx sitt state siden den også brukes fra
// Scorekort- og Leaderboard-siden.
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { RoundHeader, type RoundTab } from "@/components/round-header"
type ApiRoundHeaderFields = {
name: string | null
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
started_at: string | null
play_format: string
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
export function RoundPageShell({
roundId,
activeTab,
children,
}: {
roundId: string
activeTab: RoundTab
children: React.ReactNode
}) {
const router = useRouter()
const [round, setRound] = useState<ApiRoundHeaderFields | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiRoundHeaderFields | null) => {
if (!cancelled && data) setRound(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId])
async function onFinishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
if (res.ok) router.refresh()
}
async function onDeleteRound() {
const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
if (res.ok) router.replace("/my-rounds")
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
{round && (
<RoundHeader
activeTab={activeTab}
round={{
courseName: round.course_name_snapshot,
customName: round.name?.trim() || undefined,
teeName: round.tee_name_snapshot,
date: dateFormatter.format(new Date(round.played_at)),
teeTime: round.started_at ? timeFormatter.format(new Date(round.started_at)) : undefined,
isMatchPlay: round.play_format === "match",
}}
backHref="/my-rounds"
tabHrefs={{
score: `/my-rounds/${roundId}`,
scorecard: `/my-rounds/${roundId}/scorecard`,
leaderboard: `/my-rounds/${roundId}/leaderboard`,
}}
manageHref={`/my-rounds/${roundId}?tab=manage`}
onFinishRound={onFinishRound}
onDeleteRound={onDeleteRound}
/>
)}
<div className="flex-1">{children}</div>
</div>
)
}

View file

@ -30,7 +30,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronRight } from "lucide-react"
import { ArrowLeft } from "lucide-react"
import { cn } from "@/lib/utils"
// --- API-typer ---------------------------------------------------------------
@ -812,7 +812,18 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
// --- Screen ----------------------------------------------------------------
export function RoundScorecard({ roundId }: { roundId: string }) {
export function RoundScorecard({
roundId,
embedded = false,
}: {
roundId: string
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
// slått sammen til ÉN side. `embedded` dropper denne komponentens egen
// sticky header (den delte `RoundHeader`, wired inn av
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
// av den slåtte sammen siden, i stedet for standalone.
embedded?: boolean
}) {
const [round, setRound] = useState<ApiRound | null>(null)
const [unitId, setUnitId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
@ -930,18 +941,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
// forklarende melding i stedet for en evig lastespinner.
if (isSharedBall && round.sides.length < 2) {
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Scorekort
</Link>
</div>
</header>
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Scorekort
</Link>
</div>
</header>
)}
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene selve
@ -994,19 +1007,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
return (
<div className="min-h-dvh bg-background">
{/* Header */}
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Scorekort
</Link>
</div>
</header>
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Scorekort
</Link>
</div>
</header>
)}
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */}
@ -1093,17 +1107,6 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
)}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
{/* Symmetrisk med round-stats.tsx sin "Se scorekort"-lenke tilbake
hit (2026-07-30, etterspurt av bruker -- manglet fra scorekortet
og andre veien). */}
<Link
href={`/my-rounds/${roundId}/stats`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground shadow-sm transition-colors hover:bg-accent/50"
>
Se full rundestatistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</main>
</div>
)

View file

@ -665,7 +665,23 @@ function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHole
)
}
export function RoundStats({ roundId }: { roundId: string }) {
export function RoundStats({
roundId,
embedded = false,
}: {
roundId: string
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
// slått sammen til ÉN side, statistikk RETT UNDER scorekortet.
// `embedded` dropper denne komponentens egen sticky header (den delte
// `RoundHeader` dekker det, se `RoundPageShell`) OG den redundante
// rundesammendrag-stripen/"Se scorekort"-knappen (scorekortet ligger nå
// allerede rett over på samme side).
embedded?: boolean
}) {
// Kun ÉTT <main>-landemerke tillatt per side -- når embedded (rendret
// under RoundScorecard sin egen <main>, ADR-040 Beslutning D), bruk en
// vanlig <div> i stedet.
const ContentTag = embedded ? "div" : "main"
const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
@ -788,19 +804,21 @@ export function RoundStats({ roundId }: { roundId: string }) {
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Statistikk
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Statistikk
</Link>
</div>
</header>
)}
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
@ -815,7 +833,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
</Link>{" "}
for hull-for-hull-forløpet i stedet.
</div>
</main>
</ContentTag>
</div>
)
}
@ -832,51 +850,55 @@ export function RoundStats({ roundId }: { roundId: string }) {
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
return (
<div className="min-h-dvh bg-background">
{/* Header */}
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Rundestatistikk
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */}
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span>
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Rundestatistikk
</Link>
</div>
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || ""}</span>
{s.playedCount > 0 && (
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
)}
</div>
</div>
</header>
)}
{/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert
horisontal side 2026-07-25 (erstatter en tidligere vertikal
tabell her statistikk-siden -- se ARCHITECTURE_DECISIONS.md
ADR-033) -- denne siden forblir aggregert/utledet statistikk. */}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip + "Se scorekort"-knapp -- begge redundante
når embedded (scorekortet ligger da allerede rett over
samme side, ADR-040 Beslutning D). */}
{!embedded && (
<>
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} · {round.holes_planned} hull ·{" "}
{dateFormatter.format(new Date(round.played_at))}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || ""}</span>
{s.playedCount > 0 && (
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
)}
</div>
</div>
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</>
)}
{/* Spillervelger, kun når runden har flere deltakere */}
{round.participants.length > 1 && (
@ -1207,7 +1229,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
)}
</>
)}
</main>
</ContentTag>
</div>
)
}

View file

@ -473,8 +473,8 @@ export function RoundsStatsSummary() {
</div>
) : s && s.rounds_completed === 0 ? (
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<BarChart3 aria-hidden="true" className="size-8 text-primary" />
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
<BarChart3 aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen fullførte runder i perioden</h2>

View file

@ -593,7 +593,7 @@ function TeamColumn({
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<div className="flex shrink-0 items-center gap-2">
{format === "money_ball" && p.lineup_order !== null && (
<span className="inline-flex size-6 items-center justify-center rounded-full bg-primary/15 text-xs font-extrabold text-primary">
<span className="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs font-extrabold text-muted-foreground">
{p.lineup_order}
</span>
)}

View file

@ -622,8 +622,8 @@ function EditSessionForm({
className="mb-1 flex flex-1 flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Pencil aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
@ -1057,8 +1057,8 @@ function CreateSessionCard({
className="flex flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Plus aria-hidden="true" className="size-5 text-primary" />
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
<Plus aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">

View file

@ -92,8 +92,8 @@ export function TwoFactorVerifyForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
@ -248,8 +248,8 @@ export function TwoFactorSetupForm({
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
@ -295,8 +295,8 @@ export function TwoFactorSetupForm({
return (
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
</div>

File diff suppressed because one or more lines are too long