Etter første inngripen av Gemini

This commit is contained in:
Erol Haagenrud 2026-07-29 12:44:51 +02:00
parent 19a21073ba
commit c56502ff01
34 changed files with 1511 additions and 509 deletions

153
CLAUDE.md
View file

@ -4936,6 +4936,159 @@ Ferdig og verifisert:
nøkkel, `GET /rounds/{ukjent-id}/flight-group` ga korrekt nøkkel, `GET /rounds/{ukjent-id}/flight-group` ga korrekt
`401 NOT_AUTHENTICATED` (ikke en rå 404 — bekrefter ruten faktisk når `401 NOT_AUTHENTICATED` (ikke en rå 404 — bekrefter ruten faktisk når
FastAPI), `teeoff.no` upåvirket. FastAPI), `teeoff.no` upåvirket.
- **Designinstruksen erstattet/formalisert + seks brukerpunkter, ALLE
BYGGET OG LIVE (2026-07-29):** `alternativ designinstruks.md` (fra
dagen før) erstattet med en revidert versjon fra bruker (allerede
forankret i det eksisterende token-systemet, ikke lenger i konflikt med
oransje/hardkodet-slate-spørsmålene) — INNHOLDET er nå slått sammen inn
i `DESIGN_SYSTEM.md` som den gjeldende fasiten (8-punkts rutenett,
`shadow-md shadow-black/8`, `font-normal` OK ved `text-base`+god
kontrast, `:active`-tilstander, safe-area, 16px input, `transition-all
duration-200`). `alternativ designinstruks.md` selv beholdt kun som
arkiv/historikk. **Konsistens-sveip:** det gamle svake skygge-mønsteret
(`shadow-sm shadow-black/5`) erstattet mekanisk på tvers av 20 filer
(`sed`, verifisert 0 gjenværende treff) — IKKE en fullstendig
strukturell gjennomgang av alle 27 skjermer, kun dette ene, trygge,
mekaniske mønsteret.
**Hurtighandlinger:** dashbordets tre knapper er nå `grid-cols-3` også
på mobil (var `grid-cols-1 sm:grid-cols-3`, tok unødvendig mye plass) —
mindre ikon/tekst/høyde for å fortsatt være lesbare i smalere kolonner.
**Par/stroke-index manglet på rundeleaderboardet** (`/my-rounds/{id}/
leaderboard`) — ny `HoleReferenceStrip` viser Hull/Par/Hcp ÉN gang
(banedata, identisk for alle deltakere) rett over den rangerte listen.
**Venner: kategorisering nå OBLIGATORISK fra vennskapet inngås**
(presisert av bruker: "det skal ikke finnes ukategoriserte venner") —
`POST /friends` og `POST /friends/{id}/accept` krever begge minst én
kategori (`Field(min_length=1)`), satt AV BEGGE PARTER på hvert sitt
naturlige tidspunkt (avsender ved sending, mottaker ved aksept) — ikke
en frivillig senere handling. `PUT .../categories` nekter også å sette
et tomt sett. Ny delt `CategoryPicker`-komponent (friends.tsx, alle
forhåndsvalgt — "man må heller velge bort", presisert av bruker) brukt
tre steder (send/godta/rediger), nekter å fjerne SISTE avkrysning.
Selv-helbredende sikkerhetsnett for venner fra FØR denne regelen: ny
advarselsbanner øverst i `/my-friends` ("N venner mangler kategori"),
tvinger vedkommendes kategori-panel åpent til det er løst — bekreftet
reelt nødvendig i produksjon (Erol hadde aldri kategorisert Tore
Morell, sin faste matchspill-motstander — vil nå bli fanget opp og
tvunget løst neste gang `/my-friends` besøkes).
**Synlighetsregelen for "Venner"-runder endret fra "minst én treffende
kategori" til "ALLE vennens kategorier må være i det synlige settet"**
(presisert av bruker: "'ikke vise' overstyrer 'vise'") — en venn med
ÉN ikke-valgt kategori ekskluderes nå helt, selv om en annen av
kategoriene deres er valgt. En venn med NULL kategorier vises ALDRI
(avklart eksplisitt med bruker via spørsmål — skal i praksis aldri
forekomme lenger pga. regelen over, kun en igjenværende tilstand fra
FØR den ble håndhevet). `_can_view_round`/`_friends_who_can_see_round`
(rounds.py) omskrevet til denne AND-semantikken (fra tidligere OR).
`new-round.tsx` sin kategori-multiselect for "Venner"-synlighet starter
nå med ALLE forhåndsvalgt (samme "velg heller bort"-prinsipp).
**"Match leaderboard" — undersøkt grundig, IKKE en backend-bug:**
bekreftet direkte mot ekte `teecup_db` (kun lesing) at brukerens egen
Tjøme-matchrunde (`d857289c-...`) hadde begge sider korrekt satt opp
med beregnet `playing_handicap``format-result`-endepunktet ville
altså allerede regnet ut riktig "1 UP (A)"-status. Det reelle hullet
var at `FormatResultPanel` (matchstatus/skins-tavle, med hull-for-hull
vinner/AS-visning) KUN lå under "Spillere og runde"-fanen, usynlig fra
"Score"-fanen der scoring naturlig skjer. Fikset ved å vise samme
komponent (ikke duplisert logikk) øverst på BEGGE faner.
**Scratch-verifisert grundig** (isolert rolle+MinIO+engangs API-
container, samme mønster som hele prosjektet): full kategori-håndheving
(422 uten kategorier ved både send/godta/rediger, 200 med), presis
eksklusjon-overstyrer-inklusjon-test (venn med golf_friends+close_family
ekskludert når kun golf_friends er synlig, inkludert når begge er
synlige — bekreftet mot BÅDE rå SQL og de faktiske API-endepunktene),
match-runde med sider satt opp fra bunnen ga korrekt "1 UP (A)".
Deretter en FULL, ekte nettleser-gjennomgang (Chrome DevTools, mobil
390×844): 3-kolonners hurtighandlinger, matchstatus-banner synlig på
Score-fanen, Hull/Par/Hcp-referanserad på leaderboardet, hele venne-
søk→kategorivelgeralle forhåndsvalgt, avkrysning fungerer)→send-
flyten, og selv-helbredende-banner-flyten (simulerte en gammel
ukategorisert venn direkte i databasen, bekreftet banneret dukket opp
OG forsvant igjen etter kategorisering via UI-et).
**Rullet ut live 2026-07-29**, ingen migrasjon (kun eksisterende felt/
logikk endret), `docker compose up -d --build teecup_api
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, anonymt `POST /friends` ga korrekt `401` (ikke en rå 404 —
bekrefter ruten når FastAPI), `teeoff.no` upåvirket.
- **Match-scorekort redesignet på tvers av appen (frittstående runde +
turnering), BYGGET, GRUNDIG SCRATCH-/BROWSERVERIFISERT OG LIVE
(2026-07-29):** brukeren delte et referansebilde av en konkurrentapp sitt
1v1-matchscorekort (horisontalt rutenett, farget etter hvem som vant
hvert hull, løpende "Stilling"-rad AS/X UP/X&Y, navn+HCP-banner) og ba om
det tilsvarende for ALLE match-scorekort i TeeCup, uten direkte plagiat.
**Bevisst IKKE en kopi:** beholdt appens egne, allerede etablerte
fargespråk i stedet for referansens røde/blå -- frittstående runder bruker
primær/oransje (samme "side A/side B"-konvensjon som `ResultChip`/
`FormatResultPanel` fra tidligere runder), turnering-matcher bruker lagets
faktiske `team.color` (samme konvensjon som resten av turnering-UI-et).
Droppet bevisst referansens "Matchplay NET/Stableford NET"-fane (ga ikke
entydig mening for delt-ball-formater) og "Lik/Kommentar til
spillfeeden/Spillere"-ikonraden (en helt ny sosial funksjon, ikke en
scorekort-redesign -- utenfor denne rundens omfang).
**Kjernemekanisme, ny og delt idé (separat implementert i begge filer,
ikke faktisk delt kode siden filene allerede har egne lokale typer per
etablert konvensjon):** `computeRunning()` speiler
`handicap_engine.py` sin `compute_match_state()`/`describe()` presist,
men regnet ETT PREFIKS om gangen client-side (backend cacher i dag kun
SLUTT-tilstanden) -- gir en løpende AS/X UP/dormie/X&Y-status per hull i
stedet for kun et sluttresultat.
**`round-scorecard.tsx`:** `MatchProgressTable` (vertikal hull-for-hull-
**`round-scorecard.tsx`:** den generiske `ScoreBlock`-tabellen erstattet med `MatchScorecardGrid` -- horisontalt rutenett (Hull/
liste) erstattet med `MatchScorecardGrid` -- horisontalt rutenett (Hull/
Par/en rad per spiller-ELLER-side/Stilling), identitetsbanner (navn+HCP,
farget dot, løpende sentral status + "Ferdig"-merke når avgjort). Fargen
på scorecellen følger HVEM SOM VANT hullet (fylt sirkel), ikke over/under
par -- egen semantikk fra `ScoreMark` med vilje, siden dette er en match,
ikke en individuell runde. `ApiParticipant` fikk `playing_handicap`
(allerede eksponert av backend, kun en frontend-typeutvidelse).
**`session-scorecard.tsx`:** `HoleSummaryTable`/`OutcomeBadge`/
`grossPairLabel` erstattet med `TournamentMatchGrid` (samme struktur,
brukt av BÅDE den pågående "Vis full oversikt"-seksjonen og
`DecidedView`s "Hull for hull" -- én komponent, ikke to). `Unit`-typen
fikk `playingHandicap`.
**Ekte, nødvendig backend-endring:** `match_participant.playing_handicap`
ble beregnet og lagret siden ADR-039, men var ALDRI eksponert i
`MatchParticipantOut` (matches.py) -- lagt til i modellen og i begge
SELECT-spørringene som populerer den (`fetch_matches` og
`add_participant`). **Reelt funn, fikset FØR utrulling:**
`add_participant` sin `row` hentes FØR
`compute_and_store_side_handicaps()` kjører -- en naiv
`MatchParticipantOut(**dict(row))` ville derfor alltid returnert
`playing_handicap: null` for en NYLIG lagt til deltaker, selv når verdien
faktisk ble beregnet et øyeblikk senere i samme kall. Fikset med et
eksplisitt re-oppslag av `playing_handicap` RETT FØR responsen bygges.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-container, alle 37 migrasjoner kjørt
friskt, samme mønster som resten av prosjektet): en full match bygget fra
bunnen for BEGGE surfacene (en frittstående match-runde med to sider, OG
en full turnering-scaffold -- org/bane-import/tee/tournament/to lag/
roster/singel-økt/match/deltakere -- bygget fra API-et for FØRSTE gang i
et testskript denne uken, siden frittstående runder aldri har trengt det
apparatet før). 8-9 hull registrert med et bevisst blandet vinn/tap/delt-
mønster, bekreftet at `format-result`/`scorecard` sine per-hull
resultater matchet forventet handicap-justert utfall.
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP):
et REELT, tidkrevende miljøproblem ble diagnostisert og løst underveis --
React-komponentenes egne `useEffect`-datahentinger kjørte ALDRI (evig
lastespinner på HVER side, ikke bare de nye) når dev-serveren ble besøkt
via `127.0.0.1:3100` i stedet for `localhost:3100`; Next.js 16 sin
`allowedDevOrigins`-beskyttelse blokkerer stille dev-ressurser (HMR-
websocket m.m.) for det som oppfattes som et fremmed opphav, og dette
fikk hele klient-hydreringen til å henge uten en eneste konsollfeil.
Løst ved å konsekvent bruke `localhost` i stedet for `127.0.0.1` --
ren miljø-lærdom for fremtidige scratch-frontend-økter, ikke en bug i
selve appen. Etter fiksen: bekreftet BEGGE match-scorekortene visuelt,
i BÅDE lys og mørk modus, i BÅDE pågående (løpende "2 UP"/farget
Stilling-rad) og avgjort tilstand ("8 UP"/"Ferdig"-merke, cachet
banner), inkl. et helt nytt 2FA-oppsett fullført på fersk konto via en
engangs e-post-2FA-kode lest fra dev-loggen (org-eier/admin krever 2FA,
ADR-021) for å nå frem til turnering-siden. Ingen konsollfeil i noen av
rundene.
**Rullet ut live 2026-07-29**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`.
Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
→ 200 upåvirket.
Neste steg: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet

View file

@ -14,6 +14,14 @@
> trykkflater, ikke ikon-only uten tekstlabel for viktige handlinger, ikke > trykkflater, ikke ikon-only uten tekstlabel for viktige handlinger, ikke
> avhengig av finmotorikk/skarpt syn. Enhver visuell beslutning under > avhengig av finmotorikk/skarpt syn. Enhver visuell beslutning under
> veies mot dette først. > veies mot dette først.
>
> **2026-07-29:** dette dokumentet ble revidert og formalisert basert på
> "alternativ designinstruks.md" (brukerens eget forslag, forsøkt først på
> dashbordet, deretter godkjent som ny fasit). Innholdet under ER derfor nå
> den gjeldende regelen — men er ikke nødvendigvis rullet ut på ALLE
> skjermer ennå (se `CLAUDE.md`-status for hvor langt konsistens-sjekken har
> kommet). Rett opportunistisk opp eldre skjermer mot dette dokumentet når
> de likevel røres, samme mønster som tilgjengelighetsregelen over.
## Merkevare-opprinnelse ## Merkevare-opprinnelse
@ -34,8 +42,11 @@ se FEATURE_BACKLOG.md. Anbefalingen der var å gjenbruke `--chart-3`
Alle farger er definert som OKLCH-variabler, med egne verdier for lys og Alle farger er definert som OKLCH-variabler, med egne verdier for lys og
mørk modus (`:root` / `.dark` / `@media (prefers-color-scheme: dark)`, mørk modus (`:root` / `.dark` / `@media (prefers-color-scheme: dark)`,
alle tre holdt i synk). Bruk ALLTID token-navnene under (Tailwind-klasser alle tre holdt i synk). Bruk ALLTID token-navnene under (Tailwind-klasser
som `bg-primary`, `text-muted-foreground` osv.) — aldri rå hex/oklch i ny som `bg-primary`, `text-muted-foreground` osv.) — aldri rå hex/oklch, og
kode. ALDRI hardkodede Tailwind-fargeklasser som `slate-500`/`gray-100` i ny
kode. Dette er ikke bare en stilregel: hardkodede farger bryter den
automatiske mørk/lys-tema-logikken (se "Lyst og mørkt tema" under), som
ellers virker helt av seg selv så lenge token-klassene brukes.
| Token | Lys modus | Rolle | | Token | Lys modus | Rolle |
|---|---|---| |---|---|---|
@ -80,10 +91,14 @@ er et direkte utslag av tilgjengelighetsregelen øverst.
- `text-lg` / `text-xl` — kortoverskrifter, seksjonstitler - `text-lg` / `text-xl` — kortoverskrifter, seksjonstitler
- `text-2xl` / `text-3xl` — sideoverskrifter, hull-header, store - `text-2xl` / `text-3xl` — sideoverskrifter, hull-header, store
resultat-tall resultat-tall
- **Vekt:** `font-semibold`/`font-bold` for all vektlagt tekst, - **Vekt (revidert 2026-07-29):** ved `text-base` (16px) og god kontrast
`font-extrabold` for tall og hovedoverskrifter. Vanlig (regular) vekt (`text-foreground`) er `font-normal` helt akseptabelt og FORETRUKKET for
brukes nesten aldri i UI-kontroller — selv sekundærtekst er ofte brødtekst — unngår visuell støy av at alt roper likt. Hierarki skapes
`font-semibold` for lesbarhet på avstand/i sollys. primært med STØRRELSE og FARGE (kontrast), ikke bare vekt. `text-sm`
eller mindre (bildetekst/metadata) bør derimot ofte gis `font-medium`
for å kompensere for den mindre størrelsen — aldri `font-normal` under
`text-base`. Overskrifter/tall/primærhandlinger beholder
`font-bold`/`font-extrabold` som før.
- **`tabular-nums` på ALL numerisk visning** (skår, HCP, datoer, - **`tabular-nums` på ALL numerisk visning** (skår, HCP, datoer,
rangeringer) — hindrer tall fra å "hoppe" i bredde når de endres. rangeringer) — hindrer tall fra å "hoppe" i bredde når de endres.
- **Golfvis fortegn:** til-par-tall formateres ALLTID som "E" (jevnt med - **Golfvis fortegn:** til-par-tall formateres ALLTID som "E" (jevnt med
@ -93,6 +108,17 @@ er et direkte utslag av tilgjengelighetsregelen øverst.
## Avstand, hjørner og lag ## Avstand, hjørner og lag
- **8-punkts rutenett (2026-07-29):** marger, padding og `gap` følger
8-gangen (`p-2`=8px, `p-4`=16px, `p-6`=24px, `p-8`=32px) for
forutsigbar rytme. Halv-steg (`gap-1.5`, `py-2.5` osv.) unngås i nytt
arbeid — brukes kun der et etablert mønster (f.eks. shadcn sine egne
primitiver) allerede definerer det.
- **Kort får tydeligere løft (2026-07-29):** `shadow-md shadow-black/8`
(opp fra `shadow-sm shadow-black/5`) på kort/paneler i lyst tema — mer
synlig separasjon fra bakgrunnen. Se `round-card.tsx`/
`tournament-card.tsx`/`install-prompt.tsx`/`dashboard.tsx` for det
gjeldende mønsteret; eldre skjermer som fortsatt har `shadow-sm
shadow-black/5` bør rettes opp neste gang de røres.
- **Hjørneradius** (fra `--radius: 0.625rem`, skalert opp via - **Hjørneradius** (fra `--radius: 0.625rem`, skalert opp via
`--radius-sm/md/lg/xl/2xl/3xl/4xl`): `rounded-xl` for felt/knapper i `--radius-sm/md/lg/xl/2xl/3xl/4xl`): `rounded-xl` for felt/knapper i
tette rader, `rounded-2xl` for kort/paneler/knapper med normal vekt, tette rader, `rounded-2xl` for kort/paneler/knapper med normal vekt,
@ -121,6 +147,43 @@ er et direkte utslag av tilgjengelighetsregelen øverst.
- Gap mellom relaterte kontroller: `gap-2`/`gap-2.5`/`gap-3` normalt, - Gap mellom relaterte kontroller: `gap-2`/`gap-2.5`/`gap-3` normalt,
`gap-4`/`gap-6` mellom distinkte seksjoner. `gap-4`/`gap-6` mellom distinkte seksjoner.
## Interaksjon (2026-07-29)
- Alle interaktive elementer skal ha en definert `:hover`- OG
`:active`-tilstand — `:active` (f.eks. `active:scale-[0.98]` på kort/
knapper, `active:opacity-70` på lenker/ikon-knapper) gir umiddelbar,
taktil bekreftelse på at trykket faktisk ble registrert. shadcn sin
`Button`-primitiv har allerede `active:translate-y-px` innebygd; egne
håndbygde trykkflater (kort, `<Link>`-baserte rader) må legge det til
eksplisitt siden de ikke arver primitivens klasser.
- `disabled` bruker `disabled:opacity-50 disabled:pointer-events-none`
(allerede standard i `Input`/`Button`).
- Bevegelse: `transition-all duration-200 ease-in-out` for state-
endringer på knapper/kort — kjapt og "snappy", ikke en treg nettside-
følelse. Ingen animasjon/fade lenger enn 300ms.
## Skjemaer og inndata (mobil-først)
- Tekst i `<input>`/`<select>`/`<textarea>` er ALLTID minst `text-base`
(16px) — mindre utløser tvungen auto-zoom på iOS Safari. shadcn sin
`Input`-primitiv løser dette allerede (`text-base md:text-sm` — større
på mobil, kan trappes ned på desktop der zoom ikke er et problem).
- Riktig `inputMode` for numeriske felt (`"numeric"`/`"decimal"` for HCP/
slag/score) slik at riktig mobiltastatur vises med det samme — se
tallvelgerne i scoring-flyten for eksisterende bruk.
- Fokus: `focus-visible:ring-2 focus-visible:ring-ring` (aldri
nettleser-standard outline).
## Safe areas og skjermkanter (2026-07-29)
Elementer festet til topp (sticky header) eller bunn (footer-handlinger)
skal ikke havne bak en fysisk notch/Dynamic Island eller hjem-
indikatoren. Praktisk løsning: `pt-[env(safe-area-inset-top)]` på selve
sticky-headeren, `pb-[max(4rem,env(safe-area-inset-bottom))]` på siste
innholdscontainer — `env()` resolver til 0 på enheter uten notch, så dette
er trygt å legge til overalt, ikke bare på skjermer der det faktisk
trengs.
## Layout-containere ## Layout-containere
- Innloggede/app-sider: `mx-auto w-full max-w-3xl` for innholdsrike - Innloggede/app-sider: `mx-auto w-full max-w-3xl` for innholdsrike

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -0,0 +1,117 @@
# Alternativ designinstruks — PROMOTERT til DESIGN_SYSTEM.md (2026-07-29)
**Status: AVSLUTTET/ARKIVERT.** Forsøket på dashbordet ble godkjent samme
dag ("det ser bra ut") — innholdet under er nå slått sammen inn i
`DESIGN_SYSTEM.md` (som fortsatt er DEN autoritative kilden, sammen med
CLAUDE.md sin ufravikelige tilgjengelighetsregel). Denne filen beholdes
kun som historisk referanse for selve forslaget slik det opprinnelig ble
formulert — les `DESIGN_SYSTEM.md` for gjeldende regelverk, ikke denne.
## De nye instruksene (shadcn-basert, klar til bruk i prompter)
### 1. Fargesystem og Design Tokens (Shadcn-basert)
- **Bruk eksisterende CSS-variabler:** systemet bygger allerede på
semantiske variabler (`bg-background`, `text-foreground`, `bg-card`,
`border-border`, `text-muted-foreground`). Aldri hardkod
Tailwind-farger som `slate-500`/`gray-100` — det bryter den
eksisterende mørk/lys-tema-logikken.
- **Merkevarefarger:** Grønn (primær) — hovedhandling, valgt tilstand,
positivt (f.eks. HCP-nedgang). Oransje (sekundær/kontrast) — merkevarens
andre pol, brukes til oppmerksomhet/motsetninger (over par,
motstanderlag, "ventende"-indikator). **Oransje er IKKE en advarsel-
eller feilfarge.**
- **Feil/advarsler:** faktiske feil (sletting/fare) bruker `destructive`
(rød).
### 2. Typografi og lesbarhet (designet for "uten lesebriller")
- Standard brødtekst er `text-base` (16px). Ved denne størrelsen og med
god kontrast (f.eks. `text-foreground`) er `font-normal` helt
akseptabelt og foretrukket, for å unngå visuell støy. Mindre tekst
(`text-sm`) brukes kun til metadata, og bør da ofte gis `font-medium`
for å kompensere for størrelsen.
- `tabular-nums` konsekvent på all numerisk visning (HCP, score, datoer).
- Signerte tall (HCP-trend, score mot par) bruker ekte minustegn (),
aldri bindestrek (-).
### 3. Layout, avstander og "grid"
- **8-punkts rutenett:** alle marger, avstander (`gap`) og padding følger
multipler av 8 (`p-2`, `p-4`, `p-6` osv.).
- **Trykkflater:** absolutt ufravikelig krav om minimum 44×44px for enhver
interaktiv flate.
- **Kort og beholder:** `rounded-2xl`, `border border-border`, `bg-card`,
svak skygge (`shadow-sm`) i lyst tema. Tomtilstander kan bruke
`border-dashed`.
### 4. Interaksjon og navigasjon
- Alle interaktive elementer må ha tydelige tilstander for `:hover` og
`:active` — umiddelbar, taktil bekreftelse på at trykket er registrert.
- Deaktivert tilstand: `opacity-50` og `pointer-events-none`.
- `lucide-react`. Viktige handlinger skal alltid ha en tekstlabel ved
siden av ikonet. Rene ikon-knapper tillates kun for universelt
forståtte symboler (tannhjul, lukkekryss, varselbjelle) og MÅ da ha en
beskrivende `aria-label`.
### 5. Lyst og mørkt tema
- Tema styres automatisk av shadcn-variablene (punkt 1) — ingen egen
logikk trengs per skjerm.
- Dybde i mørkt tema skapes automatisk av at `bg-card` er marginalt
lysere enn `bg-background`, adskilt med `border-border`.
- Kontrastnivå skal møte WCAG AA i begge oppsett (shadcn-variablene
håndterer dette ut av boksen).
### 6. Skjemaer og inndata (mobil-først UX)
- Tekst i `<input>`/`<select>`/`<textarea>` MÅ være minst `text-base`
(16px) — mindre utløser tvungen auto-zoom på iOS.
- Riktig inndatamodus: `inputMode="numeric"`/`"decimal"` der brukeren
skriver tall (HCP, antall slag).
### 7. Safe areas og skjermkanter
- Elementer festet til topp eller bunn skal ha nok ekstra padding (`pt`/
`pb`) til at ingenting havner utenfor trygg sone (Dynamic Island/
hjem-indikator).
### 8. Bevegelse og overganger (motion)
- `transition-all duration-200 ease-in-out` for state-endringer på
knapper og kort. Appen skal føles som en lynrask "native" app.
- Ingen animasjoner/fades lenger enn 300ms.
---
## Anvendelse på dashbordet (2026-07-29)
Denne versjonen er allerede forankret i det eksisterende token-systemet
(punkt 1) og retter selv opp spenningen rundt oransje (punkt 1) — begge
ting forrige runde av dette notatet måtte avvike fra. Konkret gjort denne
runden, oppå forrige rundes 8-punkts/safe-area/`:active`-arbeid:
- **Punkt 2:** `text-sm`-metadata (bane-radens "N runder · Sist …",
"Ingen nye forespørsler") satt til `font-medium`. Body-tekst ved
`text-base` (undertittel under hilsenen) var allerede `font-normal` fra
før — ingen endring nødvendig der.
- **Punkt 8:** gikk tilbake til `transition-all duration-200 ease-in-out`
overalt (forrige runde brukte målrettede `transition-[...]`-lister av
ytelseshensyn — reversert på eksplisitt instruks).
- Alt annet (tokens, ikke-hardkodet farge, 44px-trykkflater, `:active`-
tilstander, safe-area, `tabular-nums`, ekte minustegn, `text-base`
input-felt) stod allerede riktig fra forrige runde og er uendret.
**Reell bug fikset i forrige runde, fortsatt gjeldende:** "Ny turnering"/
"Bli med med kode"-knappene i turnering-tomtilstanden var koblet til
ingenting. Rettet ved å løfte hurtighandling-skjemaets åpne/lukk-tilstand
til dashbord-roten. Bekreftet på nytt i denne runden med en ekte
klikk-gjennom i nettleser (Chrome DevTools, mobilvisning 390×844):
trykk på tomtilstand-knappen åpner nå det samme skjemaet og scroller dit.
**Verifisert i nettleser denne runden (Chrome DevTools, mobil 390×844,
isolert scratch-backend, ryddet opp etterpå):** full registrering →
obligatorisk profilutfylling → dashbord, opprettelse av en turnering via
den nettopp fikserte tomtilstand-knappen, og en visuell sjekk av BÅDE lyst
og mørkt tema (automatisk kontrast/dybde, ingen egen kode nødvendig).

View file

@ -19,7 +19,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
from .. import storage from .. import storage
from ..auth import CurrentUser, get_current_user, get_current_user_optional from ..auth import CurrentUser, get_current_user, get_current_user_optional
@ -165,6 +165,12 @@ async def get_person(
class FriendRequestCreate(BaseModel): class FriendRequestCreate(BaseModel):
addressee_user_id: str addressee_user_id: str
# Ingen ukategoriserte venner (2026-07-29, presisert av bruker) --
# kategorisering er nå obligatorisk fra det ØYEBLIKKET man blir venn,
# ikke en frivillig, senere handling. Krevd her (avsenderens side) OG i
# /accept (mottakerens side) -- de to eneste naturlige punktene en
# ny relasjon oppstår.
categories: list[Category] = Field(min_length=1)
class FriendEntry(BaseModel): class FriendEntry(BaseModel):
@ -200,12 +206,24 @@ async def send_friend_request(body: FriendRequestCreate, user: CurrentUser = Dep
target = await conn.fetchrow("SELECT id FROM app_user WHERE id = $1", body.addressee_user_id) target = await conn.fetchrow("SELECT id FROM app_user WHERE id = $1", body.addressee_user_id)
if target is None: if target is None:
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.") raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
async with conn.transaction():
async with translate_db_errors(): async with translate_db_errors():
row = await conn.fetchrow( row = await conn.fetchrow(
"INSERT INTO friendship (requester_user_id, addressee_user_id) VALUES ($1, $2) RETURNING id::text AS id", "INSERT INTO friendship (requester_user_id, addressee_user_id) VALUES ($1, $2) RETURNING id::text AS id",
user.user_id, user.user_id,
body.addressee_user_id, body.addressee_user_id,
) )
# Kategorisering settes med det samme -- harmløst inert helt til
# forespørselen faktisk aksepteres (_can_view_round krever uansett
# status='accepted' FØRST), men lukker gapet der avsenderen ellers
# aldri ble bedt om å kategorisere i det hele tatt.
for category in sorted(set(body.categories)):
await conn.execute(
"INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) VALUES ($1, $2, $3)",
user.user_id,
body.addressee_user_id,
category,
)
requester_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) requester_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
await create_notification( await create_notification(
conn, conn,
@ -217,11 +235,22 @@ async def send_friend_request(body: FriendRequestCreate, user: CurrentUser = Dep
return {"id": row["id"]} return {"id": row["id"]}
class FriendAcceptRequest(BaseModel):
# Samme "ingen ukategoriserte venner"-regel som FriendRequestCreate,
# denne gangen for MOTTAKERENS side av relasjonen.
categories: list[Category] = Field(min_length=1)
@router.post("/friends/{friendship_id}/accept") @router.post("/friends/{friendship_id}/accept")
async def accept_friend_request(friendship_id: str, user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]: async def accept_friend_request(
friendship_id: str,
body: FriendAcceptRequest,
user: CurrentUser = Depends(get_current_user),
) -> dict[str, bool]:
async with plain_connection() as conn: async with plain_connection() as conn:
row = await conn.fetchrow( row = await conn.fetchrow(
"SELECT addressee_user_id::text AS addressee_user_id, status FROM friendship WHERE id = $1", "SELECT addressee_user_id::text AS addressee_user_id, requester_user_id::text AS requester_user_id, status "
"FROM friendship WHERE id = $1",
friendship_id, friendship_id,
) )
if row is None: if row is None:
@ -230,9 +259,22 @@ async def accept_friend_request(friendship_id: str, user: CurrentUser = Depends(
raise app_error(403, "NOT_AUTHORIZED", "Du kan ikke svare på denne forespørselen.") raise app_error(403, "NOT_AUTHORIZED", "Du kan ikke svare på denne forespørselen.")
if row["status"] == "accepted": if row["status"] == "accepted":
raise app_error(409, "ALREADY_ACCEPTED", "Forespørselen er allerede akseptert.") raise app_error(409, "ALREADY_ACCEPTED", "Forespørselen er allerede akseptert.")
requester_id = row["requester_user_id"]
async with conn.transaction():
await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id) await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id)
await conn.execute(
"DELETE FROM friend_categorization WHERE owner_user_id = $1 AND friend_user_id = $2",
user.user_id,
requester_id,
)
for category in sorted(set(body.categories)):
await conn.execute(
"INSERT INTO friend_categorization (owner_user_id, friend_user_id, category) VALUES ($1, $2, $3)",
user.user_id,
requester_id,
category,
)
addressee_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) addressee_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
requester_id = await conn.fetchval("SELECT requester_user_id::text FROM friendship WHERE id = $1", friendship_id)
await create_notification( await create_notification(
conn, conn,
user_id=requester_id, user_id=requester_id,
@ -348,7 +390,9 @@ async def list_friends(user: CurrentUser = Depends(get_current_user)) -> Friends
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class CategorizationUpdate(BaseModel): class CategorizationUpdate(BaseModel):
categories: list[Category] # Minst én -- "ingen ukategoriserte venner" gjelder også ved senere
# redigering, ikke bare ved selve vennskaps-opprettelsen.
categories: list[Category] = Field(min_length=1)
@router.put("/friends/{friend_user_id}/categories") @router.put("/friends/{friend_user_id}/categories")

View file

@ -55,6 +55,7 @@ class MatchParticipantOut(BaseModel):
player_name: str player_name: str
tee_id: str tee_id: str
tee_name: str tee_name: str
playing_handicap: int | None = None
class MatchCreate(BaseModel): class MatchCreate(BaseModel):
@ -172,7 +173,7 @@ async def fetch_matches(
""" """
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text, SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
mp.team_roster_id::text, p.display_name AS player_name, mp.team_roster_id::text, p.display_name AS player_name,
mp.tee_id::text, tee.name AS tee_name mp.tee_id::text, tee.name AS tee_name, mp.playing_handicap
FROM match_participant mp FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id JOIN player p ON p.id = tr.player_id
@ -195,6 +196,7 @@ async def fetch_matches(
player_name=r["player_name"], player_name=r["player_name"],
tee_id=r["tee_id"], tee_id=r["tee_id"],
tee_name=r["tee_name"], tee_name=r["tee_name"],
playing_handicap=r["playing_handicap"],
) )
) )
@ -394,7 +396,12 @@ async def add_participant(
await compute_and_store_side_handicaps( await compute_and_store_side_handicaps(
conn, match_id, body.team_side, session["format"], config conn, match_id, body.team_side, session["format"], config
) )
return MatchParticipantOut(**dict(row)) # row ble hentet FØR beregningen over -- les playing_handicap på nytt
# slik at responsen ikke lyver om en verdi som faktisk finnes.
playing_handicap = await conn.fetchval(
"SELECT playing_handicap FROM match_participant WHERE id = $1::uuid", row["id"]
)
return MatchParticipantOut(**dict(row), playing_handicap=playing_handicap)
@router.delete( @router.delete(

View file

@ -1349,8 +1349,15 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
return False return False
# 'friends' -- krever et AKSEPTERT vennskap MED eieren OG at EIERENS # 'friends' -- krever et AKSEPTERT vennskap MED eieren OG at EIERENS
# kategorisering av viewer (ikke omvendt -- det er eieren som # kategorisering av viewer (ikke omvendt -- det er eieren som
# begrenser, basert på egen gruppering, ADR-036 Beslutning B) treffer # begrenser, basert på egen gruppering, ADR-036 Beslutning B).
# minst én av rundens synlige kategorier. #
# Presisert av bruker 2026-07-29: "ikke vise" overstyrer "vise" -- er
# vennen satt i FLERE kategorier, holder det IKKE at én av dem er
# synlig for runden; ALLE vennens kategorier må være i rundens synlige
# sett. En venn med INGEN kategorier i det hele tatt vises ALDRI (samme
# runde: kategorisering er nå obligatorisk fra vennskapet inngås, se
# friends.py -- en ukategorisert venn er derfor alltid en igjenværende
# unntakstilstand fra FØR den regelen, ikke en normal, forventet sti).
if viewer_user_id is None: if viewer_user_id is None:
return False return False
owner_id = row["owner_user_id"] owner_id = row["owner_user_id"]
@ -1371,9 +1378,13 @@ async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bo
return await conn.fetchval( return await conn.fetchval(
""" """
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM friend_categorization
WHERE owner_user_id = $1 AND friend_user_id = $2
)
AND NOT EXISTS(
SELECT 1 FROM friend_categorization fc SELECT 1 FROM friend_categorization fc
JOIN round_visible_category rvc ON rvc.category = fc.category WHERE fc.owner_user_id = $1 AND fc.friend_user_id = $2
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = $2 AND rvc.round_id = $3 AND fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
) )
""", """,
owner_id, owner_id,
@ -1413,14 +1424,22 @@ async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) ->
return [] return []
if row["visibility_mode"] == "public": if row["visibility_mode"] == "public":
return friend_ids return friend_ids
# Samme "alle kategorier må være synlige, ikke bare én"-regel som
# _can_view_round -- en venn med minst én ikke-synlig kategori
# ekskluderes fra fan-out-varselet, selv om en annen av kategoriene
# deres er valgt.
matched = await conn.fetch( matched = await conn.fetch(
""" """
SELECT DISTINCT fc.friend_user_id::text AS friend_id SELECT fc.friend_user_id::text AS friend_id
FROM friend_categorization fc FROM friend_categorization fc
JOIN round_visible_category rvc ON rvc.category = fc.category WHERE fc.owner_user_id = $1 AND fc.friend_user_id = ANY($2::uuid[])
WHERE fc.owner_user_id = $1 AND rvc.round_id = $2 GROUP BY fc.friend_user_id
HAVING COUNT(*) FILTER (
WHERE fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
) = 0
""", """,
owner_user_id, owner_user_id,
friend_ids,
round_id, round_id,
) )
return [r["friend_id"] for r in matched] return [r["friend_id"] for r in matched]

View file

@ -261,7 +261,7 @@ export function AccountSettings() {
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8"> <main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
{settingUp2fa ? ( {settingUp2fa ? (
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm shadow-black/5 sm:p-8"> <div className="rounded-3xl border border-border bg-card p-6 shadow-md shadow-black/8 sm:p-8">
<TwoFactorSetupForm <TwoFactorSetupForm
forced={false} forced={false}
onSuccess={() => { onSuccess={() => {
@ -284,7 +284,7 @@ export function AccountSettings() {
<PushNotificationSection /> <PushNotificationSection />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-5 text-primary" /> <ShieldCheck aria-hidden="true" className="size-5 text-primary" />
@ -440,7 +440,7 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6" 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="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -628,7 +628,7 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
} }
return ( return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<User aria-hidden="true" className="size-5 text-primary" /> <User aria-hidden="true" className="size-5 text-primary" />
@ -1017,7 +1017,7 @@ function EmailSection({ email }: { email: string }) {
} }
return ( return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" /> <Mail aria-hidden="true" className="size-5 text-primary" />
@ -1136,7 +1136,7 @@ function SecondaryEmailSection({
} }
return ( return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" /> <Mail aria-hidden="true" className="size-5 text-primary" />
@ -1269,7 +1269,7 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
} }
return ( return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Lock aria-hidden="true" className="size-5 text-primary" /> <Lock aria-hidden="true" className="size-5 text-primary" />
@ -1380,7 +1380,7 @@ function PushNotificationSection() {
} }
return ( return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<BellRing aria-hidden="true" className="size-5 text-primary" /> <BellRing aria-hidden="true" className="size-5 text-primary" />
@ -1464,7 +1464,7 @@ function NotificationEmailPrefsSection() {
} }
return ( return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Bell aria-hidden="true" className="size-5 text-primary" /> <Bell aria-hidden="true" className="size-5 text-primary" />

View file

@ -283,6 +283,17 @@ export function Dashboard() {
} }
}, [me]) }, [me])
// Delt åpen/lukk-tilstand for hurtighandling-skjemaene (turnering/kode) --
// løftet til roten (2026-07-29, "alternativ designinstruks") slik at BÅDE
// Hurtighandlinger-kortene OG tomtilstands-knappene i "Kommende
// turneringer" kan åpne samme inline-skjema, i stedet for at sistnevnte
// var uten href/onClick og ikke gjorde noe ved trykk.
const [quickActionOpen, setQuickActionOpen] = useState<"tournament" | "code" | null>(null)
const openQuickAction = useCallback((which: "tournament" | "code") => {
setQuickActionOpen(which)
document.getElementById("hurtighandlinger")?.scrollIntoView({ behavior: "smooth", block: "start" })
}, [])
// "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) -- // "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) --
// ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet. // ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet.
// Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles // Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles
@ -368,14 +379,14 @@ export function Dashboard() {
return ( return (
<div className="flex min-h-[100dvh] flex-col bg-background"> <div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur"> <header className="sticky top-0 z-10 border-b border-border bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4"> <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact /> <Wordmark compact />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NotificationBell unreadCount={unreadNotifications} /> <NotificationBell unreadCount={unreadNotifications} />
<Link <Link
href="/account" href="/account"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground" className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
> >
<UserCircle aria-hidden="true" className="size-4" /> <UserCircle aria-hidden="true" className="size-4" />
Konto Konto
@ -383,7 +394,7 @@ export function Dashboard() {
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground" className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
> >
<LogOut aria-hidden="true" className="size-4" /> <LogOut aria-hidden="true" className="size-4" />
Logg ut Logg ut
@ -392,7 +403,7 @@ export function Dashboard() {
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-16 pt-6 sm:pt-8"> <main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance"> <h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
God dag, {me.first_name ?? me.display_name} God dag, {me.first_name ?? me.display_name}
@ -413,9 +424,18 @@ export function Dashboard() {
</div> </div>
<div className="mt-6 flex flex-col gap-8"> <div className="mt-6 flex flex-col gap-8">
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} /> <QuickActions
organizations={me.organizations}
onCreateTournament={handleCreateTournament}
open={quickActionOpen}
onOpenChange={setQuickActionOpen}
/>
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} /> <UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
<UpcomingTournaments entries={combinedTournaments} /> <UpcomingTournaments
entries={combinedTournaments}
onNewTournament={() => openQuickAction("tournament")}
onJoinWithCode={() => openQuickAction("code")}
/>
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} /> <StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
<PlayedCourses courses={playedCourses} /> <PlayedCourses courses={playedCourses} />
<FriendsSection summary={friendsSummary} /> <FriendsSection summary={friendsSummary} />
@ -438,7 +458,7 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
<Link <Link
href="/my-notifications" href="/my-notifications"
aria-label={label} aria-label={label}
className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<Bell aria-hidden="true" className="size-5" /> <Bell aria-hidden="true" className="size-5" />
{hasUnread && ( {hasUnread && (
@ -459,23 +479,25 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
function QuickActions({ function QuickActions({
organizations, organizations,
onCreateTournament, onCreateTournament,
open,
onOpenChange,
}: { }: {
organizations: MyOrg[] organizations: MyOrg[]
onCreateTournament: (name: string, orgId: string | null) => Promise<void> onCreateTournament: (name: string, orgId: string | null) => Promise<void>
open: "tournament" | "code" | null
onOpenChange: (value: "tournament" | "code" | null) => void
}) { }) {
const [open, setOpen] = useState<"tournament" | "code" | null>(null)
return ( return (
<section aria-label="Hurtighandlinger" className="flex flex-col gap-3"> <section id="hurtighandlinger" aria-label="Hurtighandlinger" className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-3 gap-2 sm:gap-3">
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" /> <QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => setOpen(open === "tournament" ? null : "tournament")} /> <QuickAction icon={Trophy} label="Ny turnering" onClick={() => onOpenChange(open === "tournament" ? null : "tournament")} />
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => setOpen(open === "code" ? null : "code")} /> <QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => onOpenChange(open === "code" ? null : "code")} />
</div> </div>
{open === "tournament" && ( {open === "tournament" && (
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => setOpen(null)} /> <NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => onOpenChange(null)} />
)} )}
{open === "code" && <JoinByCodeInline onClose={() => setOpen(null)} />} {open === "code" && <JoinByCodeInline onClose={() => onOpenChange(null)} />}
</section> </section>
) )
} }
@ -492,13 +514,11 @@ function QuickAction({
onClick?: () => void onClick?: () => void
}) { }) {
const className = const className =
"flex min-h-[108px] flex-1 flex-col items-center justify-center gap-2.5 rounded-2xl border border-border bg-card p-4 text-center shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" "flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl bg-primary px-2 py-3 text-center text-primary-foreground shadow-md shadow-primary/25 transition-all duration-200 ease-in-out hover:bg-primary/90 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
const inner = ( const inner = (
<> <>
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/15 text-primary"> <Icon aria-hidden="true" className="size-5 sm:size-6" />
<Icon aria-hidden="true" className="size-6" /> <span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</span>
<span className="text-sm font-bold leading-tight text-foreground text-balance">{label}</span>
</> </>
) )
if (href) { if (href) {
@ -538,7 +558,7 @@ function NewTournamentInline({
} }
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end"> <form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-tournament-name" className="text-sm font-semibold"> <Label htmlFor="new-tournament-name" className="text-sm font-semibold">
Navn turnering Navn turnering
@ -612,7 +632,7 @@ function JoinByCodeInline({ onClose }: { onClose: () => void }) {
} }
return ( return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end"> <form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="join-code-inline" className="text-sm font-semibold"> <Label htmlFor="join-code-inline" className="text-sm font-semibold">
Invitasjonskode Invitasjonskode
@ -654,7 +674,7 @@ function SeeAllLink({ href, label }: { href: string; label: string }) {
return ( return (
<Link <Link
href={href} href={href}
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-primary transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-primary transition-all duration-200 ease-in-out hover:bg-accent/50 active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
{label} {label}
<ChevronRight aria-hidden="true" className="size-4" /> <ChevronRight aria-hidden="true" className="size-4" />
@ -691,15 +711,17 @@ function ShortcutButton({
icon: Icon, icon: Icon,
label, label,
href, href,
onClick,
variant = "primary", variant = "primary",
}: { }: {
icon: typeof Flag icon: typeof Flag
label: string label: string
href?: string href?: string
onClick?: () => void
variant?: "primary" | "outline" variant?: "primary" | "outline"
}) { }) {
const className = cn( const className = cn(
"inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", "inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50", variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
) )
const inner = ( const inner = (
@ -716,7 +738,7 @@ function ShortcutButton({
) )
} }
return ( return (
<button type="button" className={className}> <button type="button" onClick={onClick} className={className}>
{inner} {inner}
</button> </button>
) )
@ -750,7 +772,15 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
// --- 3. Kommende turneringer ----------------------------------------------- // --- 3. Kommende turneringer -----------------------------------------------
function UpcomingTournaments({ entries }: { entries: CombinedTournament[] }) { function UpcomingTournaments({
entries,
onNewTournament,
onJoinWithCode,
}: {
entries: CombinedTournament[]
onNewTournament: () => void
onJoinWithCode: () => void
}) {
return ( return (
<section aria-label="Kommende turneringer"> <section aria-label="Kommende turneringer">
<SectionHeader title="Kommende turneringer" /> <SectionHeader title="Kommende turneringer" />
@ -762,8 +792,8 @@ function UpcomingTournaments({ entries }: { entries: CombinedTournament[] }) {
</div> </div>
) : ( ) : (
<EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen."> <EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen.">
<ShortcutButton icon={Trophy} label="Ny turnering" /> <ShortcutButton icon={Trophy} label="Ny turnering" onClick={onNewTournament} />
<ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" /> <ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" onClick={onJoinWithCode} />
</EmptyState> </EmptyState>
)} )}
</section> </section>
@ -789,8 +819,8 @@ function Sparkline({ values }: { values: number[] }) {
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) { function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return ( return (
<div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span> <span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
{children} {children}
</div> </div>
@ -846,21 +876,21 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
<section aria-label="Spilte baner"> <section aria-label="Spilte baner">
<SectionHeader title="Spilte baner" /> <SectionHeader title="Spilte baner" />
{courses.length > 0 ? ( {courses.length > 0 ? (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card"> <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{courses.map((course) => { {courses.map((course) => {
const roundLabel = course.visits === 1 ? "runde" : "runder" const roundLabel = course.visits === 1 ? "runde" : "runder"
return ( return (
<li key={course.name}> <li key={course.name}>
<Link <Link
href={`/my-rounds/course/${encodeURIComponent(course.name)}`} href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-accent/50 active:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
> >
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary"> <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
<MapPin aria-hidden="true" className="size-5" /> <MapPin aria-hidden="true" className="size-5" />
</span> </span>
<span className="flex min-w-0 flex-1 flex-col"> <span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{course.name}</span> <span className="truncate text-base font-bold text-foreground">{course.name}</span>
<span className="truncate text-sm text-muted-foreground"> <span className="truncate text-sm font-medium text-muted-foreground">
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)} {course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</span> </span>
</span> </span>
@ -886,6 +916,28 @@ function friendInitials(first: string | null, last: string | null): string {
return (f + l).toUpperCase() || "?" return (f + l).toUpperCase() || "?"
} }
// Fargede avatar-initialer (i stedet for alle i samme tonede grønn) --
// deterministisk per person via en enkel navne-hash, gjenbruker de
// allerede validerte statistikk-fargetokenene (chart-1..6, DESIGN_SYSTEM.md)
// i stedet for å finne opp nye, hardkodede farger. Literal klasse-strenger
// (ikke sammensatt av en variabel) siden Tailwind krever det for at JIT-en
// skal fange dem opp.
const AVATAR_PALETTE = [
"bg-chart-1/20 text-chart-1",
"bg-chart-2/20 text-chart-2",
"bg-chart-3/20 text-chart-3",
"bg-chart-4/20 text-chart-4",
"bg-chart-5/20 text-chart-5",
"bg-chart-6/20 text-chart-6",
]
function avatarColorClass(first: string | null, last: string | null): string {
const name = `${first ?? ""}${last ?? ""}`
let hash = 0
for (let i = 0; i < name.length; i++) hash = (hash + name.charCodeAt(i)) % AVATAR_PALETTE.length
return AVATAR_PALETTE[hash]
}
function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) { function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
const friends = summary?.friends ?? [] const friends = summary?.friends ?? []
const pending = summary?.incoming_requests.length ?? 0 const pending = summary?.incoming_requests.length ?? 0
@ -894,20 +946,26 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
<section aria-label="Venner"> <section aria-label="Venner">
<SectionHeader title="Venner" /> <SectionHeader title="Venner" />
{friends.length > 0 ? ( {friends.length > 0 ? (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center"> <div className="flex items-center">
{friends.slice(0, 4).map((friend, i) => ( {friends.slice(0, 4).map((friend, i) => (
<span <span
key={i} key={i}
className={cn( className={cn(
"flex size-11 items-center justify-center rounded-full border-2 border-card bg-primary/15 text-sm font-bold text-foreground", "flex size-11 items-center justify-center rounded-full border-2 border-card text-sm font-bold",
avatarColorClass(friend.first_name, friend.last_name),
i > 0 && "-ml-3", i > 0 && "-ml-3",
)} )}
> >
{friendInitials(friend.first_name, friend.last_name)} {friendInitials(friend.first_name, friend.last_name)}
</span> </span>
))} ))}
{friends.length > 4 && (
<span className="-ml-3 flex size-11 items-center justify-center rounded-full border-2 border-card bg-muted text-xs font-bold text-muted-foreground">
+{friends.length - 4}
</span>
)}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-base font-bold text-foreground"> <span className="text-base font-bold text-foreground">
@ -919,7 +977,7 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"} {pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
</span> </span>
) : ( ) : (
<span className="text-sm text-muted-foreground">Ingen nye forespørsler</span> <span className="text-sm font-medium text-muted-foreground">Ingen nye forespørsler</span>
)} )}
</div> </div>
</div> </div>
@ -942,7 +1000,7 @@ function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
return ( return (
<div className="flex justify-center pt-2"> <div className="flex justify-center pt-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> <DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Building2 aria-hidden="true" className="size-4" /> <Building2 aria-hidden="true" className="size-4" />
Dine organisasjoner ({organizations.length}) Dine organisasjoner ({organizations.length})
</DropdownMenuTrigger> </DropdownMenuTrigger>

View file

@ -221,7 +221,7 @@ export function FlightGroupLeaderboard({ roundId }: { roundId: string }) {
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{data.flights.length} flighter satt opp sammen {data.flights.length} flighter satt opp sammen
</span> </span>

View file

@ -76,7 +76,7 @@ function RoundRow({ round }: { round: ApiPersonRound }) {
return ( return (
<Link <Link
href={`/watch/${round.id}`} href={`/watch/${round.id}`}
className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/30" className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/30"
> >
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -183,7 +183,7 @@ export function FriendProfile({ userId }: { userId: string }) {
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex items-center gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex items-center gap-4 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<Avatar name={name} url={person.avatar_url} /> <Avatar name={name} url={person.avatar_url} />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xl font-extrabold tracking-tight text-foreground">{name}</span> <span className="truncate text-xl font-extrabold tracking-tight text-foreground">{name}</span>

View file

@ -44,6 +44,53 @@ function categoryLabel(code: string): string {
return CATEGORY_OPTIONS.find((c) => c.code === code)?.label ?? code return CATEGORY_OPTIONS.find((c) => c.code === code)?.label ?? code
} }
const ALL_CATEGORY_CODES = CATEGORY_OPTIONS.map((c) => c.code)
// Delt sjekkboks-rutenett (2026-07-29) -- brukt tre steder nå (ny
// forespørsel, godta innkommende, rediger en eksisterende venn), derfor
// trukket ut fremfor duplisert. "Ingen ukategoriserte venner" (samme dag,
// presisert av bruker) håndheves her ved å nekte å fjerne den SISTE
// avkrysningen -- backend krever uansett minst én, men UI-et skal aldri
// tillate å komme i den tilstanden i utgangspunktet.
function CategoryPicker({ selected, onToggle }: { selected: string[]; onToggle: (code: string) => void }) {
return (
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{CATEGORY_OPTIONS.map(({ code, label }) => {
const checked = selected.includes(code)
const blocked = checked && selected.length <= 1
return (
<li key={code}>
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={blocked}
onClick={() => onToggle(code)}
title={blocked ? "Minst én kategori er påkrevd" : undefined}
className={cn(
"flex min-h-[44px] w-full items-center gap-3 rounded-xl border px-3 py-2 text-left text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
blocked && "cursor-not-allowed opacity-70",
)}
>
<span
aria-hidden="true"
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors",
checked ? "border-primary bg-primary text-primary-foreground" : "border-border",
)}
>
{checked && <Check className="size-4" strokeWidth={3} />}
</span>
{label}
</button>
</li>
)
})}
</ul>
)
}
// --- API-typer --------------------------------------------------------------- // --- API-typer ---------------------------------------------------------------
type ApiPersonMatch = { id: string; first_name: string; last_name: string; avatar_url: string | null; home_club: string | null } type ApiPersonMatch = { id: string; first_name: string; last_name: string; avatar_url: string | null; home_club: string | null }
@ -162,6 +209,25 @@ function SearchSection({
const [results, setResults] = useState<ApiPersonMatch[]>([]) const [results, setResults] = useState<ApiPersonMatch[]>([])
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
const [justSent, setJustSent] = useState<Record<string, boolean>>({}) const [justSent, setJustSent] = useState<Record<string, boolean>>({})
// Ingen ukategoriserte venner (2026-07-29) -- kategorisering skjer nå
// FØR forespørselen sendes, ikke som et eget steg etterpå. Alle
// kategorier forhåndsvalgt (bruker: "man må heller velge bort") --
// ett åpent valg-panel om gangen (personId som nøkkel).
const [pickerFor, setPickerFor] = useState<string | null>(null)
const [pickerCategories, setPickerCategories] = useState<string[]>(ALL_CATEGORY_CODES)
const [sending, setSending] = useState(false)
function openPicker(personId: string) {
setPickerFor(personId)
setPickerCategories(ALL_CATEGORY_CODES)
}
function toggleCategoryInPicker(code: string) {
setPickerCategories((prev) => {
if (prev.includes(code)) return prev.length <= 1 ? prev : prev.filter((c) => c !== code)
return [...prev, code]
})
}
useEffect(() => { useEffect(() => {
const trimmed = query.trim() const trimmed = query.trim()
@ -188,17 +254,21 @@ function SearchSection({
}, [query]) }, [query])
async function sendRequest(personId: string) { async function sendRequest(personId: string) {
setJustSent((prev) => ({ ...prev, [personId]: true })) setSending(true)
try { try {
const res = await fetch("/friends", { const res = await fetch("/friends", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
body: JSON.stringify({ addressee_user_id: personId }), body: JSON.stringify({ addressee_user_id: personId, categories: pickerCategories }),
}) })
if (res.ok) onRequestSent() if (res.ok) {
} catch { setJustSent((prev) => ({ ...prev, [personId]: true }))
setJustSent((prev) => ({ ...prev, [personId]: false })) setPickerFor(null)
onRequestSent()
}
} finally {
setSending(false)
} }
} }
@ -235,11 +305,13 @@ function SearchSection({
{results.map((person) => { {results.map((person) => {
const isFriend = friendUserIds.has(person.id) const isFriend = friendUserIds.has(person.id)
const isSent = outgoingUserIds.has(person.id) || justSent[person.id] const isSent = outgoingUserIds.has(person.id) || justSent[person.id]
const pickerOpen = pickerFor === person.id
return ( return (
<li <li
key={person.id} key={person.id}
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5" className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
> >
<div className="flex items-center gap-4">
<Avatar name={fullName(person.first_name, person.last_name)} /> <Avatar name={fullName(person.first_name, person.last_name)} />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-lg font-bold text-foreground"> <span className="truncate text-lg font-bold text-foreground">
@ -262,13 +334,42 @@ function SearchSection({
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => sendRequest(person.id)} onClick={() => (pickerOpen ? setPickerFor(null) : openPicker(person.id))}
aria-expanded={pickerOpen}
className="inline-flex min-h-[44px] shrink-0 items-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="inline-flex min-h-[44px] shrink-0 items-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<UserPlus aria-hidden="true" className="size-5" /> <UserPlus aria-hidden="true" className="size-5" />
Send forespørsel Send forespørsel
</button> </button>
)} )}
</div>
{pickerOpen && (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-4">
<p className="text-sm font-semibold text-foreground">
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
forhåndsvalgt velg heller bort det som ikke passer.
</p>
<CategoryPicker selected={pickerCategories} onToggle={toggleCategoryInPicker} />
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => sendRequest(person.id)}
disabled={sending}
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:flex-none"
>
{sending ? "Sender…" : "Bekreft og send"}
</button>
<button
type="button"
onClick={() => setPickerFor(null)}
className="inline-flex min-h-[44px] items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Avbryt
</button>
</div>
</div>
)}
</li> </li>
) )
})} })}
@ -289,12 +390,50 @@ function RequestsSection({
outgoing: ApiPendingRequest[] outgoing: ApiPendingRequest[]
onChanged: () => void onChanged: () => void
}) { }) {
async function respond(friendshipId: string, action: "accept" | "remove") { // Ingen ukategoriserte venner (2026-07-29) -- å godta krever nå at
if (action === "accept") { // MOTTAKEREN også velger kategorier med det samme (samme regel som
await fetch(`/friends/${friendshipId}/accept`, { method: "POST", credentials: "include" }) // avsenderen i SearchSection over), ikke en frivillig senere handling.
} else { const [acceptPickerFor, setAcceptPickerFor] = useState<string | null>(null)
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" }) const [acceptCategories, setAcceptCategories] = useState<string[]>(ALL_CATEGORY_CODES)
const [accepting, setAccepting] = useState(false)
function openAcceptPicker(friendshipId: string) {
setAcceptPickerFor(friendshipId)
setAcceptCategories(ALL_CATEGORY_CODES)
} }
function toggleAcceptCategory(code: string) {
setAcceptCategories((prev) => {
if (prev.includes(code)) return prev.length <= 1 ? prev : prev.filter((c) => c !== code)
return [...prev, code]
})
}
async function confirmAccept(friendshipId: string) {
setAccepting(true)
try {
const res = await fetch(`/friends/${friendshipId}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ categories: acceptCategories }),
})
if (res.ok) {
setAcceptPickerFor(null)
onChanged()
}
} finally {
setAccepting(false)
}
}
async function decline(friendshipId: string) {
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" })
onChanged()
}
async function cancelOutgoing(friendshipId: string) {
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" })
onChanged() onChanged()
} }
@ -309,11 +448,14 @@ function RequestsSection({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mottatt</h3> <h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mottatt</h3>
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{incoming.map((person) => ( {incoming.map((person) => {
const pickerOpen = acceptPickerFor === person.friendship_id
return (
<li <li
key={person.friendship_id} key={person.friendship_id}
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5" className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
> >
<div className="flex flex-wrap items-center gap-4">
<Avatar name={fullName(person.first_name, person.last_name)} /> <Avatar name={fullName(person.first_name, person.last_name)} />
<div className="flex min-w-0 flex-1 basis-40 flex-col"> <div className="flex min-w-0 flex-1 basis-40 flex-col">
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span> <span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
@ -321,7 +463,8 @@ function RequestsSection({
<div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto"> <div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto">
<button <button
type="button" type="button"
onClick={() => respond(person.friendship_id, "accept")} onClick={() => (pickerOpen ? setAcceptPickerFor(null) : openAcceptPicker(person.friendship_id))}
aria-expanded={pickerOpen}
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none" className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none"
> >
<Check aria-hidden="true" className="size-5" /> <Check aria-hidden="true" className="size-5" />
@ -329,15 +472,44 @@ function RequestsSection({
</button> </button>
<button <button
type="button" type="button"
onClick={() => respond(person.friendship_id, "remove")} onClick={() => decline(person.friendship_id)}
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none" className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none"
> >
<X aria-hidden="true" className="size-5" /> <X aria-hidden="true" className="size-5" />
Avslå Avslå
</button> </button>
</div> </div>
</div>
{pickerOpen && (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-4">
<p className="text-sm font-semibold text-foreground">
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
forhåndsvalgt velg heller bort det som ikke passer.
</p>
<CategoryPicker selected={acceptCategories} onToggle={toggleAcceptCategory} />
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => confirmAccept(person.friendship_id)}
disabled={accepting}
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:flex-none"
>
{accepting ? "Godtar…" : "Bekreft og godta"}
</button>
<button
type="button"
onClick={() => setAcceptPickerFor(null)}
className="inline-flex min-h-[44px] items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Avbryt
</button>
</div>
</div>
)}
</li> </li>
))} )
})}
</ul> </ul>
</div> </div>
)} )}
@ -349,7 +521,7 @@ function RequestsSection({
{outgoing.map((person) => ( {outgoing.map((person) => (
<li <li
key={person.friendship_id} key={person.friendship_id}
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5" className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
> >
<Avatar name={fullName(person.first_name, person.last_name)} /> <Avatar name={fullName(person.first_name, person.last_name)} />
<div className="flex min-w-0 flex-1 basis-40 flex-col"> <div className="flex min-w-0 flex-1 basis-40 flex-col">
@ -358,7 +530,7 @@ function RequestsSection({
</div> </div>
<button <button
type="button" type="button"
onClick={() => respond(person.friendship_id, "remove")} onClick={() => cancelOutgoing(person.friendship_id)}
className="inline-flex min-h-[44px] shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="inline-flex min-h-[44px] shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<X aria-hidden="true" className="size-5" /> <X aria-hidden="true" className="size-5" />
@ -376,10 +548,32 @@ function RequestsSection({
// --- 3. Friends ---------------------------------------------------------------- // --- 3. Friends ----------------------------------------------------------------
function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onChanged: () => void }) { function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onChanged: () => void }) {
// Ingen ukategoriserte venner (2026-07-29) -- selv-helbredende sikkerhets-
// nett for venner fra FØR den regelen ble håndhevet ved selve vennskaps-
// opprettelsen (send/godta). Skal i praksis aldri ramme en NY relasjon,
// kun eldre data. Vises som en umulig-å-overse advarsel øverst, og
// tvinger vedkommendes kategori-panel åpent (ikke bare en lenke dit).
const uncategorized = friends.filter((f) => f.categories.length === 0)
return ( return (
<section aria-label="Venner" className="flex flex-col gap-4"> <section aria-label="Venner" className="flex flex-col gap-4">
<SectionHeading>Venner</SectionHeading> <SectionHeading>Venner</SectionHeading>
{uncategorized.length > 0 && (
<div
role="alert"
className="flex flex-col gap-2 rounded-2xl border-2 border-brand-orange bg-brand-orange/10 p-4"
>
<p className="text-base font-bold text-foreground">
{uncategorized.length} {uncategorized.length === 1 ? "venn mangler" : "venner mangler"} kategori
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
Rundene dine kan ikke vises riktig for {uncategorized.length === 1 ? "denne vennen" : "disse vennene"} før
de er kategorisert. Kategoriseringen er allerede åpnet under velg minst én kategori for hver.
</p>
</div>
)}
{friends.length === 0 ? ( {friends.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center"> <div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15"> <span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
@ -406,7 +600,9 @@ function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onC
} }
function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: () => void }) { function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: () => void }) {
const [expanded, setExpanded] = useState(false) // Tvinges åpent for en ukategorisert venn (se banneret i FriendsSection)
// -- ingen vei til å la den stå lukket og utsette valget.
const [expanded, setExpanded] = useState(friend.categories.length === 0)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
// Lokal, optimistisk kopi -- lagres til backend ved hver endring (PUT // Lokal, optimistisk kopi -- lagres til backend ved hver endring (PUT
// erstatter HELE settet, matcher backend-kontrakten), men holdes lokalt // erstatter HELE settet, matcher backend-kontrakten), men holdes lokalt
@ -414,6 +610,10 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
const [categories, setCategories] = useState<string[]>(friend.categories) const [categories, setCategories] = useState<string[]>(friend.categories)
async function toggleCategory(code: string) { async function toggleCategory(code: string) {
// Ingen ukategoriserte venner (2026-07-29) -- nekter å fjerne den
// SISTE kategorien (backend krever uansett minst én, men UI-et skal
// aldri kunne komme dit i utgangspunktet).
if (categories.includes(code) && categories.length <= 1) return
const next = categories.includes(code) ? categories.filter((c) => c !== code) : [...categories, code] const next = categories.includes(code) ? categories.filter((c) => c !== code) : [...categories, code]
setCategories(next) setCategories(next)
await fetch(`/friends/${friend.user_id}/categories`, { await fetch(`/friends/${friend.user_id}/categories`, {
@ -432,7 +632,7 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
const name = fullName(friend.first_name, friend.last_name) const name = fullName(friend.first_name, friend.last_name)
return ( return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<Link <Link
href={`/my-friends/${friend.user_id}`} href={`/my-friends/${friend.user_id}`}
className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@ -476,37 +676,10 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
{expanded && ( {expanded && (
<div className="flex flex-col gap-3 border-t border-border p-4"> <div className="flex flex-col gap-3 border-t border-border p-4">
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <CategoryPicker selected={categories} onToggle={toggleCategory} />
{CATEGORY_OPTIONS.map(({ code, label }) => { <p className="text-sm leading-relaxed text-muted-foreground">
const checked = categories.includes(code) Kun du ser hvilke kategorier du har satt en venn i. Minst én kategori er påkrevd.
return ( </p>
<li key={code}>
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggleCategory(code)}
className={cn(
"flex min-h-[44px] w-full items-center gap-3 rounded-xl border px-3 py-2 text-left text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<span
aria-hidden="true"
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors",
checked ? "border-primary bg-primary text-primary-foreground" : "border-border",
)}
>
{checked && <Check className="size-4" strokeWidth={3} />}
</span>
{label}
</button>
</li>
)
})}
</ul>
<p className="text-sm leading-relaxed text-muted-foreground">Kun du ser hvilke kategorier du har satt en venn i.</p>
</div> </div>
)} )}
</div> </div>

View file

@ -92,7 +92,7 @@ export function InstallPrompt() {
<div <div
role="region" role="region"
aria-label="Installer TeeCup" aria-label="Installer TeeCup"
className="flex items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm" className="flex items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
> >
<div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"> <div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
{platform === "ios" ? ( {platform === "ios" ? (
@ -121,7 +121,7 @@ export function InstallPrompt() {
<button <button
type="button" type="button"
onClick={handleInstall} onClick={handleInstall}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-bold text-primary-foreground transition-colors hover:bg-primary/90" className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg bg-foreground px-4 text-sm font-bold text-background transition-colors hover:opacity-90"
> >
<Download aria-hidden="true" className="size-4" /> <Download aria-hidden="true" className="size-4" />
Installer Installer

View file

@ -451,7 +451,7 @@ function SourceCard({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex min-h-[44px] flex-col items-start gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="flex min-h-[44px] flex-col items-start gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">{icon}</div> <div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">{icon}</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -629,7 +629,7 @@ function OfficialSearchStep({
courses: [], courses: [],
}) })
} }
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-60" className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-60"
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Building2 aria-hidden="true" className="size-6 text-primary" /> <Building2 aria-hidden="true" className="size-6 text-primary" />
@ -673,7 +673,7 @@ function OfficialCoursesStep({
<button <button
type="button" type="button"
onClick={() => onSelectCourse(course)} onClick={() => onSelectCourse(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" /> <Flag aria-hidden="true" className="size-6 text-primary" />
@ -759,7 +759,7 @@ function OwnSearchStep({
<button <button
type="button" type="button"
onClick={() => onSelectCourse(course)} onClick={() => onSelectCourse(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" /> <Flag aria-hidden="true" className="size-6 text-primary" />
@ -1029,7 +1029,7 @@ function TeeEditor({
onRemove: () => void onRemove: () => void
}) { }) {
return ( return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5"> <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-2"> <div className="flex flex-1 flex-col gap-2">
<Label htmlFor={`tee-name-${tee.id}`} className="text-base font-semibold"> <Label htmlFor={`tee-name-${tee.id}`} className="text-base font-semibold">
@ -1212,7 +1212,10 @@ function ConfirmStep({
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry") const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private". // ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private") const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
const [visibleCategories, setVisibleCategories] = useState<string[]>([]) // Alle forhåndsvalgt (2026-07-29, presisert av bruker: "man må heller
// velge bort") -- kun brukt/sendt når visibilityMode faktisk er
// "friends", så det er trygt at dette alltid starter fullt uansett.
const [visibleCategories, setVisibleCategories] = useState<string[]>(VISIBILITY_CATEGORY_OPTIONS.map((c) => c.code))
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1274,7 +1277,7 @@ function ConfirmStep({
)} )}
{/* Course */} {/* Course */}
<div className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5"> <div className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" /> <Flag aria-hidden="true" className="size-6 text-primary" />
</div> </div>
@ -1580,7 +1583,8 @@ function ConfirmStep({
{visibilityMode === "friends" && ( {visibilityMode === "friends" && (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4"> <div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
Hvilke av dine venne-kategorier skal kunne se runden? Hvilke av dine venne-kategorier skal kunne se runden? Alle er forhåndsvalgt velg heller bort det
du ikke vil skal se den.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => { {VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {

View file

@ -180,7 +180,7 @@ function NotificationRow({
notification.created_at, notification.created_at,
)}`} )}`}
className={cn( className={cn(
"group flex items-center gap-3 rounded-2xl border p-4 text-left shadow-sm shadow-black/5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "group flex items-center gap-3 rounded-2xl border p-4 text-left shadow-md shadow-black/8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
unread unread
? "border-brand-orange/40 bg-brand-orange/5 hover:bg-brand-orange/10" ? "border-brand-orange/40 bg-brand-orange/5 hover:bg-brand-orange/10"
: "border-border bg-card hover:bg-accent/50", : "border-border bg-card hover:bg-accent/50",

View file

@ -258,7 +258,7 @@ function InviteForm({
return ( return (
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end sm:p-5" className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end sm:p-5"
> >
<div className="flex flex-1 flex-col gap-1.5"> <div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="invite-email" className="text-sm font-semibold"> <Label htmlFor="invite-email" className="text-sm font-semibold">
@ -324,7 +324,7 @@ function MemberRow({
const canRemove = canManage || isSelf const canRemove = canManage || isSelf
return ( return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground"> <span className="truncate text-sm font-semibold text-foreground">
{member.display_name} {member.display_name}

View file

@ -238,7 +238,7 @@ function LeaderboardCard({ leaderboard }: { leaderboard: Leaderboard }) {
return ( return (
<section <section
aria-label="Stilling" aria-label="Stilling"
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5" className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-4 text-primary" /> <Trophy aria-hidden="true" className="size-4 text-primary" />

View file

@ -243,7 +243,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
<Link <Link
href={`/t/${tournamentId}/live${codeParam}`} href={`/t/${tournamentId}/live${codeParam}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:bg-accent/40" className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:bg-accent/40"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
@ -394,7 +394,7 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
<p className="text-sm text-muted-foreground">Ingenting postet ennå -- bli den første.</p> <p className="text-sm text-muted-foreground">Ingenting postet ennå -- bli den første.</p>
)} )}
{messages.map((m) => ( {messages.map((m) => (
<div key={m.id} className="rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div key={m.id} className="rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="mb-1.5 flex items-baseline justify-between gap-2"> <div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="text-sm font-bold text-foreground">{m.author_display_name}</span> <span className="text-sm font-bold text-foreground">{m.author_display_name}</span>
<span className="shrink-0 text-xs text-muted-foreground"> <span className="shrink-0 text-xs text-muted-foreground">
@ -523,7 +523,7 @@ function StatusArea({
return ( return (
<section className="grid gap-3 sm:grid-cols-2" aria-label="Status"> <section className="grid gap-3 sm:grid-cols-2" aria-label="Status">
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Calendar aria-hidden="true" className="size-5 text-primary" /> <Calendar aria-hidden="true" className="size-5 text-primary" />
</div> </div>
@ -537,7 +537,7 @@ function StatusArea({
</div> </div>
</div> </div>
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15"> <div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Users aria-hidden="true" className="size-5 text-primary" /> <Users aria-hidden="true" className="size-5 text-primary" />
</div> </div>
@ -562,7 +562,7 @@ function Program({ sessions }: { sessions: Session[] }) {
return ( return (
<section aria-label="Program" className="flex flex-col gap-3"> <section aria-label="Program" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Program</h2> <h2 className="text-base font-bold text-foreground">Program</h2>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm shadow-black/5"> <ul className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{sessions.map((s) => { {sessions.map((s) => {
const time = s.time ? TIME_FMT.format(new Date(s.time)) : null const time = s.time ? TIME_FMT.format(new Date(s.time)) : null
return ( return (
@ -732,7 +732,7 @@ function RegistrationCard({
return ( return (
<section <section
aria-label="Påmelding" aria-label="Påmelding"
className="flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6" className="flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Meld deg </h2> <h2 className="text-lg font-extrabold tracking-tight text-foreground">Meld deg </h2>
@ -991,7 +991,7 @@ function RegistrationOutcome({
<section <section
aria-live="polite" aria-live="polite"
className={cn( className={cn(
"flex flex-col items-center gap-4 rounded-3xl border bg-card px-6 py-9 text-center shadow-sm shadow-black/5", "flex flex-col items-center gap-4 rounded-3xl border bg-card px-6 py-9 text-center shadow-md shadow-black/8",
config.ring, config.ring,
)} )}
> >

View file

@ -1,5 +1,6 @@
import type React from "react"
import Link from "next/link" import Link from "next/link"
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Medal, Trophy, Users } from "lucide-react" import { ChevronRight, Gauge, MapPin, Medal, Trophy } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type RoundStatus = "active" | "completed" export type RoundStatus = "active" | "completed"
@ -25,17 +26,9 @@ export type Round = {
isPersonalBest?: boolean isPersonalBest?: boolean
} }
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = { const STATUS_CONFIG: Record<RoundStatus, { label: string }> = {
active: { active: { label: "Pågår" },
label: "Pågår", completed: { label: "Fullført" },
dot: "bg-primary",
badge: "bg-primary/15 text-foreground",
},
completed: {
label: "Fullført",
dot: "bg-brand-orange",
badge: "bg-brand-orange/12 text-foreground",
},
} }
const dateFormatter = new Intl.DateTimeFormat("no-NO", { const dateFormatter = new Intl.DateTimeFormat("no-NO", {
@ -61,74 +54,46 @@ function toParDescription(toPar: number) {
return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par` return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par`
} }
function RoundStatusBadge({ status }: { status: RoundStatus }) { // Status vises nå som fet, farget tekst (samme "form+farge, aldri farge
const config = STATUS_CONFIG[status] // alene"-prinsipp -- teksten SIER "Pågår"/"Fullført", fargen er kun en
return ( // forsterkning) i stedet for en pille-badge, for en tettere,
<span // nøkkel/verdi-orientert korttype.
className={cn( const STATUS_TEXT_CLASS: Record<RoundStatus, string> = {
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold", active: "text-primary",
config.badge, completed: "text-brand-orange",
)}
>
<span className={cn("size-2 rounded-full", config.dot)} aria-hidden="true" />
{config.label}
</span>
)
} }
// Prominent right-side tile: final score for completed/scored rounds, function RoundProgressBar({ played, holes }: { played: number; holes: number }) {
// hole progress for rounds still in play. const pct = holes > 0 ? Math.min(100, Math.round((played / holes) * 100)) : 0
function ScoreTile({ round }: { round: Round }) {
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
if (round.status === "active" || !hasScore) {
const played = round.holesPlayed ?? 0
const pct = round.holes > 0 ? Math.min(100, Math.round((played / round.holes) * 100)) : 0
return ( return (
<div className="flex w-24 shrink-0 flex-col items-center gap-1.5 rounded-2xl bg-secondary px-3 py-3 sm:w-28">
<span className="text-2xl font-extrabold leading-none tabular-nums text-foreground">
{played}
<span className="text-base font-bold text-muted-foreground">/{round.holes}</span>
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
hull spilt
</span>
<div <div
className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-border" className="h-1.5 w-full overflow-hidden rounded-full bg-border"
role="progressbar" role="progressbar"
aria-valuenow={played} aria-valuenow={played}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={round.holes} aria-valuemax={holes}
aria-label={`${played} av ${round.holes} hull spilt`} aria-label={`${played} av ${holes} hull spilt`}
> >
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} /> <div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div> </div>
</div>
) )
} }
const toPar = round.toPar as number // Kompakt nøkkel/verdi-celle -- selve rammen for grid-strukturen under
const overPar = toPar > 0 // tittelen (label liten/dempet over, verdi stor/fet under).
const underPar = toPar < 0 function InfoCell({
label,
value,
valueClassName,
}: {
label: string
value: React.ReactNode
valueClassName?: string
}) {
return ( return (
<div className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-2xl bg-secondary px-3 py-3 sm:w-28"> <div className="flex flex-col gap-0.5">
<span className="text-3xl font-extrabold leading-none tabular-nums text-foreground"> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
{round.totalScore} <span className={cn("text-base font-bold tabular-nums text-foreground", valueClassName)}>{value}</span>
</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-base font-bold tabular-nums",
underPar && "bg-primary/20 text-foreground",
overPar && "bg-brand-orange/20 text-foreground",
!underPar && !overPar && "bg-muted text-foreground",
)}
>
{formatToPar(toPar)}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
til par
</span>
</div> </div>
) )
} }
@ -154,6 +119,11 @@ export function RoundCard({ round }: { round: Round }) {
const displayTitle = round.name?.trim() || round.courseName const displayTitle = round.name?.trim() || round.courseName
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
const played = round.holesPlayed ?? 0
const showResultRow = round.status === "completed" && hasScore
const toPar = round.toPar as number | undefined
const toParClassName = toPar === undefined ? "" : toPar > 0 ? "text-brand-orange" : toPar < 0 ? "text-primary" : ""
return ( return (
// Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve // Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve
// lenken til rundesiden; en leaderboard-lenke kan derfor ikke ligge // lenken til rundesiden; en leaderboard-lenke kan derfor ikke ligge
@ -165,15 +135,33 @@ export function RoundCard({ round }: { round: Round }) {
<Link <Link
href={`/my-rounds/${round.id}`} href={`/my-rounds/${round.id}`}
aria-label={ariaLabel} aria-label={ariaLabel}
className="group flex min-h-[88px] w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:p-5" className="group flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-all duration-200 ease-in-out hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:p-5"
> >
<div className="flex min-w-0 flex-1 flex-col gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex min-w-0 flex-col">
<h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground"> <h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" /> <MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{displayTitle}</span> <span className="truncate">{displayTitle}</span>
</h3> </h3>
<RoundStatusBadge status={round.status} /> {round.name?.trim() && (
<span className="truncate pl-7 text-sm text-muted-foreground">{round.courseName}</span>
)}
</div>
<ChevronRight
aria-hidden="true"
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
{STATUS_CONFIG[round.status].label}
</span>
{round.status === "active" && (
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
{played}/{round.holes} hull spilt
</span>
)}
{showPersonalBest && ( {showPersonalBest && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-gold/20 px-2.5 py-1 text-xs font-bold text-foreground"> <span className="inline-flex items-center gap-1.5 rounded-full bg-gold/20 px-2.5 py-1 text-xs font-bold text-foreground">
<Medal aria-hidden="true" className="size-3.5 text-gold" /> <Medal aria-hidden="true" className="size-3.5 text-gold" />
@ -181,40 +169,27 @@ export function RoundCard({ round }: { round: Round }) {
</span> </span>
)} )}
</div> </div>
{round.status === "active" && <RoundProgressBar played={played} holes={round.holes} />}
<div className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-border pt-3">
<InfoCell label="Tee" value={round.teeName} />
<InfoCell label="Hull" value={round.holes} />
<InfoCell label="Dato" value={formatDate(round.date)} />
<InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} />
{showResultRow && (
<>
<InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" />
<InfoCell label="Til par" value={formatToPar(toPar as number)} valueClassName={cn("text-lg", toParClassName)} />
</>
)}
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
{round.name?.trim() && <span className="truncate">{round.courseName}</span>}
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>
{round.teeName} · {round.holes} hull
</span>
</span>
<span className="flex items-center gap-1.5">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">{formatDate(round.date)}</span>
</span>
<span className="flex items-center gap-1.5">
<Users aria-hidden="true" className="size-4 shrink-0" />
<span>
{round.playerCount} {playerLabel}
</span>
</span>
{differentialText && ( {differentialText && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-info/15 px-2.5 py-0.5 text-sm font-semibold text-foreground"> <span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-info/15 px-2.5 py-0.5 text-sm font-semibold text-foreground">
<Gauge aria-hidden="true" className="size-4 shrink-0 text-info" /> <Gauge aria-hidden="true" className="size-4 shrink-0 text-info" />
<span className="tabular-nums">Hcp spilt til {differentialText}</span> <span className="tabular-nums">Hcp spilt til {differentialText}</span>
</span> </span>
)} )}
</div>
</div>
<ScoreTile round={round} />
<ChevronRight
aria-hidden="true"
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/>
</Link> </Link>
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn {/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn

View file

@ -1245,6 +1245,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{pageTab === "score" && ( {pageTab === "score" && (
<> <>
{/* Matchstatus/skins-tavle øverst på Score-fanen også (2026-07-29,
etterspurt av bruker -- fantes fra før KUN under "Spillere og
runde", lett å overse siden scoring naturlig skjer her).
Samme komponent, ikke duplisert logikk. */}
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} />
{/* Completed summary banner */} {/* Completed summary banner */}
{completed && ( {completed && (
<CompletedBanner <CompletedBanner

View file

@ -290,6 +290,41 @@ function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondar
) )
} }
// Par/Hcp-referanserad (2026-07-29, etterspurt av bruker -- manglet helt)
// -- vist ÉN gang for hele leaderboardet (par/stroke-index er banedata,
// identisk for alle deltakere), i stedet for gjentatt i hver enkelt
// deltakers utvidbare HoleStrip. Samme kolonnebredde (w-8) som HoleMark,
// slik at radene visuelt stemmer overens med hver deltakers strip under.
function HoleReferenceStrip({
holes,
startHole,
holesPlanned,
}: {
holes: ApiLeaderboardHole[]
startHole: number
holesPlanned: number
}) {
const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1)
const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined)
if (ordered.length === 0) return null
return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-3">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Hull · Par · Hcp</span>
<div className="-mx-1 overflow-x-auto px-1">
<div className="flex gap-1.5">
{ordered.map((h) => (
<span key={h.hole_number} className="flex w-8 shrink-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold text-muted-foreground">{h.hole_number}</span>
<span className="text-xs font-bold tabular-nums text-foreground">{h.par}</span>
<span className="text-[10px] font-medium tabular-nums text-muted-foreground">{h.stroke_index}</span>
</span>
))}
</div>
</div>
</div>
)
}
function HoleStrip({ function HoleStrip({
holes, holes,
startHole, startHole,
@ -388,7 +423,7 @@ function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; s
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
return ( return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"> <div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span> <span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>} {result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
@ -547,7 +582,7 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot} {round.name?.trim() || round.course_name_snapshot}
</span> </span>
@ -572,6 +607,14 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
<ModeToggle mode={mode} onChange={setMode} /> <ModeToggle mode={mode} onChange={setMode} />
</div> </div>
{leaderboard.entries[0]?.holes && (
<HoleReferenceStrip
holes={leaderboard.entries[0].holes}
startHole={round.start_hole}
holesPlanned={leaderboard.holes_planned}
/>
)}
{/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) -- {/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) --
IKKE separate kort med mellomrom lenger. Kun ytterkanten er IKKE separate kort med mellomrom lenger. Kun ytterkanten er
avrundet/rammet inn; radene skilles med en tynn linje avrundet/rammet inn; radene skilles med en tynn linje

View file

@ -42,6 +42,7 @@ type ApiParticipant = {
display_name: string display_name: string
is_owner: boolean is_owner: boolean
round_side_id: string | null round_side_id: string | null
playing_handicap: number | null
} }
type ApiSide = { type ApiSide = {
@ -399,67 +400,129 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
return result return result
} }
function ResultChip({ result }: { result: string | null }) { // --- Matchforløp: horisontalt scorekort med løpende stilling ---------------
if (!result) return <span className="text-sm text-muted-foreground"></span> // Egen visuell utforming (ikke en kopi av noe referansebilde) -- gjenbruker
if (result === "halved") { // appens etablerte vinn/tap-språk (grønn=side A, oransje=side B, nøytral=delt,
return ( // se ScoreMark/ResultChip andre steder) fremfor referansens rød/blå, og
<span // erstatter den tidligere vertikale hull-for-hull-listen med et horisontalt
aria-label="Hullet ble delt" // rutenett (Hull/Par/enhet-rader/Stilling) + en identitets-/resultatbanner --
className="flex size-7 items-center justify-center rounded-full border border-border bg-muted text-xs font-extrabold text-muted-foreground" // samme informasjon (hvem vant hvilket hull, AS/X UP/X&Y), annen presentasjon.
>
½ type RunningState = {
</span> lead: number
) label: string
} tone: "a" | "b" | "neutral"
return ( isClosed: boolean
<span
aria-label={result === "a" ? "Side A vant hullet" : "Side B vant hullet"}
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
result === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
)}
>
{result.toUpperCase()}
</span>
)
} }
// Én kolonne-celle for en side -- viser hver enhets brutto → netto, med den // Speiler handicap_engine.py sin compute_match_state()/describe() presist,
// enheten som IKKE talte for siden (fourball: partneren med høyest netto) // men regner ett PREFIKS om gangen slik at hvert hull får sin egen løpende
// tonet ned i stedet for fjernet, slik at "hvorfor" forblir synlig. // stilling (backend cacher i dag kun SLUTT-tilstanden -- se ADR-039-notatet).
function SideEntryCell({ entries }: { entries: ApiFormatHoleEntry[] }) { function computeRunning(results: ("a" | "b" | "halved")[], totalHoles: number): RunningState[] {
if (entries.length === 0) return <span className="text-sm text-muted-foreground"></span> const out: RunningState[] = []
let lead = 0
for (let i = 0; i < results.length; i++) {
lead += results[i] === "a" ? 1 : results[i] === "b" ? -1 : 0
const holesPlayed = i + 1
const holesRemaining = totalHoles - holesPlayed
const isClosed = Math.abs(lead) > holesRemaining
const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
const margin = Math.abs(lead)
let label: string
if (isClosed) {
label = holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
} else if (lead === 0) {
label = "AS"
} else {
label = isDormie ? `Dormie ${margin}` : `${margin} UP`
}
out.push({ lead, label, tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", isClosed })
}
return out
}
type HeaderIdentity = { name: string; hcp: number | null }
function IdentitySide({ identity, side }: { identity: HeaderIdentity; side: "a" | "b" }) {
return ( return (
<div className="flex flex-col gap-1"> <div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", side === "a" ? "items-start" : "items-end")}>
{entries.map((e) => ( <div className={cn("flex min-w-0 items-center gap-1.5", side === "b" && "flex-row-reverse")}>
<div <span
key={e.id} aria-hidden="true"
className={cn( className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
"flex items-baseline justify-between gap-2", />
entries.length > 1 && !e.counted && "opacity-40", <span className="min-w-0 truncate text-sm font-extrabold text-foreground">{identity.name}</span>
)}
>
{entries.length > 1 && (
<span className="min-w-0 truncate text-[11px] font-semibold text-muted-foreground">{e.label}</span>
)}
<span className={cn("flex items-baseline gap-1 tabular-nums", entries.length === 1 && "ml-0")}>
<span className="text-sm font-extrabold text-foreground">{e.gross ?? ""}</span>
{e.net !== null && e.net !== e.gross && (
<span className="text-[11px] font-semibold text-muted-foreground"> {e.net}</span>
)}
</span>
</div> </div>
))} {identity.hcp !== null && (
<span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {identity.hcp}</span>
)}
</div> </div>
) )
} }
function MatchProgressTable({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) { function CenterBadge({ state, holesPlayed }: { state: RunningState | null; holesPlayed: number }) {
if (!state) {
return (
<div className="flex shrink-0 flex-col items-center px-2">
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
</div>
)
}
const toneClass = state.tone === "a" ? "text-primary" : state.tone === "b" ? "text-brand-orange" : "text-foreground"
return (
<div className="flex shrink-0 flex-col items-center px-2">
<span className={cn("text-2xl font-extrabold leading-none tabular-nums", toneClass)}>{state.label}</span>
{state.isClosed ? (
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
Ferdig
</span>
) : (
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
)}
</div>
)
}
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
function MatchScorecardGrid({
round,
roundId,
viewerId,
refreshKey,
}: {
round: ApiRound
roundId: string
viewerId: string | null
refreshKey: number
}) {
const result = useFormatResult(roundId, refreshKey) const result = useFormatResult(roundId, refreshKey)
if (!result) return null if (!result) return null
const sideALabel = sides[0]?.label?.trim() || "Side A" const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const sideBLabel = sides[1]?.label?.trim() || "Side B"
const rows: MatchRow[] = isSharedBall
? round.sides.map((s, idx) => ({
id: s.id,
side: idx === 0 ? "a" : ("b" as const),
label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B"),
hcp: round.participants.find((p) => p.round_side_id === s.id)?.playing_handicap ?? null,
}))
: round.participants
.filter((p) => p.round_side_id)
.map((p) => ({
id: p.id,
side: (p.round_side_id === round.sides[0]?.id ? "a" : "b") as "a" | "b",
label: participantLabel(p, viewerId),
hcp: p.playing_handicap,
}))
function headerIdentity(side: "a" | "b"): HeaderIdentity {
const sideRows = rows.filter((r) => r.side === side)
if (sideRows.length === 1) return { name: sideRows[0].label, hcp: sideRows[0].hcp }
const idx = side === "a" ? 0 : 1
return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null }
}
if (!result.ready || !result.holes) { if (!result.ready || !result.holes) {
return ( return (
@ -469,75 +532,138 @@ function MatchProgressTable({ roundId, sides, refreshKey }: { roundId: string; s
) )
} }
if (result.holes.length === 0) { const decidedHoles = result.holes
return ( const running = computeRunning(decidedHoles.map((h) => h.result as "a" | "b" | "halved"), round.holes_planned)
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5"> const lastState = running.length > 0 ? running[running.length - 1] : null
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har registrert.
</div>
)
}
return ( return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4"> <div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-muted/40 px-3 py-3">
<h2 className="text-base font-bold text-foreground">Matchforløp</h2> <IdentitySide identity={headerIdentity("a")} side="a" />
{result.match_status_text && ( <CenterBadge state={lastState} holesPlayed={decidedHoles.length} />
<span className="text-sm font-extrabold tabular-nums text-foreground">{result.match_status_text}</span> <IdentitySide identity={headerIdentity("b")} side="b" />
)}
</div> </div>
{decidedHoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har
registrert.
</p>
) : (
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4"> <div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
<table className="w-full min-w-[440px] table-fixed border-collapse text-sm"> <table
className="w-full border-collapse text-sm"
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
>
<colgroup> <colgroup>
<col className="w-10" /> <col className="w-16" />
<col className="w-9" /> {decidedHoles.map((h) => (
<col /> <col key={h.hole_number} className="w-10" />
<col /> ))}
<col className="w-10" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-left text-xs font-extrabold text-muted-foreground"> <th
scope="col"
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
>
Hull Hull
</th> </th>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground"> {decidedHoles.map((h) => (
Par <th
</th> key={h.hole_number}
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground"> scope="col"
{sideALabel} className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
</th> >
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground"> {h.hole_number}
{sideBLabel}
</th>
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
Res.
</th> </th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{result.holes.map((h) => ( <tr>
<tr key={h.hole_number}> <th
<td className="border border-border px-1 py-1.5 text-xs font-semibold text-muted-foreground">{h.hole_number}</td> scope="row"
<td className="border border-border px-1 py-1.5 text-center text-xs text-muted-foreground">{h.par}</td> className="border border-border px-2 py-1.5 text-left text-xs font-bold text-muted-foreground"
<td className="border border-border px-2 py-1.5"> >
<SideEntryCell entries={h.entries.filter((e) => e.side === "a")} /> Par
</th>
{decidedHoles.map((h) => (
<td
key={h.hole_number}
className="border border-border py-1.5 text-center text-xs font-semibold text-muted-foreground"
>
{h.par}
</td> </td>
<td className="border border-border px-2 py-1.5"> ))}
<SideEntryCell entries={h.entries.filter((e) => e.side === "b")} /> </tr>
</td> {rows.map((row) => (
<td className="border border-border px-1 py-1.5"> <tr key={row.id}>
<span className="flex items-center justify-center"> <th scope="row" className="border border-border px-2 py-1.5 text-left">
<ResultChip result={h.result} /> <span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className={cn(
"size-2 shrink-0 rounded-full",
row.side === "a" ? "bg-primary" : "bg-brand-orange",
)}
/>
<span className="min-w-0 truncate text-xs font-bold text-foreground">{row.label}</span>
</span>
</th>
{decidedHoles.map((h) => {
const entry = h.entries.find((e) => e.id === row.id)
const won = !!entry && h.result === row.side && entry.counted !== false
const shown = entry ? (entry.net ?? entry.gross) : null
return (
<td key={h.hole_number} className="border border-border p-0.5 text-center">
<span
aria-label={
entry
? `${row.label}: ${shown} netto${entry.gross !== null && entry.gross !== shown ? `, ${entry.gross} brutto` : ""}${won ? " -- vant hullet" : ""}`
: undefined
}
className={cn(
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
won
? row.side === "a"
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: entry && entry.counted === false
? "text-muted-foreground/50"
: "text-foreground",
)}
>
{shown ?? ""}
</span> </span>
</td> </td>
)
})}
</tr> </tr>
))} ))}
<tr className="bg-muted/30">
<th scope="row" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
Stilling
</th>
{running.map((s, i) => (
<td
key={decidedHoles[i].hole_number}
className={cn(
"border border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-muted-foreground",
)}
>
{s.label}
</td>
))}
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
)}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Netto = brutto minus slag mottatt (kombinert match-hcp den dagen) -- vist med "→" der det avviker fra brutto. Fylt sirkel = vunnet hull (grønn = {headerIdentity("a").name}, oransje = {headerIdentity("b").name}). Nedtonet
{result.holes.some((h) => h.entries.length > 2) && tall talte ikke for siden det hullet (laveste netto teller, gjelder fourball).
" Nedtonet spiller sitt tall talte ikke for siden det hullet (laveste netto teller)."}
</p> </p>
</div> </div>
) )
@ -550,7 +676,7 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null)) const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
return ( return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4"> <div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
<h2 className="text-base font-bold text-foreground">Skins, hull for hull</h2> <h2 className="text-base font-bold text-foreground">Skins, hull for hull</h2>
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border"> <div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{result.holes.map((h) => ( {result.holes.map((h) => (
@ -802,7 +928,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */} {/* Round summary strip */}
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
@ -863,7 +989,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
<Legend /> <Legend />
{/* Scorecard blocks */} {/* Scorecard blocks */}
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4"> <div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
{front.length > 0 && ( {front.length > 0 && (
<ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} /> <ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} />
)} )}
@ -885,7 +1011,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble {/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */} (brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
{isTwoSided && round.sides.length === 2 && ( {isTwoSided && round.sides.length === 2 && (
<MatchProgressTable roundId={roundId} sides={round.sides} refreshKey={refreshKey} /> <MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
)} )}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />} {isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
</main> </main>

View file

@ -71,7 +71,7 @@ function StatCard({
const [open, setOpen] = useState(defaultOpen) const [open, setOpen] = useState(defaultOpen)
const bodyId = `sect-${title.replace(/\s+/g, "-").toLowerCase()}` const bodyId = `sect-${title.replace(/\s+/g, "-").toLowerCase()}`
return ( return (
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5"> <section className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
<h2> <h2>
<button <button
type="button" type="button"
@ -638,7 +638,7 @@ function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHole
} }
return ( return (
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Utslag brukt</h2> <h2 className="text-base font-bold text-foreground">Utslag brukt</h2>
{rows.map(({ side, playedCount, registeredCount, entries }, idx) => ( {rows.map(({ side, playedCount, registeredCount, entries }, idx) => (
<div key={side.id} className="flex flex-col gap-2"> <div key={side.id} className="flex flex-col gap-2">
@ -847,7 +847,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
{/* Round summary strip */} {/* Round summary strip */}
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot} {round.name?.trim() || round.course_name_snapshot}

View file

@ -134,7 +134,7 @@ function StatTile({
delta?: React.ReactNode delta?: React.ReactNode
}) { }) {
return ( return (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-1 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span> <span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
{delta} {delta}
@ -145,7 +145,7 @@ function StatTile({
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
return ( return (
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"> <section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div> <div>
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2> <h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} {subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}

View file

@ -499,7 +499,7 @@ function TeamColumn({
return ( return (
<section <section
className="flex flex-col gap-3 rounded-3xl border border-border bg-card/40 p-4 shadow-sm shadow-black/5 sm:p-5" className="flex flex-col gap-3 rounded-3xl border border-border bg-card/40 p-4 shadow-md shadow-black/8 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: color }} style={{ borderLeftWidth: 6, borderLeftColor: color }}
aria-label={`Oppstilling for ${team.name}`} aria-label={`Oppstilling for ${team.name}`}
> >
@ -727,7 +727,7 @@ function RevealedView({
<Link <Link
key={match.id} key={match.id}
href={scorecardHrefFor(match.id)} href={scorecardHrefFor(match.id)}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5" className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined} style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

View file

@ -247,7 +247,7 @@ export function SessionIndividualLeaderboard({
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span> <span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
</div> </div>

View file

@ -52,6 +52,7 @@ type ApiParticipant = {
player_name: string player_name: string
tee_id: string tee_id: string
tee_name: string tee_name: string
playing_handicap: number | null
} }
type ApiMatch = { type ApiMatch = {
@ -86,7 +87,13 @@ type ApiScorecard = {
// Enhet som scores: én per SPILLER for individuelle formater (singles/ // Enhet som scores: én per SPILLER for individuelle formater (singles/
// fourball), én per SIDE for delt-ball-formater (foursome/greensome/ // fourball), én per SIDE for delt-ball-formater (foursome/greensome/
// scramble) -- se app/routers/scoring.py sin is_individual-sjekk. // scramble) -- se app/routers/scoring.py sin is_individual-sjekk.
type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null } type Unit = {
id: string
side: "a" | "b"
label: string
matchParticipantId: string | null
playingHandicap: number | null
}
// Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per // Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per
// hull+side+enhet, samme identitet som backend bruker for upsert. // hull+side+enhet, samme identitet som backend bruker for upsert.
@ -287,11 +294,27 @@ export function SessionScorecard({
side: p.team_side, side: p.team_side,
label: p.player_name, label: p.player_name,
matchParticipantId: p.id, matchParticipantId: p.id,
playingHandicap: p.playing_handicap,
})) }))
} }
// Delt-ball (foursome/greensome/scramble): enheten er selve SIDEN, men
// playing_handicap er den kombinerte side-verdien lagret på HVER av
// sidens deltakere (ADR-039) -- plukk den fra en av dem.
return [ return [
{ id: "a", side: "a", label: teams[0].name, matchParticipantId: null }, {
{ id: "b", side: "b", label: teams[1].name, matchParticipantId: null }, id: "a",
side: "a" as const,
label: teams[0].name,
matchParticipantId: null,
playingHandicap: match.participants.find((p) => p.team_side === "a")?.playing_handicap ?? null,
},
{
id: "b",
side: "b" as const,
label: teams[1].name,
matchParticipantId: null,
playingHandicap: match.participants.find((p) => p.team_side === "b")?.playing_handicap ?? null,
},
] ]
}, [session, match, teams]) }, [session, match, teams])
@ -563,6 +586,7 @@ export function SessionScorecard({
holes={holes} holes={holes}
units={units} units={units}
scoringMode={session.scoring_mode} scoringMode={session.scoring_mode}
holeConfig={session.hole_config}
/> />
) : ( ) : (
<> <>
@ -614,7 +638,14 @@ export function SessionScorecard({
</button> </button>
{showSummary && ( {showSummary && (
<div className="border-t border-border"> <div className="border-t border-border">
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={session.scoring_mode} /> <TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={session.scoring_mode}
holeConfig={session.hole_config}
/>
</div> </div>
)} )}
</div> </div>
@ -700,7 +731,7 @@ export function SessionScorecard({
return ( return (
<div <div
key={unit.id} key={unit.id}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5" className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: teamColor }} style={{ borderLeftWidth: 6, borderLeftColor: teamColor }}
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@ -890,74 +921,248 @@ function StrokePicker({
) )
} }
function HoleSummaryTable({ // --- Match-scorekort: horisontalt rutenett med løpende stilling ------------
// Egen visuell utforming (ikke en kopi av noe referansebilde) -- samme
// informasjon som den tidligere vertikale hull-for-hull-listen (hvem vant
// hvilket hull, brutto der relevant), pluss en løpende "Stilling"-rad
// (AS/X UP/X&Y) regnet frem hull for hull i stedet for kun sluttresultatet.
// Bruker lagenes egne farger (samme konvensjon som resten av appens
// turnering-UI, ikke appens generiske primær/oransje-golfscore-språk --
// laget ER identiteten her, ikke over/under par).
type RunningState = { lead: number; label: string; tone: "a" | "b" | "neutral"; isClosed: boolean }
// Speiler handicap_engine.py sin compute_match_state()/describe() presist,
// regnet ett prefiks om gangen -- backend cacher i dag kun SLUTT-tilstanden.
function computeRunning(results: ("a" | "b" | "halved")[], totalHoles: number): RunningState[] {
const out: RunningState[] = []
let lead = 0
for (let i = 0; i < results.length; i++) {
lead += results[i] === "a" ? 1 : results[i] === "b" ? -1 : 0
const holesPlayed = i + 1
const holesRemaining = totalHoles - holesPlayed
const isClosed = Math.abs(lead) > holesRemaining
const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
const margin = Math.abs(lead)
let label: string
if (isClosed) {
label = holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
} else if (lead === 0) {
label = "AS"
} else {
label = isDormie ? `Dormie ${margin}` : `${margin} UP`
}
out.push({ lead, label, tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", isClosed })
}
return out
}
function unitGross(scorecard: ApiScorecard, unit: Unit, holeNumber: number): number | null {
const entry = (scorecard.stroke_entries ?? []).find(
(e) =>
e.hole_number === holeNumber &&
e.team_side === unit.side &&
e.match_participant_id === unit.matchParticipantId,
)
return entry ? entry.gross_strokes : null
}
function IdentityBlock({
name,
hcp,
color,
side,
}: {
name: string
hcp: number | null
color: string
side: "a" | "b"
}) {
return (
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", side === "a" ? "items-start" : "items-end")}>
<div className={cn("flex min-w-0 items-center gap-1.5", side === "b" && "flex-row-reverse")}>
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="min-w-0 truncate text-sm font-extrabold text-foreground">{name}</span>
</div>
{hcp !== null && <span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {hcp}</span>}
</div>
)
}
function TournamentMatchGrid({
teams, teams,
scorecard, units,
holes, holes,
scorecard,
scoringMode, scoringMode,
holeConfig,
}: { }: {
teams: [ApiTeam, ApiTeam] teams: [ApiTeam, ApiTeam]
scorecard: ApiScorecard units: Unit[]
holes: ApiHole[] holes: ApiHole[]
scorecard: ApiScorecard
scoringMode: ScoringMode scoringMode: ScoringMode
holeConfig: HoleConfig
}) { }) {
if (scorecard.holes.length === 0) { if (scorecard.holes.length === 0) {
return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p> return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
} }
const decidedHoles = [...scorecard.holes].sort((a, b) => a.hole_number - b.hole_number)
const totalHoles = playedHoleNumbers(holeConfig).length
const running = computeRunning(
decidedHoles.map((h) => h.result),
totalHoles,
)
const lastState = running.length > 0 ? running[running.length - 1] : null
function headerIdentity(side: "a" | "b"): { name: string; hcp: number | null; color: string } {
const team = side === "a" ? teams[0] : teams[1]
const color = team.color ?? "#64748b"
const sideUnits = units.filter((u) => u.side === side)
if (sideUnits.length === 1) return { name: sideUnits[0].label, hcp: sideUnits[0].playingHandicap, color }
return { name: team.name, hcp: null, color }
}
const identityA = headerIdentity("a")
const identityB = headerIdentity("b")
return ( return (
<ul className="divide-y divide-border"> <div className="flex flex-col gap-3 p-3 sm:p-4">
{scorecard.holes.map((h) => { <div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-muted/40 px-3 py-3">
const par = holes.find((x) => x.hole_number === h.hole_number)?.par <IdentityBlock name={identityA.name} hcp={identityA.hcp} color={identityA.color} side="a" />
return ( <div className="flex shrink-0 flex-col items-center px-2">
<li key={h.hole_number} className="flex items-center justify-between gap-3 px-4 py-2.5"> {lastState ? (
<div className="flex items-center gap-2.5"> <>
<span className="flex size-8 items-center justify-center rounded-xl bg-secondary text-sm font-extrabold tabular-nums text-secondary-foreground"> <span
{h.hole_number} className="text-2xl font-extrabold leading-none tabular-nums"
style={{
color:
lastState.tone === "a" ? identityA.color : lastState.tone === "b" ? identityB.color : undefined,
}}
>
{lastState.label}
</span>
{lastState.isClosed ? (
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
Ferdig
</span>
) : (
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{decidedHoles.length} hull spilt
</span> </span>
{par !== undefined && (
<span className="text-xs font-medium text-muted-foreground">Par {par}</span>
)} )}
{scoringMode === "stroke" && ( </>
<span className="text-xs tabular-nums text-muted-foreground"> ) : (
{grossPairLabel(scorecard, h.hole_number)} <span className="text-xs font-bold text-muted-foreground">Ingen hull spilt ennå</span>
</span>
)} )}
</div> </div>
<OutcomeBadge teams={teams} result={h.result} /> <IdentityBlock name={identityB.name} hcp={identityB.hcp} color={identityB.color} side="b" />
</li> </div>
<div className="-mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0">
<table
className="w-full border-collapse text-sm"
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
>
<colgroup>
<col className="w-16" />
{decidedHoles.map((h) => (
<col key={h.hole_number} className="w-10" />
))}
</colgroup>
<thead>
<tr>
<th
scope="col"
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
>
Hull
</th>
{decidedHoles.map((h) => (
<th
key={h.hole_number}
scope="col"
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
>
{h.hole_number}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<th
scope="row"
className="border border-border px-2 py-1.5 text-left text-xs font-bold text-muted-foreground"
>
Par
</th>
{decidedHoles.map((h) => (
<td
key={h.hole_number}
className="border border-border py-1.5 text-center text-xs font-semibold text-muted-foreground"
>
{holes.find((x) => x.hole_number === h.hole_number)?.par ?? ""}
</td>
))}
</tr>
{units.map((unit) => {
const color = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
return (
<tr key={unit.id}>
<th scope="row" className="border border-border px-2 py-1.5 text-left">
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 truncate text-xs font-bold text-foreground">{unit.label}</span>
</span>
</th>
{decidedHoles.map((h) => {
const won = h.result === unit.side
const gross = scoringMode === "stroke" ? unitGross(scorecard, unit, h.hole_number) : null
return (
<td key={h.hole_number} className="border border-border p-0.5 text-center">
<span
aria-label={won ? `${unit.label} vant hullet` : undefined}
className={cn(
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
won ? "text-primary-foreground" : "text-foreground",
)}
style={won ? { backgroundColor: color } : undefined}
>
{scoringMode === "stroke" ? (gross ?? "") : won ? "✓" : ""}
</span>
</td>
) )
})} })}
</ul> </tr>
) )
} })}
<tr className="bg-muted/30">
function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string { <th
const entries = scorecard.stroke_entries ?? [] scope="row"
const sideGross = (side: "a" | "b"): string => { className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground"
const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes)
if (vals.length === 0) return ""
return String(Math.min(...vals))
}
return `${sideGross("a")}${sideGross("b")}`
}
function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) {
if (result === "halved") {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
Delt
</span>
)
}
const team = result === "a" ? teams[0] : teams[1]
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold text-primary-foreground"
style={{ backgroundColor: team.color ?? "#64748b" }}
> >
{team.name} Stilling
</span> </th>
{running.map((s, i) => (
<td
key={decidedHoles[i].hole_number}
className="border border-border py-1.5 text-center text-xs font-extrabold tabular-nums"
style={{ color: s.tone === "a" ? identityA.color : s.tone === "b" ? identityB.color : undefined }}
>
{s.label}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
) )
} }
@ -978,12 +1183,14 @@ function DecidedView({
holes, holes,
units, units,
scoringMode, scoringMode,
holeConfig,
}: { }: {
teams: [ApiTeam, ApiTeam] teams: [ApiTeam, ApiTeam]
scorecard: ApiScorecard scorecard: ApiScorecard
holes: ApiHole[] holes: ApiHole[]
units: Unit[] units: Unit[]
scoringMode: ScoringMode scoringMode: ScoringMode
holeConfig: HoleConfig
}) { }) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@ -1021,8 +1228,15 @@ function DecidedView({
<div> <div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
<div className="overflow-hidden rounded-2xl border border-border bg-card"> <div className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={scoringMode} /> <TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={scoringMode}
holeConfig={holeConfig}
/>
</div> </div>
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty"> <p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
Resultatet kan ikke endres etter at matchen er avgjort. Resultatet kan ikke endres etter at matchen er avgjort.

View file

@ -194,7 +194,7 @@ export function TeamChat({
<div key={m.id} className={cn("flex flex-col gap-1", own ? "items-end" : "items-start")}> <div key={m.id} className={cn("flex flex-col gap-1", own ? "items-end" : "items-start")}>
<div <div
className={cn( className={cn(
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm shadow-black/5", "group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-md shadow-black/8",
own ? "bg-primary text-primary-foreground" : "border border-border bg-card text-foreground", own ? "bg-primary text-primary-foreground" : "border border-border bg-card text-foreground",
)} )}
> >

View file

@ -53,7 +53,7 @@ export function TournamentCard({
return ( return (
<Link <Link
href={href} href={href}
className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5" className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-all duration-200 ease-in-out hover:border-primary/50 hover:bg-accent/50 sm:p-5"
> >
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2">

View file

@ -622,7 +622,7 @@ function TeamPanel({
return ( return (
<section <section
className="flex flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5" className="flex flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8"
style={{ borderLeftWidth: 6, borderLeftColor: team.color }} style={{ borderLeftWidth: 6, borderLeftColor: team.color }}
aria-label={`Lag ${team.name}`} aria-label={`Lag ${team.name}`}
> >

View file

@ -227,7 +227,7 @@ function SegmentedBar({
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span> <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<div className="flex h-11 w-full overflow-hidden rounded-2xl shadow-sm shadow-black/5"> <div className="flex h-11 w-full overflow-hidden rounded-2xl shadow-md shadow-black/8">
<div <div
className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300" className="flex items-center justify-center text-sm font-extrabold text-white transition-[width] duration-300"
style={{ width: `${widthA}%`, backgroundColor: colorA }} style={{ width: `${widthA}%`, backgroundColor: colorA }}
@ -261,7 +261,7 @@ function Scoreboard({
leaderId: string | null leaderId: string | null
}) { }) {
return ( return (
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5"> <div className="grid grid-cols-[1fr_auto_1fr] items-stretch overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
<TeamScore team={teamA} isLeader={leaderId === teamA.team_id} align="left" /> <TeamScore team={teamA} isLeader={leaderId === teamA.team_id} align="left" />
<div className="flex items-center justify-center bg-border/40 px-2"> <div className="flex items-center justify-center bg-border/40 px-2">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">vs</span> <span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">vs</span>
@ -337,7 +337,7 @@ function SessionRow({
const colorB = teamB.color ?? "#64748b" const colorB = teamB.color ?? "#64748b"
return ( return (
<div className="rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"> <div className="rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h4 className="text-base font-bold text-foreground">{sessionTitle(session)}</h4> <h4 className="text-base font-bold text-foreground">{sessionTitle(session)}</h4>
{complete ? ( {complete ? (

View file

@ -423,7 +423,7 @@ function SessionCard({
}} }}
/> />
) : ( ) : (
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5"> <div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<Link href={href} className="flex min-w-0 flex-1 flex-col gap-1"> <Link href={href} className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3> <h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>

View file

@ -161,7 +161,7 @@ function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSid
} }
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
return ( return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"> <div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span> <span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>} {result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
@ -280,7 +280,7 @@ export function WatchRound({ roundId }: { roundId: string }) {
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16"> <main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot} {round.name?.trim() || round.course_name_snapshot}