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(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/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/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 —
|
rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo —
|
||||||
det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra
|
det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra
|
||||||
start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i
|
start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i
|
||||||
samme runde. Ekte typesjekket produksjonsbuild kompilerte rent.
|
samme runde. Ekte typesjekket produksjonsbuild kompilerte rent. Rullet
|
||||||
**IKKE rullet ut ennå (frontend-delen)** — venter på brukerens
|
ut live 2026-07-25, bruker bekreftet eksplisitt.
|
||||||
bekreftelse. Fase 2 (rundevisibilitet) og fase 3 (ekte medspillere, inkl.
|
|
||||||
den avklarte "skriv for hele flighten"-regelen) er fortsatt kun
|
**Fase 3 (ekte medspillere på en frittstående runde) BYGGET OG SCRATCH-
|
||||||
designet, ikke bygget. Se "Venner og kategorisert deling"-seksjonen i
|
VERIFISERT 2026-07-26**, utløst av at brukeren rapporterte at "+ Gjest"-
|
||||||
FEATURE_BACKLOG.md for full detalj.
|
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
|
begge containere boot-et rent (`Application startup complete`, Next.js
|
||||||
`Ready`), `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
`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:
|
Neste steg:
|
||||||
0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
|
0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
|
||||||
varsler (in-app varslingssenter) + leaderboard-BACKENDEN for
|
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
|
Reist av brukeren samme runde som dashbord-forslaget over. Fullt design
|
||||||
skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for
|
skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for
|
||||||
|
|
@ -2072,17 +2072,12 @@ faser:**
|
||||||
lese-endepunkter, sanntids-livevisning for venner/offentlighet).
|
lese-endepunkter, sanntids-livevisning for venner/offentlighet).
|
||||||
3. Ekte medspillere (ikke bare gjester) — utvider
|
3. Ekte medspillere (ikke bare gjester) — utvider
|
||||||
`POST /rounds/{id}/participants` til å godta et søkt `user_id`.
|
`POST /rounds/{id}/participants` til å godta et søkt `user_id`.
|
||||||
**Avklart med bruker 2026-07-25: JA, runden skal vises i spillerens
|
**✅ DENNE DELEN BYGGET 2026-07-26** (se egen "Fase 3 (delvis)"-
|
||||||
egen rundeliste.** Teknisk konsekvens (se ADR-036 for full detalj):
|
underseksjon under Fase 1) — søk+legg til + "skriv for hele
|
||||||
`list_rounds` må utvides fra kun `owner_user_id` til også lenkede
|
flighten" er live. `list_rounds`/`RoundOut` sine viewer-relative
|
||||||
`round_participant.user_id`-rader, og `RoundOut` sine
|
`my_*`-felt (se under) er del av denne leveransen.
|
||||||
`owner_*`-statistikkfelt må bli "viewer-relative" (vise DEN som ser
|
Sletting av runden og bane-/utslagsbytte forblir eier-eksklusivt
|
||||||
på sin egen score, ikke alltid eierens). **Skrivetilgang også
|
(bekreftet, se ADR-036).
|
||||||
avklart 2026-07-25: en medspiller skal kunne registrere score for
|
|
||||||
ALLE i flighten**, ikke bare egen rad — samme rettighet som eieren
|
|
||||||
har i dag, utvidet til enhver lenket ekte deltaker. Sletting av
|
|
||||||
runden og bane-/utslagsbytte forblir eier-eksklusivt (se ADR-036 for
|
|
||||||
presis autorisasjonssjekk).
|
|
||||||
|
|
||||||
**Andre åpne spørsmål (se ADR-036 for full drøfting):**
|
**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
|
- 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
|
Migrasjon `025_friends.sql` (`friendship` + `friend_categorization`, ingen
|
||||||
RLS — samme `plain_connection()`-mønster som runder), nytt
|
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
|
## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget
|
||||||
|
|
||||||
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
|
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,11 @@ class RoundParticipantOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str | None
|
user_id: str | None
|
||||||
guest_name: 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
|
is_owner: bool
|
||||||
gender: str
|
gender: str
|
||||||
handicap_index_snapshot: float | None
|
handicap_index_snapshot: float | None
|
||||||
|
|
@ -436,17 +441,17 @@ class RoundOut(BaseModel):
|
||||||
started_at: str | None
|
started_at: str | None
|
||||||
completed_at: str | None
|
completed_at: str | None
|
||||||
participants: list[RoundParticipantOut]
|
participants: list[RoundParticipantOut]
|
||||||
# Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) --
|
# Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036
|
||||||
# brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i
|
# fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra
|
||||||
# det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker
|
# round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme
|
||||||
# 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er
|
# dag, siden feltet nå er VIEWER-relativt, ikke alltid eierens.
|
||||||
# registrert.
|
# `my_score_to_par` er None helt til minst ett hull er registrert.
|
||||||
owner_holes_played: int
|
my_holes_played: int
|
||||||
owner_total_score: int | None
|
my_total_score: int | None
|
||||||
owner_score_to_par: 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(
|
round_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
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(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
|
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||||
handicap_index_snapshot::float AS handicap_index_snapshot,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
course_handicap_snapshot, counts_for_handicap,
|
rp.is_owner, rp.gender,
|
||||||
score_differential::float AS score_differential, stat_level
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
|
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,
|
round_id,
|
||||||
)
|
)
|
||||||
owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None)
|
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
|
||||||
owner_agg = await conn.fetchrow(
|
# 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,
|
SELECT COUNT(*) FILTER (WHERE played) AS played_count,
|
||||||
COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
|
COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
|
||||||
COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
|
COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
|
||||||
FROM round_hole WHERE round_participant_id = $1
|
FROM round_hole WHERE round_participant_id = $1
|
||||||
""",
|
""",
|
||||||
owner_id,
|
target_id,
|
||||||
)
|
)
|
||||||
owner_holes_played = owner_agg["played_count"] if owner_agg else 0
|
my_holes_played = viewer_agg["played_count"] if viewer_agg else 0
|
||||||
owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None
|
my_total_score = viewer_agg["total_score"] if my_holes_played > 0 else None
|
||||||
owner_score_to_par = (
|
my_score_to_par = (
|
||||||
owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None
|
viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None
|
||||||
)
|
)
|
||||||
return RoundOut(
|
return RoundOut(
|
||||||
id=round_row["id"],
|
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,
|
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,
|
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||||
owner_holes_played=owner_holes_played,
|
my_holes_played=my_holes_played,
|
||||||
owner_total_score=owner_total_score,
|
my_total_score=my_total_score,
|
||||||
owner_score_to_par=owner_score_to_par,
|
my_score_to_par=my_score_to_par,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -613,20 +629,32 @@ async def create_round(
|
||||||
stat_level=body.stat_level,
|
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])
|
@router.get("/rounds", response_model=list[RoundOut])
|
||||||
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
|
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
|
||||||
async with plain_connection() as conn:
|
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(
|
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,
|
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):
|
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)
|
row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
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.")
|
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)
|
@router.get("/rounds/{round_id}", response_model=RoundOut)
|
||||||
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
|
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
|
||||||
async with plain_connection() as conn:
|
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)
|
||||||
return await _load_round_out(conn, round_id)
|
return await _load_round_out(conn, round_id, user.user_id)
|
||||||
|
|
||||||
|
|
||||||
class RoundUpdate(BaseModel):
|
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],
|
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)
|
@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):
|
class ParticipantCreate(BaseModel):
|
||||||
guest_name: str = Field(min_length=1, max_length=100)
|
# Nøyaktig én av user_id/guest_name.
|
||||||
gender: Literal["m", "f", "x"]
|
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)
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
stat_level: StatLevel = "strokes_only"
|
stat_level: StatLevel = "strokes_only"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
|
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
|
||||||
async def add_guest_participant(
|
async def add_participant(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
body: GuestParticipantCreate,
|
body: ParticipantCreate,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundParticipantOut:
|
) -> 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:
|
async with plain_connection() as conn:
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
|
||||||
round_row = await conn.fetchrow(
|
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,
|
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":
|
if round_row["course_source"] == "teeoff":
|
||||||
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
|
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
|
||||||
else:
|
else:
|
||||||
resolved = await _resolve_personal_course(round_row["personal_course_id"])
|
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(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED",
|
400, "VALIDATION_FAILED",
|
||||||
"Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
|
"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():
|
async with conn.transaction(), translate_db_errors():
|
||||||
participant_id = await _create_participant(
|
participant_id = await _create_participant(
|
||||||
conn, round_id, resolved, round_row["tee_name_snapshot"],
|
conn, round_id, resolved, round_row["tee_name_snapshot"],
|
||||||
user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
|
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
|
||||||
gender=body.gender, handicap_index=body.handicap_index,
|
gender=gender, handicap_index=handicap_index,
|
||||||
stat_level=body.stat_level,
|
stat_level=body.stat_level,
|
||||||
)
|
)
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
|
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||||
handicap_index_snapshot::float AS handicap_index_snapshot,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
course_handicap_snapshot, counts_for_handicap,
|
rp.is_owner, rp.gender,
|
||||||
score_differential::float AS score_differential, stat_level
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
FROM round_participant WHERE id = $1
|
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,
|
participant_id,
|
||||||
)
|
)
|
||||||
|
|
@ -881,19 +977,26 @@ async def update_participant(
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
row = await conn.fetchrow(
|
updated_id = await conn.fetchval(
|
||||||
"""
|
"UPDATE round_participant SET stat_level = $3 WHERE id = $1 AND round_id = $2 RETURNING id",
|
||||||
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
|
|
||||||
""",
|
|
||||||
participant_id, round_id, body.stat_level,
|
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.")
|
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))
|
return RoundParticipantOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -953,7 +1056,7 @@ async def list_holes(
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[RoundHoleOut]:
|
) -> list[RoundHoleOut]:
|
||||||
async with plain_connection() as conn:
|
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(
|
participant_row = await conn.fetchrow(
|
||||||
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||||
participant_id, round_id,
|
participant_id, round_id,
|
||||||
|
|
@ -1008,14 +1111,14 @@ class LeaderboardOut(BaseModel):
|
||||||
@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
|
@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
|
||||||
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
|
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
|
||||||
async with plain_connection() as conn:
|
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(
|
round_row = await conn.fetchrow(
|
||||||
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
|
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
|
||||||
)
|
)
|
||||||
participant_rows = await conn.fetch(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot,
|
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
|
FROM round_participant rp
|
||||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||||
WHERE rp.round_id = $1
|
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)
|
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
|
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(
|
entries.append(
|
||||||
LeaderboardEntryOut(
|
LeaderboardEntryOut(
|
||||||
participant_id=p["id"],
|
participant_id=p["id"],
|
||||||
|
|
@ -1093,7 +1199,11 @@ async def update_hole(
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundHoleOut:
|
) -> RoundHoleOut:
|
||||||
async with plain_connection() as conn:
|
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(
|
participant_row = await conn.fetchrow(
|
||||||
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||||
participant_id, round_id,
|
participant_id, round_id,
|
||||||
|
|
@ -1149,7 +1259,9 @@ async def update_hole(
|
||||||
@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
|
@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
|
||||||
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
|
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
|
||||||
async with plain_connection() as conn:
|
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)
|
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)
|
||||||
|
|
||||||
participants = await conn.fetch(
|
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)
|
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:
|
def _course_handicap_from_row(p) -> int:
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,12 @@ type ApiTournament = {
|
||||||
end_date: string | null
|
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 = {
|
type ApiRound = {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -94,9 +99,9 @@ type ApiRound = {
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
participants: ApiRoundParticipant[]
|
participants: ApiRoundParticipant[]
|
||||||
owner_holes_played: number
|
my_holes_played: number
|
||||||
owner_total_score: number | null
|
my_total_score: number | null
|
||||||
owner_score_to_par: number | null
|
my_score_to_par: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHandicapPoint = { handicap_index: number; recorded_at: string }
|
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 }
|
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 {
|
function toRound(r: ApiRound, viewerId: string | null): Round {
|
||||||
const owner = r.participants.find((p) => p.is_owner)
|
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
|
|
@ -123,10 +128,10 @@ function toRound(r: ApiRound): Round {
|
||||||
holes: r.holes_planned === 9 ? 9 : 18,
|
holes: r.holes_planned === 9 ? 9 : 18,
|
||||||
date: r.played_at,
|
date: r.played_at,
|
||||||
playerCount: r.participants.length,
|
playerCount: r.participants.length,
|
||||||
holesPlayed: r.owner_holes_played,
|
holesPlayed: r.my_holes_played,
|
||||||
totalScore: r.owner_total_score ?? undefined,
|
totalScore: r.my_total_score ?? undefined,
|
||||||
toPar: r.owner_score_to_par ?? undefined,
|
toPar: r.my_score_to_par ?? undefined,
|
||||||
differential: owner?.counts_for_handicap ? owner.score_differential : null,
|
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 roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
|
||||||
|
|
||||||
const avgToParSample = completedRounds
|
const avgToParSample = completedRounds
|
||||||
.filter((r) => r.owner_score_to_par !== null)
|
.filter((r) => r.my_score_to_par !== null)
|
||||||
.slice(0, 5)
|
.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 avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
|
||||||
|
|
||||||
const hcpValues = hcpHistory.map((h) => h.handicap_index)
|
const hcpValues = hcpHistory.map((h) => h.handicap_index)
|
||||||
|
|
@ -390,7 +395,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-8">
|
<div className="mt-6 flex flex-col gap-8">
|
||||||
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
|
<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} />
|
<UpcomingTournaments entries={combinedTournaments} />
|
||||||
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
||||||
<PlayedCourses courses={playedCourses} />
|
<PlayedCourses courses={playedCourses} />
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
type ApiRoundParticipant = {
|
type ApiRoundParticipant = {
|
||||||
id: string
|
id: string
|
||||||
|
user_id: string | null
|
||||||
is_owner: boolean
|
is_owner: boolean
|
||||||
guest_name: string | null
|
guest_name: string | null
|
||||||
counts_for_handicap: boolean
|
counts_for_handicap: boolean
|
||||||
|
|
@ -30,14 +31,16 @@ type ApiRound = {
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
participants: ApiRoundParticipant[]
|
participants: ApiRoundParticipant[]
|
||||||
owner_holes_played: number
|
my_holes_played: number
|
||||||
owner_total_score: number | null
|
my_total_score: number | null
|
||||||
owner_score_to_par: number | null
|
my_score_to_par: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRound(r: ApiRound): Round {
|
// viewerId er null helt til /auth/me har svart -- faller da tilbake til
|
||||||
const owner = r.participants.find((p) => p.is_owner)
|
// eierens rad (samme oppførsel som før ADR-036 fase 3-utvidelsen).
|
||||||
const differential = owner?.counts_for_handicap ? owner.score_differential : null
|
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 {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
|
|
@ -47,9 +50,9 @@ function toRound(r: ApiRound): Round {
|
||||||
holes: r.holes_planned === 9 ? 9 : 18,
|
holes: r.holes_planned === 9 ? 9 : 18,
|
||||||
date: r.played_at,
|
date: r.played_at,
|
||||||
playerCount: r.participants.length,
|
playerCount: r.participants.length,
|
||||||
holesPlayed: r.owner_holes_played,
|
holesPlayed: r.my_holes_played,
|
||||||
totalScore: r.owner_total_score ?? undefined,
|
totalScore: r.my_total_score ?? undefined,
|
||||||
toPar: r.owner_score_to_par ?? undefined,
|
toPar: r.my_score_to_par ?? undefined,
|
||||||
differential,
|
differential,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +61,20 @@ export function OwnRounds() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [rounds, setRounds] = useState<Round[] | null>(null)
|
const [rounds, setRounds] = useState<Round[] | null>(null)
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -70,7 +87,7 @@ export function OwnRounds() {
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
const data: ApiRound[] = await res.json()
|
const data: ApiRound[] = await res.json()
|
||||||
if (!cancelled) setRounds(data.map(toRound))
|
if (!cancelled) setRounds(data.map((r) => toRound(r, viewerId)))
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +96,7 @@ export function OwnRounds() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [router])
|
}, [router, viewerId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,9 @@ type ApiParticipant = {
|
||||||
id: string
|
id: string
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
guest_name: 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
|
is_owner: boolean
|
||||||
gender: ApiGender
|
gender: ApiGender
|
||||||
handicap_index_snapshot: number | null
|
handicap_index_snapshot: number | null
|
||||||
|
|
@ -128,6 +131,14 @@ type ApiParticipant = {
|
||||||
stat_level: StatLevel
|
stat_level: StatLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PersonMatch = {
|
||||||
|
id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
avatar_url: string | null
|
||||||
|
home_club: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type ApiRound = {
|
type ApiRound = {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -211,8 +222,11 @@ function statToPatchBody(s: HoleStat) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playerLabel(p: ApiParticipant): string {
|
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26): "Deg" skal vises
|
||||||
return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
|
// 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 -------------------------------------------------------------
|
// --- Component -------------------------------------------------------------
|
||||||
|
|
@ -227,6 +241,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
|
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
|
||||||
// for eieren selv (gjester har ingen profil å hente dette fra).
|
// for eieren selv (gjester har ingen profil å hente dette fra).
|
||||||
const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
|
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
|
// `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`
|
// 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å
|
// 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
|
let cancelled = false
|
||||||
fetch("/auth/me", { credentials: "include" })
|
fetch("/auth/me", { credentials: "include" })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data: { bag_clubs: string[] } | null) => {
|
.then((data: { id: string; bag_clubs: string[] } | null) => {
|
||||||
if (!cancelled && data) setOwnBagClubs(data.bag_clubs)
|
if (!cancelled && data) {
|
||||||
|
setOwnBagClubs(data.bag_clubs)
|
||||||
|
setViewerId(data.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -306,15 +328,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
const players: Player[] =
|
const players: Player[] =
|
||||||
round?.participants.map((p) => ({
|
round?.participants.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: playerLabel(p),
|
name: playerLabel(p, viewerId),
|
||||||
gender: apiGenderToUi(p.gender),
|
gender: apiGenderToUi(p.gender),
|
||||||
hcp: p.handicap_index_snapshot,
|
hcp: p.handicap_index_snapshot,
|
||||||
isSelf: p.is_owner,
|
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
||||||
countsForHandicap: p.counts_for_handicap,
|
countsForHandicap: p.counts_for_handicap,
|
||||||
scoreDifferential: p.score_differential,
|
scoreDifferential: p.score_differential,
|
||||||
statLevel: p.stat_level,
|
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 activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
|
||||||
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
|
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
|
||||||
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
|
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)
|
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) {
|
async function removeGuest(id: string) {
|
||||||
if (!confirm("Fjerne denne spilleren fra runden?")) return
|
if (!confirm("Fjerne denne spilleren fra runden?")) return
|
||||||
const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
|
const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
|
||||||
|
|
@ -551,15 +596,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
forbi hull-registreringen for å nå dem i det hele tatt. */}
|
forbi hull-registreringen for å nå dem i det hele tatt. */}
|
||||||
<div className="mb-5 flex flex-col gap-3">
|
<div className="mb-5 flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
{isOwnerViewer && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowEditRound((v) => !v)}
|
type="button"
|
||||||
aria-expanded={showEditRound}
|
onClick={() => setShowEditRound((v) => !v)}
|
||||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
aria-expanded={showEditRound}
|
||||||
>
|
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
<Settings2 aria-hidden="true" className="size-4" />
|
>
|
||||||
Rediger runde
|
<Settings2 aria-hidden="true" className="size-4" />
|
||||||
</button>
|
Rediger runde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!completed && (
|
{!completed && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -573,18 +620,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
{isOwnerViewer && (
|
||||||
type="button"
|
<button
|
||||||
disabled={deleting}
|
type="button"
|
||||||
onClick={deleteRound}
|
disabled={deleting}
|
||||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
onClick={deleteRound}
|
||||||
>
|
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
||||||
<Trash2 aria-hidden="true" className="size-4" />
|
>
|
||||||
{deleting ? "Sletter…" : "Slett runde"}
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
</button>
|
{deleting ? "Sletter…" : "Slett runde"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showEditRound && (
|
{showEditRound && isOwnerViewer && (
|
||||||
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
|
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -607,6 +656,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
onAdd={() => setShowAddGuest((v) => !v)}
|
onAdd={() => setShowAddGuest((v) => !v)}
|
||||||
addOpen={showAddGuest}
|
addOpen={showAddGuest}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
canManage={isOwnerViewer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|
@ -623,7 +673,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
mens runden pågår. */}
|
mens runden pågår. */}
|
||||||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
{!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 */}
|
{/* Hole navigation */}
|
||||||
{holes.length === 0 ? (
|
{holes.length === 0 ? (
|
||||||
|
|
@ -1182,6 +1238,7 @@ function PlayerTabs({
|
||||||
onAdd,
|
onAdd,
|
||||||
addOpen,
|
addOpen,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
canManage,
|
||||||
}: {
|
}: {
|
||||||
players: Player[]
|
players: Player[]
|
||||||
activePlayerId: string
|
activePlayerId: string
|
||||||
|
|
@ -1190,13 +1247,17 @@ function PlayerTabs({
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
addOpen: boolean
|
addOpen: boolean
|
||||||
readOnly: 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 (
|
return (
|
||||||
<div className="-mx-5 overflow-x-auto px-5">
|
<div className="-mx-5 overflow-x-auto px-5">
|
||||||
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
|
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
|
||||||
{players.map((player) => {
|
{players.map((player) => {
|
||||||
const active = player.id === activePlayerId
|
const active = player.id === activePlayerId
|
||||||
const canRemove = !player.isSelf && !readOnly
|
const canRemove = !player.isSelf && !readOnly && canManage
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.id}
|
key={player.id}
|
||||||
|
|
@ -1225,16 +1286,16 @@ function PlayerTabs({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{!readOnly && (
|
{!readOnly && canManage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
aria-label="Legg til gjestespiller"
|
aria-label="Legg til medspiller"
|
||||||
aria-expanded={addOpen}
|
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"
|
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" />
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
Gjest
|
Medspiller
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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({
|
function AddGuestForm({
|
||||||
onAdd,
|
onAdd,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
|
onBack?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [gender, setGender] = useState<Gender>("male")
|
const [gender, setGender] = useState<Gender>("male")
|
||||||
|
|
@ -1266,6 +1456,15 @@ function AddGuestForm({
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="guest-name" className="text-base font-semibold">
|
<Label htmlFor="guest-name" className="text-base font-semibold">
|
||||||
Navn
|
Navn
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
id: string
|
id: string
|
||||||
|
user_id: string | null
|
||||||
guest_name: string | null
|
guest_name: string | null
|
||||||
|
display_name: string
|
||||||
is_owner: boolean
|
is_owner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,8 +49,10 @@ type ApiHole = {
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
function participantLabel(p: ApiParticipant): string {
|
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
|
||||||
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
|
// 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 -------------------------------------------------------
|
// --- Scoring helpers -------------------------------------------------------
|
||||||
|
|
@ -314,6 +318,20 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
const [participantId, setParticipantId] = useState<string | null>(null)
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -330,13 +348,22 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
const data: ApiRound = await res.json()
|
const data: ApiRound = await res.json()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setRound(data)
|
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()
|
void load()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [roundId])
|
}, [roundId, viewerId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!participantId) return
|
if (!participantId) return
|
||||||
|
|
@ -452,7 +479,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{participantLabel(p)}
|
{participantLabel(p, viewerId)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,9 @@ function MiniDistribution({
|
||||||
|
|
||||||
type ApiParticipant = {
|
type ApiParticipant = {
|
||||||
id: string
|
id: string
|
||||||
|
user_id: string | null
|
||||||
guest_name: string | null
|
guest_name: string | null
|
||||||
|
display_name: string
|
||||||
is_owner: boolean
|
is_owner: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,8 +559,10 @@ function computeStats(holes: ApiHole[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function participantLabel(p: ApiParticipant): string {
|
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
|
||||||
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
|
// 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 ----------------------------------------------------------------
|
// --- Screen ----------------------------------------------------------------
|
||||||
|
|
@ -568,6 +572,20 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
const [participantId, setParticipantId] = useState<string | null>(null)
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -584,13 +602,20 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
const data: ApiRound = await res.json()
|
const data: ApiRound = await res.json()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setRound(data)
|
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()
|
void load()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [roundId])
|
}, [roundId, viewerId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!participantId) return
|
if (!participantId) return
|
||||||
|
|
@ -699,7 +724,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{participantLabel(p)}
|
{participantLabel(p, viewerId)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue