Etter første inngripen av Gemini
This commit is contained in:
parent
19a21073ba
commit
c56502ff01
34 changed files with 1511 additions and 509 deletions
153
CLAUDE.md
153
CLAUDE.md
|
|
@ -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→kategorivelger(alle 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
BIN
Screenshot_20260729-104329.png
Normal file
BIN
Screenshot_20260729-104329.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
Screenshot_20260729-123421.png
Normal file
BIN
Screenshot_20260729-123421.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
117
alternativ designinstruks.md
Normal file
117
alternativ designinstruks.md
Normal 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).
|
||||||
|
|
@ -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 translate_db_errors():
|
async with conn.transaction():
|
||||||
row = await conn.fetchrow(
|
async with translate_db_errors():
|
||||||
"INSERT INTO friendship (requester_user_id, addressee_user_id) VALUES ($1, $2) RETURNING id::text AS id",
|
row = await conn.fetchrow(
|
||||||
user.user_id,
|
"INSERT INTO friendship (requester_user_id, addressee_user_id) VALUES ($1, $2) RETURNING id::text AS id",
|
||||||
body.addressee_user_id,
|
user.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.")
|
||||||
await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id)
|
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(
|
||||||
|
"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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 på turnering
|
Navn på 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,39 +305,70 @@ 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"
|
||||||
>
|
>
|
||||||
<Avatar name={fullName(person.first_name, person.last_name)} />
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
||||||
<span className="truncate text-lg font-bold text-foreground">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{fullName(person.first_name, person.last_name)}
|
<span className="truncate text-lg font-bold text-foreground">
|
||||||
</span>
|
{fullName(person.first_name, person.last_name)}
|
||||||
<span className="truncate text-base text-muted-foreground">
|
</span>
|
||||||
{person.home_club ?? "Ingen hjemmeklubb"}
|
<span className="truncate text-base text-muted-foreground">
|
||||||
</span>
|
{person.home_club ?? "Ingen hjemmeklubb"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isFriend ? (
|
||||||
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-muted px-3 text-sm font-semibold text-muted-foreground">
|
||||||
|
<Check aria-hidden="true" className="size-4" />
|
||||||
|
Venn
|
||||||
|
</span>
|
||||||
|
) : isSent ? (
|
||||||
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-brand-orange/15 px-3 text-sm font-semibold text-foreground">
|
||||||
|
<Check aria-hidden="true" className="size-4" />
|
||||||
|
Sendt
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<UserPlus aria-hidden="true" className="size-5" />
|
||||||
|
Send forespørsel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isFriend ? (
|
|
||||||
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-muted px-3 text-sm font-semibold text-muted-foreground">
|
{pickerOpen && (
|
||||||
<Check aria-hidden="true" className="size-4" />
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-4">
|
||||||
Venn
|
<p className="text-sm font-semibold text-foreground">
|
||||||
</span>
|
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
|
||||||
) : isSent ? (
|
forhåndsvalgt — velg heller bort det som ikke passer.
|
||||||
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-brand-orange/15 px-3 text-sm font-semibold text-foreground">
|
</p>
|
||||||
<Check aria-hidden="true" className="size-4" />
|
<CategoryPicker selected={pickerCategories} onToggle={toggleCategoryInPicker} />
|
||||||
Sendt
|
<div className="flex items-center gap-3">
|
||||||
</span>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<button
|
onClick={() => sendRequest(person.id)}
|
||||||
type="button"
|
disabled={sending}
|
||||||
onClick={() => sendRequest(person.id)}
|
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"
|
||||||
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"
|
>
|
||||||
>
|
{sending ? "Sender…" : "Bekreft og send"}
|
||||||
<UserPlus aria-hidden="true" className="size-5" />
|
</button>
|
||||||
Send forespørsel
|
<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,35 +448,68 @@ 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) => {
|
||||||
<li
|
const pickerOpen = acceptPickerFor === person.friendship_id
|
||||||
key={person.friendship_id}
|
return (
|
||||||
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
<li
|
||||||
>
|
key={person.friendship_id}
|
||||||
<Avatar name={fullName(person.first_name, person.last_name)} />
|
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
|
||||||
<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>
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
</div>
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
||||||
<div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto">
|
<div className="flex min-w-0 flex-1 basis-40 flex-col">
|
||||||
<button
|
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => respond(person.friendship_id, "accept")}
|
<div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto">
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Check aria-hidden="true" className="size-5" />
|
onClick={() => (pickerOpen ? setAcceptPickerFor(null) : openAcceptPicker(person.friendship_id))}
|
||||||
Godta
|
aria-expanded={pickerOpen}
|
||||||
</button>
|
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"
|
||||||
<button
|
>
|
||||||
type="button"
|
<Check aria-hidden="true" className="size-5" />
|
||||||
onClick={() => respond(person.friendship_id, "remove")}
|
Godta
|
||||||
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"
|
</button>
|
||||||
>
|
<button
|
||||||
<X aria-hidden="true" className="size-5" />
|
type="button"
|
||||||
Avslå
|
onClick={() => decline(person.friendship_id)}
|
||||||
</button>
|
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"
|
||||||
</div>
|
>
|
||||||
</li>
|
<X aria-hidden="true" className="size-5" />
|
||||||
))}
|
Avslå
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 på</h2>
|
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Meld deg på</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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
// forsterkning) i stedet for en pille-badge, for en tettere,
|
||||||
|
// nøkkel/verdi-orientert korttype.
|
||||||
|
const STATUS_TEXT_CLASS: Record<RoundStatus, string> = {
|
||||||
|
active: "text-primary",
|
||||||
|
completed: "text-brand-orange",
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundProgressBar({ played, holes }: { played: number; holes: number }) {
|
||||||
|
const pct = holes > 0 ? Math.min(100, Math.round((played / holes) * 100)) : 0
|
||||||
return (
|
return (
|
||||||
<span
|
<div
|
||||||
className={cn(
|
className="h-1.5 w-full overflow-hidden rounded-full bg-border"
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold",
|
role="progressbar"
|
||||||
config.badge,
|
aria-valuenow={played}
|
||||||
)}
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={holes}
|
||||||
|
aria-label={`${played} av ${holes} hull spilt`}
|
||||||
>
|
>
|
||||||
<span className={cn("size-2 rounded-full", config.dot)} aria-hidden="true" />
|
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
||||||
{config.label}
|
</div>
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prominent right-side tile: final score for completed/scored rounds,
|
// Kompakt nøkkel/verdi-celle -- selve rammen for grid-strukturen under
|
||||||
// hole progress for rounds still in play.
|
// tittelen (label liten/dempet over, verdi stor/fet under).
|
||||||
function ScoreTile({ round }: { round: Round }) {
|
function InfoCell({
|
||||||
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
|
label,
|
||||||
|
value,
|
||||||
if (round.status === "active" || !hasScore) {
|
valueClassName,
|
||||||
const played = round.holesPlayed ?? 0
|
}: {
|
||||||
const pct = round.holes > 0 ? Math.min(100, Math.round((played / round.holes) * 100)) : 0
|
label: string
|
||||||
return (
|
value: React.ReactNode
|
||||||
<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">
|
valueClassName?: string
|
||||||
<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
|
|
||||||
className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-border"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={played}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={round.holes}
|
|
||||||
aria-label={`${played} av ${round.holes} hull spilt`}
|
|
||||||
>
|
|
||||||
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toPar = round.toPar as number
|
|
||||||
const overPar = toPar > 0
|
|
||||||
const underPar = toPar < 0
|
|
||||||
|
|
||||||
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,56 +135,61 @@ 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() && (
|
||||||
{showPersonalBest && (
|
<span className="truncate pl-7 text-sm text-muted-foreground">{round.courseName}</span>
|
||||||
<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" />
|
|
||||||
Personlig rekord
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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 && (
|
|
||||||
<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">
|
|
||||||
<Gauge aria-hidden="true" className="size-4 shrink-0 text-info" />
|
|
||||||
<span className="tabular-nums">Hcp spilt til {differentialText}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<ScoreTile round={round} />
|
<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 && (
|
||||||
|
<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" />
|
||||||
|
Personlig rekord
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{round.status === "active" && <RoundProgressBar played={played} holes={round.holes} />}
|
||||||
|
|
||||||
<ChevronRight
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-border pt-3">
|
||||||
aria-hidden="true"
|
<InfoCell label="Tee" value={round.teeName} />
|
||||||
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
<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>
|
||||||
|
|
||||||
|
{differentialText && (
|
||||||
|
<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" />
|
||||||
|
<span className="tabular-nums">Hcp spilt til {differentialText}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn
|
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
</div>
|
||||||
>
|
{identity.hcp !== null && (
|
||||||
{entries.length > 1 && (
|
<span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {identity.hcp}</span>
|
||||||
<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>
|
</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>
|
||||||
<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">
|
{decidedHoles.length === 0 ? (
|
||||||
<colgroup>
|
<p className="text-sm text-muted-foreground">
|
||||||
<col className="w-10" />
|
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har
|
||||||
<col className="w-9" />
|
registrert.
|
||||||
<col />
|
</p>
|
||||||
<col />
|
) : (
|
||||||
<col className="w-10" />
|
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
|
||||||
</colgroup>
|
<table
|
||||||
<thead>
|
className="w-full border-collapse text-sm"
|
||||||
<tr>
|
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
|
||||||
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-left text-xs font-extrabold text-muted-foreground">
|
>
|
||||||
Hull
|
<colgroup>
|
||||||
</th>
|
<col className="w-16" />
|
||||||
<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
|
<col key={h.hole_number} className="w-10" />
|
||||||
</th>
|
))}
|
||||||
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
|
</colgroup>
|
||||||
{sideALabel}
|
<thead>
|
||||||
</th>
|
<tr>
|
||||||
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
|
<th
|
||||||
{sideBLabel}
|
scope="col"
|
||||||
</th>
|
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
|
||||||
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
|
>
|
||||||
Res.
|
Hull
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
{decidedHoles.map((h) => (
|
||||||
</thead>
|
<th
|
||||||
<tbody>
|
key={h.hole_number}
|
||||||
{result.holes.map((h) => (
|
scope="col"
|
||||||
<tr key={h.hole_number}>
|
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
|
||||||
<td className="border border-border px-1 py-1.5 text-xs font-semibold text-muted-foreground">{h.hole_number}</td>
|
>
|
||||||
<td className="border border-border px-1 py-1.5 text-center text-xs text-muted-foreground">{h.par}</td>
|
{h.hole_number}
|
||||||
<td className="border border-border px-2 py-1.5">
|
</th>
|
||||||
<SideEntryCell entries={h.entries.filter((e) => e.side === "a")} />
|
))}
|
||||||
</td>
|
|
||||||
<td className="border border-border px-2 py-1.5">
|
|
||||||
<SideEntryCell entries={h.entries.filter((e) => e.side === "b")} />
|
|
||||||
</td>
|
|
||||||
<td className="border border-border px-1 py-1.5">
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<ResultChip result={h.result} />
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<tr>
|
||||||
</div>
|
<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"
|
||||||
|
>
|
||||||
|
{h.par}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.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={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>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</table>
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</span>
|
||||||
{par !== undefined && (
|
{lastState.isClosed ? (
|
||||||
<span className="text-xs font-medium text-muted-foreground">Par {par}</span>
|
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
|
||||||
)}
|
Ferdig
|
||||||
{scoringMode === "stroke" && (
|
</span>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">
|
) : (
|
||||||
{grossPairLabel(scorecard, h.hole_number)}
|
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
|
||||||
|
{decidedHoles.length} hull spilt
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
<OutcomeBadge teams={teams} result={h.result} />
|
) : (
|
||||||
</li>
|
<span className="text-xs font-bold text-muted-foreground">Ingen hull spilt ennå</span>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
</ul>
|
<IdentityBlock name={identityB.name} hcp={identityB.hcp} color={identityB.color} side="b" />
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string {
|
<div className="-mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0">
|
||||||
const entries = scorecard.stroke_entries ?? []
|
<table
|
||||||
const sideGross = (side: "a" | "b"): string => {
|
className="w-full border-collapse text-sm"
|
||||||
const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes)
|
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
|
||||||
if (vals.length === 0) return "–"
|
>
|
||||||
return String(Math.min(...vals))
|
<colgroup>
|
||||||
}
|
<col className="w-16" />
|
||||||
return `${sideGross("a")}–${sideGross("b")}`
|
{decidedHoles.map((h) => (
|
||||||
}
|
<col key={h.hole_number} className="w-10" />
|
||||||
|
))}
|
||||||
function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) {
|
</colgroup>
|
||||||
if (result === "halved") {
|
<thead>
|
||||||
return (
|
<tr>
|
||||||
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
<th
|
||||||
Delt
|
scope="col"
|
||||||
</span>
|
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
|
||||||
)
|
>
|
||||||
}
|
Hull
|
||||||
const team = result === "a" ? teams[0] : teams[1]
|
</th>
|
||||||
return (
|
{decidedHoles.map((h) => (
|
||||||
<span
|
<th
|
||||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold text-primary-foreground"
|
key={h.hole_number}
|
||||||
style={{ backgroundColor: team.color ?? "#64748b" }}
|
scope="col"
|
||||||
>
|
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
|
||||||
{team.name}
|
>
|
||||||
</span>
|
{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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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="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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue