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(curl -s -o /dev/null -w \"friends/on-course anonym: %{http_code}\\\\n\" https://teecup.teeoff.no/friends/on-course)",
|
||||
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch >/dev/null 2>&1 *)",
|
||||
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)"
|
||||
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
|
||||
"Bash(echo \"tsc exit: $?\")",
|
||||
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
|
|
@ -3611,6 +3611,207 @@ Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
|
|||
|
||||
---
|
||||
|
||||
## ADR-040: «Det store grepet» — sammenhengende rundeoppsett + delt
|
||||
Score/Scorekort/Leaderboard-navigasjon
|
||||
|
||||
**Kontekst:** reist 2026-08-01/02, som en direkte oppfølging av flere
|
||||
visuelle redesign-runder (login-skjerm og dashbord, begge inspirert av et
|
||||
eksternt design-verktøy, "Stitch"). Brukeren pekte på to strukturelle
|
||||
problemer som selve fargeredesignet ikke løser: (1) rundeoppsettet er i
|
||||
dag spredt over ETT langt skjema (`new-round.tsx`) OG en helt annen skjerm
|
||||
(`round-detail.tsx`, etter at runden allerede er opprettet, for sider/
|
||||
Money Ball-rekkefølge), (2) Score/Scorekort/Leaderboard for en frittstående
|
||||
runde deler ingen fast navigasjon — hver side bygger sin egen fulle
|
||||
side-chrome, ulikt Stitchs skisse.
|
||||
|
||||
**Beslutning A — rundeoppsettet blir én sammenhengende veiviser, 5 steg:**
|
||||
Bane & tid → Spilleform (inkl. HCP-innstillinger) → Spillere → Lag/
|
||||
rekkefølge (kun to-sidede formater/Money Ball) → Deling. Erstatter dagens
|
||||
ett-langt-skjema-pluss-en-helt-annen-skjerm-mønster.
|
||||
|
||||
**Beslutning B — HCP-prosent er ALLTID justerbar, Match-HCP er en egen
|
||||
bryter:** brukeren presiserte eksplisitt at prosenten (ADR-014s
|
||||
`strategy`) aldri skal være låst til formatets standardverdi, og at
|
||||
to-sidede formater i tillegg skal kunne skru `use_matchplay_handicap`
|
||||
av/på — differensial-fordeling mellom sidene (laveste settes til 0
|
||||
mottatte, resten mottar differansen fordelt fra laveste stroke index) i
|
||||
stedet for ren brutto når det ikke er noen forskjell. **Funnet ved
|
||||
verifisering, ikke antatt:** begge deler fantes allerede FERDIGBYGD i
|
||||
motoren (`match_play_strokes()` i `handicap_engine.py`, ADR-014s "fire
|
||||
brytere" i `app/handicap.py`) — kun brukt av org-scopede turneringer
|
||||
(`session.allowance_override`). Frittstående runder (`round`-tabellen)
|
||||
manglet bare selve kolonnen; ingen ny regnelogikk.
|
||||
|
||||
**Beslutning C — "Spillere og runde" (inkl. flighter) blir værende på
|
||||
rundeoppsettsiden**, flytter IKKE inn i den nye delte fane-raden — dette
|
||||
er forvaltning, ikke visning. Score/Scorekort/Leaderboard trenger i
|
||||
stedet en kompakt, alltid tilgjengelig vei tilbake til oppsettsiden
|
||||
(sammen med "Fullfør runde"/"Slett runde") — plassering (toppmeny vs.
|
||||
footer vs. noe annet) er bevisst overlatt til V0 å foreslå ut fra en
|
||||
presist beskrevet kontekst, ikke forhåndsbestemt her.
|
||||
|
||||
**Beslutning D — Scorekort og Statistikk slås sammen til ÉN side,**
|
||||
statistikk RETT UNDER scorekortet — den delte fane-raden blir dermed tre
|
||||
faner (Score / Scorekort / Leaderboard), ikke fire. `/my-rounds/[id]/
|
||||
stats` forsvinner som egen rute, innholdet flytter inn i `/my-rounds/
|
||||
[id]/scorecard`.
|
||||
|
||||
**Beslutning E — reelt, tidligere udokumentert funn: spillere i
|
||||
scorekortet var ALDRI gruppert lagvis.** Brukeren rapporterte det
|
||||
konkret (en ekte fourball-runde der Erol+Kåre var ett lag, Ellen
|
||||
Anette+Christer det andre, men vist interleaved i rekkefølgen de ble
|
||||
lagt til). Bekreftet i koden på BEGGE steder spillere vises for et
|
||||
to-sidet format: `ScorecardGrid` (`round-detail.tsx`) og
|
||||
`MatchScorecardGrid` (`round-scorecard.tsx`) sorterte ingen av dem på
|
||||
`round_side_id` — ren innsettingsrekkefølge. Fikset med en liten,
|
||||
presis sortering (stabil sort på side, side A samlet FØR side B) i
|
||||
begge, IKKE en ny funksjon. Om celle-nivå-fargelegging (hvem som vant
|
||||
hullet) alene er nok til å skille lagene visuelt, eller om det trengs
|
||||
en tydeligere lag-blokk-markering, er et åpent spørsmål overlatt til V0
|
||||
(samme "spør V0"-mønster som Beslutning C).
|
||||
|
||||
**Bygget og verifisert denne runden (2026-08-02):**
|
||||
- Ny migrasjon `051_round_allowance_override.sql` — `round.allowance_
|
||||
override jsonb`, rent additivt.
|
||||
- `app/routers/rounds.py`: ny `_round_allowance_override()`-hjelpe-
|
||||
funksjon (henter/parser friskt fra `round`-raden, samme mønster som
|
||||
`matches.py`/`scoring.py` sin `session.allowance_override` — IKKE
|
||||
cachet i selve config-objektet), koblet inn i `_recompute_side_
|
||||
handicaps`/`_relative_strokes_for_round`. `RoundCreate`/`RoundUpdate`/
|
||||
`RoundOut` fikk `allowance_override`. PATCH gjenberegner `playing_
|
||||
handicap` for allerede tildelte sider ved endring (samme mønster som
|
||||
`tournaments.py` sin `_recompute_session_matches`).
|
||||
- Scratch-verifisert presist: 100%→50%-prosent ga `playing_handicap`
|
||||
24/10 → 12/5 (nøyaktig som beregnet for hånd fra HCP 20,0/10,0 mot en
|
||||
slope/rating-satt egendefinert bane), `use_matchplay_handicap:false`
|
||||
ga rå 12/5 i stedet for differensial 7/0 (`match_play_strokes([12,5])`
|
||||
→ laveste=5 til 0, differanse 7). Slagspill upåvirket (regresjon
|
||||
bekreftet).
|
||||
- Lag-grupperingsfiksen (Beslutning E) browserverifisert mot en fersk
|
||||
scratch-fourball-runde som gjenskapte brukerens eget rapporterte
|
||||
scenario nøyaktig (fire spillere lagt til i interleaved rekkefølge
|
||||
A→Rødt, B→Blått, C→Rødt, D→Blått) — bekreftet visuelt i BÅDE
|
||||
`ScorecardGrid` (live) og `MatchScorecardGrid` (post-runde) at Rødt
|
||||
lag nå vises samlet, deretter Blått.
|
||||
- Rullet ut mot ekte systemer 2026-08-02, bruker bekreftet eksplisitt:
|
||||
migrasjon 051 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||||
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||||
--build teecup_api teecup_frontend`. Begge containere boot-et rent,
|
||||
`/health`/`/dashboard` → 200, anonym `PATCH /rounds/{ukjent-id}` ga
|
||||
korrekt `401` (ikke en rå 404 — bekrefter ruten når FastAPI),
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
**Steg 3 (V0-prompt 1, rundeoppsett-veiviseren) ✅ BYGGET, GRUNDIG
|
||||
VERIFISERT OG LIVE (2026-08-02), samme dag:** V0-prompten (Beslutning A/B)
|
||||
skrevet og kjørt av bruker (zip 25), `frontend/components/new-round.tsx`
|
||||
skrevet fullstendig om — V0s 5-stegs veiviser-skall flettet med ekte
|
||||
logikk: offisiell/egen bane-søk (inkl. nearby-geolokasjon), ekte egen-
|
||||
bane-opprettelse (portert fra tidligere versjon), ekte `/people/search`,
|
||||
ny `allowance_override`-konstruksjon i steg 2 (samme mønster som
|
||||
`CreateSessionCard`s `buildAllowanceOverride`), og en helt ny
|
||||
innsendingsorkestrering — runden (+ deltakere + sider/lineup) finnes
|
||||
FØRST når HELE veiviseren er fullført (runde → sider → medspillere med
|
||||
`round_side_id` satt direkte på opprettelse → eierens egen side-
|
||||
tildeling via PATCH → Money Ball-rekkefølge via PATCH → naviger til
|
||||
runden), ulikt den gamle "opprett runde først, legg til spillere
|
||||
etterpå"-flyten.
|
||||
**Reelle feil funnet og rettet under integrering:** V0s egen `Tee`-type
|
||||
manglet kjønnsfelt (ville vist ufiltrerte utslag for både eier og
|
||||
gjester) — lagt til `genders: ApiGender[]`, filtrert riktig per kjent
|
||||
kjønn. To TS-feil (prop-spredning som overskrev `course`/`ownGender`
|
||||
tilbake til `null`, og en `"x"`-kjønn-mismatch i tee-filtreringen).
|
||||
**Bevisst, ærlig begrensning:** en spiller funnet via `/people/search`
|
||||
har ukjent kjønn før de faktisk er lagt til runden (samme begrensning
|
||||
som `PersonMatch`-modellen alltid har hatt) — utslagslisten for en slik
|
||||
spiller er derfor UFILTRERT i veiviseren, med en forklarende tekst;
|
||||
server-validering gir en klar feil ved et faktisk uforenlig valg.
|
||||
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
|
||||
ruter listet), og to komplette nettleser-gjennomkjøringer mot en fersk
|
||||
scratch-backend — én Fourball-runde (egen bane, gjest, to sider
|
||||
opprettet og tildelt interleaved, 90 % HCP-prosent) og én Slagspill-
|
||||
runde (rene standardvalg). Bekreftet direkte mot databasen at
|
||||
`allowance_override` ble bygget nøyaktig riktig i begge tilfeller
|
||||
(`{"strategy":{"type":"per_player","percentage":0.9},...}` for
|
||||
Fourball — riktig `per_player`, ikke `combined`, siden fourball ikke er
|
||||
en side-enhet — og `null` for Slagspill, ingen unødvendig payload),
|
||||
sidene/tildelingene stemte, og HCP ble beregnet korrekt fra ekte
|
||||
profildata (kryssjekket mot hånd-regnet Course Handicap for begge
|
||||
testspillerne).
|
||||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon (ren frontend), `docker compose up -d --build teecup_frontend`
|
||||
(gjenskapte også `teecup_api` som vanlig bivirkning, ingen backend-kode
|
||||
rørt). Begge containere boot-et rent, `/health`/`/dashboard`/
|
||||
`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
**Steg 4-5 (V0-prompt 2, den delte Score/Scorekort/Leaderboard-fane-raden
|
||||
+ integrering) ✅ BYGGET, GRUNDIG VERIFISERT OG LIVE (2026-08-02), samme
|
||||
dag:** V0-prompten (Beslutning C/D) skrevet — bevisst SMALERE i omfang
|
||||
enn prompt 1: kun den delte header/fane-raden, ikke en redesign av selve
|
||||
innholdet på Scorekort-siden (den sammenslåingen er ren sammenstabling av
|
||||
to allerede ferdige komponenter, løst direkte i kode uten V0). Kjørt av
|
||||
bruker (zip 26) — leverte `components/round-header.tsx` (kontekstblokk
|
||||
med tilbakeknapp/bane/tee/dato/tid/Matchspill-merke, tre-fanet
|
||||
segmentert kontroll, og en "Administrer"-knapp som åpner en bunnsheet-
|
||||
dialog med lenke til "Spillere og runde" + "Fullfør runde" + to-stegs
|
||||
"Slett runde") — tatt inn UENDRET (ren presentasjon, ingen logikk å
|
||||
bytte ut).
|
||||
**Ekte datalag bygget rundt den:** ny `components/round-page-shell.tsx`
|
||||
— henter kun de feltene headeren trenger via `/rounds/{id}`, bygger
|
||||
`tabHrefs` til de tre ekte rutene, og implementerer `onFinishRound`/
|
||||
`onDeleteRound` som egne, selvstendige handlinger (samme
|
||||
`POST .../complete`/`DELETE ...`-endepunkter som `round-detail.tsx` sin
|
||||
opprinnelige logikk, men uavhengig av den siden komponenten også må
|
||||
fungere fra Scorekort- og Leaderboard-siden).
|
||||
**Wiret inn i alle tre destinasjonene:** Score (`round-detail.tsx`sin
|
||||
gamle egne header fjernet, erstattet med `RoundPageShell` — den
|
||||
INTERNE "Score"/"Spillere og runde"-fanevekslingen beholdt UENDRET som
|
||||
et bevisst lavrisiko-valg, nås nå i tillegg via headerens
|
||||
"Administrer"-dialog med en ny `?tab=manage`-URL-parameter lest ved
|
||||
mount), Scorekort (`round-scorecard.tsx` + `round-stats.tsx` slått
|
||||
sammen til ÉN side via en ny `embedded`-prop på begge, statistikk RETT
|
||||
UNDER scorekortet som spec-et i Beslutning D — gammel `/stats`-rute er
|
||||
nå en ren redirect dit), Leaderboard.
|
||||
**Reelle feil funnet og rettet under integrering:** to `<main>`-
|
||||
landemerker på samme side etter sammenslåingen av Scorekort+Statistikk
|
||||
(ugyldig HTML/tilgjengelighet) — rettet med en `ContentTag`-switch
|
||||
(`div` når embedded, `main` ellers) i `round-stats.tsx`. Fjernet nå
|
||||
overflødige kryss-lenker mellom de to sammenslåtte sidene, kollapset
|
||||
"Se scorekort"/"Se full rundestatistikk" til én "Se scorekort og
|
||||
statistikk"-knapp i `round-detail.tsx` sitt fullført-banner.
|
||||
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
|
||||
ruter listet), full nettleser-gjennomkjøring gjennom hele "Administrer"-
|
||||
flyten — headeren konsistent på tvers av alle tre fanene, dialogens
|
||||
lenke til "Spillere og runde" bekreftet å faktisk åpne riktig intern
|
||||
fane via `?tab=manage`, ekte "Fullfør runde" (bekreftet `completed_at`
|
||||
satt via et direkte API-kall etterpå) og ekte to-stegs "Slett runde"
|
||||
(bekreftet navigerte korrekt til tom-tilstand på `/my-rounds`
|
||||
etterpå). Traff en Turbopack-flakighet i selve scratch-dev-serveren
|
||||
underveis (stuck spinner, ingen nettverkskall i det hele tatt) — løst
|
||||
med en frisk containerstart, bekreftet IKKE en kodefeil (samme
|
||||
kjente ustabilitetsklasse som tidligere dokumentert i dette
|
||||
prosjektet, ikke noe nytt).
|
||||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon (ren frontend), `docker compose up -d --build
|
||||
teecup_frontend` (gjenskapte også `teecup_api` som vanlig bivirkning,
|
||||
ingen backend-kode rørt). Begge containere boot-et rent,
|
||||
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
**ADR-040 («det store grepet») er dermed HELT FERDIG, alle 5 steg,
|
||||
backend + frontend, live:**
|
||||
1. ~~Backend: `allowance_override`-migrasjon + kobling~~ ✅
|
||||
2. ~~Kode-fiks: lag-sortering~~ ✅
|
||||
3. ~~V0-prompt 1: den samlede rundeoppsett-veiviseren~~ ✅
|
||||
4. ~~V0-prompt 2: den delte Score/Scorekort/Leaderboard-fane-raden~~ ✅
|
||||
5. ~~Integrering + browserverifisering + utrulling~~ ✅
|
||||
|
||||
Bevisst utenfor omfang, egen fremtidig vurdering om ønskelig: den
|
||||
INTERNE "Score"/"Spillere og runde"-fanevekslingen på selve Score-siden
|
||||
ble bevisst beholdt (ikke fjernet/erstattet av `?tab=manage`-mekanismen
|
||||
alene) som et lavrisiko-valg — kan forenkles videre senere om ønsket,
|
||||
ikke noe brukeren har bedt om.
|
||||
|
||||
---
|
||||
|
||||
## Åpne spørsmål (ikke besluttet ennå)
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
|
|
|||
213
CLAUDE.md
213
CLAUDE.md
|
|
@ -5952,6 +5952,211 @@ Ferdig og verifisert:
|
|||
teecup_api`-kjøring, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
- **Login-skjermen redesignet ("Forest Green", inspirert av et eksternt
|
||||
design-verktøy kalt "Stitch"), BYGGET OG LIVE (2026-08-01/02):**
|
||||
brukeren ba eksplisitt om et V0-prompt basert på Stitchs skisse, kjørte
|
||||
det selv, sendte zip-eksporten tilbake. `frontend/components/login-
|
||||
form.tsx` skrevet fullstendig om — V0s nye visuelle skall (låst
|
||||
fargepalett `const C = {...}`, WCAG-kontrast verifisert presist, ikke
|
||||
anslått, for hvert fargevalg) flettet med 100 % ekte logikk fra den
|
||||
gamle komponenten uendret: `sendLink()` (`POST /auth/request-link`),
|
||||
passord-innlogging (`POST /auth/login-password`), invitasjonskode-
|
||||
oppslag (`GET /public/tournaments/by-code/{code}` + navigasjon).
|
||||
`TwoFactorVerifyForm`/`TwoFactorSetupForm` (ADR-021) beholdt HELT
|
||||
uendret, kun rendret inni det nye kortskallet. **Reell bug funnet under
|
||||
integrering, browserverifisert:** `app/page.tsx` hadde sin egen
|
||||
`<Wordmark/>`+tagline-header OVER selve `LoginForm`, som nå OGSÅ hadde
|
||||
fått sin egen header inni det nye kortet — duplikat. Fjernet den ytre
|
||||
headeren + det nå ubrukte `Wordmark`-importet fra `page.tsx`.
|
||||
**Rullet ut live**, bruker bekreftet eksplisitt ("Rull ut live. Jeg har
|
||||
comitted til git og kan reversere om nødvendig."): ren frontend-endring,
|
||||
ingen migrasjon.
|
||||
|
||||
- **Dashbordet redesignet i samme visuelle retning, BYGGET OG LIVE
|
||||
(2026-08-01/02), to runder samme dag:** første runde (V0-eksport)
|
||||
skrev om `dashboard.tsx` fra bunnen — flettet ekte ADR-035-datalag
|
||||
(organisasjoner/turneringer/runder/HCP-historikk/venner/varsler) med
|
||||
V0s nye visuelle seksjoner, la til en NY «Venner på banen»-seksjon
|
||||
(bekreftet av bruker at den skal ligge RETT UNDER hurtighandlingene,
|
||||
jf. Stitchs egen skisse — jeg hadde ikke selv sjekket dette FØR
|
||||
brukeren spurte eksplisitt, rettet før prompten ble sendt).
|
||||
**Ny backend-endepunkt bygget for dette:** `GET /friends/on-course`
|
||||
(`app/routers/rounds.py`, ny `FriendOnCourseEntry`-modell) — venner med
|
||||
en PÅGÅENDE runde, eller en fullført innen siste 24 timer (bekreftet
|
||||
av bruker), filtrert gjennom SAMME synlighets-SQL som `_can_view_
|
||||
round` sin venner-gren (ADR-036 fase 2) — ingen egen, parallell
|
||||
synlighetsregel.
|
||||
**Andre runde, presise fargekorreksjoner mot Stitchs FAKTISKE
|
||||
skjermbilde** (ikke bare beskrivelse — brukeren lastet opp det ekte
|
||||
bildet etter at jeg først måtte innrømme jeg hadde slettet den
|
||||
opprinnelige zip-en og derfor sammenlignet blindt): pikselverdier
|
||||
hentet presist via PIL i en engangs Docker-container (Stitchs egen
|
||||
knappegrønn viste seg å være `#2d950c`, kontrast mot hvit tekst kun
|
||||
3,88:1 — FEILER WCAG AA, flagget proaktivt FØR den ble brukt).
|
||||
`const C` fikk `primary:"#1f6b08"` (samme fargefamilie, kontrast
|
||||
~6,6:1, verifisert med samme relative-luminans-formel), `primaryInk:
|
||||
"#ffffff"`, `borderSoft:"#e4ebe3"`, ny `AVATAR_HUES`-array (pastell-
|
||||
par til venne-avatarer). Header endret fra glassmorfisme til flat
|
||||
opak bakgrunn, flagg-ikonet fikk lys grønn bakgrunn i stedet for
|
||||
oransje, `QuickAction`/`ShortcutButton` og `LiveFriends`-kortene
|
||||
fargekorrigert tilsvarende.
|
||||
**Ny fast bunn-fanerad** (`BottomTabBar`, eksportert fra
|
||||
`dashboard.tsx`, brukt av flere sider): fem faner (Hjem/Runder/
|
||||
Turneringer/Profil/Mer). **Reell rute-kollisjon funnet og rettet FØR
|
||||
utrulling** (samme klasse feil som tidligere i prosjektet, `/rounds`
|
||||
vs. API-prefikset) — V0s genererte `href="/rounds"`/`href="/tournaments"`
|
||||
pekte begge feil (ingen slik side finnes) — rettet til `/my-rounds` og
|
||||
et ankerpunkt `/dashboard#kommende-turneringer` (ingen egen turnering-
|
||||
liste-side finnes). Ny, tidligere ikke-eksisterende `/more`-side bygget
|
||||
(`components/more-menu.tsx`, `app/more/page.tsx`) som femte fanes reelle
|
||||
mål — samler Konto/Venner/Varsler/organisasjoner ett sted, ekte
|
||||
`handleLogout()`. `<main>`-padding økt (`pb-24`) for å ikke overlappe
|
||||
den nye faste bunnraden.
|
||||
`install-prompt.tsx` fikk kun ny JSX (mørk grønn gradient-banner),
|
||||
100 % av den ekte iOS/Android-deteksjons-/`beforeinstallprompt`-/
|
||||
14-dagers-utsettelseslogikken uendret.
|
||||
**Rullet ut live**, bruker bekreftet eksplisitt: ingen migrasjon for
|
||||
visuelle deler, ny `/friends/on-course`-ruten dekket av samme
|
||||
`docker compose up -d --build teecup_api teecup_frontend`. Begge
|
||||
containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
- **«Det store grepet»: sammenhengende rundeoppsett + delt Score/
|
||||
Scorekort/Leaderboard-navigasjon — steg 1+2 av 5 BYGGET, SCRATCH-/
|
||||
BROWSERVERIFISERT OG LIVE (2026-08-02), se ADR-040 for full
|
||||
beslutningslogg:** direkte oppfølging av login-/dashbord-redesignet —
|
||||
brukeren pekte på to strukturelle hull utover selve fargespråket
|
||||
(rundeoppsettet spredt over to skjermer, Score/Scorekort/Leaderboard
|
||||
deler ingen fast navigasjon). Fem beslutninger bekreftet (ADR-040
|
||||
Beslutning A-E), plan skrevet som en levende Artifact-skisse før
|
||||
bygging.
|
||||
**Steg 1 (backend, HCP-prosent alltid justerbar + Match-HCP-bryter):**
|
||||
ny migrasjon `051_round_allowance_override.sql` (`round.allowance_
|
||||
override jsonb`), koblet inn i `_recompute_side_handicaps`/`_relative_
|
||||
strokes_for_round`/`POST`+`PATCH /rounds` (`app/routers/rounds.py`).
|
||||
Gjenbruker ADR-014s allerede ferdigbygde motor uendret (`match_play_
|
||||
strokes()`, `parse_allowance_config`) — frittstående runder manglet
|
||||
bare selve kolonnen.
|
||||
**Steg 2 (ren kode-fiks, ikke V0): spillere i scorekortet var ALDRI
|
||||
gruppert lagvis** — reelt, tidligere udokumentert funn rapportert av
|
||||
bruker med en konkret lenke (fourball-runde der to kjente lagkamerater
|
||||
ble vist interleaved). Bekreftet i koden: `ScorecardGrid` (`round-
|
||||
detail.tsx`) og `MatchScorecardGrid` (`round-scorecard.tsx`) sorterte
|
||||
ingen av dem på `round_side_id`. Fikset med stabil sortering (side A
|
||||
samlet, deretter side B) i begge.
|
||||
**Scratch-verifisert presist:** 100 %→50 %-prosent ga `playing_
|
||||
handicap` 24/10 → 12/5 (nøyaktig som beregnet for hånd), `use_
|
||||
matchplay_handicap:false` ga rå 12/5 i stedet for differensial 7/0.
|
||||
Lag-grupperingsfiksen browserverifisert mot en fersk scratch-fourball-
|
||||
runde som gjenskapte brukerens eget scenario nøyaktig (interleaved
|
||||
tilføyelsesrekkefølge A→Rødt, B→Blått, C→Rødt, D→Blått) — bekreftet
|
||||
visuelt i BEGGE visningene at lagene nå vises samlet.
|
||||
**Rullet ut mot ekte systemer 2026-08-02**, bruker bekreftet eksplisitt
|
||||
(plan vist FØR migrasjonen): migrasjon 051 kjørt mot ekte `teecup_db`
|
||||
(kolonne bekreftet, `test_isolation.sql` fortsatt 12/12), deretter
|
||||
`docker compose up -d --build teecup_api teecup_frontend`. Begge
|
||||
containere boot-et rent, `/health`/`/dashboard` → 200, anonym
|
||||
`PATCH /rounds/{ukjent-id}` ga korrekt `401` (ikke en rå 404), `teeoff.no`
|
||||
upåvirket.
|
||||
**Gjenstår (steg 3-5 av 5, ingen V0-prompt skrevet/sendt ennå):**
|
||||
V0-prompt 1 (den samlede rundeoppsett-veiviseren), V0-prompt 2 (den
|
||||
delte fane-raden + spørsmålet om lag-markering utover sortering),
|
||||
deretter integrering/browserverifisering/utrulling av begge. Se
|
||||
FEATURE_BACKLOG.md for detaljert arbeidsnotat.
|
||||
|
||||
- **«Det store grepet»: steg 3 av 5 (V0-prompt 1, rundeoppsett-
|
||||
veiviseren) BYGGET, GRUNDIG VERIFISERT OG LIVE (2026-08-02), samme
|
||||
dag:** V0-prompten (Beslutning A/B) skrevet, kjørt av bruker (zip 25),
|
||||
`frontend/components/new-round.tsx` skrevet fullstendig om —
|
||||
V0s 5-stegs veiviser-skall flettet med ekte logikk (offisiell/egen
|
||||
bane-søk inkl. nearby-geolokasjon, ekte egen-bane-opprettelse portert
|
||||
fra tidligere versjon, ekte `/people/search`, ny `allowance_override`-
|
||||
konstruksjon i steg 2 samme mønster som `CreateSessionCard`, og en helt
|
||||
ny innsendingsorkestrering — runden+deltakere+sider/lineup finnes
|
||||
FØRST når hele veiviseren er fullført, ulikt den gamle "opprett runden
|
||||
først"-flyten).
|
||||
**Reelle feil funnet og rettet under integrering:** V0s egen
|
||||
`Tee`-type manglet kjønnsfelt (ville vist ufiltrerte utslag) — lagt til
|
||||
`genders`, filtrert riktig. To TS-feil (prop-spredning som overskrev
|
||||
`course`/`ownGender` tilbake til `null`, en `"x"`-kjønn-mismatch i
|
||||
tee-filtreringen).
|
||||
**Verifisert grundig i isolert scratch:** full produksjonsbuild (alle
|
||||
ruter listet), to komplette nettleser-gjennomkjøringer mot en fersk
|
||||
scratch-backend (én Fourball-runde med egen bane/gjest/to sider/90 %
|
||||
HCP-prosent, én Slagspill-runde med rene standardvalg) — bekreftet
|
||||
direkte mot databasen at `allowance_override` ble bygget nøyaktig
|
||||
riktig i begge tilfeller (`per_player` for Fourball siden det ikke er
|
||||
en side-enhet, `null` for Slagspill), sidene/tildelingene stemte, og
|
||||
HCP ble beregnet korrekt fra ekte profildata.
|
||||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, `docker compose up -d --build teecup_frontend` (gjenskapte
|
||||
også `teecup_api` som vanlig bivirkning). Begge containere boot-et
|
||||
rent, `/health`/`/dashboard`/`/my-rounds/new` → 200, `teeoff.no`
|
||||
upåvirket. Full detalj i ARCHITECTURE_DECISIONS.md (ADR-040).
|
||||
|
||||
- **«Det store grepet»: steg 4-5 av 5 (V0-prompt 2, den delte Score/
|
||||
Scorekort/Leaderboard-fane-raden + integrering) BYGGET, GRUNDIG
|
||||
VERIFISERT OG LIVE (2026-08-02), samme dag — ADR-040 dermed HELT
|
||||
FERDIG:** V0-prompten (Beslutning C/D) bevisst smalere i omfang enn
|
||||
prompt 1 — kun selve header/fane-chrome-en, ikke en redesign av
|
||||
Scorekort-sidens innhold (den sammenslåingen med Statistikk er ren
|
||||
kode-sammenstabling, løst direkte uten V0). Kjørt av bruker (zip 26)
|
||||
— ny `components/round-header.tsx` (tatt inn uendret: kontekstblokk
|
||||
+ tre-fanet segmentert kontroll + en "Administrer"-knapp som åpner en
|
||||
bunnsheet-dialog med "Spillere og runde"-lenke + "Fullfør runde" +
|
||||
to-stegs "Slett runde").
|
||||
**Ekte datalag:** ny `components/round-page-shell.tsx` — henter
|
||||
rundedata for headeren, implementerer `onFinishRound`/`onDeleteRound`
|
||||
som egne, selvstendige kall (samme `POST .../complete`/`DELETE ...`
|
||||
som før, men uavhengig siden komponenten deles av alle tre sidene).
|
||||
**Wiret inn:** Score (`round-detail.tsx` sin gamle egne header
|
||||
fjernet, erstattet med `RoundPageShell` — den interne "Score"/
|
||||
"Spillere og runde"-fanevekslingen bevisst BEHOLDT som lavrisiko-valg,
|
||||
nås nå i tillegg via headerens dialog med en ny `?tab=manage`-URL-
|
||||
parameter), Scorekort (`round-scorecard.tsx`+`round-stats.tsx` slått
|
||||
sammen til ÉN side via en ny `embedded`-prop på begge -- statistikk
|
||||
RETT UNDER scorekortet, gammel `/stats`-rute er nå en redirect dit),
|
||||
Leaderboard.
|
||||
**Reelt funn og fiks under integrering:** to `<main>`-landemerker på
|
||||
samme side etter sammenslåingen (ugyldig HTML/a11y) -- rettet med en
|
||||
`ContentTag`-switch (`div` når embedded) i `round-stats.tsx`. Fjernet
|
||||
nå overflødige kryss-lenker, kollapset "Se scorekort"/"Se full
|
||||
rundestatistikk" til én knapp i `round-detail.tsx` sitt fullført-
|
||||
banner.
|
||||
**Verifisert grundig i isolert scratch:** full produksjonsbuild, full
|
||||
nettleser-gjennomkjøring av hele "Administrer"-flyten -- headeren
|
||||
konsistent på tvers av alle tre fanene, dialogens lenke til "Spillere
|
||||
og runde" bekreftet å faktisk åpne riktig intern fane, ekte "Fullfør
|
||||
runde" (bekreftet `completed_at` satt via API etterpå) og ekte
|
||||
to-stegs "Slett runde" (bekreftet navigerte korrekt til tom-tilstand
|
||||
etterpå). Traff en Turbopack-flakighet i scratch-dev-serveren
|
||||
underveis (stuck spinner) -- løst med frisk containerstart, bekreftet
|
||||
IKKE en kodefeil.
|
||||
**Rullet ut live 2026-08-02**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, `docker compose up -d --build teecup_frontend` (gjenskapte
|
||||
også `teecup_api` som vanlig bivirkning). Begge containere boot-et
|
||||
rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
**Bevisst IKKE dekket:** Beslutning E sitt spørsmål om lag-
|
||||
gruppering trenger mer enn celle-fargelegging (sortering i seg selv
|
||||
ble fikset i steg 2) -- prompt 2 ble bevisst avgrenset til kun
|
||||
header-chrome-en, dette spørsmålet er derfor fortsatt åpent, ikke
|
||||
stilt til V0 ennå. Se FEATURE_BACKLOG.md.
|
||||
|
||||
- **Rundeoppsett-veiviseren: dato/klokkeslett forhåndsutfylt, LIVE
|
||||
(2026-08-02), samme dag:** brukeren ba om at dagens dato og gjeldende
|
||||
klokkeslett skal være default når man setter opp en runde, i stedet
|
||||
for tomme felt. `frontend/components/new-round.tsx` sitt Steg 1 fikk
|
||||
to nye lokale hjelpefunksjoner (`todayIso()` -- lokal tidssone, ikke
|
||||
UTC, portert fra en tidligere versjon av filen, samme presisjonshensyn
|
||||
som `<input type="date">` alltid har krevd i dette prosjektet;
|
||||
`nowTimeString()`) — `date`/`time`-state initialiseres nå med disse i
|
||||
stedet for tomme strenger (dato respekterer fortsatt `prefillPlayedAt`
|
||||
fra "legg til en flight til"-flyten der den er satt). Browserverifisert
|
||||
i isolert scratch: begge feltene viste korrekt dagens dato/klokkeslett
|
||||
ved første besøk til Steg 1s felt-visning. Rullet ut live, ingen
|
||||
migrasjon, kun `docker compose up -d --build teecup_frontend`, `/health`/
|
||||
`/my-rounds/new` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||
|
|
@ -6214,3 +6419,11 @@ Neste steg:
|
|||
poeng). Backend er fullt funksjonelt og testet, men ubrukelig fra
|
||||
selve appen inntil dette bygges — samme lagdelings-rekkefølge
|
||||
(motor→skjema→API→frontend) som ADR-037/038/039.
|
||||
15. **Ferdig, kun for historikk:** «Det store grepet» (ADR-040) — alle
|
||||
5 steg (backend/allowance_override, lag-sortering, rundeoppsett-
|
||||
veiviseren, delt Score/Scorekort/Leaderboard-fane-rad, integrering)
|
||||
BYGGET, VERIFISERT OG LIVE 2026-08-02, se status over. **Ett bevisst
|
||||
utsatt, fortsatt åpent spørsmål:** trenger lag-grupperingen i
|
||||
scorekortet mer enn celle-fargelegging for å skille lagene tydelig
|
||||
nok (utover selve sorteringen, som allerede er fikset) — ikke stilt
|
||||
til V0 ennå, egen liten vurdering om ønskelig. Se FEATURE_BACKLOG.md.
|
||||
|
|
|
|||
|
|
@ -50,18 +50,28 @@ ellers virker helt av seg selv så lenge token-klassene brukes.
|
|||
|
||||
| Token | Lys modus | Rolle |
|
||||
|---|---|---|
|
||||
| `background` / `foreground` | nesten hvit / mørk grønn-grå | sideflate / brødtekst |
|
||||
| `card` / `card-foreground` | hvit / mørk grønn-grå | kort, paneler, seksjoner |
|
||||
| `background` / `foreground` | nesten hvit / mørk grå | sideflate / brødtekst |
|
||||
| `card` / `card-foreground` | hvit / mørk grå | kort, paneler, seksjoner |
|
||||
| `primary` / `primary-foreground` | `oklch(0.7512 0.1613 130.33)` (grønn) / mørk | hovedhandling, valgt tilstand, "under par"/positiv |
|
||||
| `secondary` / `secondary-foreground` | lys grønn-tint | sekundær vekt, sjeldent brukt alene |
|
||||
| `muted` / `muted-foreground` | lys grå-grønn / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
|
||||
| `accent` / `accent-foreground` | lys grønn-tint | hover-tilstand på nøytrale flater |
|
||||
| `secondary` / `secondary-foreground` | lys grå | sekundær vekt, sjeldent brukt alene |
|
||||
| `muted` / `muted-foreground` | lys grå / dempet | bakgrunnstoner, sekundær tekst/bildetekst |
|
||||
| `accent` / `accent-foreground` | lys grå | hover-tilstand på nøytrale flater |
|
||||
| `destructive` | rød-oransje | slett/fare-handlinger |
|
||||
| `border` / `input` | lys grå-grønn | kant på kort, felt, delelinjer |
|
||||
| `border` / `input` | lys grå | kant på kort, felt, delelinjer |
|
||||
| `ring` | samme som `primary` | fokus-ring (tastatur-tilgjengelighet) |
|
||||
| `brand-orange` / `brand-orange-foreground` | `oklch(0.6792 0.2128 36.53)` | sekundærfarge — "over par"/advarsel, IKKE feil (det er `destructive`) |
|
||||
| `chart-1` … `chart-6` | grønn→blå→gul→oransje→rød-skala | statistikk-/fordelingsvisualiseringer (ADR-033), ALDRI brukt for kategoriske UI-tilstander |
|
||||
|
||||
**2026-08-02:** alle nøytrale tokens (`background`/`muted`/`accent`/
|
||||
`border`/`secondary` m.fl.) hadde tidligere en svak grønn hue (130-145)
|
||||
iblandet selv når de skulle være "nøytral grå" — dette ga et vedvarende,
|
||||
utilsiktet grønt skjær på nesten hver bakgrunn, hover-tilstand og kant i
|
||||
hele appen (brukeren opplevde appen som "stuck i det lysegrønne
|
||||
utseendet"). Rettet til ekte nøytral grå (`chroma 0`) på tvers av lys/
|
||||
mørk/media-query-blokkene. `primary` er UENDRET og fortsatt den eneste
|
||||
tokenen som skal lese som grønn — reservert til valgt/aktiv tilstand og
|
||||
primærhandling, se semantikk-listen under.
|
||||
|
||||
**Farge-semantikk, konsekvent på tvers av hele appen:**
|
||||
- `primary` (grønn) = valgt/aktiv, positivt resultat (under par, ledende,
|
||||
bekreftet), primærhandling.
|
||||
|
|
|
|||
|
|
@ -3825,6 +3825,63 @@ beregnet og vist, banneret erstattet av det eksisterende
|
|||
felles `docker compose up -d --build teecup_frontend`), bruker bekreftet
|
||||
eksplisitt.
|
||||
|
||||
## Notat 2026-08-01/02: «Det store grepet» — sammenhengende rundeoppsett + delt Score/Scorekort/Leaderboard-navigasjon — ✅ HELT FERDIG (alle 5 steg), se ADR-040
|
||||
|
||||
Full beslutningslogg i ARCHITECTURE_DECISIONS.md (ADR-040) — dette notatet
|
||||
er kun arbeidssporet/gjenstående plan, ikke beslutningene selv.
|
||||
|
||||
**Bakgrunn:** oppstod som en direkte oppfølging av login-/dashbord-
|
||||
redesign-rundene (inspirert av et eksternt design-verktøy, "Stitch").
|
||||
Brukeren pekte på to strukturelle hull utover selve fargespråket:
|
||||
rundeoppsettet er spredt over to helt separate skjermer, og Score/
|
||||
Scorekort/Leaderboard for en frittstående runde deler ingen fast
|
||||
navigasjon.
|
||||
|
||||
**Fem load-bærende beslutninger bekreftet av bruker** (se ADR-040 for full
|
||||
tekst): (A) rundeoppsettet blir én 5-stegs veiviser, (B) HCP-prosent alltid
|
||||
justerbar + egen Match-HCP-bryter (`use_matchplay_handicap`), (C)
|
||||
"Spillere og runde" (inkl. flighter) blir værende på oppsettsiden, ikke i
|
||||
den nye fane-raden — plassering av vei-tilbake/fullfør/slett overlatt til
|
||||
V0, (D) Scorekort+Statistikk slås sammen til ÉN side (statistikk under
|
||||
scorekortet) — fane-raden blir 3 faner, ikke 4, (E) et reelt, tidligere
|
||||
udokumentert funn: spillere i scorekortet var ALDRI gruppert lagvis (ren
|
||||
innsettingsrekkefølge) — fikset med stabil sortering på `round_side_id`.
|
||||
|
||||
**Status per 2026-08-02:**
|
||||
1. ✅ Backend: migrasjon `051_round_allowance_override.sql` +
|
||||
`_round_allowance_override()`-kobling i `app/routers/rounds.py`.
|
||||
Scratch- og produksjonsverifisert (se ADR-040 for tallene).
|
||||
2. ✅ Kode-fiks: lag-sortering i `ScorecardGrid` (`round-detail.tsx`) og
|
||||
`MatchScorecardGrid` (`round-scorecard.tsx`). Browserverifisert mot
|
||||
brukerens eget rapporterte bug-scenario (interleaved lag-tilføyelse).
|
||||
3. ✅ **V0-prompt 1 BYGGET, VERIFISERT OG LIVE (2026-08-02):**
|
||||
`frontend/components/new-round.tsx` er nå den samlede 5-stegs
|
||||
veiviseren (zip 25 fra V0, flettet med ekte logikk — bane-søk/
|
||||
-opprettelse, `/people/search`, `allowance_override`-konstruksjon,
|
||||
full innsendingsorkestrering runde→sider→medspillere→tildelinger).
|
||||
Full detalj (inkl. de to reelle feilene funnet under integrering) i
|
||||
ARCHITECTURE_DECISIONS.md (ADR-040).
|
||||
4. ✅ **V0-prompt 2 BYGGET, VERIFISERT OG LIVE (2026-08-02):** ny delt
|
||||
header/fane-rad (`components/round-header.tsx`, tatt inn uendret fra
|
||||
V0 zip 26, presentasjon-only) + ny `components/round-page-shell.tsx`
|
||||
(ekte datalag/handlinger) — koblet inn på alle tre destinasjonene.
|
||||
Scorekort+Statistikk slått sammen til ÉN side (ren kode-sammenstabling
|
||||
av to eksisterende komponenter, ikke sendt til V0 — bevisst utenfor
|
||||
prompt 2s omfang). "Spillere og runde"/Fullfør/Slett nås via en
|
||||
"Administrer"-dialog i headeren. Full detalj (inkl. den ene reelle
|
||||
feilen funnet — to `<main>`-landemerker på samme side) i
|
||||
ARCHITECTURE_DECISIONS.md (ADR-040).
|
||||
5. ✅ Integrering + browserverifisering + utrulling — ferdig samme dag.
|
||||
|
||||
**Bevisst IKKE dekket av V0-prompt 2, fortsatt et åpent spørsmål:**
|
||||
Beslutning E sitt spørsmål om lag-grupperingen (sortering fikset i
|
||||
steg 2) trenger mer enn celle-fargelegging for å skille lagene tydelig
|
||||
nok i scorekortet — prompt 2 ble bevisst smalere i omfang enn først
|
||||
planlagt (kun selve header/fane-chrome-en), så dette spørsmålet er
|
||||
IKKE stilt til V0 ennå. Egen, senere, liten vurdering om ønskelig.
|
||||
|
||||
---
|
||||
|
||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||
|
||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||
|
|
|
|||
|
|
@ -55,23 +55,28 @@
|
|||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: oklch(0.985 0.005 130);
|
||||
--foreground: oklch(0.24 0.02 145);
|
||||
/* Nøytrale flater er EKTE nøytral grå (chroma 0) -- før 2026-08-02 hadde
|
||||
disse en svak grønn hue (130-145) iblandet, som ga et vedvarende
|
||||
grønt skjær 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-foreground: oklch(0.24 0.02 145);
|
||||
--card-foreground: oklch(0.24 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.24 0.02 145);
|
||||
--popover-foreground: oklch(0.24 0 0);
|
||||
--primary: oklch(0.7512 0.1613 130.33);
|
||||
--primary-foreground: oklch(0.22 0.04 140);
|
||||
--secondary: oklch(0.96 0.02 130);
|
||||
--secondary-foreground: oklch(0.3 0.03 140);
|
||||
--muted: oklch(0.96 0.01 130);
|
||||
--muted-foreground: oklch(0.52 0.02 140);
|
||||
--accent: oklch(0.94 0.05 130);
|
||||
--accent-foreground: oklch(0.3 0.04 140);
|
||||
--primary-foreground: oklch(0.22 0 0);
|
||||
--secondary: oklch(0.96 0 0);
|
||||
--secondary-foreground: oklch(0.3 0 0);
|
||||
--muted: oklch(0.96 0 0);
|
||||
--muted-foreground: oklch(0.52 0 0);
|
||||
--accent: oklch(0.94 0 0);
|
||||
--accent-foreground: oklch(0.3 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.9 0.015 130);
|
||||
--input: oklch(0.9 0.015 130);
|
||||
--border: oklch(0.9 0 0);
|
||||
--input: oklch(0.9 0 0);
|
||||
--ring: oklch(0.7512 0.1613 130.33);
|
||||
--brand-orange: oklch(0.6792 0.2128 36.53);
|
||||
--brand-orange-foreground: oklch(0.99 0 0);
|
||||
|
|
@ -104,20 +109,20 @@
|
|||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.19 0.02 150);
|
||||
--foreground: oklch(0.96 0.01 130);
|
||||
--card: oklch(0.24 0.02 150);
|
||||
--card-foreground: oklch(0.96 0.01 130);
|
||||
--popover: oklch(0.24 0.02 150);
|
||||
--popover-foreground: oklch(0.96 0.01 130);
|
||||
--background: oklch(0.19 0 0);
|
||||
--foreground: oklch(0.96 0 0);
|
||||
--card: oklch(0.24 0 0);
|
||||
--card-foreground: oklch(0.96 0 0);
|
||||
--popover: oklch(0.24 0 0);
|
||||
--popover-foreground: oklch(0.96 0 0);
|
||||
--primary: oklch(0.7512 0.1613 130.33);
|
||||
--primary-foreground: oklch(0.2 0.04 140);
|
||||
--secondary: oklch(0.28 0.02 150);
|
||||
--secondary-foreground: oklch(0.96 0.01 130);
|
||||
--muted: oklch(0.28 0.02 150);
|
||||
--muted-foreground: oklch(0.7 0.02 135);
|
||||
--accent: oklch(0.32 0.04 140);
|
||||
--accent-foreground: oklch(0.96 0.01 130);
|
||||
--primary-foreground: oklch(0.2 0 0);
|
||||
--secondary: oklch(0.28 0 0);
|
||||
--secondary-foreground: oklch(0.96 0 0);
|
||||
--muted: oklch(0.28 0 0);
|
||||
--muted-foreground: oklch(0.7 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(0.96 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
|
|
@ -148,20 +153,20 @@
|
|||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.19 0.02 150);
|
||||
--foreground: oklch(0.96 0.01 130);
|
||||
--card: oklch(0.24 0.02 150);
|
||||
--card-foreground: oklch(0.96 0.01 130);
|
||||
--popover: oklch(0.24 0.02 150);
|
||||
--popover-foreground: oklch(0.96 0.01 130);
|
||||
--background: oklch(0.19 0 0);
|
||||
--foreground: oklch(0.96 0 0);
|
||||
--card: oklch(0.24 0 0);
|
||||
--card-foreground: oklch(0.96 0 0);
|
||||
--popover: oklch(0.24 0 0);
|
||||
--popover-foreground: oklch(0.96 0 0);
|
||||
--primary: oklch(0.7512 0.1613 130.33);
|
||||
--primary-foreground: oklch(0.2 0.04 140);
|
||||
--secondary: oklch(0.28 0.02 150);
|
||||
--secondary-foreground: oklch(0.96 0.01 130);
|
||||
--muted: oklch(0.28 0.02 150);
|
||||
--muted-foreground: oklch(0.7 0.02 135);
|
||||
--accent: oklch(0.32 0.04 140);
|
||||
--accent-foreground: oklch(0.96 0.01 130);
|
||||
--primary-foreground: oklch(0.2 0 0);
|
||||
--secondary: oklch(0.28 0 0);
|
||||
--secondary-foreground: oklch(0.96 0 0);
|
||||
--muted: oklch(0.28 0 0);
|
||||
--muted-foreground: oklch(0.7 0 0);
|
||||
--accent: oklch(0.32 0 0);
|
||||
--accent-foreground: oklch(0.96 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { RoundPageShell } from "@/components/round-page-shell"
|
||||
import { RoundLeaderboard } from "@/components/round-leaderboard"
|
||||
|
||||
export default async function RoundLeaderboardPage({
|
||||
|
|
@ -6,5 +7,9 @@ export default async function RoundLeaderboardPage({
|
|||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
return <RoundLeaderboard roundId={id} />
|
||||
return (
|
||||
<RoundPageShell roundId={id} activeTab="leaderboard">
|
||||
<RoundLeaderboard roundId={id} embedded />
|
||||
</RoundPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import { Suspense } from "react"
|
||||
import { RoundDetail } from "@/components/round-detail"
|
||||
|
||||
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return <RoundDetail roundId={id} />
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<RoundDetail roundId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
import { RoundPageShell } from "@/components/round-page-shell"
|
||||
import { RoundScorecard } from "@/components/round-scorecard"
|
||||
import { RoundStats } from "@/components/round-stats"
|
||||
|
||||
// Scorekort + Statistikk slått sammen til ÉN side (ADR-040 Beslutning D,
|
||||
// "det store grepet") -- statistikk RETT UNDER scorekortet, delt header/
|
||||
// fane-rad via RoundPageShell.
|
||||
export default async function RoundScorecardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
return <RoundScorecard roundId={id} />
|
||||
return (
|
||||
<RoundPageShell roundId={id} activeTab="scorecard">
|
||||
<RoundScorecard roundId={id} embedded />
|
||||
<RoundStats roundId={id} embedded />
|
||||
</RoundPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { RoundStats } from "@/components/round-stats"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
// Slått sammen med scorekortet (ADR-040 Beslutning D, "det store grepet",
|
||||
// 2026-08-02) -- statistikk vises nå RETT UNDER scorekortet på ÉN side.
|
||||
// Redirect bevarer eventuelle eksisterende lenker/bokmerker til denne
|
||||
// stien.
|
||||
export default async function RoundStatsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
return <RoundStats roundId={id} />
|
||||
redirect(`/my-rounds/${id}/scorecard`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,8 +286,8 @@ export function AccountSettings() {
|
|||
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<ShieldCheck aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
|
||||
</div>
|
||||
|
|
@ -630,8 +630,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<User aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<User aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
|
||||
</div>
|
||||
|
|
@ -1019,8 +1019,8 @@ function EmailSection({ email }: { email: string }) {
|
|||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Mail aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">E-post</h2>
|
||||
</div>
|
||||
|
|
@ -1138,8 +1138,8 @@ function SecondaryEmailSection({
|
|||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Mail aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
|
||||
</div>
|
||||
|
|
@ -1271,8 +1271,8 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
|
|||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Lock aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Lock aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Passord</h2>
|
||||
</div>
|
||||
|
|
@ -1382,8 +1382,8 @@ function PushNotificationSection() {
|
|||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<BellRing aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<BellRing aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Push-varsler på denne enheten</h2>
|
||||
</div>
|
||||
|
|
@ -1466,8 +1466,8 @@ function NotificationEmailPrefsSection() {
|
|||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Bell aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Bell aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ function LoadingState() {
|
|||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<MapPin aria-hidden="true" className="size-8 text-primary" />
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||
<MapPin aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex max-w-md flex-col gap-2">
|
||||
<h2 className="text-xl font-bold text-foreground">Ingen runder funnet</h2>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function Avatar({ name, url, size = "large" }: { name: string; url: string | nul
|
|||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-primary/15 font-bold text-foreground")}
|
||||
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-muted font-bold text-foreground")}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -576,8 +576,8 @@ function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onC
|
|||
|
||||
{friends.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center">
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<Users aria-hidden="true" className="size-7 text-primary" />
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Users aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-lg font-bold text-foreground">Ingen venner ennå</p>
|
||||
|
|
@ -647,8 +647,8 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
|
|||
{categories.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
||||
{categories.map((code) => (
|
||||
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-3 py-1 text-sm font-semibold text-foreground">
|
||||
<Tag aria-hidden="true" className="size-3.5 text-primary" />
|
||||
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-sm font-semibold text-foreground">
|
||||
<Tag aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
{categoryLabel(code)}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -732,7 +732,7 @@ function Avatar({ name }: { name: string }) {
|
|||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/15 text-base font-bold text-foreground"
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-muted text-base font-bold text-foreground"
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -199,8 +199,8 @@ function LoadingState() {
|
|||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<ClipboardList aria-hidden="true" className="size-8 text-primary" />
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||
<ClipboardList aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex max-w-md flex-col gap-2">
|
||||
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>
|
||||
|
|
|
|||
|
|
@ -246,8 +246,8 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
|||
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
|
||||
</div>
|
||||
|
|
@ -524,8 +524,8 @@ function StatusArea({
|
|||
return (
|
||||
<section className="grid gap-3 sm:grid-cols-2" aria-label="Status">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Calendar aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Calendar aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
|
|
@ -538,8 +538,8 @@ function StatusArea({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Users aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@
|
|||
import type React from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { RoundPageShell } from "@/components/round-page-shell"
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Flag,
|
||||
MapPin,
|
||||
Minus,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
|
|
@ -153,13 +152,6 @@ function uiGenderToApi(g: Gender): ApiGender {
|
|||
return g === "male" ? "m" : g === "female" ? "f" : "x"
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||
function formatDate(value: string) {
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return dateFormatter.format(parsed)
|
||||
}
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
||||
type ApiParticipant = {
|
||||
|
|
@ -302,12 +294,6 @@ const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "mon
|
|||
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
||||
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
||||
// lagret varighet, kun utledet ved visning.
|
||||
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
|
||||
function formatTime(value: string) {
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return timeFormatter.format(parsed)
|
||||
}
|
||||
function formatDuration(startedAt: string, completedAt: string): string | null {
|
||||
const start = new Date(startedAt).getTime()
|
||||
const end = new Date(completedAt).getTime()
|
||||
|
|
@ -407,6 +393,13 @@ function playerLabel(p: ApiParticipant): string {
|
|||
|
||||
export function RoundDetail({ roundId }: { roundId: string }) {
|
||||
const router = useRouter()
|
||||
// Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
|
||||
// ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
|
||||
// siden -- lest KUN ved mount (bevisst ikke reaktiv på senere endringer,
|
||||
// matcher den eksisterende lokale pill-vekslingen som fortsatt fungerer
|
||||
// uendret for navigering INNAD på siden).
|
||||
const searchParams = useSearchParams()
|
||||
const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
|
||||
|
|
@ -443,7 +436,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
// scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt
|
||||
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
||||
// fleste trenger under selve spillingen.
|
||||
const [pageTab, setPageTab] = useState<"score" | "manage">("score")
|
||||
const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
|
||||
const [completing, setCompleting] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showEditRound, setShowEditRound] = useState(false)
|
||||
|
|
@ -1138,53 +1131,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
{/* Sticky header */}
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href="/my-rounds"
|
||||
aria-label="Tilbake til egne runder"
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
|
||||
<span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
|
||||
{round.name?.trim() && (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<MapPin aria-hidden="true" className="size-4 shrink-0" />
|
||||
{round.course_name_snapshot}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||
{round.tee_name_snapshot}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 tabular-nums">
|
||||
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
|
||||
{formatDate(round.played_at)}
|
||||
</span>
|
||||
{round.started_at && (
|
||||
<span className="flex items-center gap-1.5 tabular-nums">
|
||||
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
||||
{formatTime(round.started_at)}
|
||||
</span>
|
||||
)}
|
||||
{round.play_format === "match" && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Matchspill
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RoundPageShell roundId={roundId} activeTab="score">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 text-base font-medium text-destructive">
|
||||
|
|
@ -1555,7 +1502,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
strokesReceived={wizardApiHole?.strokes_received ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</RoundPageShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2008,22 +1955,15 @@ function CompletedBanner({
|
|||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/scorecard`}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Se scorekort
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/stats`}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
Se full rundestatistikk
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
{/* Scorekort og statistikk slått sammen til ÉN side (ADR-040
|
||||
Beslutning D) -- ett felles knapp i stedet for to separate. */}
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/scorecard`}
|
||||
className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Se scorekort og statistikk
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3603,7 +3543,7 @@ function AddParticipantForm({
|
|||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground"
|
||||
>
|
||||
{person.first_name[0]}
|
||||
{person.last_name[0]}
|
||||
|
|
|
|||
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",
|
||||
}
|
||||
|
||||
export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||
export function RoundLeaderboard({
|
||||
roundId,
|
||||
embedded = false,
|
||||
}: {
|
||||
roundId: string
|
||||
// ADR-040 ("det store grepet") -- dropper denne komponentens egen
|
||||
// sticky header når den rendres under den delte `RoundHeader`
|
||||
// (via `RoundPageShell`) i stedet for standalone.
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
|
||||
const [mode, setMode] = useState<Mode>("gross")
|
||||
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
||||
|
|
@ -621,18 +630,20 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
|||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Leaderboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Leaderboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
|
|
|
|||
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 Link from "next/link"
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
|
@ -812,7 +812,18 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
|
|||
|
||||
// --- Screen ----------------------------------------------------------------
|
||||
|
||||
export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||
export function RoundScorecard({
|
||||
roundId,
|
||||
embedded = false,
|
||||
}: {
|
||||
roundId: string
|
||||
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
|
||||
// slått sammen til ÉN side. `embedded` dropper denne komponentens egen
|
||||
// sticky header (den delte `RoundHeader`, wired inn av
|
||||
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
|
||||
// av den slåtte sammen siden, i stedet for standalone.
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [unitId, setUnitId] = useState<string | null>(null)
|
||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||
|
|
@ -930,18 +941,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
|||
// forklarende melding i stedet for en evig lastespinner.
|
||||
if (isSharedBall && round.sides.length < 2) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Scorekort
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Scorekort
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve
|
||||
|
|
@ -994,19 +1007,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
|||
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Scorekort
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Scorekort
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
{/* Round summary strip */}
|
||||
|
|
@ -1093,17 +1107,6 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
|||
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
|
||||
)}
|
||||
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
|
||||
|
||||
{/* Symmetrisk med round-stats.tsx sin "Se scorekort"-lenke tilbake
|
||||
hit (2026-07-30, etterspurt av bruker -- manglet fra scorekortet
|
||||
og andre veien). */}
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/stats`}
|
||||
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground shadow-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
Se full rundestatistikk
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -665,7 +665,23 @@ function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHole
|
|||
)
|
||||
}
|
||||
|
||||
export function RoundStats({ roundId }: { roundId: string }) {
|
||||
export function RoundStats({
|
||||
roundId,
|
||||
embedded = false,
|
||||
}: {
|
||||
roundId: string
|
||||
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
|
||||
// slått sammen til ÉN side, statistikk RETT UNDER scorekortet.
|
||||
// `embedded` dropper denne komponentens egen sticky header (den delte
|
||||
// `RoundHeader` dekker det, se `RoundPageShell`) OG den redundante
|
||||
// rundesammendrag-stripen/"Se scorekort"-knappen (scorekortet ligger nå
|
||||
// allerede rett over på samme side).
|
||||
embedded?: boolean
|
||||
}) {
|
||||
// Kun ÉTT <main>-landemerke tillatt per side -- når embedded (rendret
|
||||
// under RoundScorecard sin egen <main>, ADR-040 Beslutning D), bruk en
|
||||
// vanlig <div> i stedet.
|
||||
const ContentTag = embedded ? "div" : "main"
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||
|
|
@ -788,19 +804,21 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
|
||||
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Statistikk
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Statistikk
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
|
||||
|
||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
|
|
@ -815,7 +833,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
</Link>{" "}
|
||||
for hull-for-hull-forløpet i stedet.
|
||||
</div>
|
||||
</main>
|
||||
</ContentTag>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -832,51 +850,55 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Rundestatistikk
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
{/* Round summary strip */}
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
{round.name?.trim() || round.course_name_snapshot}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||
{round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
||||
</span>
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
Rundestatistikk
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || "–"}</span>
|
||||
{s.playedCount > 0 && (
|
||||
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert
|
||||
horisontal side 2026-07-25 (erstatter en tidligere vertikal
|
||||
tabell her på statistikk-siden -- se ARCHITECTURE_DECISIONS.md
|
||||
ADR-033) -- denne siden forblir aggregert/utledet statistikk. */}
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/scorecard`}
|
||||
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Se scorekort
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<ContentTag className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
{/* Round summary strip + "Se scorekort"-knapp -- begge redundante
|
||||
når embedded (scorekortet ligger da allerede rett over 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-col">
|
||||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
{round.name?.trim() || round.course_name_snapshot}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||
{round.tee_name_snapshot} · {round.holes_planned} hull ·{" "}
|
||||
{dateFormatter.format(new Date(round.played_at))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || "–"}</span>
|
||||
{s.playedCount > 0 && (
|
||||
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/scorecard`}
|
||||
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Se scorekort
|
||||
<ChevronRight aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spillervelger, kun når runden har flere deltakere */}
|
||||
{round.participants.length > 1 && (
|
||||
|
|
@ -1207,7 +1229,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</ContentTag>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -473,8 +473,8 @@ export function RoundsStatsSummary() {
|
|||
</div>
|
||||
) : s && s.rounds_completed === 0 ? (
|
||||
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<BarChart3 aria-hidden="true" className="size-8 text-primary" />
|
||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
|
||||
<BarChart3 aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex max-w-md flex-col gap-2">
|
||||
<h2 className="text-xl font-bold text-foreground">Ingen fullførte runder i perioden</h2>
|
||||
|
|
|
|||
|
|
@ -593,7 +593,7 @@ function TeamColumn({
|
|||
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{format === "money_ball" && p.lineup_order !== null && (
|
||||
<span className="inline-flex size-6 items-center justify-center rounded-full bg-primary/15 text-xs font-extrabold text-primary">
|
||||
<span className="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs font-extrabold text-muted-foreground">
|
||||
{p.lineup_order}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -622,8 +622,8 @@ function EditSessionForm({
|
|||
className="mb-1 flex flex-1 flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Pencil aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
|
|
@ -1057,8 +1057,8 @@ function CreateSessionCard({
|
|||
className="flex flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Plus aria-hidden="true" className="size-5 text-primary" />
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
||||
<Plus aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ export function TwoFactorVerifyForm({
|
|||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
|
||||
|
|
@ -248,8 +248,8 @@ export function TwoFactorSetupForm({
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
|
||||
|
|
@ -295,8 +295,8 @@ export function TwoFactorSetupForm({
|
|||
return (
|
||||
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue