Update Todos

Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro

Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting

Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere

Frontend: profil-seksjon i /account

Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx

Scratch-verifisere alt (15 sjekker bestått)

Typesjekket frontend-build

ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:

Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.

"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.

Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.

Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.

Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
This commit is contained in:
Erol Haagenrud 2026-07-20 10:41:30 +02:00
parent f9c3050583
commit 3dd6224bfd
9 changed files with 820 additions and 44 deletions

View file

@ -327,7 +327,8 @@
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hcp_fix.py)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_tee_gender.py)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)"
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_profile_and_myrounds.py)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

37
015_user_profile.sql Normal file
View file

@ -0,0 +1,37 @@
-- =====================================================================
-- TeeCup — personlig profil på app_user + tverr-org spiller-oppslag
-- (migrasjon 015)
-- =====================================================================
-- ADR-031: personlig landingsside for enhver registrert bruker. Ett
-- canonical profil-sett PER KONTO (ikke per org-scopet `player`-rad --
-- se ADR-031 for hvorfor de to bevisst holdes atskilt). Alt additivt/
-- nullable -- trygt mot eksisterende kontoer.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE app_user ADD COLUMN first_name text;
ALTER TABLE app_user ADD COLUMN last_name text;
ALTER TABLE app_user ADD COLUMN birth_date date;
ALTER TABLE app_user ADD COLUMN gender text CHECK (gender IN ('m', 'f', 'x'));
ALTER TABLE app_user ADD COLUMN handicap_index numeric(4,1);
ALTER TABLE app_user ADD COLUMN home_club text;
ALTER TABLE app_user ADD COLUMN avatar_key text;
-- Samme "smale SECURITY DEFINER-bro"-mønster som public_tournament_org()
-- (007) / link_player_by_email() (008) / public_org_by_slug() (009) /
-- public_tournament_by_code() (011): `player` er RLS-beskyttet per org, så
-- et tverr-org-oppslag ("hvilke organisasjoner har jeg en spiller-rad i")
-- kan ikke gjøres med en vanlig org-scopet tilkobling. Eksponerer KUN
-- uuid-listen, ingenting annet fra player-raden.
CREATE FUNCTION player_organizations_for_user(p_user_id uuid)
RETURNS TABLE(organization_id uuid)
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT DISTINCT organization_id FROM player WHERE user_id = p_user_id;
$$;
GRANT EXECUTE ON FUNCTION player_organizations_for_user(uuid) TO teecup_app;

View file

@ -1449,6 +1449,92 @@ migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
---
## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil
Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
hjemmeklubb).
**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
org-medlem — også for en bruker som KUN er spiller (koblet via
`player.user_id`, ADR-017 B), aldri organisator.
**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
— ingen tvungen valg mellom to identiteter.
**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
konsepter, ikke ett duplisert.
**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).
**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
brukes for `organizations`).
**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
finnes intet scenario der en organisator ønsker å skjule en turnering for
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.
**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
til lagets private chat eller det organisator-vendte scorekortet — disse
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
strengere sperre enn deltaker-status alene, brukt av dusinvis av
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.
**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
**Status: ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20, IKKE ENNÅ RULLET UT.**
Se CLAUDE.md-status for full byggerunde.
---
## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene:

107
CLAUDE.md
View file

@ -1941,31 +1941,114 @@ Ferdig og verifisert:
upåvirket. Verifisert mot EKTE data (ikke bare scratch): "De Gamle er
Eldst" viser nå korrekt 11. juli 2026 i stedet for "Ingen datoer satt".
- **To nye punkter reist 2026-07-20, GJENNOMTENKT OG FORESLÅTT, IKKE
bygget:** brukeren ba eksplisitt om at punkt 1 tenkes grundig gjennom
og legges frem som et forslag FØR bygging (ikke kode med en gang), og
markerte det eksplisitt som prioritet over punkt 2.
1. **Personlig landingsside for enhver registrert bruker (PRIORITERT).**
Bekreftet reelt hull ved kodegjennomgang: `app/page.tsx` sender
enhver innlogget bruker til `/dashboard`, som viser "opprett
organisasjon" så snart `organizations.length === 0` — også for en
bruker som KUN er spiller (koblet via `player.user_id`,
ADR-017 B), aldri organisator. Fullt forslag skrevet i
FEATURE_BACKLOG.md: ett samlet dashboard (ikke to atskilte ruter),
ny "Mine runder"-seksjon (tverr-org, krever en ny SECURITY
DEFINER-bro `player_organizations_for_user()` + utvidelse av
`/auth/me`, samme mønster som `public_tournament_org()` m.fl.),
organisasjonsseksjonen uendret under. Venter på brukerens
bekreftelse på retningen før bygging starter.
2. **Midlertidige spillere + automatisk etter-runde-e-post (lavere
prioritet, likevel dokumentert grundig).** Presisert ved
kodegjennomgang: det meste av "midlertidig spiller"-behovet
dekkes ALLEREDE av eksisterende `POST /orgs/{id}/players`
(krever aldri en konto). Det som faktisk mangler er en PROAKTIV
e-post-utsending etter runden (scorekort + innloggingslenke) —
foreslått som en eksplisitt organisator-knapp per økt (ikke en
automatisk bakgrunnsjobb, for å unngå uventede e-poster fra en
gjettet "runden er ferdig"-deteksjon). Tre åpne spørsmål notert i
FEATURE_BACKLOG.md (økt- vs. turnering-nivå, dobbel-utsending-
sperre, locale).
**Ingen kode skrevet for noen av de to ennå** — dette var bevisst en
tenke-og-foreslå-runde, ikke en byggerunde.
- **Punkt 1 BYGGET OG SCRATCH-VERIFISERT (2026-07-20), samme dag, rett etter
forslaget over:** bruker svarte "Gjør punkt 1" med et utvidet omfang —
inkluder også opprettelse/redigering/sletting av personlig informasjon
(profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb).
**Ny migrasjon `015_user_profile.sql`:** `app_user` får de sju nye
profilfeltene — ETT sett PER KONTO, bevisst IKKE slått sammen med de
org-scopede `player`-radene (se ADR-031 Beslutning B for full
begrunnelse — to reelt atskilte konsepter). Ny
`player_organizations_for_user()`-bro (femte instans av samme
SECURITY DEFINER-mønster som `public_tournament_org()` m.fl.).
**Backend:** `/auth/me` utvidet med profilfeltene + `avatar_url` +
`my_tournaments` (turneringer brukeren er ROSTRET i, tverr-org, samme
N+1-org_connection()-mønster som organisasjonslisten). Ny
`PATCH /auth/profile` (vanlig exclude_unset), `POST`/
`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-mønster som
turnering-hero-bilder).
**Reelt sikkerhetshull funnet UNDER bygging, ikke antatt på forhånd:**
testet "Mine runder" mot en EKTE ren spiller (rostret, ingen
org-medlemskap) og oppdaget at `check_visibility()` (ADR-018) kun ga
deltaker-tilgang for `visibility='participants'` — IKKE for `'org'`
(DEFAULT for enhver ny turnering). En ren spiller ville altså vært
stengt ute fra sin EGEN, helt vanlige turnering — nøyaktig
brukergruppen "Mine runder" er bygget for. Fikset: deltaker-sjekken
gjelder nå begge ikke-offentlige tier, med eksplisitt begrunnelse om
at visibility styrer eksponering mot UTENFORSTÅENDE, aldri mot faktiske
deltakere. Verifisert presist at dette er en REN UTVIDELSE, ingen
innstramming: samme scratch-test bekreftet at en helt ubeslektet
FREMMED (innlogget, ikke deltaker) og en ANONYM leser fortsatt begge
avvises identisk som før (403 NOT_VISIBLE).
**Frontend:** `account-settings.tsx` fikk en ny "Personlig profil"-
seksjon (avatar-opplasting/fjerning, fornavn/etternavn/fødselsdato/
kjønn/HCP/hjemmeklubb-skjema). `dashboard.tsx` fikk en ny
`MyToursSection` («Mine runder», øverst, lenker til den offentlige
turnering-siden) + en mykere, sekundær utgave av "opprett organisasjon"-
tomtilstanden når brukeren allerede har spiller-data å vise.
**Bevisst UTENFOR omfang, klart flagget, IKKE en del av denne rundens
leveranse:** "Mine runder" lenker IKKE til lag-chat/scorekort ennå — de
krever fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en
strengere, bredt brukt sperre som ikke ble endret denne runden (egen,
større og mer risikofylt endring, se ADR-031).
**Scratch-verifisert, 15 sjekker:** full profil-CRUD (alle felt satt,
delvis PATCH lar andre felt stå urørt, eksplisitt `null` sletter et
felt, tomt PATCH avvist, avatar lastet opp med ekte AVIF-URL og
slettet igjen, ugyldig filtype avvist), "Mine runder" for en EKTE ren
spiller uten org-medlemskap, OG den kritiske sikkerhetssjekken over.
Ekte typesjekket produksjonsbuild kjørt og bekreftet.
**Ikke rullet ut ennå** — venter på utrullingsbekreftelse
(`015_user_profile.sql` mot ekte `teecup_db`, deretter begge
containere).
Neste steg:
1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
forespørsel:** offline-scoreregistrering er ALDRI browser-testet i
1. **Personlig landingsside + profil er BYGGET (ADR-031), venter på
utrullingsbekreftelse** — se over (migrasjon 015 + begge containere).
Naturlig oppfølging etterpå: deltaker-tilgang (uten org-medlemskap) til
lag-chat/scorekort — bevisst utenfor omfang denne runden, se
FEATURE_BACKLOG.md.
2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):**
midlertidige spillere + automatisk etter-runde-e-post — forslag klart
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.
3. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
synkes — først da er flyten reelt bevist.
2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå.
4. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå.
Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er
helt utilgjengelig i dag.
3. **Alle fire nye hull (spillerliste, tee/kjønn-modell ADR-029,
tallvelger, hcp-scope-bug) er FIKSET OG LIVE** (se over, 2026-07-19,
migrasjon 014 kjørt mot ekte data). En liten, urelatert 500-krasj
(stroke-innsending på bane uten hull) ble også funnet under
verifiseringen, ikke fikset — egen, liten sak.
4. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
5. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
(bevisst utsatt i ADR-025).
5. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/
6. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/
Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå.
6. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute,
7. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute,
ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`.
7. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
8. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
FORVENT en full re-eksport hver gang — diff mot live-treet i et
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR

View file

@ -1011,6 +1011,103 @@ bekreftet.
---
## Personlig landingsside for ENHVER registrert bruker + personlig profil — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20 (ADR-031)
Reist av brukeren 2026-07-20 som en "tenk igjennom og foreslå"-instruks,
deretter et "gjør det" med utvidet omfang (personlig profil-CRUD lagt til:
profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb). Full
detalj i ARCHITECTURE_DECISIONS.md ADR-031 — kort her:
| Del | Status | Notat |
|---|---|---|
| Ett samlet dashboard (ikke to atskilte ruter) | ✅ | Ny "Mine runder"-seksjon øverst i `dashboard.tsx`, organisasjonsseksjonen uendret under, begge vises hvis begge finnes. |
| "Mine runder" — tverr-org spiller-oppslag | ✅ | Ny `player_organizations_for_user()`-bro (migrasjon 015, samme mønster som fire tidligere), `/auth/me` utvidet med `my_tournaments`. Kun rostrede lag i v1 (ikke rene påmeldinger uten roster). |
| Personlig profil (fornavn/etternavn/fødselsdato/kjønn/HCP/hjemmeklubb) | ✅ | Nye felt på `app_user` (migrasjon 015) — ETT sett per konto, BEVISST atskilt fra org-scopede `player`-rader (se ADR-031 Beslutning B for hvorfor). `PATCH /auth/profile`, ny seksjon i `/account`. |
| Profilbilde | ✅ | `POST`/`DELETE /auth/profile/avatar`, samme ekte multipart→AVIF-opplasting som turnering-hero-bilder (ADR-018). |
| **Sikkerhetsutvidelse funnet UNDER bygging:** deltaker-tilgang uansett synlighetsnivå | ✅ | `check_visibility()` ga tidligere kun deltaker-tilgang for `visibility='participants'` — IKKE for `'org'` (DEFAULT for enhver ny turnering), som ville stengt ute enhver spiller uten org-medlemskap fra sin EGEN turnering. Utvidet til å gjelde begge ikke-offentlige tier. Verifisert: deltaker FÅR nå tilgang, fremmed+anonym fortsatt avvist (ingen innstramming, ren utvidelse). |
**Bevisst UTENFOR omfang, kjent gjenstående begrensning (se ADR-031 for full
begrunnelse):** "Mine runder" lenker til den offentlige turnering-siden
(`/t/{id}`), IKKE til lagets private chat eller scorekortet — disse krever
fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en strengere
sperre enn deltaker-status alene, brukt av dusinvis av endepunkter på tvers
av appen. Å utvide DEN sperren til også å godta "faktisk deltaker" er en
egen, større og mer risikofylt endring (påvirker autorisasjonsarkitekturen
bredt, ikke ett enkelt endepunkt) — naturlig neste steg, men bevisst ikke
gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
forslag).
**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se
sin `'org'`-synlige turnering, mens fremmed/anonym fortsatt avvises). Ekte
typesjekket produksjonsbuild kjørt og bekreftet.
**Ikke rullet ut ennå** — se CLAUDE.md-status.
### Naturlig neste steg (ikke bygget, notert for senere)
- **Deltaker-tilgang (uten org-medlemskap) til lag-chat og scorekort** — det
gjenstående hullet nevnt over. Krever en egen, forsiktig gjennomgang av
`get_authorized_org`-bruken (brukt bredt i hele appen) — ikke en rask fiks.
- Notifikasjons-/aktivitetsfeed på "Mine runder".
- HCP-historikk over tid (personlig profil sin `handicap_index` endres i
dag uten noen logg).
- "Mine runder" for RENE påmeldinger (`tournament_registration` uten
roster ennå) — v1 viser kun rostrede lag.
---
## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå
Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert
som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå).
**Viktig presisering, funnet ved kodegjennomgang FØR noe ble antatt:** det
meste av "midlertidig spiller"-behovet er allerede dekket av eksisterende
funksjonalitet, ikke et hull i seg selv — `POST /orgs/{id}/players` krever
ALDRI at spilleren har en konto (`player.user_id` er nullable, kobles først
automatisk når/hvis noen logger inn på matchende e-post, ADR-017
Beslutning B). En organisator kan altså allerede legge til "Ola Nordmann,
ola@example.com" uten at Ola noensinne har hørt om TeeCup. Det som
FAKTISK mangler er den PROAKTIVE oppfølgingen brukeren ber om: et
automatisk e-post-utsendelse-steg etter runden, med scorekort + invitasjon
til å logge inn og "ta eierskap" over spiller-profilen sin — dette finnes
ikke i noen form i dag (en spiller må selv, uoppfordret, logge inn for at
koblingen skal skje).
**Foreslått design, IKKE bygget:**
- **Utløses av en EKSPLISITT organisator-handling, ikke en automatisk
bakgrunnsjobb** ("Send scorekort og invitasjon til alle med e-post i
denne økten", en knapp på øktnivå når øktens matcher er avgjort).
Anbefalt fremfor helautomatisk utsendelse ved et gjettet
"runden er ferdig"-tidspunkt — unngår uventede e-poster fra en
feilaktig auto-deteksjon, og matcher prosjektets øvrige mønster (blind
draw krever eksplisitt lås, walkover er en eksplisitt handling — ingen
"magisk" auto-trigger noe annet sted i appen).
- **Ingen ny databasekolonne nødvendig for selve "midlertidig"-begrepet**
— enhver `player`-rad UTEN `user_id` ER allerede "midlertidig" i praksis.
Kun en NY, liten `sent_at`-lignende sporingskolonne kan trengs for å
unngå dobbel utsending ved gjentatt klikk (åpent spørsmål, se under).
- **E-posten gjenbruker eksisterende infrastruktur** (`app/email.py`,
samme SMTP-oppsett som magic-link/2FA) — innhold: spillerens
hull-for-hull-resultat for økten + en ekte innloggingslenke (vanlig
magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_
email()` allerede kobler kontoen automatisk ved første innlogging).
**Åpne spørsmål, trengs FØR bygging:**
- Skal utsendingsknappen ligge på ØKT-nivå (send til alle i denne ene
runden) eller TURNERING-nivå (send til alle på tvers av alle økter, når
hele turneringen er ferdig)? Økt-nivå virker riktigst — en spiller kan
ha spilt kun én av flere økter.
- Skal systemet spore "allerede sendt til denne spilleren for denne økten"
for å hindre dobbel utsending ved et nytt klikk (sannsynligvis ja — én
liten ny tabell/kolonne)?
- Skal e-posten sendes på spillerens/organisasjonens foretrukne språk
(samme `locale`-mønster som magic-link-e-posten, ADR-015 Beslutning C)?
---
## UX / frontend (senere fase)
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp

View file

@ -38,14 +38,15 @@ import base64
import hashlib
import secrets
import traceback
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from io import BytesIO
from typing import Literal
import qrcode
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, Request, Response, UploadFile
from pydantic import BaseModel, EmailStr, Field
from .. import storage
from ..auth import (
CurrentUser,
PendingUser,
@ -555,6 +556,22 @@ class MyOrg(BaseModel):
role: str
class MyTournament(BaseModel):
"""Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder').
Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT
(`tournament_registration`) men ikke ennå rostret et lag."""
organization_id: str
organization_name: str
tournament_id: str
tournament_name: str
status: str
team_id: str
team_name: str
team_color: str | None
next_session_at: datetime | None
class Me(BaseModel):
id: str
email: str
@ -563,6 +580,17 @@ class Me(BaseModel):
has_password: bool
two_factor_method: Literal["totp", "email"] | None
organizations: list[MyOrg]
# Personlig profil (ADR-031) -- ETT sett per konto, atskilt fra de
# org-scopede `player`-radene (se ADR-031 for hvorfor de ikke slås
# sammen til én ting).
first_name: str | None
last_name: str | None
birth_date: date | None
gender: str | None
handicap_index: float | None
home_club: str | None
avatar_url: str | None
my_tournaments: list[MyTournament]
@router.get("/me", response_model=Me)
@ -577,11 +605,18 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
# (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
# men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
#
# ADR-031: samme N+1-mønster brukt for "Mine runder", nå over org-er
# funnet via player_organizations_for_user() (migrasjon 015) i stedet
# for organization_membership -- en bruker kan være SPILLER i en org
# helt uavhengig av om de er MEDLEM der.
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method
(password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender,
handicap_index::float AS handicap_index, home_club, avatar_key
FROM app_user WHERE id = $1
""",
user.user_id,
@ -590,6 +625,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
user.user_id,
)
player_org_rows = await conn.fetch(
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
user.user_id,
)
organizations = []
for m in membership_rows:
@ -599,6 +638,32 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
)
organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))
my_tournaments: list[MyTournament] = []
for p in player_org_rows:
org_id = p["organization_id"]
async with org_connection(org_id) as org_conn:
org_name = await org_conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
rows = await org_conn.fetch(
"""
SELECT t.id::text AS tournament_id, t.name AS tournament_name,
t.status::text AS status,
tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color,
(SELECT min(s.scheduled_at) FROM session s
WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at
FROM player pl
JOIN team_roster tr ON tr.player_id = pl.id
JOIN team tm ON tm.id = tr.team_id
JOIN tournament t ON t.id = tm.tournament_id
WHERE pl.user_id = $1
ORDER BY t.created_at DESC
""",
user.user_id,
)
for r in rows:
my_tournaments.append(
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
)
return Me(
id=user_row["id"],
email=user_row["email"],
@ -607,4 +672,79 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
has_password=user_row["has_password"],
two_factor_method=user_row["two_factor_method"],
organizations=organizations,
first_name=user_row["first_name"],
last_name=user_row["last_name"],
birth_date=user_row["birth_date"],
gender=user_row["gender"],
handicap_index=user_row["handicap_index"],
home_club=user_row["home_club"],
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
my_tournaments=my_tournaments,
)
class ProfileUpdate(BaseModel):
"""Personlig profil (ADR-031) -- PATCH-semantikk via exclude_unset, som
resten av appen. Et felt sendt eksplisitt som `null` NULLES (f.eks.
fjern fødselsdato), et UTELATT felt endres ikke -- vanlig
"sletting" av et enkeltfelt trenger derfor ingen egen DELETE-vei her,
kun avatar (binært innhold) har sin egen under."""
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
birth_date: date | None = None
gender: str | None = Field(default=None, pattern="^[mfx]$")
handicap_index: float | None = None
home_club: str | None = Field(default=None, max_length=200)
@router.patch("/profile", response_model=Me)
async def update_profile(
body: ProfileUpdate,
user: CurrentUser = Depends(get_current_user),
) -> Me:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(user.user_id)
async with plain_connection() as conn:
await conn.execute(
f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
*values,
)
return await me(user)
@router.post("/profile/avatar", response_model=Me)
async def upload_avatar(
file: UploadFile,
user: CurrentUser = Depends(get_current_user),
) -> Me:
"""Samme ekte multipart->AVIF-mønster som tournament.hero_image_key
(ADR-018 MinIO-runden) -- se app/storage.py."""
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
try:
key = await storage.upload_image("avatars", user.user_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET avatar_key = $1 WHERE id = $2", key, user.user_id)
return await me(user)
@router.delete("/profile/avatar", response_model=Me)
async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me:
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id)
return await me(user)

View file

@ -91,9 +91,15 @@ async def check_visibility(
)
if is_member:
return
if visibility == "participants" and await is_participant(
conn, user.user_id, organization_id, tournament_id
):
# ADR-031: en faktisk DELTAKER skal aldri stenges ute av øktens
# egen turnering, uansett hvilken av de to ikke-offentlige tierene
# som er valgt -- visibility styrer eksponering mot UTENFORSTÅENDE,
# ikke mot spillere som faktisk er med. Utvidet fra kun
# visibility == "participants" (opprinnelig ADR-018 Beslutning C)
# da "Mine runder"-dashbordet gjorde det tydelig at 'org' (som er
# DEFAULT for enhver ny turnering) ellers ville stengt ute enhver
# spiller uten organisasjonsmedlemskap fra sin egen turnering.
if await is_participant(conn, user.user_id, organization_id, tournament_id):
return
raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.")

View file

@ -6,20 +6,29 @@
// (forced=false her -- kun tekst-copyen skiller de to).
import type React from "react"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { ArrowLeft, KeyRound, Lock, ShieldCheck, ShieldOff } from "lucide-react"
import { ArrowLeft, Camera, KeyRound, Lock, ShieldCheck, ShieldOff, User, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer.
type Me = {
id: string
email: string
display_name: string
has_password: boolean
two_factor_method: "totp" | "email" | null
first_name: string | null
last_name: string | null
birth_date: string | null
gender: "m" | "f" | "x" | null
handicap_index: number | null
home_club: string | null
avatar_url: string | null
}
export function AccountSettings() {
@ -90,6 +99,8 @@ export function AccountSettings() {
</div>
) : (
<div className="flex flex-col gap-6">
<ProfileSection me={me} onChanged={loadMe} />
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
@ -143,6 +154,225 @@ export function AccountSettings() {
)
}
// --- Personlig profil (ADR-031) ---------------------------------------------
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [firstName, setFirstName] = useState(me.first_name ?? "")
const [lastName, setLastName] = useState(me.last_name ?? "")
const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
const [gender, setGender] = useState(me.gender ?? "")
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
const res = await fetch("/auth/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim() === "" ? null : firstName.trim(),
last_name: lastName.trim() === "" ? null : lastName.trim(),
birth_date: birthDate === "" ? null : birthDate,
gender: gender === "" ? null : gender,
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
}
setSuccess(true)
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSaving(false)
}
}
async function handleAvatarSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
setError(null)
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/auth/profile/avatar", {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) throw new Error("Klarte ikke å laste opp profilbildet.")
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å laste opp profilbildet.")
} finally {
setUploadingAvatar(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
async function handleRemoveAvatar() {
setUploadingAvatar(true)
try {
const res = await fetch("/auth/profile/avatar", { method: "DELETE", credentials: "include" })
if (res.ok) onChanged()
} finally {
setUploadingAvatar(false)
}
}
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">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<User aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
</div>
<div className="flex items-center gap-4">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary">
{me.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={me.avatar_url} alt="" className="size-full object-cover" />
) : (
<User aria-hidden="true" className="size-7 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleAvatarSelected}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingAvatar}
onClick={() => fileInputRef.current?.click()}
className="h-9 rounded-xl font-semibold"
>
<Camera aria-hidden="true" className="size-4" />
{me.avatar_url ? "Bytt bilde" : "Last opp bilde"}
</Button>
{me.avatar_url && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={uploadingAvatar}
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
>
<X aria-hidden="true" className="size-3" />
Fjern bilde
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="first-name" className="text-sm font-semibold">
Fornavn
</Label>
<Input
id="first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="last-name" className="text-sm font-semibold">
Etternavn
</Label>
<Input
id="last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="birth-date" className="text-sm font-semibold">
Fødselsdato
</Label>
<Input
id="birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Ikke satt</option>
<option value="f">Dame</option>
<option value="m">Herre</option>
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="hcp" className="text-sm font-semibold">
HCP
</Label>
<Input
id="hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="home-club" className="text-sm font-semibold">
Hjemmeklubb
</Label>
<Input
id="home-club"
value={homeClub}
onChange={(e) => setHomeClub(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Lagre profil"}
</Button>
</form>
</section>
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)

View file

@ -4,7 +4,7 @@ import type React from "react"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Building2, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -16,16 +16,32 @@ import {
} from "@/components/ui/dropdown-menu"
import { Wordmark } from "@/components/wordmark"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import type { TournamentStatus } from "@/components/tournament-status-badge"
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
type MyOrg = { organization_id: string; name: string; role: string }
// ADR-031: "Mine runder" -- turneringer brukeren er SPILLER i (rostret på et
// lag), på tvers av organisasjoner, uavhengig av organisasjonsmedlemskap.
type MyTournament = {
organization_id: string
organization_name: string
tournament_id: string
tournament_name: string
status: TournamentStatus
team_id: string
team_name: string
team_color: string | null
next_session_at: string | null
}
type Me = {
id: string
email: string
display_name: string
preferred_locale: string
organizations: MyOrg[]
my_tournaments: MyTournament[]
}
type ApiTournament = {
@ -180,6 +196,7 @@ export function Dashboard() {
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
const hasOrg = me.organizations.length > 0 && activeOrg !== null
const hasMyTournaments = me.my_tournaments.length > 0
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
@ -213,26 +230,97 @@ export function Dashboard() {
</p>
)}
{hasOrg && activeOrg ? (
<OrganizationView
org={activeOrg}
orgs={me.organizations}
tournaments={tournaments}
loadingTournaments={loadingTournaments}
onSelectOrg={setActiveOrgId}
onCreateTournament={handleCreateTournament}
/>
) : (
<CreateOrganizationState onCreate={handleCreateOrg} />
)}
<div className="flex flex-col gap-8">
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
{hasOrg && activeOrg ? (
<OrganizationView
org={activeOrg}
orgs={me.organizations}
tournaments={tournaments}
loadingTournaments={loadingTournaments}
onSelectOrg={setActiveOrgId}
onCreateTournament={handleCreateTournament}
/>
) : (
<CreateOrganizationState onCreate={handleCreateOrg} compact={hasMyTournaments} />
)}
</div>
</main>
</div>
)
}
// --- "Mine runder" (ADR-031) -----------------------------------------------
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker,
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent,
// notert begrensning, se FEATURE_BACKLOG.md).
function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
return (
<section className="flex flex-col gap-3" aria-label="Mine runder">
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
<div className="flex flex-col gap-3">
{tournaments.map((t) => (
<Link
key={`${t.organization_id}-${t.tournament_id}`}
href={`/t/${t.tournament_id}`}
className="group flex 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"
>
<span
aria-hidden="true"
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: t.team_color ?? "#64748b" }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
<TournamentStatusBadge status={t.status} />
</div>
<span className="truncate text-sm text-muted-foreground">
{t.organization_name} · {t.team_name}
</span>
{t.next_session_at && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
</div>
)}
</div>
<ChevronRight
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/>
</Link>
))}
</div>
</section>
)
}
function formatNextSession(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat("no-NO", {
weekday: "short",
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date)
}
// --- State A: no organization ---------------------------------------------
function CreateOrganizationState({ onCreate }: { onCreate: (name: string) => void }) {
function CreateOrganizationState({
onCreate,
compact = false,
}: {
onCreate: (name: string) => void
compact?: boolean
}) {
const [name, setName] = useState("")
const valid = name.trim().length >= 2
@ -244,18 +332,26 @@ function CreateOrganizationState({ onCreate }: { onCreate: (name: string) => voi
}
return (
<div className="mx-auto flex max-w-md flex-col items-center gap-6 pt-6 text-center sm:pt-12">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<Building2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div
className={cn(
"mx-auto flex max-w-md flex-col items-center text-center",
compact ? "gap-4" : "gap-6 pt-6 sm:pt-12",
)}
>
{!compact && (
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<Building2 aria-hidden="true" className="size-8 text-primary" />
</div>
)}
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Du har ingen organisasjon ennå
<h1 className={cn("font-extrabold tracking-tight text-foreground text-balance", compact ? "text-lg" : "text-2xl")}>
{compact ? "Opprett din egen organisasjon" : "Du har ingen organisasjon ennå"}
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for
å komme i gang.
{compact
? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak."
: "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}
</p>
</div>