Før bakgrunnsfjerning
This commit is contained in:
parent
3da637b7a2
commit
5e66ce63f5
28 changed files with 3206 additions and 1468 deletions
|
|
@ -392,7 +392,9 @@
|
||||||
"Bash(rm -f /opt/teecup/v0-zip22-login-*.png /opt/teecup/v0-integrated-*.png *)",
|
"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(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(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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
213
CLAUDE.md
213
CLAUDE.md
|
|
@ -5952,6 +5952,211 @@ Ferdig og verifisert:
|
||||||
teecup_api`-kjøring, `/health`/`/dashboard` → 200, `teeoff.no`
|
teecup_api`-kjøring, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||||
upåvirket.
|
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:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
(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
|
poeng). Backend er fullt funksjonelt og testet, men ubrukelig fra
|
||||||
selve appen inntil dette bygges — samme lagdelings-rekkefølge
|
selve appen inntil dette bygges — samme lagdelings-rekkefølge
|
||||||
(motor→skjema→API→frontend) som ADR-037/038/039.
|
(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.
|
||||||
|
|
|
||||||
|
|
@ -50,18 +50,28 @@ ellers virker helt av seg selv så lenge token-klassene brukes.
|
||||||
|
|
||||||
| Token | Lys modus | Rolle |
|
| Token | Lys modus | Rolle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `background` / `foreground` | nesten hvit / mørk grønn-grå | sideflate / brødtekst |
|
| `background` / `foreground` | nesten hvit / mørk grå | sideflate / brødtekst |
|
||||||
| `card` / `card-foreground` | hvit / mørk grønn-grå | kort, paneler, seksjoner |
|
| `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 |
|
| `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 |
|
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |
|
||||||
| `muted` / `muted-foreground` | lys grå-grønn / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
|
| `muted` / `muted-foreground` | lys grå / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
|
||||||
| `accent` / `accent-foreground` | lys grønn-tint | hover-tilstand på nøytrale flater |
|
| `accent` / `accent-foreground` | lys grå | hover-tilstand på nøytrale flater |
|
||||||
| `destructive` | rød-oransje | slett/fare-handlinger |
|
| `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) |
|
| `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`) |
|
| `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 |
|
| `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:**
|
**Farge-semantikk, konsekvent på tvers av hele appen:**
|
||||||
- `primary` (grønn) = valgt/aktiv, positivt resultat (under par, ledende,
|
- `primary` (grønn) = valgt/aktiv, positivt resultat (under par, ledende,
|
||||||
bekreftet), primærhandling.
|
bekreftet), primærhandling.
|
||||||
|
|
|
||||||
|
|
@ -3825,6 +3825,63 @@ beregnet og vist, banneret erstattet av det eksisterende
|
||||||
felles `docker compose up -d --build teecup_frontend`), bruker bekreftet
|
felles `docker compose up -d --build teecup_frontend`), bruker bekreftet
|
||||||
eksplisitt.
|
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
|
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||||
|
|
||||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||||
|
|
|
||||||
|
|
@ -55,23 +55,28 @@
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--background: oklch(0.985 0.005 130);
|
/* Nøytrale flater er EKTE nøytral grå (chroma 0) -- før 2026-08-02 hadde
|
||||||
--foreground: oklch(0.24 0.02 145);
|
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
|
||||||
|
grønt skjær på 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: 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: 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: oklch(0.7512 0.1613 130.33);
|
||||||
--primary-foreground: oklch(0.22 0.04 140);
|
--primary-foreground: oklch(0.22 0 0);
|
||||||
--secondary: oklch(0.96 0.02 130);
|
--secondary: oklch(0.96 0 0);
|
||||||
--secondary-foreground: oklch(0.3 0.03 140);
|
--secondary-foreground: oklch(0.3 0 0);
|
||||||
--muted: oklch(0.96 0.01 130);
|
--muted: oklch(0.96 0 0);
|
||||||
--muted-foreground: oklch(0.52 0.02 140);
|
--muted-foreground: oklch(0.52 0 0);
|
||||||
--accent: oklch(0.94 0.05 130);
|
--accent: oklch(0.94 0 0);
|
||||||
--accent-foreground: oklch(0.3 0.04 140);
|
--accent-foreground: oklch(0.3 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.9 0.015 130);
|
--border: oklch(0.9 0 0);
|
||||||
--input: oklch(0.9 0.015 130);
|
--input: oklch(0.9 0 0);
|
||||||
--ring: oklch(0.7512 0.1613 130.33);
|
--ring: oklch(0.7512 0.1613 130.33);
|
||||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||||
--brand-orange-foreground: oklch(0.99 0 0);
|
--brand-orange-foreground: oklch(0.99 0 0);
|
||||||
|
|
@ -104,20 +109,20 @@
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: oklch(0.19 0.02 150);
|
--background: oklch(0.19 0 0);
|
||||||
--foreground: oklch(0.96 0.01 130);
|
--foreground: oklch(0.96 0 0);
|
||||||
--card: oklch(0.24 0.02 150);
|
--card: oklch(0.24 0 0);
|
||||||
--card-foreground: oklch(0.96 0.01 130);
|
--card-foreground: oklch(0.96 0 0);
|
||||||
--popover: oklch(0.24 0.02 150);
|
--popover: oklch(0.24 0 0);
|
||||||
--popover-foreground: oklch(0.96 0.01 130);
|
--popover-foreground: oklch(0.96 0 0);
|
||||||
--primary: oklch(0.7512 0.1613 130.33);
|
--primary: oklch(0.7512 0.1613 130.33);
|
||||||
--primary-foreground: oklch(0.2 0.04 140);
|
--primary-foreground: oklch(0.2 0 0);
|
||||||
--secondary: oklch(0.28 0.02 150);
|
--secondary: oklch(0.28 0 0);
|
||||||
--secondary-foreground: oklch(0.96 0.01 130);
|
--secondary-foreground: oklch(0.96 0 0);
|
||||||
--muted: oklch(0.28 0.02 150);
|
--muted: oklch(0.28 0 0);
|
||||||
--muted-foreground: oklch(0.7 0.02 135);
|
--muted-foreground: oklch(0.7 0 0);
|
||||||
--accent: oklch(0.32 0.04 140);
|
--accent: oklch(0.32 0 0);
|
||||||
--accent-foreground: oklch(0.96 0.01 130);
|
--accent-foreground: oklch(0.96 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 12%);
|
--border: oklch(1 0 0 / 12%);
|
||||||
--input: oklch(1 0 0 / 16%);
|
--input: oklch(1 0 0 / 16%);
|
||||||
|
|
@ -148,20 +153,20 @@
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not(.light) {
|
:root:not(.light) {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--background: oklch(0.19 0.02 150);
|
--background: oklch(0.19 0 0);
|
||||||
--foreground: oklch(0.96 0.01 130);
|
--foreground: oklch(0.96 0 0);
|
||||||
--card: oklch(0.24 0.02 150);
|
--card: oklch(0.24 0 0);
|
||||||
--card-foreground: oklch(0.96 0.01 130);
|
--card-foreground: oklch(0.96 0 0);
|
||||||
--popover: oklch(0.24 0.02 150);
|
--popover: oklch(0.24 0 0);
|
||||||
--popover-foreground: oklch(0.96 0.01 130);
|
--popover-foreground: oklch(0.96 0 0);
|
||||||
--primary: oklch(0.7512 0.1613 130.33);
|
--primary: oklch(0.7512 0.1613 130.33);
|
||||||
--primary-foreground: oklch(0.2 0.04 140);
|
--primary-foreground: oklch(0.2 0 0);
|
||||||
--secondary: oklch(0.28 0.02 150);
|
--secondary: oklch(0.28 0 0);
|
||||||
--secondary-foreground: oklch(0.96 0.01 130);
|
--secondary-foreground: oklch(0.96 0 0);
|
||||||
--muted: oklch(0.28 0.02 150);
|
--muted: oklch(0.28 0 0);
|
||||||
--muted-foreground: oklch(0.7 0.02 135);
|
--muted-foreground: oklch(0.7 0 0);
|
||||||
--accent: oklch(0.32 0.04 140);
|
--accent: oklch(0.32 0 0);
|
||||||
--accent-foreground: oklch(0.96 0.01 130);
|
--accent-foreground: oklch(0.96 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 12%);
|
--border: oklch(1 0 0 / 12%);
|
||||||
--input: oklch(1 0 0 / 16%);
|
--input: oklch(1 0 0 / 16%);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { RoundPageShell } from "@/components/round-page-shell"
|
||||||
import { RoundLeaderboard } from "@/components/round-leaderboard"
|
import { RoundLeaderboard } from "@/components/round-leaderboard"
|
||||||
|
|
||||||
export default async function RoundLeaderboardPage({
|
export default async function RoundLeaderboardPage({
|
||||||
|
|
@ -6,5 +7,9 @@ export default async function RoundLeaderboardPage({
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
return <RoundLeaderboard roundId={id} />
|
return (
|
||||||
|
<RoundPageShell roundId={id} activeTab="leaderboard">
|
||||||
|
<RoundLeaderboard roundId={id} embedded />
|
||||||
|
</RoundPageShell>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
|
import { Suspense } from "react"
|
||||||
import { RoundDetail } from "@/components/round-detail"
|
import { RoundDetail } from "@/components/round-detail"
|
||||||
|
|
||||||
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
|
import { RoundPageShell } from "@/components/round-page-shell"
|
||||||
import { RoundScorecard } from "@/components/round-scorecard"
|
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({
|
export default async function RoundScorecardPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
return <RoundScorecard roundId={id} />
|
return (
|
||||||
|
<RoundPageShell roundId={id} activeTab="scorecard">
|
||||||
|
<RoundScorecard roundId={id} embedded />
|
||||||
|
<RoundStats roundId={id} embedded />
|
||||||
|
</RoundPageShell>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
export default async function RoundStatsPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
return <RoundStats roundId={id} />
|
redirect(`/my-rounds/${id}/scorecard`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
|
<ShieldCheck aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
|
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -630,8 +630,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<User aria-hidden="true" className="size-5 text-primary" />
|
<User aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
|
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1019,8 +1019,8 @@ function EmailSection({ email }: { email: string }) {
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<Mail aria-hidden="true" className="size-5 text-primary" />
|
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">E-post</h2>
|
<h2 className="text-base font-bold text-foreground">E-post</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1138,8 +1138,8 @@ function SecondaryEmailSection({
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<Mail aria-hidden="true" className="size-5 text-primary" />
|
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
|
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1271,8 +1271,8 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<Lock aria-hidden="true" className="size-5 text-primary" />
|
<Lock aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Passord</h2>
|
<h2 className="text-base font-bold text-foreground">Passord</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1382,8 +1382,8 @@ function PushNotificationSection() {
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<BellRing aria-hidden="true" className="size-5 text-primary" />
|
<BellRing aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Push-varsler på denne enheten</h2>
|
<h2 className="text-base font-bold text-foreground">Push-varsler på denne enheten</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1466,8 +1466,8 @@ function NotificationEmailPrefsSection() {
|
||||||
return (
|
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">
|
<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 items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
<Bell aria-hidden="true" className="size-5 text-primary" />
|
<Bell aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,8 @@ function LoadingState() {
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
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 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">
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||||
<MapPin aria-hidden="true" className="size-8 text-primary" />
|
<MapPin aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-w-md flex-col gap-2">
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
<h2 className="text-xl font-bold text-foreground">Ingen runder funnet</h2>
|
<h2 className="text-xl font-bold text-foreground">Ingen runder funnet</h2>
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ function Avatar({ name, url, size = "large" }: { name: string; url: string | nul
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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)}
|
{initials(name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -576,8 +576,8 @@ function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onC
|
||||||
|
|
||||||
{friends.length === 0 ? (
|
{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">
|
<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">
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
<Users aria-hidden="true" className="size-7 text-primary" />
|
<Users aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-lg font-bold text-foreground">Ingen venner ennå</p>
|
<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 && (
|
{categories.length > 0 && (
|
||||||
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
||||||
{categories.map((code) => (
|
{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">
|
<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-primary" />
|
<Tag aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||||
{categoryLabel(code)}
|
{categoryLabel(code)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -732,7 +732,7 @@ function Avatar({ name }: { name: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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)}
|
{initials(name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -199,8 +199,8 @@ function LoadingState() {
|
||||||
function EmptyState() {
|
function EmptyState() {
|
||||||
return (
|
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 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">
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||||
<ClipboardList aria-hidden="true" className="size-8 text-primary" />
|
<ClipboardList aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-w-md flex-col gap-2">
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>
|
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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 items-center gap-3">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
|
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -524,8 +524,8 @@ function StatusArea({
|
||||||
return (
|
return (
|
||||||
<section className="grid gap-3 sm:grid-cols-2" aria-label="Status">
|
<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 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">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Calendar aria-hidden="true" className="size-5 text-primary" />
|
<Calendar aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
|
@ -538,8 +538,8 @@ function StatusArea({
|
||||||
</div>
|
</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 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">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Users aria-hidden="true" className="size-5 text-primary" />
|
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { RoundPageShell } from "@/components/round-page-shell"
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
CalendarDays,
|
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Flag,
|
Flag,
|
||||||
MapPin,
|
|
||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -153,13 +152,6 @@ function uiGenderToApi(g: Gender): ApiGender {
|
||||||
return g === "male" ? "m" : g === "female" ? "f" : "x"
|
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 ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
||||||
type ApiParticipant = {
|
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
|
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
||||||
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
||||||
// lagret varighet, kun utledet ved visning.
|
// 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 {
|
function formatDuration(startedAt: string, completedAt: string): string | null {
|
||||||
const start = new Date(startedAt).getTime()
|
const start = new Date(startedAt).getTime()
|
||||||
const end = new Date(completedAt).getTime()
|
const end = new Date(completedAt).getTime()
|
||||||
|
|
@ -407,6 +393,13 @@ function playerLabel(p: ApiParticipant): string {
|
||||||
|
|
||||||
export function RoundDetail({ roundId }: { roundId: string }) {
|
export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
const router = useRouter()
|
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 [round, setRound] = useState<ApiRound | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [activePlayerId, setActivePlayerId] = 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
|
// 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
|
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
||||||
// fleste trenger under selve spillingen.
|
// 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 [completing, setCompleting] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [showEditRound, setShowEditRound] = useState(false)
|
const [showEditRound, setShowEditRound] = useState(false)
|
||||||
|
|
@ -1138,53 +1131,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
<RoundPageShell roundId={roundId} activeTab="score">
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mb-4 text-base font-medium text-destructive">
|
<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}
|
strokesReceived={wizardApiHole?.strokes_received ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</RoundPageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2008,22 +1955,15 @@ function CompletedBanner({
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
{/* Scorekort og statistikk slått sammen til ÉN side (ADR-040
|
||||||
|
Beslutning D) -- ett felles knapp i stedet for to separate. */}
|
||||||
<Link
|
<Link
|
||||||
href={`/my-rounds/${roundId}/scorecard`}
|
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"
|
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
|
Se scorekort og statistikk
|
||||||
<ChevronRight aria-hidden="true" className="size-5" />
|
<ChevronRight aria-hidden="true" className="size-5" />
|
||||||
</Link>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3603,7 +3543,7 @@ function AddParticipantForm({
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
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.first_name[0]}
|
||||||
{person.last_name[0]}
|
{person.last_name[0]}
|
||||||
|
|
|
||||||
347
frontend/components/round-header.tsx
Normal file
347
frontend/components/round-header.tsx
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -589,7 +589,16 @@ const MODE_MISSING_LABEL: Record<Mode, string> = {
|
||||||
points: "poengsum",
|
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 { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
|
||||||
const [mode, setMode] = useState<Mode>("gross")
|
const [mode, setMode] = useState<Mode>("gross")
|
||||||
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
||||||
|
|
@ -621,7 +630,8 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<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">
|
<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">
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -633,6 +643,7 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
<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">
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
|
|
||||||
93
frontend/components/round-page-shell.tsx
Normal file
93
frontend/components/round-page-shell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ChevronRight } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- API-typer ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
@ -812,7 +812,18 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
|
||||||
|
|
||||||
// --- Screen ----------------------------------------------------------------
|
// --- 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 [round, setRound] = useState<ApiRound | null>(null)
|
||||||
const [unitId, setUnitId] = useState<string | null>(null)
|
const [unitId, setUnitId] = useState<string | null>(null)
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
|
|
@ -930,7 +941,8 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
// forklarende melding i stedet for en evig lastespinner.
|
// forklarende melding i stedet for en evig lastespinner.
|
||||||
if (isSharedBall && round.sides.length < 2) {
|
if (isSharedBall && round.sides.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<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">
|
<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">
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -942,6 +954,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
<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">
|
<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 på selve
|
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve
|
||||||
|
|
@ -994,8 +1007,8 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||||
{/* Header */}
|
{!embedded && (
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
<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">
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -1007,6 +1020,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||||
{/* Round summary strip */}
|
{/* Round summary strip */}
|
||||||
|
|
@ -1093,17 +1107,6 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
|
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
|
||||||
)}
|
)}
|
||||||
{isSkins && <SkinsProgressTable roundId={roundId} 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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 [round, setRound] = useState<ApiRound | null>(null)
|
||||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
const [participantId, setParticipantId] = useState<string | null>(null)
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
|
|
@ -788,7 +804,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
|
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<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">
|
<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">
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -800,7 +817,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
)}
|
||||||
|
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||||
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
|
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
|
||||||
|
|
||||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
<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>{" "}
|
</Link>{" "}
|
||||||
for hull-for-hull-forløpet i stedet.
|
for hull-for-hull-forløpet i stedet.
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</ContentTag>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -832,8 +850,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
|
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||||
{/* Header */}
|
{!embedded && (
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
<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">
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -845,9 +863,14 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||||
{/* Round summary strip */}
|
{/* Round summary strip + "Se scorekort"-knapp -- begge redundante
|
||||||
|
når embedded (scorekortet ligger da allerede rett over på
|
||||||
|
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-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">
|
<div className="flex flex-col">
|
||||||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
|
@ -855,7 +878,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||||
{round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
{round.tee_name_snapshot} · {round.holes_planned} hull ·{" "}
|
||||||
|
{dateFormatter.format(new Date(round.played_at))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
|
|
@ -866,10 +890,6 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert
|
|
||||||
horisontal side 2026-07-25 (erstatter en tidligere vertikal
|
|
||||||
tabell her på statistikk-siden -- se ARCHITECTURE_DECISIONS.md
|
|
||||||
ADR-033) -- denne siden forblir aggregert/utledet statistikk. */}
|
|
||||||
<Link
|
<Link
|
||||||
href={`/my-rounds/${roundId}/scorecard`}
|
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"
|
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"
|
||||||
|
|
@ -877,6 +897,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
Se scorekort
|
Se scorekort
|
||||||
<ChevronRight aria-hidden="true" className="size-5" />
|
<ChevronRight aria-hidden="true" className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spillervelger, kun når runden har flere deltakere */}
|
{/* Spillervelger, kun når runden har flere deltakere */}
|
||||||
{round.participants.length > 1 && (
|
{round.participants.length > 1 && (
|
||||||
|
|
@ -1207,7 +1229,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</ContentTag>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -473,8 +473,8 @@ export function RoundsStatsSummary() {
|
||||||
</div>
|
</div>
|
||||||
) : s && s.rounds_completed === 0 ? (
|
) : 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 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">
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||||
<BarChart3 aria-hidden="true" className="size-8 text-primary" />
|
<BarChart3 aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex max-w-md flex-col gap-2">
|
<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>
|
<h2 className="text-xl font-bold text-foreground">Ingen fullførte runder i perioden</h2>
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ function TeamColumn({
|
||||||
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{format === "money_ball" && p.lineup_order !== null && (
|
{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}
|
{p.lineup_order}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
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 items-center gap-3">
|
||||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||||
<Pencil aria-hidden="true" className="size-5 text-primary" />
|
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<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"
|
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 items-center gap-3">
|
||||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||||
<Plus aria-hidden="true" className="size-5 text-primary" />
|
<Plus aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,8 @@ export function TwoFactorVerifyForm({
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<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">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
|
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
|
||||||
|
|
@ -248,8 +248,8 @@ export function TwoFactorSetupForm({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<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">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
|
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
|
||||||
|
|
@ -295,8 +295,8 @@ export function TwoFactorSetupForm({
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
|
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<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">
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
|
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue