From c8c4ad29b6d05207a187c548ab4f2d030e0120fc Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 25 Jul 2026 07:51:32 +0200 Subject: [PATCH] =?UTF-8?q?Dashbordet=20er=20bygget=20(zip=2018=20integrer?= =?UTF-8?q?t):=20kun=20dashboard.tsx=20var=20reelt=20nytt=20fra=20V0,=20og?= =?UTF-8?q?=20jeg=20fant=20en=20genuin=20merge-situasjon=20i=20tournament-?= =?UTF-8?q?card.tsx=20=E2=80=94=20V0s=20nye=20"Arrang=C3=B8r"-merkelapp=20?= =?UTF-8?q?lagt=20til=20ved=20siden=20av=20den=20eksisterende=20offentlig/?= =?UTF-8?q?innlogget-lenkelogikken,=20ikke=20en=20revert.=20"Ny=20turnerin?= =?UTF-8?q?g"=20implementerer=20n=C3=A5=20selve=20ADR-035-mekanismen=20(us?= =?UTF-8?q?ynlig=20org-opprettelse=20ved=20innsending),=20"Statistikk"/"Sp?= =?UTF-8?q?ilte=20baner"=20er=20ren=20klientside-utledning=20fra=20data=20?= =?UTF-8?q?vi=20allerede=20har,=20og=20"Venner"=20viser=20en=20=C3=A6rlig?= =?UTF-8?q?=20tom-tilstand=20siden=20ADR-036=20ikke=20er=20bygget=20enn?= =?UTF-8?q?=C3=A5.=20Typesjekket=20produksjonsbuild=20kompilerte=20rent.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notert og bekreftet: Medspillere skal kunne registrere score for hele flighten, ikke bare egen rad — oppdatert i ADR-036 (sletting av runden/bane-bytte forblir eier-eksklusivt). To nye backlog-notater: flytte spillere mellom lag i turneringsoppsett, og flere flighter i én frittstående runde (sistnevnte har en reell modelleringsspenning mellom to retninger — ikke besluttet, bør trolig vente til ADR-036 fase 1 er bygget). --- .claude/settings.json | 13 + ARCHITECTURE_DECISIONS.md | 236 +++++ CLAUDE.md | 89 +- FEATURE_BACKLOG.md | 218 +++- frontend/components/dashboard.tsx | 1233 ++++++++++++----------- frontend/components/tournament-card.tsx | 11 + tee-cup-login-screen (18).zip | Bin 0 -> 153520 bytes 7 files changed, 1220 insertions(+), 580 deletions(-) create mode 100644 .claude/settings.json create mode 100644 tee-cup-login-screen (18).zip diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..008ad80 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(find / -maxdepth 3 -iname \"*zip*18*\")", + "Bash(find / -maxdepth 4 -iname \"*.zip\" -mmin -120)", + "Bash(rm -rf /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip18)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip18)", + "Bash(unzip -q \"/opt/teecup/tee-cup-login-screen \\(18\\).zip\")", + "Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(18\\).zip\" .)", + "Bash(grep -v \"ui/dropdown-menu\\\\|^.*DropdownMenuItem,$\\\\|import\")" + ] + } +} diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index d0b2c4e..fd24644 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -2808,6 +2808,242 @@ ekte https, `teeoff.no` upåvirket. --- +## ADR-035: Dashbord-redesign — organisasjon blir implisitt + +Direkte oppfølging av en refleksjonsrunde 2026-07-25: brukeren spurte +eksplisitt hvorfor organisasjon i det hele tatt trengs, gitt at en enkelt +bruker som vil sette opp ÉN turnering for en vennegjeng ikke får noe igjen +for opprettelses-seremonien. To alternativer ble veid opp mot hverandre: + +- **A — bruker-eide turneringer** (samme mønster som ADR-033 sine + frittstående runder, `user_id`-eierskap, ingen RLS): AVVIST. Praktisk + talt HELE turnering-apparatet (lag/roster/blind draw/scoring/chat/ + leaderboard/offentlig side, se CLAUDE.md sin arkitektur-invariant om + `organization_id` på alle domenetabeller) er bygget rundt RLS og + org-medlemskap. Å gjøre turneringer bruker-eide ville krevd enten en + full duplisering av dette apparatet i en parallell, RLS-fri variant, + eller å gjøre `organization_id` valgfri overalt — et brudd på + invarianten CLAUDE.md eksplisitt krever en ny ADR for, med en + reverseringskostnad som går GALT begge veier (migrere eksisterende + bruker-eide turneringer inn i organisasjoner senere, eller gjenoppfinne + medadministrasjon fra bunnen om den viser seg nødvendig uansett). +- **B — organisasjon beholdes, men opprettelsen gjøres usynlig/automatisk**: + VALGT. Rører verken skjema, RLS eller noen av de ni routerne som + allerede er bygget rundt organisasjon — alt fortsetter å virke uendret. + Kun UX-seremonien fjernes. + +### Beslutning A — Hva "usynlig organisasjon" betyr konkret + +`POST /orgs` (`app/routers/organizations.py`) krever i dag KUN `name` — +verifisert direkte i koden: `slug`/`public_profile` settes separat via +`PATCH /orgs/{id}` (ADR-018), ikke ved opprettelse. Dette betyr at +"usynlig opprettelse" krever **null backend-endring** — kun en endret +frontend-orkestrering: + +- Trykker en bruker "Ny turnering" og har PRESIS ÉN organisasjon fra før: + den gjenbrukes direkte, ingen synlig org-steg. +- Har brukeren INGEN organisasjon ennå: `POST /orgs` kalles automatisk med + et generert navn (`"{fornavn} {etternavn}s turneringer"`, eller + `"Mine turneringer"` som fallback hvis navn mangler) FØR turnering- + opprettelsen vises — brukeren ser aldri et eget "opprett organisasjon"- + skjema. +- Har brukeren FLERE organisasjoner (f.eks. fordi de også er invitert inn + i en ekte klubb, ADR-022): et lett org-valg vises FØRST da — samme + `OrganizationView`-mønster som i dag, men kun synlig i dette ene + tilfellet, ikke som standard. +- Alt annet er UENDRET: en bruker som ønsker en ekte klubb-identitet + (navn, slug, offentlig side, medadministratorer) kan fortsatt navngi + orgen sin og bygge den ut senere — B fjerner kun ceremonien ved + FØRSTE opprettelse, ikke noen av de eksisterende org-funksjonene. + +### Reversibilitet + +B kan reverseres på en ettermiddag: legg navnesteget tilbake i UI-flyten +FØR "Ny turnering" fullføres. Ingen datamigrering — eksisterende org-rader +er identiske uansett om de ble navngitt av brukeren eller auto-generert. + +**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se +"Dashbord: tom-tilstand"-seksjonen i FEATURE_BACKLOG.md for det fulle +blokk-forslaget og V0-prompten som ble skrevet i samme runde. + +--- + +## ADR-036: Venner, kategorisert deling av runder, og tiered personsøk for medspillere + +Reist av brukeren 2026-07-25, samme runde som ADR-035. To sammenhengende +behov: (1) når man legger til en medspiller på en frittstående runde, skal +det søkes opp EKTE personer — venner FØRST, deretter samme hjemmeklubb, +deretter samme land, til slutt globalt, uavhengig av om for- eller +etternavn skrives først, i samme UI-mønster som bane-/klubbsøket; (2) +appen skal ha et fullverdig vennekonsept, der venner (med mindre runden er +satt til "Privat") kan se livescoren din, og der DU kan kategorisere hver +venn i én eller flere av et fast sett grupper — uten at vennen selv vet +hvilke grupper du har puttet dem i. + +**Direkte oppfølging av et allerede notert, men avvist punkt:** ADR-033 +Beslutning D satte `round_participant.user_id` i skjemaet, men avgrenset +bevisst til KUN gjeste-deltakere i v1 ("en deltaker med `user_id` satt får +IKKE egen tilgang til runden"), med e-post-kobling notert som en mulig, +ikke-designet senere utvidelse. Denne ADR-en erstatter e-post-kobling- +ideen med noe som passer bedre til det brukeren faktisk ba om: ekte +personsøk (samme mønster som bane/klubb), ikke en e-postadresse man må +kjenne på forhånd. + +### Beslutning A — Vennskap er gjensidig; kategorisering er privat og ensidig + +Et vennskap krever en forespørsel + aksept (samme grunnmønster som +organisasjon-invitasjoner, ADR-022) — "venner ser livescoren din" gir kun +mening som et GJENSIDIG, bekreftet forhold, ikke en ensidig følging. +Kategoriseringen ("Make", "Golfvenner" osv.) er derimot et HELT SEPARAT, +privat attributt EIET av den som kategoriserer — A kan sette B i +"Golfvenner" uten at B noensinne får vite det, og B kategoriserer A helt +uavhengig (kan sette A i en annen gruppe, eller ingen). Dette er bevisst +samme idé som Facebooks "nære venner"-lister: vennskapet er symmetrisk, +men grupperingen er det ikke. + +**Datamodell (arbeidsnavn, avgjøres ved migrasjonsskriving):** +- `friendship`: `requester_user_id`, `addressee_user_id`, `status` + (`pending`/`accepted`/`declined`), tidsstempler. Et UNIK + uttrykks-indeks på `(LEAST(requester_user_id, addressee_user_id), + GREATEST(...))` hindrer duplikate forespørsler i begge retninger + samtidig. +- Kategori er IKKE en egen tabell — et fast, CHECK-constrained sett + (samme mønster som `BAG_CLUBS`/`gender`/`stat_level` ellers i appen), + ikke brukerdefinerbart: `spouse` (Make), `close_family` (Nær familie), + `extended_family` (Storfamilie), `close_friends` (Nære venner), + `golf_friends` (Golfvenner), `colleagues` (Kollegaer), `business` + (Forretningsforbindelser), `classmates` (Studiekamerater), + `acquaintances` (Perifere bekjente), `other` (Ymse). +- `friend_categorization`: `owner_user_id`, `friend_user_id`, `category` + — én rad PER kategori en venn er satt i (en venn kan ha flere rader, + "de skal kunne være tilknyttet forskjellige kategorier"). Håndheves i + app-laget (skriving krever en `accepted`-vennskapsrad mellom de to), + ikke en direkte FK til `friendship` (unngår å måtte holde styr på + requester/addressee-retning to steder). + +### Beslutning B — Rundevisibilitet: tre nivåer, samme struktur som turnering, men egen mekanisme + +`round` får `visibility_mode` (`public`/`private`/`friends`, default +`private` — trygg standard, samme filosofi som RLS-policyenes "se +ingenting" ved manglende kontekst). Når `friends` er valgt: hvilke +KATEGORIER som får se runden velges eksplisitt (`round_visible_category`, +`round_id`+`category`) — ikke "alle venner", siden brukeren eksplisitt ba +om å velge blant gruppene sine ved rundestart. + +**Viktig presisering, unngår en reell forvekslingsfelle:** dette er +ADSKILT fra om noen er lagt til som faktisk MEDSPILLER (Beslutning C +under). En medspiller du har lagt til ser ALLTID runden dere spiller +sammen, uavhengig av `visibility_mode` — visibility-nivået styrer kun +TREDJEPARTS innsyn (venner/offentligheten), ikke de som faktisk er med i +flighten. + +**Autorisasjonssjekk** (app-lag, `plain_connection()`-mønsteret fra +ADR-033 Beslutning A — INGEN RLS, samme begrunnelse som der): +1. `viewer == round.owner_user_id` → alltid tilgang. +2. `viewer` er en lenket medspiller (`round_participant.user_id == + viewer`) → alltid tilgang til DEN runden. +3. `visibility_mode == 'public'` → alle, også anonyme (samme mønster som + turneringers offentlige side). +4. `visibility_mode == 'private'` → kun 1+2. +5. `visibility_mode == 'friends'` → krever innlogget bruker MED en + `accepted`-vennskapsrad til eieren OG minst én + `friend_categorization`-rad (`owner_user_id=eier, friend_user_id= + viewer`) hvor kategorien finnes i `round_visible_category` for akkurat + denne runden. Legg merke til retningen: det er EIERENS kategorisering + av VIEWER som brukes, ikke omvendt — riktig, siden det er eieren som + begrenser hvem som får se, basert på eierens egen gruppering. + +**"Livescore"** — forstått som sanntidsoppdatering mens runden pågår, +samme idé som turnering sin `/t/[id]/live` (ADR-027). Gjenbruker samme +kringkastingsmønster (`app/realtime.py`, `broadcast_live_update`) — en +tilsvarende funksjon for runder, kringkastet ved hver hull-PATCH når +`visibility_mode != 'private'`, med et WS-endepunkt gated av samme +autorisasjonssjekk som over. + +### Beslutning C — Tiered personsøk: delt mellom "finn venn" og "legg til medspiller" + +Samme underliggende endepunkt brukes til BEGGE formål (finn en venn å +sende forespørsel til, OG søk opp en medspiller å legge til på en runde) +— begge er i essens "finn en person", kun hva som skjer ETTER valg +skiller dem. Foreslått: `GET /people/search?q=...`. + +**Navnerekkefølge-uavhengig matching:** spørringen splittes i tokens +(mellomrom-separert). For HVER token må minst ett av `first_name`/ +`last_name` matche (`ILIKE token||'%'`) — ALLE tokens må matche (AND på +tvers av tokens, OR på tvers av feltene per token). Dette gjør at "Erol +Haagenrud" og "Haagenrud Erol" gir identisk treff, uten noen spesiell +"gjett rekkefølgen"-logikk — det faller naturlig ut av at hvert ord kan +matche HVILKET SOM HELST av de to feltene. + +**Tiered rangering, én spørring:** en beregnet prioritet per kandidat — +0 hvis venn (uavhengig av kategorisering — ALLE venner, ikke bare de i +en bestemt gruppe, siden dette er søk-for-å-legge-til, ikke visibility- +sjekken over), 1 hvis samme `home_club` som søkeren (nå en pålitelig +eksakt streng siden ADR-025-runden 2026-07-25 gjorde Hjemmeklubb til en +ekte dropdown-verdi i stedet for fritekst — retroaktivt en god +begrunnelse for den endringen), 2 hvis samme `country`, 3 ellers. +`ORDER BY prioritet, fornavn, etternavn LIMIT 20` gir venner-først uten +behov for separate spørringer per nivå. + +**Personvernhensyn, bevisst innebygd i designet, ikke tilføyd i +etterkant:** +- Svaret inneholder KUN navn, avatar, hjemmeklubb — ALDRI e-post/mobil/ + fødselsdato. +- Foreslått minimum 2 tegn i søket før noe returneres i det hele tatt + (hindrer triviell enumerering av alle brukere via ett enkelt + bokstavsøk) — MIN anbefaling, ikke bekreftet med bruker. +- Kun innloggede brukere kan søke (`get_current_user`, ikke anonymt). + +### Beslutning D — Byggerekkefølge (foreslått, IKKE bekreftet) + +Gitt omfanget (nytt vennekonsept + ny rundevisibilitet + nytt delt +søkeendepunkt + sanntidsutvidelse) foreslås tre uavhengig leverbare faser, +samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet: + +1. **Venner-kjernen**: `friendship`+`friend_categorization`, forespørsel/ + aksept/fjern-endepunkter, `/people/search`, en ny `/friends`-side + (søk, forespørsler, kategoriser). Leverbar og nyttig helt alene. +2. **Rundevisibilitet**: `round.visibility_mode`+`round_visible_category`, + valg ved rundestart, `can_view_round`-gatede lese-endepunkter for + ikke-eiere, sanntidsutvidelse for live-visning. Avhenger av fase 1. +3. **Ekte medspillere** (ikke bare gjester): utvid `POST .../participants` + til å godta et søkt `user_id` i tillegg til `guest_name`. Avhenger av + fase 1 (samme søk). **Avklart med bruker 2026-07-25: JA** — en + lagt-til ekte medspiller skal se runden i SIN EGEN "Egne runder"-liste, + ikke bare eieren. + **Konkret teknisk konsekvens, presisert her siden det ikke er + opplagt:** `list_rounds` filtrerer i dag KUN på `owner_user_id`, og + `RoundOut` sine `owner_holes_played`/`owner_total_score`/ + `owner_score_to_par`-felt (lagt til 2026-07-25, se status) er alltid + utledet fra EIERENS `round_participant`-rad. For en medspiller som + ser runden i SIN liste må disse tallene i stedet vise DERES EGEN + score i runden, ikke eierens — feltene må derfor bli + "viewer-relative" (utledet fra HVILKEN SOM HELST deltaker-rad som + matcher innlogget bruker, enten `is_owner` eller lenket `user_id`), + ikke hardkodet til `is_owner=true`-raden. `list_rounds`-spørringen må + utvides til `WHERE owner_user_id = $1 OR id IN (SELECT round_id FROM + round_participant WHERE user_id = $1)`. Ingen endring i selve + eierskapet (`round.owner_user_id` er fortsatt entydig én person). + **Skrivetilgang avklart med bruker 2026-07-25: en medspiller skal + kunne registrere score for ALLE i flighten**, ikke bare sin egen rad + — samme skrivetilgang som eieren allerede har i dag (via + `_get_owned_round_or_404`), nå utvidet til å gjelde ENHVER lenket + ekte deltaker (`round_participant.user_id`), ikke kun eieren. Praktisk + presisering: dette gjelder KUN hull-registrering + (`PATCH .../participants/{id}/holes/{hole}`) og legg-til-deltaker — + sletting av selve runden og bane-/utslagsbytte (`DELETE /rounds/{id}`, + `PATCH /rounds/{id}` sine bane-felt) forblir eier-eksklusivt, siden + disse er destruktive/strukturelle handlinger uavhengig av hvem som + fører score. Autorisasjonssjekken for hull-PATCH blir dermed: + `viewer == owner_user_id OR viewer IN (SELECT user_id FROM + round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`. + +**Status: 📋 DESIGNET 2026-07-25, IKKE BYGGET.** Se "Venner og +kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for åpne spørsmål og +videre kontekst. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index 1b9f00e..3c8ff4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2574,17 +2574,86 @@ Ferdig og verifisert: 12/12, begge containere redeployet, `/health`/`/dashboard`/`/my-rounds`/ `/account` → 200, `teeoff.no` upåvirket. +- **Refleksjonsrunde 2026-07-25: hvorfor organisasjon? + venner/deling — + DESIGNET, IKKE bygget.** Brukeren spurte hvorfor organisasjon i det + hele tatt trengs, gitt at ADR-033 nå gjør en enkelt bruker fullt + selvstendig. Veide A (bruker-eide turneringer, som runder) mot B + (organisasjon beholdt, men opprettelsen gjøres usynlig/automatisk) — + A avvist (ville krevd duplisering av HELE turnering-apparatet som er + bygget rundt RLS/`organization_id`, og er ikke reversibelt i noen + retning), B valgt (rører verken skjema eller de ni eksisterende + routerne, kun frontend-orkestrering — `POST /orgs` krever allerede kun + `name`). Skrevet som **ADR-035** (organisasjon-B-beslutningen + et + konkret 7-blokks dashbord-forslag: Hurtighandlinger/Kommende runder/ + Kommende turneringer/Statistikk/Spilte baner/Venner/Organisasjoner — + sistnevnte nedtonet, kun synlig ved reelt flere org-er) og **ADR-036** + (nytt vennekonsept — gjensidig forespørsel/aksept, privat + kategorisering i faste grupper som Make/Nær familie/Golfvenner/osv., + ny rundevisibilitet `public`/`private`/`friends` med eksplisitt + gruppevalg, og et tiered personsøk — venner→samme klubb→samme + land→globalt, navnerekkefølge-uavhengig — delt mellom "finn venn" og + en fremtidig "legg til ekte medspiller"-utvidelse av + `round_participant.user_id`, som allerede lå ubrukt i skjemaet som en + eksplisitt notert "v1-avgrensning" i ADR-033). V0-prompt for det nye + dashbordet skrevet (i FEATURE_BACKLOG.md), IKKE sendt til V0 ennå. + **Ingen kode skrevet** — bevisst en design-/dokumentasjonsrunde, ikke + en byggerunde, på brukerens eksplisitte instruks. Full detalj i + ARCHITECTURE_DECISIONS.md (ADR-035/036) og FEATURE_BACKLOG.md (åpne + spørsmål, foreslått 3-fase byggerekkefølge for venner-delen). +- **Oppfølging samme dag:** bruker bekreftet at en lagt-til ekte + medspiller SKAL se runden i sin egen "Egne runder"-liste (ADR-036 fase + 3, tidligere bevisst uavklart). Presiserte samtidig en ny teknisk + konsekvens i ARCHITECTURE_DECISIONS.md: `RoundOut` sine + `owner_*`-statistikkfelt må bli viewer-relative, ikke alltid eierens, + og et NYTT åpent spørsmål dukket opp — skal medspilleren også kunne + SKRIVE egne hull-tall, ikke bare lese? Ikke avgjort. Fortsatt ingen + kode skrevet. + +- **Dashbord-redesign (ADR-035) BYGGET, IKKE ENNÅ RULLET UT (2026-07-25):** + zip 18 mottatt og integrert — kun `dashboard.tsx` var reelt nytt (samme + full-reeksport-mønster som alltid), pluss en genuin MERGE (ikke revert) + i `tournament-card.tsx`: V0s nye `organizer`-merkelapp lagt til SIDE OM + SIDE med den eksisterende `orgId`-baserte lenkelogikken (offentlig vs. + innlogget kontekst) som V0 ikke kjente til. Datalag skrevet fra bunnen: + "Kommende turneringer" slår sammen deltaker- (`me.my_tournaments`) og + arrangør-turneringer (hentet per org, alle organisasjoner brukeren er + medlem i) til ÉN tidssortert liste, filtrert til `draft`/`active`-status. + "Statistikk"/"Spilte baner" er REN klientside-utledning fra eksisterende + `GET /rounds` + `GET /auth/profile/handicap-history` — ingen nye + endepunkter trengt. "Ny turnering"-hurtighandlingen implementerer selve + ADR-035-mekanismen: oppretter/gjenbruker organisasjon USYNLIG først + (kun ved faktisk innsending, ikke når skjemaet åpnes — unngår en + foreldreløs org ved avbrutt skjema), deretter turneringen, deretter + navigerer rett inn i den. "Bli med med kode" gjenbruker samme + `by-code`-oppslag som `login-form.tsx` sin `JoinByCode`, nå som en + dashbord-lokal variant. "Venner"-seksjonen vises med en ærlig + tom-tilstand (ADR-036 er ikke bygget ennå) — CTA-en er bevisst uten + href/onClick, ikke en lenke til noe som ikke finnes. "Spilte baner" + fikk sine listeelementer gjort om til ren visning (ikke lenker) siden + API-et ikke eksponerer noen stabil bane-id å lenke til per runde. + Ekte typesjekket produksjonsbuild kompilerte rent, alle 21 ruter + listet. **IKKE rullet ut ennå** — venter på brukerens bekreftelse + (ren frontend-endring, ingen migrasjon). +- **Oppfølging samme runde, avklart med bruker:** medspillere skal kunne + registrere score for HELE flighten (ikke bare egen rad) når ADR-036 + fase 3 bygges — oppdatert i ARCHITECTURE_DECISIONS.md/FEATURE_BACKLOG.md. + To nye notater lagt i FEATURE_BACKLOG.md (ingen design/bygging ennå, + kun fanget opp): (1) turneringsoppsett mangler en vei til å FLYTTE en + rostret spiller til et annet lag (kun fjerning finnes i dag), (2) et + reelt modelleringsspørsmål om flere flighter i én frittstående runde + (f.eks. "min flight + vennenes flight bak oss samme dag") — to + prinsipielt ulike retninger skissert, ingen valgt. + Neste steg: -1. **Pauset, venter på retning:** dashbordets tom-tilstand ved første - innlogging. Brukeren ba om å justere det opprinnelige 2026-07-20- - forslaget i lys av et dypere spørsmål — bør organisasjon fortsatt være - "det som meldes først"? Min vurdering (se FEATURE_BACKLOG.md): nei, - bør bli ett likestilt valg blant flere. **Delvis besvart 2026-07-22:** - den ALLER første tingen en ny/ufullstendig bruker nå ser er den - obligatoriske profil-fullføringen (se status over), ikke selve - dashbordet — men spørsmålet om HVA dashbordets tom-tilstand skal vise - for en bruker som HAR fullført profilen, men ennå ikke har noen - organisasjon/turnering, står fortsatt åpent. +1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign + (ADR-035) + venner/kategorisert deling (ADR-036), begge designet + 2026-07-25 (se status over og FEATURE_BACKLOG.md). Retningen er + avklart (organisasjon blir usynlig/automatisk, dashbordet får syv + konkrete blokker, venner bygges i tre uavhengige faser) — men INGEN + av delene er bekreftet klar til bygging ennå. Naturlig neste steg: + send V0-prompten for dashbordet (se FEATURE_BACKLOG.md) til v0.app, + ELLER start på venner-kjernen (fase 1 av ADR-036), etter brukerens + valg. 3. **Frittstående rundeføring + detaljert statistikk — ADR-033 skrevet 2026-07-22, IKKE bygget.** Brukeren avklarte 2026-07-22 at dette skal bli appens HOVEDFOKUS (turneringsoppsett skal bli ekstremt enkelt diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index e3b063d..1758ca0 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1329,7 +1329,7 @@ og vil derfor begge se profil-fullførings-skjemaet ved neste innlogging. --- -## Dashboard: tom-tilstand ved første innlogging — 📋 UNDER REVURDERING (2026-07-21), IKKE bygget +## Dashboard: tom-tilstand ved første innlogging — 📋 KONKRET FORSLAG LAGT FREM (2026-07-25), IKKE bygget Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med @@ -1381,6 +1381,222 @@ tom-skjermens endelige form kan bestemmes. --- +### 2026-07-25 — avhengigheten er løst, konkret forslag lagt frem (📋 DESIGNET, IKKE BYGGET) + +Frittstående runder (ADR-033) er nå ferdig bygget (backend+frontend, alle +oppfølgingsrunder), så blokkeringen over er borte. Brukeren reiste samtidig +det dypere spørsmålet "hvorfor har vi organisasjon i det hele tatt" — +besvart og designet som **ADR-035** (organisasjon beholdes, men +opprettelsen gjøres usynlig/automatisk — Beslutning B, se +ARCHITECTURE_DECISIONS.md for full A-vs-B-avveining og +reversibilitetsvurdering). + +**Konkret blokk-forslag for det nye dashbordet** (rekkefølge, topp til +bunn): + +1. **Hurtighandlinger** — tre likestilte kort/knapper: "Ny runde", "Ny + turnering" (oppretter/gjenbruker organisasjon usynlig, ADR-035), "Bli + med med kode" (ADR-020). Ingen av de tre skal kreve noe org-steg + synlig for brukeren. +2. **Kommende runder** — egne runder som ikke er fullført + (`completed_at IS NULL`), sortert på dato, inntil 3 vist + "se alle" + til `/my-rounds`. Tomtilstand: kort tekst + snarvei til "Ny runde". +3. **Kommende turneringer** — SLÅR SAMMEN turneringer man er deltaker i + (dagens "Mine runder"-data) OG turneringer man arrangerer (dagens + organisasjons-turneringsliste, på tvers av ALLE organisasjoner + brukeren er medlem i, flatt) til ÉN tidssortert liste. Kort man + arrangerer får en liten "Arrangør"-merkelapp. Tomtilstand: snarvei til + "Ny turnering"/"Bli med med kode". +4. **Statistikk** — smått aggregert: antall runder spilt, HCP-trend + (sparkline fra den eksisterende `handicap_history`-tabellen, kun vist + ved ≥2 datapunkter), snitt til par siste 5 runder. Tomtilstand til + minst én runde er fullført. +5. **Spilte baner** — utledet fra `round.course_name_snapshot`, gruppert + med besøksantall + sist spilt. Ingen ny datamodell trengs. +6. **Venner** — kort med antall ventende forespørsler + snarvei til en ny + `/friends`-side (se ADR-036 under). Tomtilstand: "Du har ingen venner + ennå — søk etter noen". +7. **Organisasjoner** — KUN vist hvis brukeren er medlem i mer enn den + auto-opprettede sin egen (dvs. har en ekte, navngitt klubb-tilknytning) + — én liten, nedtonet lenke, ikke en egen fremtredende seksjon. Dette er + selve poenget med ADR-035: organisasjon skal ikke lenger dominere + dashbordet. + +**V0-prompt skrevet, IKKE sendt til V0 ennå** (venter på brukerens +gjennomgang av blokk-forslaget over først): + +> Design et nytt dashbord for TeeCup (golf-app, Next.js + Tailwind + +> shadcn/ui, mobil-først, eksisterende merkevarefarger: grønn primær, +> oransje sekundær — bruk appens eksisterende design-tokens, ikke nye +> farger). Dette ERSTATTER dagens dashbord, som feilaktig satte +> "organisasjon" som det første og viktigste en bruker møtte — ny +> retning: brukeren er først og fremst en GOLFSPILLER, organisasjon er en +> liten, valgfri detalj lengre ned. +> +> Innhold, i denne rekkefølgen, som distinkte kort/seksjoner (ikke faner): +> 1. Hurtighandlinger: tre like store, likestilte knapper/kort side ved +> side (stables på smal skjerm) — "Ny runde", "Ny turnering", "Bli med +> med kode". Tydelige, tekstede (ikke kun ikon), store trykkflater. +> 2. "Kommende runder" — liste over inntil 3 pågående/ikke-fullførte +> runder (banenavn eller eget rundenavn, dato, en liten +> fremdriftsindikator "X/18 hull"), med en "se alle"-lenke. Vis en +> tydelig, vennlig tomtilstand med snarvei til "Ny runde" hvis ingen. +> 3. "Kommende turneringer" — liste over turneringer brukeren enten +> deltar i eller arrangerer, sortert på dato, en liten +> "Arrangør"-merkelapp på kort man selv arrangerer. Tomtilstand med +> snarveier til "Ny turnering"/"Bli med med kode". +> 4. "Statistikk" — en kompakt rad med 2-3 nøkkeltall (antall runder, +> HCP nå + en liten trendpil/sparkline, snitt til par) i staselige +> tall-fliser (stort, fet skrift, høy kontrast). +> 5. "Spilte baner" — en kompakt liste/rutenett av baner med +> besøksantall og sist spilt-dato. +> 6. "Venner" — et lite kort: avatar-stabel av noen få venner (om noen), +> et tall-merke for ventende forespørsler, snarvei "Se venner". +> Tomtilstand: oppfordring til å søke opp noen. +> 7. "Organisasjoner" — KUN når relevant: én liten, nedtonet tekstlenke +> nederst, IKKE et fremtredende kort — dette skal se ut som en +> bakgrunnsdetalj, ikke en hovedseksjon. +> +> Tilgjengelighet er et ufravikelig krav, ikke en estetisk sluttpuss: god +> kontrast, stor nok skrift, store trykkflater (min. 44px), ALDRI +> ikon-only uten tekstlabel på viktige handlinger, appen skal være +> brukbar uten finmotorikk eller skarpt syn. Design ALLE tomtilstander +> eksplisitt, ikke bare den fylte varianten — de fleste nye brukere vil +> se flere tomme blokker samtidig, og det skal fortsatt se innbydende ut, +> ikke ufullstendig. + +**Åpne spørsmål før bygging:** +- Skal blokk 3 og 4 lenke til nye, dedikerte sider (f.eks. en egen + aggregert statistikk-side), eller er de rene dashbord-widgets uten + "se mer"? Statistikk-tallene over er enkle å beregne, men en FULL + aggregert statistikk-side (grafer over tid på tvers av alle runder) er + et større, eget stykke arbeid — ikke inkludert i dette forslaget. +- Nøyaktig terskel for når "Organisasjoner"-lenken vises (mer enn 1 org + totalt? Eller kun når minst én org har et eksplisitt satt `slug`/ + `public_profile`, dvs. faktisk er gjort til en "ekte" klubb?). + +--- + +## Venner, kategorisert deling av runder, og tiered personsøk — 📋 DESIGNET 2026-07-25, IKKE bygget + +Reist av brukeren samme runde som dashbord-forslaget over. Fullt design +skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for +datamodell, autorisasjonslogikk og søke-algoritme i detalj. Kort +oppsummert her, pluss åpne spørsmål og foreslått byggerekkefølge. + +**Tre sammenhengende deler:** +1. Et ekte vennekonsept — gjensidig forespørsel/aksept (som org- + invitasjoner), pluss et fast sett kategorier en venn kan settes i + (flere samtidig): Make, Nær familie, Storfamilie, Nære venner, + Golfvenner, Kollegaer, Forretningsforbindelser, Studiekamerater, + Perifere bekjente, Ymse. **Kategoriseringen er privat** — vennen vet + ikke hvilken/hvilke grupper du har satt dem i. +2. Rundevisibilitet — ny `round.visibility_mode` + (`public`/`private`/`friends`, default `private`). Ved `friends` + velger man EKSPLISITT hvilke av gruppene sine som får se runden (ikke + "alle venner"). Styrer kun tredjeparts innsyn — en faktisk lagt-til + medspiller ser alltid runden uansett innstilling. +3. Tiered personsøk — samme søkbare-liste-mønster som bane-/klubbsøket + (`HomeClubField`/`OfficialSearchStep`), navnerekkefølge-uavhengig + (skriv for- ELLER etternavn først, begge treffer), rangert: venner → + samme hjemmeklubb → samme land → globalt. Ett delt endepunkt brukt + BÅDE til "finn en venn" og (i en senere fase) "legg til medspiller på + en runde". + +**Reell synergi funnet under design, ikke tilfeldig:** forrige rundes +redesign av "Hjemmeklubb" til en ekte dropdown-verdi (i stedet for +fritekst) gjør nå "samme klubb"-rangeringen i søket pålitelig — et +eksakt strengmatch fungerer nå, noe det ikke ville gjort med den gamle +fritekst-versjonen. + +**Foreslått byggerekkefølge (IKKE bekreftet), tre uavhengig leverbare +faser:** +1. Venner-kjernen (vennskap, kategorisering, `/people/search`, ny + `/friends`-side) — leverbar og nyttig helt alene. +2. Rundevisibilitet (visibility-valg ved rundestart, gatede + lese-endepunkter, sanntids-livevisning for venner/offentlighet). +3. Ekte medspillere (ikke bare gjester) — utvider + `POST /rounds/{id}/participants` til å godta et søkt `user_id`. + **Avklart med bruker 2026-07-25: JA, runden skal vises i spillerens + egen rundeliste.** Teknisk konsekvens (se ADR-036 for full detalj): + `list_rounds` må utvides fra kun `owner_user_id` til også lenkede + `round_participant.user_id`-rader, og `RoundOut` sine + `owner_*`-statistikkfelt må bli "viewer-relative" (vise DEN som ser + på sin egen score, ikke alltid eierens). **Skrivetilgang også + avklart 2026-07-25: en medspiller skal kunne registrere score for + ALLE i flighten**, ikke bare egen rad — samme rettighet som eieren + har i dag, utvidet til enhver lenket ekte deltaker. Sletting av + runden og bane-/utslagsbytte forblir eier-eksklusivt (se ADR-036 for + presis autorisasjonssjekk). + +**Andre åpne spørsmål (se ADR-036 for full drøfting):** +- Skal en bruker kunne gjøre seg "usøkbar"? Foreslått default: alle med + fullført profil er søkbare, ingen opt-out i første omgang. +- Minimum antall tegn før søket returnerer globale (ikke-venn/klubb/land) + treff — foreslått 2, for å hindre triviell enumerering av alle + brukere. +- Skal en "offentlig" runde være synlig for ANONYME besøkende (som + turneringers offentlige side), eller kreve innlogging? Foreslått: + samme mønster som turnering — anonymt tilgjengelig. + +**Ingen kode skrevet ennå** — dette var en design-/dokumentasjonsrunde, +ikke en byggerunde, på brukerens eksplisitte instruks. + +--- + +## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget + +Reist av brukeren samme runde som dashbord-integreringen. I dag finnes +`DELETE .../roster/{roster_id}` (fjerner en spiller fra ETT lag) og +`PATCH .../roster/{roster_id}` (kun `is_captain`, ADR-023) — men INGEN vei +til å FLYTTE en allerede rostret spiller til et ANNET lag i samme +turnering i én operasjon. I dag må organisatoren gjøre det som to separate +kall (fjern fra lag A, legg til på lag B), og det finnes ingen slik +UI-handling i `tournament-detail.tsx` sin `TeamPanel` i det hele tatt — +kun fjerning. + +**Ikke designet i detalj ennå** — rent notert som et reelt hull. Naturlig +retning ved bygging: enten et eget `POST .../roster/{roster_id}/move` +(target-team-id) som gjør begge operasjonene atomisk (unngår en +mellomtilstand der spilleren midlertidig ikke er rostret noe sted), eller +en UI-snarvei som bare kjører de to eksisterende kallene i sekvens — det +første er tryggere (ingen delvis fullført tilstand ved feil midtveis). + +--- + +## Frittstående runder: flere flighter i én "vanlig" runde — 📋 NOTERT 2026-07-25, IKKE bygget + +Reist av brukeren samme runde: eksempel gitt — "jeg går i den første +flighten sammen med tre andre, mens fire venner går i flighten bak." +Dagens datamodell (`round`, ADR-033 Beslutning A) er implisitt ÉN flight += ÉN runde: `round.owner_user_id` er entydig, og alle +`round_participant`-rader (eier + gjester/medspillere) spiller sammen i +SAMME flight på SAMME hull-for-hull-registrering. + +**Reell modelleringsspenning, ikke bare en UI-mangel:** å støtte flere +flighter "i samme runde" krever et valg mellom to prinsipielt ulike +retninger: +1. **Løs gruppering av flere separate `round`-rader** — hver flight er + fortsatt sin egen `round` (egen eier, egne deltakere, egen + hull-registrering, uendret datamodell), men et nytt, tynt + "delt arrangement"-konsept binder flere runder sammen visuelt (samme + dag, samme bane, "spilt sammen med disse flightene") — minst + invasivt, gjenbruker alt som allerede er bygget og scratch-verifisert. +2. **`round` blir en beholder for flere flighter** — ligner + turneringers `session`→`match`-struktur (én økt, flere matcher). + Større omskriving: `round_participant` må da vite hvilken flight den + hører til, og eierskap/autorisasjon (i dag: "eieren av runden ser/ + redigerer alt") må revurderes for en modell med flere selvstendige + flighter under samme paraply. + +**Ikke besluttet hvilken retning** — kun notert som et reelt, ikke-trivielt +spørsmål. Henger dessuten sammen med det pågående ADR-036-arbeidet +(medspillere/venner) — en avklaring bør trolig vente til minst fase 1 av +ADR-036 er bygget, siden "hvem er i min flight" og "hvem er min venn/ +medspiller" er beslektede, men ikke identiske spørsmål. + +--- + ## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-25 (se ADR-033 for full detalj per dag) **Fremdrift 2026-07-22:** HCP-indeks-motor (43/43 tester), databaseskjema diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 0146e21..f57537f 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -1,24 +1,31 @@ "use client" +// Dashbord-redesign (ADR-035, 2026-07-25): organisasjon er ikke lenger det +// første/viktigste en bruker møter -- en bruker er først og fremst en +// GOLFSPILLER. Presentasjon fra V0 (zip 18), datalag skrevet om fra mock +// til ekte fetch. Organisasjon-opprettelse er nå USYNLIG/automatisk ("Ny +// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget +// "opprett organisasjon"-steg for det vanlige tilfellet) -- se +// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A. + import type React from "react" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { Building2, - Calendar, ChevronRight, - ChevronsUpDown, - ClipboardList, Flag, + KeyRound, LogOut, - MessageCircle, + MapPin, Plus, + TrendingDown, + TrendingUp, Trophy, - Users, UserCircle, + UserPlus, X, - Check, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -30,14 +37,17 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Wordmark } from "@/components/wordmark" +import { RoundCard, type Round } from "@/components/round-card" import { TournamentCard, type Tournament } from "@/components/tournament-card" -import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge" +import { type TournamentStatus } from "@/components/tournament-status-badge" import { cn } from "@/lib/utils" +// --- API-typer --------------------------------------------------------------- + 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. +// ADR-031: turneringer brukeren er SPILLER i (rostret på et lag), på tvers +// av organisasjoner, uavhengig av organisasjonsmedlemskap. type MyTournament = { organization_id: string organization_name: string @@ -48,8 +58,6 @@ type MyTournament = { team_name: string team_color: string | null next_session_at: string | null - // 2026-07-21 (deltaker-tilgang-runden): en av brukerens egne matcher, hvis - // noen finnes -- lar kortet lenke direkte til lagets chat/scorekort. my_session_id: string | null my_match_id: string | null } @@ -60,6 +68,7 @@ type Me = { display_name: string preferred_locale: string profile_complete: boolean + handicap_index: number | null organizations: MyOrg[] my_tournaments: MyTournament[] } @@ -72,130 +81,201 @@ type ApiTournament = { end_date: string | null } +type ApiRoundParticipant = { is_owner: boolean; counts_for_handicap: boolean; score_differential: number | null } +type ApiRound = { + id: string + name: string | null + course_name_snapshot: string + tee_name_snapshot: string + played_at: string + holes_planned: number + completed_at: string | null + participants: ApiRoundParticipant[] + owner_holes_played: number + owner_total_score: number | null + owner_score_to_par: number | null +} + +type ApiHandicapPoint = { handicap_index: number; recorded_at: string } + function toTournament(t: ApiTournament): Tournament { + return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined } +} + +function toRound(r: ApiRound): Round { + const owner = r.participants.find((p) => p.is_owner) return { - id: t.id, - name: t.name, - status: t.status, - startDate: t.start_date ?? undefined, - endDate: t.end_date ?? undefined, + id: r.id, + name: r.name, + courseName: r.course_name_snapshot, + status: r.completed_at ? "completed" : "active", + teeName: r.tee_name_snapshot, + holes: r.holes_planned === 9 ? 9 : 18, + date: r.played_at, + playerCount: r.participants.length, + holesPlayed: r.owner_holes_played, + totalScore: r.owner_total_score ?? undefined, + toPar: r.owner_score_to_par ?? undefined, + differential: owner?.counts_for_handicap ? owner.score_differential : null, } } -// --- Component ------------------------------------------------------------- +// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------ + +type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null } + +function combineTournaments(myTournaments: MyTournament[], organizerLists: { orgId: string; tournaments: ApiTournament[] }[]): CombinedTournament[] { + const byId = new Map() + + for (const t of myTournaments) { + byId.set(t.tournament_id, { + tournament: { id: t.tournament_id, name: t.tournament_name, status: t.status, startDate: t.next_session_at ?? undefined }, + organizer: false, + orgId: t.organization_id, + sortKey: t.next_session_at, + }) + } + + for (const { orgId, tournaments } of organizerLists) { + for (const t of tournaments) { + const existing = byId.get(t.id) + byId.set(t.id, { + tournament: existing?.tournament ?? toTournament(t), + organizer: true, + orgId: existing?.orgId ?? orgId, + sortKey: existing?.sortKey ?? t.start_date, + }) + } + } + + // Kun "kommende" -- fullførte/arkiverte turneringer skal ikke fylle opp + // en "kommende"-liste, uansett dato. + const upcoming = [...byId.values()].filter((c) => c.tournament.status === "draft" || c.tournament.status === "active") + upcoming.sort((a, b) => { + if (a.sortKey && b.sortKey) return a.sortKey < b.sortKey ? -1 : 1 + if (a.sortKey) return -1 + if (b.sortKey) return 1 + return a.tournament.name.localeCompare(b.tournament.name) + }) + return upcoming +} + +// --- Formattering ------------------------------------------------------------ + +function formatSigned(value: number, decimals = 1): string { + if (value === 0) return decimals === 0 ? "0" : `0,${"0".repeat(decimals)}` + const abs = Math.abs(value).toFixed(decimals).replace(".", ",") + return value > 0 ? `+${abs}` : `−${abs}` +} + +const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }) +function formatDate(value: string) { + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) return value + return dateFormatter.format(parsed) +} + +// --- Root component ---------------------------------------------------------- export function Dashboard() { const router = useRouter() const [me, setMe] = useState(null) const [loadingMe, setLoadingMe] = useState(true) - const [activeOrgId, setActiveOrgId] = useState(null) - const [tournaments, setTournaments] = useState([]) - const [loadingTournaments, setLoadingTournaments] = useState(false) + const [rounds, setRounds] = useState(null) + const [combinedTournaments, setCombinedTournaments] = useState([]) + const [hcpHistory, setHcpHistory] = useState([]) const [error, setError] = useState(null) - // Hent innlogget bruker + organisasjonsmedlemskap. Ingen gyldig sesjon -> - // tilbake til innloggingssiden (denne siden krever auth). - useEffect(() => { - let cancelled = false - async function loadMe() { - try { - const res = await fetch("/auth/me", { credentials: "include" }) - if (!res.ok) { - router.replace("/") - return - } - const data: Me = await res.json() - if (cancelled) return - // Obligatorisk profil-fullføring (2026-07-22): dekker enhver vei - // INN til dashbordet (magic-link/passord/2FA-verifisering lander - // her direkte) -- /account viser selv fullførings-skjemaet så - // lenge profil_complete er false. - if (!data.profile_complete) { - router.replace("/account") - return - } - setMe(data) - setActiveOrgId(data.organizations[0]?.organization_id ?? null) - } catch { - if (!cancelled) router.replace("/") - } finally { - if (!cancelled) setLoadingMe(false) + const loadMe = useCallback(async () => { + try { + const res = await fetch("/auth/me", { credentials: "include" }) + if (!res.ok) { + router.replace("/") + return } - } - void loadMe() - return () => { - cancelled = true + const data: Me = await res.json() + if (!data.profile_complete) { + router.replace("/account") + return + } + setMe(data) + } catch { + router.replace("/") + } finally { + setLoadingMe(false) } }, [router]) - // Turneringer hentes per valgt organisasjon, ikke i /auth/me-kallet. useEffect(() => { - if (!activeOrgId) { - setTournaments([]) - return - } + void loadMe() + }, [loadMe]) + + useEffect(() => { + fetch("/rounds", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: ApiRound[]) => setRounds(data)) + .catch(() => setRounds([])) + fetch("/auth/profile/handicap-history", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: ApiHandicapPoint[]) => setHcpHistory(data)) + .catch(() => {}) + }, []) + + useEffect(() => { + if (!me) return let cancelled = false - async function loadTournaments() { - setLoadingTournaments(true) - setError(null) + async function loadOrganizerTournaments() { try { - const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { credentials: "include" }) - if (!res.ok) throw new Error(`tournaments: ${res.status}`) - const data: ApiTournament[] = await res.json() - if (!cancelled) setTournaments(data.map(toTournament)) + const lists = await Promise.all( + me!.organizations.map(async (o) => { + const res = await fetch(`/orgs/${o.organization_id}/tournaments`, { credentials: "include" }) + const tournaments: ApiTournament[] = res.ok ? await res.json() : [] + return { orgId: o.organization_id, tournaments } + }), + ) + if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, lists)) } catch { - if (!cancelled) setError("Klarte ikke å hente turneringer. Prøv igjen om litt.") - } finally { - if (!cancelled) setLoadingTournaments(false) + if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, [])) } } - void loadTournaments() + void loadOrganizerTournaments() return () => { cancelled = true } - }, [activeOrgId]) + }, [me]) - async function handleCreateOrg(name: string) { + // "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) -- + // ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet. + // Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles + // FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en + // foreldreløs tom org hvis brukeren avbryter). + async function handleCreateTournament(name: string, chosenOrgId: string | null) { + if (!me) return setError(null) try { - const res = await fetch("/orgs", { + let orgId = chosenOrgId ?? (me.organizations.length === 1 ? me.organizations[0].organization_id : null) + if (!orgId) { + const orgName = me.display_name ? `${me.display_name}s turneringer` : "Mine turneringer" + const orgRes = await fetch("/orgs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name: orgName }), + }) + if (!orgRes.ok) throw new Error("orgs") + const org: { id: string; name: string; role: string } = await orgRes.json() + orgId = org.id + setMe((prev) => (prev ? { ...prev, organizations: [...prev.organizations, { organization_id: org.id, name: org.name, role: org.role }] } : prev)) + } + const res = await fetch(`/orgs/${orgId}/tournaments`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name }), }) - if (!res.ok) throw new Error(`orgs: ${res.status}`) - const org: { id: string; name: string; role: string } = await res.json() - setMe((prev) => - prev - ? { - ...prev, - organizations: [ - ...prev.organizations, - { organization_id: org.id, name: org.name, role: org.role }, - ], - } - : prev, - ) - setActiveOrgId(org.id) - } catch { - setError("Klarte ikke å opprette organisasjonen. Prøv igjen.") - } - } - - async function handleCreateTournament(name: string) { - if (!activeOrgId) return - setError(null) - try { - const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ name }), - }) - if (!res.ok) throw new Error(`create tournament: ${res.status}`) + if (!res.ok) throw new Error("create tournament") const created: ApiTournament = await res.json() - setTournaments((prev) => [toTournament(created), ...prev]) + router.push(`/tournaments/${created.id}?org=${orgId}&name=${encodeURIComponent(created.name)}`) } catch { setError("Klarte ikke å opprette turneringen. Prøv igjen.") } @@ -212,19 +292,39 @@ export function Dashboard() { if (loadingMe) { return (
-