Update Todos

Backend: migrasjon 027 (unik user_id per runde)

Backend: søke-basert deltaker-innsetting (user_id via /people/search)

Backend: åpne opp co-player-tilgang (accessible vs owned) + viewer-relative RoundOut

Frontend: rename Gjest→Medspiller + søk-UI + viewer-relativ "Deg"

Frontend: fiks round-stats.tsx/round-scorecard.tsx samme viewer-bug

Typesjekket produksjonsbuild

Scratch-verifisere hele funksjonen grundig

Oppdatere .md-filer

Legge frem utrullingsplan og vente på bekreftelse
Live. Søket på "+ Medspiller" fungerer nå, og en lagt-til medspiller har full tilgang til å registrere score for hele flighten mens rundeforvaltning (rediger/slett/legg til/fjern) forblir eierens alene.
This commit is contained in:
Erol Haagenrud 2026-07-26 08:22:41 +02:00
parent a09fea3caa
commit 89136a1a83
11 changed files with 678 additions and 148 deletions

View file

@ -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<ApiRound\\\\|fetch\\(\\\\`/rounds\" /opt/teecup/frontend/components/round-scorecard.tsx)",
"Bash(python3 test_round_coplayer.py)",
"Bash(python3 test_round_coplayer_incomplete.py)",
"Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/my-rounds -> %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)"
]
}
}

View file

@ -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;

View file

@ -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).
---

View file

@ -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

View file

@ -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``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

View file

@ -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:

View file

@ -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() {
<div className="mt-6 flex flex-col gap-8">
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
<UpcomingRounds rounds={roundsForCards.map(toRound)} loading={rounds === null} />
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id))} loading={rounds === null} />
<UpcomingTournaments entries={combinedTournaments} />
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
<PlayedCourses courses={playedCourses} />

View file

@ -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<Round[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(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 (
<div className="flex min-h-[100dvh] flex-col bg-background">

View file

@ -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<string[]>([])
// 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<string | null>(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<string | null> {
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,6 +596,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
forbi hull-registreringen for å dem i det hele tatt. */}
<div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{isOwnerViewer && (
<button
type="button"
onClick={() => setShowEditRound((v) => !v)}
@ -560,6 +606,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<Settings2 aria-hidden="true" className="size-4" />
Rediger runde
</button>
)}
{!completed && (
<button
@ -573,6 +620,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
</button>
)}
{isOwnerViewer && (
<button
type="button"
disabled={deleting}
@ -582,9 +630,10 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<Trash2 aria-hidden="true" className="size-4" />
{deleting ? "Sletter…" : "Slett runde"}
</button>
)}
</div>
{showEditRound && (
{showEditRound && isOwnerViewer && (
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
)}
</div>
@ -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 && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{showAddGuest && !readOnly && isOwnerViewer && (
<AddParticipantForm
onAddGuest={addGuest}
onAddSearched={addSearchedParticipant}
onCancel={() => 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 (
<div className="-mx-5 overflow-x-auto px-5">
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
{players.map((player) => {
const active = player.id === activePlayerId
const canRemove = !player.isSelf && !readOnly
const canRemove = !player.isSelf && !readOnly && canManage
return (
<div
key={player.id}
@ -1225,16 +1286,16 @@ function PlayerTabs({
</div>
)
})}
{!readOnly && (
{!readOnly && canManage && (
<button
type="button"
onClick={onAdd}
aria-label="Legg til gjestespiller"
aria-label="Legg til medspiller"
aria-expanded={addOpen}
className="flex min-h-12 shrink-0 items-center gap-1.5 rounded-2xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<Plus aria-hidden="true" className="size-5" />
Gjest
Medspiller
</button>
)}
</div>
@ -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<string | null>
onCancel: () => void
}) {
const [mode, setMode] = useState<"search" | "guest">("search")
const [query, setQuery] = useState("")
const [results, setResults] = useState<PersonMatch[]>([])
const [searching, setSearching] = useState(false)
const [adding, setAdding] = useState(false)
const [searchError, setSearchError] = useState<string | null>(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 <AddGuestForm onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
}
return (
<div className="mt-3 flex flex-col gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label htmlFor="participant-search" className="text-base font-semibold">
Søk etter medspiller
</Label>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
id="participant-search"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Navn på medspilleren"
className="h-12 rounded-2xl pl-11 text-base"
/>
</div>
{query.trim().length > 0 && query.trim().length < 2 && (
<p className="text-sm text-muted-foreground">Skriv minst 2 tegn for å søke.</p>
)}
</div>
{searchError && (
<p role="alert" className="text-sm font-medium text-destructive">
{searchError}
</p>
)}
{results.length > 0 && (
<ul className="flex flex-col gap-2">
{results.map((person) => (
<li key={person.id}>
<button
type="button"
disabled={adding}
onClick={() => handlePick(person)}
className="flex min-h-14 w-full items-center gap-3 rounded-2xl border border-border bg-card px-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/50 disabled:opacity-60"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary"
>
{person.first_name[0]}
{person.last_name[0]}
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-base font-bold text-foreground">
{person.first_name} {person.last_name}
</span>
{person.home_club && <span className="truncate text-sm text-muted-foreground">{person.home_club}</span>}
</span>
</button>
</li>
))}
</ul>
)}
{!searching && query.trim().length >= 2 && results.length === 0 && (
<p className="text-sm text-muted-foreground">Fant ingen med det navnet.</p>
)}
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<button
type="button"
onClick={() => setMode("guest")}
className="text-sm font-semibold text-foreground underline-offset-2 hover:underline"
>
Legg til uten konto (gjest)
</button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-2xl px-5 text-base font-semibold">
Avbryt
</Button>
</div>
</div>
)
}
// --- 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<Gender>("male")
@ -1266,6 +1456,15 @@ function AddGuestForm({
return (
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
{onBack && (
<button
type="button"
onClick={onBack}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
Søk i stedet
</button>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn

View file

@ -21,7 +21,9 @@ import { cn } from "@/lib/utils"
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
display_name: string
is_owner: boolean
}
@ -47,8 +49,10 @@ type ApiHole = {
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
// begrunnelse i round-detail.tsx sin playerLabel().
function participantLabel(p: ApiParticipant, viewerId: string | null): string {
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
}
// --- Scoring helpers -------------------------------------------------------
@ -314,6 +318,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
const [participantId, setParticipantId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(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
@ -330,13 +348,22 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
const data: ApiRound = await res.json()
if (cancelled) return
setRound(data)
setParticipantId(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller,
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
setParticipantId(
(prev) =>
prev ??
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
data.participants.find((p) => p.is_owner)?.id ??
data.participants[0]?.id ??
null,
)
}
void load()
return () => {
cancelled = true
}
}, [roundId])
}, [roundId, viewerId])
useEffect(() => {
if (!participantId) return
@ -452,7 +479,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{participantLabel(p)}
{participantLabel(p, viewerId)}
</button>
)
})}

View file

@ -357,7 +357,9 @@ function MiniDistribution({
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
display_name: string
is_owner: boolean
}
@ -557,8 +559,10 @@ function computeStats(holes: ApiHole[]) {
}
}
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
// begrunnelse i round-detail.tsx sin playerLabel().
function participantLabel(p: ApiParticipant, viewerId: string | null): string {
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
}
// --- Screen ----------------------------------------------------------------
@ -568,6 +572,20 @@ export function RoundStats({ roundId }: { roundId: string }) {
const [participantId, setParticipantId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(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
@ -584,13 +602,20 @@ export function RoundStats({ roundId }: { roundId: string }) {
const data: ApiRound = await res.json()
if (cancelled) return
setRound(data)
setParticipantId(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
setParticipantId(
(prev) =>
prev ??
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
data.participants.find((p) => p.is_owner)?.id ??
data.participants[0]?.id ??
null,
)
}
void load()
return () => {
cancelled = true
}
}, [roundId])
}, [roundId, viewerId])
useEffect(() => {
if (!participantId) return
@ -699,7 +724,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{participantLabel(p)}
{participantLabel(p, viewerId)}
</button>
)
})}