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:
parent
a09fea3caa
commit
89136a1a83
11 changed files with 678 additions and 148 deletions
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
027_round_participant_user_unique.sql
Normal file
8
027_round_participant_user_unique.sql
Normal 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;
|
||||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
49
CLAUDE.md
49
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 å nå 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
Loading…
Reference in a new issue