diff --git a/.claude/settings.json b/.claude/settings.json index 18a6db1..9c43370 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -33,7 +33,11 @@ "Bash(python3 test_holeless_course_crash.py)", "Bash(sed -i 's/check\\(\"normal course: hole-score submit still succeeds \\(regression\\)\", status == 200\\)/check\\(\"normal course: hole-score submit still succeeds \\(regression\\)\", status == 201\\)/' test_holeless_course_crash.py)", "Bash(sed -i 's/if status != 200:/if status != 201:/' test_holeless_course_crash.py)", - "Bash(sed -i 's/check\\(\"normal course: second side'\"'\"'s hole-score submit succeeds\", status == 200\\)/check\\(\"normal course: second side'\"'\"'s hole-score submit succeeds\", status == 201\\)/' test_holeless_course_crash.py)" + "Bash(sed -i 's/check\\(\"normal course: second side'\"'\"'s hole-score submit succeeds\", status == 200\\)/check\\(\"normal course: second side'\"'\"'s hole-score submit succeeds\", status == 201\\)/' test_holeless_course_crash.py)", + "Bash(grep -n \"participantLabel\\(\\\\|export function\\\\|useState %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)" ] } } diff --git a/027_round_participant_user_unique.sql b/027_round_participant_user_unique.sql new file mode 100644 index 0000000..02eee39 --- /dev/null +++ b/027_round_participant_user_unique.sql @@ -0,0 +1,8 @@ +-- Hindrer at samme ekte bruker legges til som medspiller flere ganger på +-- samme runde (ADR-036 fase 3-utvidelsen, "søk og legg til medspiller på en +-- frittstående runde", 2026-07-26). Gjester (guest_name) er upåvirket -- +-- user_id er NULL for dem, og en partiell indeks dekker derfor kun de +-- ekte, lenkede radene. + +CREATE UNIQUE INDEX round_participant_unique_user + ON round_participant (round_id, user_id) WHERE user_id IS NOT NULL; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 513f86b..ef867c0 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -3062,12 +3062,52 @@ Frontend: V0-zip (zip 19) integrert som `components/friends.tsx` + ny rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo — det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i -samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. -**IKKE rullet ut ennå (frontend-delen)** — venter på brukerens -bekreftelse. Fase 2 (rundevisibilitet) og fase 3 (ekte medspillere, inkl. -den avklarte "skriv for hele flighten"-regelen) er fortsatt kun -designet, ikke bygget. Se "Venner og kategorisert deling"-seksjonen i -FEATURE_BACKLOG.md for full detalj. +samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. Rullet +ut live 2026-07-25, bruker bekreftet eksplisitt. + +**Fase 3 (ekte medspillere på en frittstående runde) BYGGET OG SCRATCH- +VERIFISERT 2026-07-26**, utløst av at brukeren rapporterte at "+ Gjest"- +skjemaet ikke søkte etter spillere i det hele tatt (rent tekstfelt, +`/people/search` var aldri koblet på). Bygget den delen av fase 3 som +faktisk var etterspurt: søk-og-legg-til en ekte bruker (gjenbruker +tiered `/people/search`, samme endepunkt som vennesøket) PLUSS den +tidligere avklarte "skriv for hele flighten"-regelen (bekreftet av +brukeren 2026-07-26 — "full tilgang nå", ikke bare rask utfylling). +**IKKE** bygget i denne runden: selve rundevisibilitet (fase 2, +offentlig/privat/delt-med-venner-i-grupper) — det er fortsatt et +separat, senere steg. + +Konkret: `round_participant.user_id` (fantes i skjemaet siden ADR-033, +aldri eksponert via API) er nå skrivbar via `POST /rounds/{id}/ +participants` (kjønn/HCP hentes automatisk fra den valgte personens +egen profil, ikke tastet manuelt). Ny migrasjon `027_round_participant_ +user_unique.sql` (partiell unik indeks, hindrer dobbel-lenking). Rundens +tilgang delt i to nivåer: `_get_accessible_round_or_404` (eier ELLER en +lenket medspiller — lesing, hull-scoring for HELE flighten, fullføring) +vs. `_get_owned_round_or_404` (fortsatt strengt eier-only — rediger/ +slett runde, legg til/fjern medspillere). `RoundOut` sine `owner_*`- +statistikkfelt omdøpt til `my_*` og gjort VIEWER-relative (regnes nå fra +den spørrende brukerens egen deltaker-rad, ikke alltid eierens — løser +et teknisk konsekvens-punkt notert allerede 2026-07-25 da "medspiller +ser runden i egen liste" ble bekreftet). `RoundParticipantOut` fikk et +nytt, alltid utfylt `display_name`-felt (levende oppslått, ikke +snapshot) — fanget OG fikset en reell latent bug i samme runde: +leaderboard-endepunktet (bygget 2026-07-26, samme dag) ville vist et +tomt navn for enhver lenket medspiller, siden dets `display_name`- +utledning den gang kun sjekket `is_owner`. +**Scratch-verifisert grundig (39/39 sjekker i to testløp):** søk-og- +legg-til (kjønn/HCP auto-fylt, avvist duplikat/selv/ukjent bruker/ +ufullstendig profil), lenket medspiller kan se runden+registrere score +for BÅDE egen OG andres rad (whole-flight), men KAN IKKE forvalte runden +(rediger/slett/legge til/fjerne/endre stat_level — alle 403), lenket +medspiller KAN fullføre runden, `/rounds`-listen viser nå runden for en +lenket medspiller (med DERES egen fremdrift, ikke eierens), en helt +urelatert bruker fortsatt 403/ikke i listen, leaderboardets navn stemmer +for alle tre deltakertyper (eier/medspiller/gjest). `test_isolation.sql` +12/12 uendret. Ekte typesjekket produksjonsbuild av frontend (ny +søk-UI i `round-detail.tsx`, "Gjest" omdøpt til "Medspiller" gjennomgående, +samme viewer-relative "Deg"-fiks portert til `round-stats.tsx`/ +`round-scorecard.tsx`, som hadde samme latente bug). --- diff --git a/CLAUDE.md b/CLAUDE.md index 75dc6fb..376137a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2982,6 +2982,55 @@ Ferdig og verifisert: begge containere boot-et rent (`Application startup complete`, Next.js `Ready`), `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **ADR-036 fase 3 (delvis): søk+legg til ekte medspiller på en + frittstående runde, BYGGET OG SCRATCH-VERIFISERT (2026-07-26), IKKE + ENNÅ RULLET UT:** brukeren rapporterte at "+ Gjest"-skjemaet ikke + søkte etter spillere når man skrev et navn — bekreftet reelt (rent + tekstfelt, `/people/search` var aldri koblet på). Spurte om + omfang før bygging: brukeren ville ha "full tilgang nå" (medspilleren + kan selv registrere score), ikke bare rask utfylling — et bevisst + større valg enn det anbefalte minimum. + **Backend:** `POST /rounds/{id}/participants` tar nå ENTEN `user_id` + (funnet via tiered `/people/search`, samme algoritme som vennesøket) + ELLER `guest_name` (uendret) — kjønn/HCP hentes automatisk fra den + valgte personens egen profil. Ny migrasjon `027_round_participant_ + user_unique.sql` (partiell unik indeks). Ny `_get_accessible_round_ + or_404` (eier ELLER lenket medspiller) for lesing/hull-scoring/ + fullføring — `_get_owned_round_or_404` (strengt eier-only) beholdt + for rediger/slett/legg til/fjern/endre stat_level. `RoundOut` sine + `owner_*`-felt omdøpt til `my_*` og gjort VIEWER-relative (regnes nå + fra den spørrende brukerens egen deltaker-rad). + **Reell latent bug funnet OG fikset i SAMME runde, før den nådde + produksjon:** leaderboard-endepunktet (bygget tidligere samme dag, + se over) ville vist et TOMT navn for enhver lenket medspiller, siden + dets `display_name`-utledning kun sjekket `is_owner`, ikke om + `user_id` var satt i det hele tatt. Fanget under scratch-testing av + DENNE runden, fikset før utrulling av noen av delene. + **Frontend:** "+ Gjest" omdøpt til "+ Medspiller" i `round-detail.tsx`, + nytt søk-som-du-skriver-felt (avatar-initialer, hjemmeklubb) med + "Legg til uten konto"-fallback til det gamle tekstfeltet. Viewer- + relativ "Deg"-visning (ny `/auth/me`-bruk for viewerId) — samme fiks + portert til `round-stats.tsx`/`round-scorecard.tsx`, som hadde + identisk latent bug (ville vist eieren som "Deg" for en medspiller + som så på). Rediger/slett/legg-til/fjern-knappene skjules nå for en + ikke-eier-viewer. + **Scratch-verifisert grundig, 39/39 sjekker i to testløp:** søk-og- + legg-til med auto-utfylt kjønn/HCP, avvist duplikat/selv-tillegg/ + ukjent bruker/ufullstendig profil, lenket medspiller kan lese runden + + registrere score for BÅDE egen OG andres rad (whole-flight- + regelen bekreftet), men nektes å forvalte runden (alle + forvaltningskall 403), lenket medspiller KAN fullføre runden, + `/rounds`-listen viser nå runden for en lenket medspiller med DERES + EGEN fremdrift (ikke eierens), urelatert bruker fortsatt 403/ikke i + listen, leaderboard-navn stemmer for alle tre deltakertyper. + `test_isolation.sql` 12/12 uendret. Ekte typesjekket produksjonsbuild + kompilerte rent. + **Rullet ut live 2026-07-26**, bruker bekreftet eksplisitt: migrasjon + 027 kjørt mot ekte `teecup_db` (unik indeks bekreftet, + `test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d + --build teecup_api teecup_frontend`. Begge containere boot-et rent, + `/health`/`/dashboard`/`/my-rounds` → 200, `teeoff.no` upåvirket. + Neste steg: 0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):** varsler (in-app varslingssenter) + leaderboard-BACKENDEN for diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 5d917db..7375d49 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -2032,7 +2032,7 @@ gjennomgang av blokk-forslaget over først): --- -## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND+FRONTEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet +## Venner, kategorisert deling av runder, og tiered personsøk — ✅ FASE 1 (venner-kjernen) FERDIG, ✅ deler av FASE 3 (søk+legg til medspiller, skriv for hele flighten) BYGGET 2026-07-26, fase 2 (rundevisibilitet) fortsatt kun designet Reist av brukeren samme runde som dashbord-forslaget over. Fullt design skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for @@ -2072,17 +2072,12 @@ faser:** 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). + **✅ DENNE DELEN BYGGET 2026-07-26** (se egen "Fase 3 (delvis)"- + underseksjon under Fase 1) — søk+legg til + "skriv for hele + flighten" er live. `list_rounds`/`RoundOut` sine viewer-relative + `my_*`-felt (se under) er del av denne leveransen. + Sletting av runden og bane-/utslagsbytte forblir eier-eksklusivt + (bekreftet, se ADR-036). **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 @@ -2099,7 +2094,7 @@ ikke en byggerunde, på brukerens eksplisitte instruks. --- -### Fase 1 (venner-kjernen) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT 2026-07-25, IKKE ennå rullet ut, frontend gjenstår +### Fase 1 (venner-kjernen) — ✅ HELT FERDIG, backend+frontend rullet ut live 2026-07-25 Migrasjon `025_friends.sql` (`friendship` + `friend_categorization`, ingen RLS — samme `plain_connection()`-mønster som runder), nytt @@ -2166,6 +2161,55 @@ typesjekket produksjonsbuild kompilerte rent. --- +### Fase 3 (delvis): søk+legg til medspiller, skriv for hele flighten — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-26 + +Utløst av at brukeren rapporterte at "+ Gjest"-skjemaet på en frittstående +runde ikke søkte etter spillere — bekreftet reelt (rent tekstfelt, ingen +søk koblet på ennå). Bygget den etterspurte delen av fase 3 (søk-og- +legg-til + full skrivetilgang for medspillere), IKKE rundevisibilitet +(fase 2, fortsatt egen, senere runde). + +**Backend** (`app/routers/rounds.py`): `POST /rounds/{id}/participants` +tar nå ENTEN `user_id` (funnet via `/people/search`, samme tiered +algoritme som vennesøket) ELLER `guest_name` (uendret fallback for +spillere uten konto) — kjønn/HCP hentes automatisk fra den valgte +personens EGEN profil ved `user_id`, ikke tastet manuelt. Ny migrasjon +`027_round_participant_user_unique.sql` (partiell unik indeks, hindrer +dobbel-lenking av samme bruker). Ny `_get_accessible_round_or_404` +(eier ELLER lenket medspiller) brukt for lesing/hull-scoring/fullføring +— `_get_owned_round_or_404` (strengt eier-only) beholdt for rediger/ +slett/legg til/fjern/endre-stat_level. `RoundOut` sine `owner_*`-felt +omdøpt til `my_*` og gjort VIEWER-relative (bekreftet nødvendig allerede +2026-07-25). Ny `display_name` på `RoundParticipantOut` (levende +oppslått, ikke snapshot) — fanget og fikset en reell latent bug i +leaderboard-endepunktet (bygget samme dag) som ville vist blankt navn +for enhver lenket medspiller. + +**Frontend** (`round-detail.tsx` + samme fiks portert til `round-stats.tsx`/ +`round-scorecard.tsx`): "+ Gjest" omdøpt til "+ Medspiller", nytt +søk-som-du-skriver-felt (debounce, avatar-initialer, hjemmeklubb) med en +"Legg til uten konto"-fallback til det gamle tekstfeltet. Viewer-relativ +"Deg"-visning (krevde en `/auth/me`-utvidelse for å kjenne den +innloggede brukerens egen id) — en lenket medspiller ser nå seg selv som +"Deg" og eieren under sitt eget navn, ikke omvendt. Rediger/slett-runde +og legg til/fjern-medspiller-knappene skjules nå for en ikke-eier-viewer +(backend avviser uansett, men UI-et bør ikke vise handlinger som bare +feiler). + +**Scratch-verifisert grundig, 39/39 sjekker i to testløp:** søk-og- +legg-til (kjønn/HCP auto-fylt fra profil), avvist duplikat/selv-tillegg/ +ukjent bruker/ufullstendig profil (mangler kjønn), lenket medspiller kan +lese runden + registrere score for BÅDE egen OG andres rad (whole- +flight-regelen), men nektes å forvalte runden (rediger/slett/legge +til/fjerne/endre stat_level — alle 403), lenket medspiller KAN fullføre +runden, `/rounds`-listen viser nå runden for en lenket medspiller (med +DERES egen fremdrift, ikke eierens), en helt urelatert bruker fortsatt +403/ikke i listen, leaderboardets navn stemmer for alle tre +deltakertyper. `test_isolation.sql` 12/12 uendret. Ekte typesjekket +produksjonsbuild kompilerte rent. + +--- + ## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget Reist av brukeren samme runde som dashbord-integreringen. I dag finnes diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 5b4eb4f..2a6d743 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -415,6 +415,11 @@ class RoundParticipantOut(BaseModel): id: str user_id: str | None guest_name: str | None + # Alltid utfylt -- guest_name for gjester, ellers en LEVENDE oppslått + # app_user.display_name for enhver lenket bruker (eier ELLER medspiller, + # ADR-036 fase 3-utvidelsen 2026-07-26) -- ikke en snapshot, samme + # "vis gjeldende navn"-mønster som org-medlemslister/chat-forfatter. + display_name: str is_owner: bool gender: str handicap_index_snapshot: float | None @@ -436,17 +441,17 @@ class RoundOut(BaseModel): started_at: str | None completed_at: str | None participants: list[RoundParticipantOut] - # Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) -- - # brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i - # det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker - # 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er - # registrert. - owner_holes_played: int - owner_total_score: int | None - owner_score_to_par: int | None + # Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036 + # fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra + # round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme + # dag, siden feltet nå er VIEWER-relativt, ikke alltid eierens. + # `my_score_to_par` er None helt til minst ett hull er registrert. + my_holes_played: int + my_total_score: int | None + my_score_to_par: int | None -async def _load_round_out(conn, round_id: str) -> RoundOut: +async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: round_row = await conn.fetchrow( """ SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, @@ -457,28 +462,39 @@ async def _load_round_out(conn, round_id: str) -> RoundOut: ) participant_rows = await conn.fetch( """ - SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, - handicap_index_snapshot::float AS handicap_index_snapshot, - course_handicap_snapshot, counts_for_handicap, - score_differential::float AS score_differential, stat_level - FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at + SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name, + rp.is_owner, rp.gender, + rp.handicap_index_snapshot::float AS handicap_index_snapshot, + rp.course_handicap_snapshot, rp.counts_for_handicap, + rp.score_differential::float AS score_differential, rp.stat_level + FROM round_participant rp + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at """, round_id, ) - owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None) - owner_agg = await conn.fetchrow( + # Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket + # medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN + # fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad + # i det usannsynlige tilfellet spørreren ikke har noen egen + # deltaker-rad (bør ikke skje, siden tilgang allerede er gatet til + # eier-eller-deltaker av kallerne). + viewer_participant_id = next((r["id"] for r in participant_rows if r["user_id"] == viewer_user_id), None) + target_id = viewer_participant_id or next((r["id"] for r in participant_rows if r["is_owner"]), None) + viewer_agg = await conn.fetchrow( """ SELECT COUNT(*) FILTER (WHERE played) AS played_count, COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score, COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par FROM round_hole WHERE round_participant_id = $1 """, - owner_id, + target_id, ) - owner_holes_played = owner_agg["played_count"] if owner_agg else 0 - owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None - owner_score_to_par = ( - owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None + my_holes_played = viewer_agg["played_count"] if viewer_agg else 0 + my_total_score = viewer_agg["total_score"] if my_holes_played > 0 else None + my_score_to_par = ( + viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None ) return RoundOut( id=round_row["id"], @@ -492,9 +508,9 @@ async def _load_round_out(conn, round_id: str) -> RoundOut: started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None, completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None, participants=[RoundParticipantOut(**dict(r)) for r in participant_rows], - owner_holes_played=owner_holes_played, - owner_total_score=owner_total_score, - owner_score_to_par=owner_score_to_par, + my_holes_played=my_holes_played, + my_total_score=my_total_score, + my_score_to_par=my_score_to_par, ) @@ -613,20 +629,32 @@ async def create_round( stat_level=body.stat_level, ) - return await _load_round_out(conn, round_id) + return await _load_round_out(conn, round_id, user.user_id) @router.get("/rounds", response_model=list[RoundOut]) async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]: async with plain_connection() as conn: + # ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller ser nå + # runden i SIN EGEN liste også, ikke bare eieren -- bekreftet av + # brukeren som ønsket allerede 2026-07-25. ids = await conn.fetch( - "SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC", + """ + SELECT r.id::text AS id + FROM round r + WHERE r.owner_user_id = $1 + OR EXISTS(SELECT 1 FROM round_participant WHERE round_id = r.id AND user_id = $1) + ORDER BY r.played_at DESC, r.created_at DESC + """, user.user_id, ) - return [await _load_round_out(conn, r["id"]) for r in ids] + return [await _load_round_out(conn, r["id"], user.user_id) for r in ids] async def _get_owned_round_or_404(conn, round_id: str, user_id: str): + """Strengt eier-only -- for runde-forvaltning (rediger/slett metadata, + legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se + `_get_accessible_round_or_404`.""" row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id) if row is None: raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") @@ -634,11 +662,34 @@ async def _get_owned_round_or_404(conn, round_id: str, user_id: str): raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.") +async def _get_accessible_round_or_404(conn, round_id: str, user_id: str): + """Eier ELLER en lenket medspiller (round_participant.user_id) -- + ADR-036 fase 3-utvidelsen (2026-07-26): en medspiller kan se runden og + registrere score for HELE flighten (bekreftet regel), men kan IKKE + forvalte runden (se `_get_owned_round_or_404` for det).""" + row = await conn.fetchrow( + """ + SELECT r.owner_user_id::text AS owner_user_id, + EXISTS( + SELECT 1 FROM round_participant + WHERE round_id = r.id AND user_id = $2 + ) AS is_linked_participant + FROM round r WHERE r.id = $1 + """, + round_id, + user_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if row["owner_user_id"] != user_id and not row["is_linked_participant"]: + raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.") + + @router.get("/rounds/{round_id}", response_model=RoundOut) async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) - return await _load_round_out(conn, round_id) + await _get_accessible_round_or_404(conn, round_id, user.user_id) + return await _load_round_out(conn, round_id, user.user_id) class RoundUpdate(BaseModel): @@ -804,7 +855,7 @@ async def update_round( p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number], ) - return await _load_round_out(conn, round_id) + return await _load_round_out(conn, round_id, user.user_id) @router.delete("/rounds/{round_id}", status_code=204) @@ -815,34 +866,75 @@ async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_us # --------------------------------------------------------------------------- -# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1 +# Medspillere (flighten) -- enten en EKTE TeeCup-bruker (funnet via det +# delte tiered personsøket, /people/search, ADR-036) eller en gjest uten +# konto (fritekstnavn). ADR-036 fase 3-utvidelsen (2026-07-26) -- inntil da +# var alle deltakere utenom eieren gjester. # --------------------------------------------------------------------------- -class GuestParticipantCreate(BaseModel): - guest_name: str = Field(min_length=1, max_length=100) - gender: Literal["m", "f", "x"] +class ParticipantCreate(BaseModel): + # Nøyaktig én av user_id/guest_name. + user_id: str | None = None + guest_name: str | None = Field(default=None, min_length=1, max_length=100) + # Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra + # personens egen profil (samme kilde som eierens egen deltaker-rad). + gender: Literal["m", "f", "x"] | None = None handicap_index: float | None = Field(default=None, ge=-10, le=54) stat_level: StatLevel = "strokes_only" @router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201) -async def add_guest_participant( +async def add_participant( round_id: str, - body: GuestParticipantCreate, + body: ParticipantCreate, user: CurrentUser = Depends(get_current_user), ) -> RoundParticipantOut: + if (body.user_id is not None) == (body.guest_name is not None): + raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.") + async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) round_row = await conn.fetchrow( - "SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1", + "SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, " + "teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1", round_id, ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if round_row["owner_user_id"] != user.user_id: + raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.") + if round_row["course_source"] == "teeoff": resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"])) else: resolved = await _resolve_personal_course(round_row["personal_course_id"]) - if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None: + if body.user_id is not None: + if body.user_id == round_row["owner_user_id"]: + raise app_error(400, "VALIDATION_FAILED", "Du er allerede med i runden som eier.") + target = await conn.fetchrow( + "SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1", + body.user_id, + ) + if target is None: + raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.") + if target["gender"] is None: + raise app_error( + 400, "VALIDATION_FAILED", + "Spilleren du valgte har ikke fullført profilen sin (mangler kjønn) og kan ikke legges til ennå.", + ) + gender = target["gender"] + handicap_index = target["handicap_index"] + guest_name = None + linked_user_id = body.user_id + else: + gender = body.gender + handicap_index = body.handicap_index + guest_name = body.guest_name.strip() + linked_user_id = None + if gender is None: + raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.") + + if resolved.rating_for(round_row["tee_name_snapshot"], gender) is None and handicap_index is not None: raise app_error( 400, "VALIDATION_FAILED", "Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.", @@ -851,17 +943,21 @@ async def add_guest_participant( async with conn.transaction(), translate_db_errors(): participant_id = await _create_participant( conn, round_id, resolved, round_row["tee_name_snapshot"], - user_id=None, guest_name=body.guest_name.strip(), is_owner=False, - gender=body.gender, handicap_index=body.handicap_index, + user_id=linked_user_id, guest_name=guest_name, is_owner=False, + gender=gender, handicap_index=handicap_index, stat_level=body.stat_level, ) row = await conn.fetchrow( """ - SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, - handicap_index_snapshot::float AS handicap_index_snapshot, - course_handicap_snapshot, counts_for_handicap, - score_differential::float AS score_differential, stat_level - FROM round_participant WHERE id = $1 + SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name, + rp.is_owner, rp.gender, + rp.handicap_index_snapshot::float AS handicap_index_snapshot, + rp.course_handicap_snapshot, rp.counts_for_handicap, + rp.score_differential::float AS score_differential, rp.stat_level + FROM round_participant rp + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.id = $1 """, participant_id, ) @@ -881,19 +977,26 @@ async def update_participant( ) -> RoundParticipantOut: async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) - row = await conn.fetchrow( - """ - UPDATE round_participant SET stat_level = $3 - WHERE id = $1 AND round_id = $2 - RETURNING id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, - handicap_index_snapshot::float AS handicap_index_snapshot, - course_handicap_snapshot, counts_for_handicap, - score_differential::float AS score_differential, stat_level - """, + updated_id = await conn.fetchval( + "UPDATE round_participant SET stat_level = $3 WHERE id = $1 AND round_id = $2 RETURNING id", participant_id, round_id, body.stat_level, ) - if row is None: + if updated_id is None: raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + row = await conn.fetchrow( + """ + SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name, + rp.is_owner, rp.gender, + rp.handicap_index_snapshot::float AS handicap_index_snapshot, + rp.course_handicap_snapshot, rp.counts_for_handicap, + rp.score_differential::float AS score_differential, rp.stat_level + FROM round_participant rp + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.id = $1 + """, + updated_id, + ) return RoundParticipantOut(**dict(row)) @@ -953,7 +1056,7 @@ async def list_holes( user: CurrentUser = Depends(get_current_user), ) -> list[RoundHoleOut]: async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) + await _get_accessible_round_or_404(conn, round_id, user.user_id) participant_row = await conn.fetchrow( "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id, @@ -1008,14 +1111,14 @@ class LeaderboardOut(BaseModel): @router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut) async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut: async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) + await _get_accessible_round_or_404(conn, round_id, user.user_id) round_row = await conn.fetchrow( "SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id ) participant_rows = await conn.fetch( """ SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot, - au.display_name AS owner_display_name + au.display_name AS linked_display_name FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.round_id = $1 @@ -1045,7 +1148,10 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) net_score_to_par = score_to_par - strokes_received_total - display_name = p["guest_name"] if not p["is_owner"] else (p["owner_display_name"] or "Deg") + # Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier + # OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et + # viewer-relativt "Deg" ville vært feil for de andre som ser på. + display_name = p["guest_name"] or p["linked_display_name"] or "Medspiller" entries.append( LeaderboardEntryOut( participant_id=p["id"], @@ -1093,7 +1199,11 @@ async def update_hole( user: CurrentUser = Depends(get_current_user), ) -> RoundHoleOut: async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) + # ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller kan + # registrere score for HELE flighten (bekreftet regel), ikke bare + # sin egen rad -- derfor accessible-sjekk, ikke owner-only, og + # INGEN participant_id == viewer-sjekk under. + await _get_accessible_round_or_404(conn, round_id, user.user_id) participant_row = await conn.fetchrow( "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id, @@ -1149,7 +1259,9 @@ async def update_hole( @router.post("/rounds/{round_id}/complete", response_model=RoundOut) async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut: async with plain_connection() as conn: - await _get_owned_round_or_404(conn, round_id, user.user_id) + # Samme begrunnelse som update_hole -- en medspiller som fører + # score for flighten skal også kunne markere runden ferdig. + await _get_accessible_round_or_404(conn, round_id, user.user_id) round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id) participants = await conn.fetch( @@ -1190,7 +1302,7 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ ) await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) - return await _load_round_out(conn, round_id) + return await _load_round_out(conn, round_id, user.user_id) def _course_handicap_from_row(p) -> int: diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 9a1da42..8ec86a0 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -84,7 +84,12 @@ type ApiTournament = { end_date: string | null } -type ApiRoundParticipant = { is_owner: boolean; counts_for_handicap: boolean; score_differential: number | null } +type ApiRoundParticipant = { + user_id: string | null + is_owner: boolean + counts_for_handicap: boolean + score_differential: number | null +} type ApiRound = { id: string name: string | null @@ -94,9 +99,9 @@ type ApiRound = { holes_planned: number completed_at: string | null participants: ApiRoundParticipant[] - owner_holes_played: number - owner_total_score: number | null - owner_score_to_par: number | null + my_holes_played: number + my_total_score: number | null + my_score_to_par: number | null } type ApiHandicapPoint = { handicap_index: number; recorded_at: string } @@ -112,8 +117,8 @@ 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) +function toRound(r: ApiRound, viewerId: string | null): Round { + const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner) return { id: r.id, name: r.name, @@ -123,10 +128,10 @@ function toRound(r: ApiRound): Round { 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, + holesPlayed: r.my_holes_played, + totalScore: r.my_total_score ?? undefined, + toPar: r.my_score_to_par ?? undefined, + differential: me?.counts_for_handicap ? me.score_differential : null, } } @@ -325,9 +330,9 @@ export function Dashboard() { const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3) const avgToParSample = completedRounds - .filter((r) => r.owner_score_to_par !== null) + .filter((r) => r.my_score_to_par !== null) .slice(0, 5) - .map((r) => r.owner_score_to_par as number) + .map((r) => r.my_score_to_par as number) const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—" const hcpValues = hcpHistory.map((h) => h.handicap_index) @@ -390,7 +395,7 @@ export function Dashboard() {
- + toRound(r, me.id))} loading={rounds === null} /> diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx index 02a3d61..2b91f00 100644 --- a/frontend/components/own-rounds.tsx +++ b/frontend/components/own-rounds.tsx @@ -15,6 +15,7 @@ import { ArrowLeft } from "lucide-react" type ApiRoundParticipant = { id: string + user_id: string | null is_owner: boolean guest_name: string | null counts_for_handicap: boolean @@ -30,14 +31,16 @@ type ApiRound = { holes_planned: number completed_at: string | null participants: ApiRoundParticipant[] - owner_holes_played: number - owner_total_score: number | null - owner_score_to_par: number | null + my_holes_played: number + my_total_score: number | null + my_score_to_par: number | null } -function toRound(r: ApiRound): Round { - const owner = r.participants.find((p) => p.is_owner) - const differential = owner?.counts_for_handicap ? owner.score_differential : null +// viewerId er null helt til /auth/me har svart -- faller da tilbake til +// eierens rad (samme oppførsel som før ADR-036 fase 3-utvidelsen). +function toRound(r: ApiRound, viewerId: string | null): Round { + const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner) + const differential = me?.counts_for_handicap ? me.score_differential : null return { id: r.id, name: r.name, @@ -47,9 +50,9 @@ function toRound(r: ApiRound): Round { 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, + holesPlayed: r.my_holes_played, + totalScore: r.my_total_score ?? undefined, + toPar: r.my_score_to_par ?? undefined, differential, } } @@ -58,6 +61,20 @@ export function OwnRounds() { const router = useRouter() const [rounds, setRounds] = useState(null) const [error, setError] = useState(null) + const [viewerId, setViewerId] = useState(null) + + useEffect(() => { + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { id: string } | null) => { + if (!cancelled && data) setViewerId(data.id) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) useEffect(() => { let cancelled = false @@ -70,7 +87,7 @@ export function OwnRounds() { } if (!res.ok) throw new Error(`rounds: ${res.status}`) const data: ApiRound[] = await res.json() - if (!cancelled) setRounds(data.map(toRound)) + if (!cancelled) setRounds(data.map((r) => toRound(r, viewerId))) } catch { if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.") } @@ -79,7 +96,7 @@ export function OwnRounds() { return () => { cancelled = true } - }, [router]) + }, [router, viewerId]) return (
diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 4d3e87f..8f19f39 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -119,6 +119,9 @@ type ApiParticipant = { id: string user_id: string | null guest_name: string | null + // Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått + // visningsnavn for en lenket bruker (eier eller medspiller). + display_name: string is_owner: boolean gender: ApiGender handicap_index_snapshot: number | null @@ -128,6 +131,14 @@ type ApiParticipant = { stat_level: StatLevel } +type PersonMatch = { + id: string + first_name: string + last_name: string + avatar_url: string | null + home_club: string | null +} + type ApiRound = { id: string name: string | null @@ -211,8 +222,11 @@ function statToPatchBody(s: HoleStat) { } } -function playerLabel(p: ApiParticipant): string { - return p.is_owner ? "Deg" : p.guest_name ?? "Gjest" +// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26): "Deg" skal vises +// for DEN SOM SER PÅ, ikke alltid runde-eieren -- en lenket medspiller som +// åpner runden skal se seg selv som "Deg" og eieren under sitt eget navn. +function playerLabel(p: ApiParticipant, viewerId: string | null): string { + return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name } // --- Component ------------------------------------------------------------- @@ -227,6 +241,11 @@ export function RoundDetail({ roundId }: { roundId: string }) { // knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun // for eieren selv (gjester har ingen profil å hente dette fra). const [ownBagClubs, setOwnBagClubs] = useState([]) + // Den innloggede brukerens egen id (ADR-036 fase 3-utvidelsen, + // 2026-07-26) -- runden kan nå åpnes av en lenket medspiller, ikke bare + // eieren, så "hvem er 'Deg'" og "hvem har lov til å forvalte runden" + // avhenger av DENNE, ikke av `is_owner` alene. + const [viewerId, setViewerId] = useState(null) // `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1 // er en gyldig, truthy hullverdi og ville gjort `prev || start_hole` // lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på @@ -275,8 +294,11 @@ export function RoundDetail({ roundId }: { roundId: string }) { let cancelled = false fetch("/auth/me", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) - .then((data: { bag_clubs: string[] } | null) => { - if (!cancelled && data) setOwnBagClubs(data.bag_clubs) + .then((data: { id: string; bag_clubs: string[] } | null) => { + if (!cancelled && data) { + setOwnBagClubs(data.bag_clubs) + setViewerId(data.id) + } }) .catch(() => {}) return () => { @@ -306,15 +328,20 @@ export function RoundDetail({ roundId }: { roundId: string }) { const players: Player[] = round?.participants.map((p) => ({ id: p.id, - name: playerLabel(p), + name: playerLabel(p, viewerId), gender: apiGenderToUi(p.gender), hcp: p.handicap_index_snapshot, - isSelf: p.is_owner, + isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner, countsForHandicap: p.counts_for_handicap, scoreDifferential: p.score_differential, statLevel: p.stat_level, })) ?? [] + // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne + // medspillere) -- en lenket medspiller kan se runden og registrere + // score for hele flighten, men ikke dette (ADR-036 fase 3-utvidelsen). + const isOwnerViewer = viewerId !== null && (round?.participants.some((p) => p.is_owner && p.user_id === viewerId) ?? false) + const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index })) @@ -398,6 +425,24 @@ export function RoundDetail({ roundId }: { roundId: string }) { setActivePlayerId(created.id) } + async function addSearchedParticipant(userId: string, statLevel: StatLevel): Promise { + const res = await fetch(`/rounds/${roundId}/participants`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ user_id: userId, stat_level: statLevel }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + return body?.detail?.message ?? "Klarte ikke å legge til spilleren." + } + setShowAddGuest(false) + const created: ApiParticipant = await res.json() + await loadRound() + setActivePlayerId(created.id) + return null + } + async function removeGuest(id: string) { if (!confirm("Fjerne denne spilleren fra runden?")) return const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" }) @@ -551,15 +596,17 @@ export function RoundDetail({ roundId }: { roundId: string }) { forbi hull-registreringen for å nå dem i det hele tatt. */}
- + {isOwnerViewer && ( + + )} {!completed && ( + {isOwnerViewer && ( + + )}
- {showEditRound && ( + {showEditRound && isOwnerViewer && ( setShowEditRound(false)} /> )}
@@ -607,6 +656,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { onAdd={() => setShowAddGuest((v) => !v)} addOpen={showAddGuest} readOnly={readOnly} + canManage={isOwnerViewer} /> {!readOnly && ( @@ -623,7 +673,13 @@ export function RoundDetail({ roundId }: { roundId: string }) { mens runden pågår. */} {!completed && } - {showAddGuest && !readOnly && setShowAddGuest(false)} />} + {showAddGuest && !readOnly && isOwnerViewer && ( + setShowAddGuest(false)} + /> + )} {/* Hole navigation */} {holes.length === 0 ? ( @@ -1182,6 +1238,7 @@ function PlayerTabs({ onAdd, addOpen, readOnly, + canManage, }: { players: Player[] activePlayerId: string @@ -1190,13 +1247,17 @@ function PlayerTabs({ onAdd: () => void addOpen: boolean readOnly: boolean + // Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase + // 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE + // mellom faner for å registrere score for hele flighten. + canManage: boolean }) { return (
{players.map((player) => { const active = player.id === activePlayerId - const canRemove = !player.isSelf && !readOnly + const canRemove = !player.isSelf && !readOnly && canManage return (
) })} - {!readOnly && ( + {!readOnly && canManage && ( )}
@@ -1242,14 +1303,143 @@ function PlayerTabs({ ) } -// --- Add guest form -------------------------------------------------------- +// --- Legg til medspiller: søk etter ekte bruker, eller gjest uten konto ---- +// (ADR-036 fase 3-utvidelsen, 2026-07-26 -- "+ Gjest" søkte tidligere ikke +// etter spillere i det hele tatt, kun et rent tekstfelt.) + +function AddParticipantForm({ + onAddGuest, + onAddSearched, + onCancel, +}: { + onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void + onAddSearched: (userId: string, statLevel: StatLevel) => Promise + onCancel: () => void +}) { + const [mode, setMode] = useState<"search" | "guest">("search") + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [searching, setSearching] = useState(false) + const [adding, setAdding] = useState(false) + const [searchError, setSearchError] = useState(null) + + useEffect(() => { + const trimmed = query.trim() + if (trimmed.length < 2) { + setResults([]) + setSearching(false) + return + } + setSearching(true) + const handle = setTimeout(() => { + fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((data: PersonMatch[]) => setResults(data)) + .catch(() => setResults([])) + .finally(() => setSearching(false)) + }, 250) + return () => clearTimeout(handle) + }, [query]) + + async function handlePick(person: PersonMatch) { + setAdding(true) + setSearchError(null) + const errorMessage = await onAddSearched(person.id, "strokes_only") + setAdding(false) + if (errorMessage) setSearchError(errorMessage) + } + + if (mode === "guest") { + return setMode("search")} /> + } + + return ( +
+
+ +
+
+ {query.trim().length > 0 && query.trim().length < 2 && ( +

Skriv minst 2 tegn for å søke.

+ )} +
+ + {searchError && ( +

+ {searchError} +

+ )} + + {results.length > 0 && ( +
    + {results.map((person) => ( +
  • + +
  • + ))} +
+ )} + + {!searching && query.trim().length >= 2 && results.length === 0 && ( +

Fant ingen med det navnet.

+ )} + +
+ + +
+
+ ) +} + +// --- Add guest form (fallback for spillere uten TeeCup-konto) -------------- function AddGuestForm({ onAdd, onCancel, + onBack, }: { onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onCancel: () => void + onBack?: () => void }) { const [name, setName] = useState("") const [gender, setGender] = useState("male") @@ -1266,6 +1456,15 @@ function AddGuestForm({ return (
+ {onBack && ( + + )}