Bygget: ADR-036 fase 2 — rundevisibilitet, helt etter det allerede designede mønsteret (privat/offentlig/venner-i-valgt-kategori):
round.visibility_mode (privat som trygg standard) + ny tabell for hvilke venne-kategorier som får se en gitt runde. Kjernesjekken (_can_view_round) viste seg å være en ren utvidelse av den eksisterende eier/medspiller-sjekken, så fire lese-endepunkter ble omstrukturert til delte funksjoner og gjenbrukt av sju nye offentlige endepunkter + et nytt offentlig sanntids-WS — i stedet for å bygge alt parallelt fra bunnen. Ny vennprofil-side (/my-friends/[id]) — navn/avatar/HCP/hjemmeklubb + liste over personens synlige runder, "pågår nå" øverst. Ny read-only live-visning (/watch/[id]) for tredjeparter — matchstatus, skins-tavle eller individuell rangering avhengig av spilleform. Synlighetsvelger lagt til både i opprett-runde og rediger-runde. Verifisert grundig: 191 automatiserte sjekker (inkl. full regresjon av to eksisterende testsuiter) + en fullstendig nettleser-gjennomgang med tre reelle brukere i separate innloggingskontekster — inkludert en helt anonym leser som beviste at "offentlig" faktisk betyr offentlig, og en reell venn/kategori-negativ-kontroll som beviste at feil kategori korrekt nekter tilgang. Migrasjon kjørt mot ekte database (kun additiv), begge containere rullet ut, teeoff.no upåvirket.
This commit is contained in:
parent
071874e8c8
commit
7631ef60e6
12 changed files with 1774 additions and 408 deletions
39
032_round_visibility.sql
Normal file
39
032_round_visibility.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — rundevisibilitet for frittstående runder (migrasjon 032,
|
||||||
|
-- ADR-036 fase 2)
|
||||||
|
-- =====================================================================
|
||||||
|
-- round.visibility_mode ('public'/'private'/'friends', default 'private'
|
||||||
|
-- -- trygg standard, samme filosofi som RLS-policyenes "se ingenting" ved
|
||||||
|
-- manglende kontekst). Ved 'friends' velges EKSPLISITT hvilke av eierens
|
||||||
|
-- private kategorier (samme faste sett som friend_categorization,
|
||||||
|
-- migrasjon 025) som får se runden -- ikke "alle venner".
|
||||||
|
--
|
||||||
|
-- Styrer KUN tredjeparts innsyn (venner/offentligheten) -- en faktisk
|
||||||
|
-- lagt-til medspiller (round_participant.user_id) ser alltid runden
|
||||||
|
-- uansett visibility_mode, uendret av denne migrasjonen.
|
||||||
|
--
|
||||||
|
-- Ingen RLS, samme `plain_connection()`-mønster som resten av
|
||||||
|
-- runde-/venne-tabellene (ADR-033 Beslutning A) -- autorisasjon
|
||||||
|
-- håndheves i app-laget.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round ADD COLUMN visibility_mode text NOT NULL DEFAULT 'private'
|
||||||
|
CHECK (visibility_mode IN ('public', 'private', 'friends'));
|
||||||
|
|
||||||
|
CREATE TABLE round_visible_category (
|
||||||
|
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
|
||||||
|
-- Samme faste kategori-sett som friend_categorization (migrasjon 025)
|
||||||
|
-- -- bevisst duplisert CHECK fremfor en delt ENUM-type, samme
|
||||||
|
-- pragmatiske mønster som resten av skjemaet (BAG_CLUBS/gender/
|
||||||
|
-- stat_level m.fl. er alle CHECK-baserte, ikke ENUM).
|
||||||
|
category text NOT NULL CHECK (category IN (
|
||||||
|
'spouse', 'close_family', 'extended_family', 'close_friends',
|
||||||
|
'golf_friends', 'colleagues', 'business', 'classmates',
|
||||||
|
'acquaintances', 'other'
|
||||||
|
)),
|
||||||
|
PRIMARY KEY (round_id, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON round_visible_category TO teecup_app;
|
||||||
168
CLAUDE.md
168
CLAUDE.md
|
|
@ -4462,6 +4462,158 @@ Ferdig og verifisert:
|
||||||
migrasjon (ren frontend), `docker compose up -d --build
|
migrasjon (ren frontend), `docker compose up -d --build
|
||||||
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
||||||
→ 200, `teeoff.no` upåvirket.
|
→ 200, `teeoff.no` upåvirket.
|
||||||
|
- **Turnering-scorekortets offline-flyt (den OPPRINNELIGE ADR-028, session-
|
||||||
|
scorecard.tsx) FAKTISK browserverifisert, samme dag (2026-07-28):**
|
||||||
|
eneste reelt gjenstående punkt fra forrige runde — offline-koden i
|
||||||
|
turnering-scorekortet er over ett år gammel (funksjonelt), men var ALDRI
|
||||||
|
browser-testet, kun kodegjennomgang/build (se opprinnelig ADR-028-notat).
|
||||||
|
Bygget en full isolert scratch-turnering fra bunnen via API for å nå
|
||||||
|
frem til selve scorekortet (organisasjon → turnering → to lag → to
|
||||||
|
spillere rostret som kapteiner → egendefinert bane med 18 hull + tee →
|
||||||
|
økt (`format=singles`, `scoring_mode=stroke`) → match → to
|
||||||
|
match-deltakere → begge lag låst) -- ingen slik full turnering-scaffold
|
||||||
|
fantes fra før i noe testskript denne uken (frittstående runder trenger
|
||||||
|
ikke dette apparatet i det hele tatt, ADR-033 Beslutning A), måtte bygges
|
||||||
|
fra grunnen ved å lese `tournaments.py`/`matches.py`/`courses.py` sine
|
||||||
|
faktiske endepunkt-kontrakter direkte.
|
||||||
|
**Verifisert identisk mønster som frittstående runder samme dag:** ekte
|
||||||
|
DevTools-nettverksemulering (`navigator.onLine` bekreftet `false`),
|
||||||
|
registrerte et slag (5, Spiller B, hull 1) offline -- bekreftet ÉN
|
||||||
|
kø-oppføring i ekte IndexedDB (`POST .../hole-scores`), "1 endring
|
||||||
|
venter"-banner, "Lagret lokalt · venter på synk"-tekst under
|
||||||
|
tallvelgeren, hull-navigasjonens sjekkmerke. Koblet til nett igjen --
|
||||||
|
bekreftet AUTOMATISK synk (ingen manuelt trykk på "Synkroniser nå"
|
||||||
|
nødvendig), kø tom etterpå, "Bogey"-etiketten dukket opp (bevis på at
|
||||||
|
`refetchScorecard()` faktisk hentet det avledede resultatet fra
|
||||||
|
serveren), OG et direkte API-kall mot `.../scorecard` som bekreftet
|
||||||
|
serveren faktisk hadde `gross_strokes=5` lagret. Ingen konsollfeil.
|
||||||
|
**Ingen kodeendring i denne runden** — ren verifisering av allerede
|
||||||
|
levert funksjonalitet, ingen utrulling nødvendig.
|
||||||
|
- **Undersøkt: grensesnitt for å følge en venns runde live — BEKREFTET AT
|
||||||
|
DET IKKE FINNES (2026-07-28), ren undersøkelse, ingen kode skrevet.**
|
||||||
|
Brukeren spurte om en spiller kan gå inn på en venns profil og følge en
|
||||||
|
pågående frittstående runde live, gitt at rettighetene er gitt. Lest
|
||||||
|
direkte i koden (ikke antatt): (1) `frontend/components/friends.tsx` har
|
||||||
|
INGEN lenke/rute til en vennprofil-side i det hele tatt — kun
|
||||||
|
send/aksepter/avvis-knapper og kategorisering, ingen `/friends/[id]`-
|
||||||
|
eller lignende rute finnes noe sted i `frontend/app/`. (2) `round`-
|
||||||
|
tabellen har INGEN `visibility`-kolonne (bekreftet med grep over ALLE
|
||||||
|
migrasjoner — `visibility` finnes kun på `tournament`, fra
|
||||||
|
`009_landing_pages_and_visibility.sql`); `020_personal_rounds.sql` sin
|
||||||
|
egen kommentar (linje 39-42) slår eksplisitt fast v1-avgrensningen: en
|
||||||
|
runde er kun synlig for `owner_user_id`. (3) `app/routers/rounds.py` sin
|
||||||
|
`_get_accessible_round_or_404` (linje 1220-1240, lest direkte) gir
|
||||||
|
tilgang KUN til eieren ELLER en lenket `round_participant.user_id`
|
||||||
|
(ADR-036 fase 3) — ingen sjekk mot `friendship`-tabellen noe sted,
|
||||||
|
bekreftet med `grep -n -i "friend" app/routers/rounds.py` → null treff.
|
||||||
|
(4) `GET /ws/rounds/{id}/live` bruker NØYAKTIG samme
|
||||||
|
`_get_accessible_round_or_404`-sjekk FØR `websocket.accept()` (samme
|
||||||
|
fil, linje ~2763) — en venn som ikke er lenket deltaker får
|
||||||
|
websocketen lukket med kode 4403, ulikt turnering-live (ADR-027), som
|
||||||
|
bevisst TILLATER anonym tilgang. Ingen forberedt/påbegynt kode for
|
||||||
|
"følg venns runde" funnet noe sted (grep etter "follow"/"følg"/"friend"
|
||||||
|
i både `rounds.py` og `friends.py` — null relevante treff).
|
||||||
|
**Konklusjon: dette er nøyaktig ADR-036 fase 2 (rundevisibilitet
|
||||||
|
public/private/friends), som lenge har stått notert som IKKE bygget i
|
||||||
|
FEATURE_BACKLOG.md/CLAUDE.md** — bekreftet nå med presis kode-evidens i
|
||||||
|
stedet for bare et notat om at det mangler. Ingen ny beslutning tatt,
|
||||||
|
ingen kode skrevet — dette var en ren undersøkelse på brukerens
|
||||||
|
eksplisitte forespørsel. Se FEATURE_BACKLOG.md for ADR-036 fase 2 sitt
|
||||||
|
design (public/private/friends, eksplisitt gruppevalg).
|
||||||
|
- **ADR-036 fase 2 (rundevisibilitet) BYGGET, GRUNDIG SCRATCH-/
|
||||||
|
BROWSERVERIFISERT OG LIVE (2026-07-28), samme dag:** direkte oppfølging
|
||||||
|
av undersøkelsen over — brukeren bekreftet eksplisitt retningen fra
|
||||||
|
Beslutning B (allerede fullt designet i ADR-036, ikke funnet opp på
|
||||||
|
nytt): tre nivåer (`public`/`private`/`friends`), der `friends` krever
|
||||||
|
et EKSPLISITT kategori-valg (ikke "alle venner").
|
||||||
|
**Migrasjon `032_round_visibility.sql`:** `round.visibility_mode`
|
||||||
|
(`public`/`private`/`friends`, default `private`), ny tabell
|
||||||
|
`round_visible_category` (samme faste kategori-sett som
|
||||||
|
`friend_categorization`, migrasjon 025, bevisst duplisert CHECK fremfor
|
||||||
|
delt ENUM — samme pragmatiske mønster som resten av skjemaet).
|
||||||
|
**Kjernestykket, `app/routers/rounds.py`:** ny `_can_view_round()` —
|
||||||
|
eier ELLER lenket medspiller ser alltid; ellers `public`→alle (også
|
||||||
|
anonyme), `private`→ingen, `friends`→krever et AKSEPTERT vennskap MED
|
||||||
|
eieren OG at EIERENS kategorisering av viewer (retningen er bevisst
|
||||||
|
omvendt av hva man skulle tro — det er eieren som begrenser, basert på
|
||||||
|
egen gruppering) treffer minst én av rundens synlige kategorier.
|
||||||
|
**Reelt funn under selve designarbeidet:** `_can_view_round()` viste
|
||||||
|
seg å være en STRIKT SUPERSETT av den eksisterende
|
||||||
|
`_get_accessible_round_or_404()` sin logikk (dens to første grener ER
|
||||||
|
nøyaktig eier+medspiller-sjekken) — i stedet for å bygge en helt
|
||||||
|
parallell endepunkt-familie fra bunnen, ble fire eksisterende
|
||||||
|
autentiserte lese-endepunkters KROPP ekstrahert til delte
|
||||||
|
hjelpefunksjoner (`_build_participant_holes`/`_build_side_holes`/
|
||||||
|
`_build_leaderboard`/`_build_format_result` — mekanisk gjort med et
|
||||||
|
Python-script for presis inndenting fremfor manuell redigering av
|
||||||
|
~270+95 linjer, verifisert med `ast.parse` + full regresjonskjøring
|
||||||
|
etterpå), gjenbrukt av BÅDE de originale autentiserte endepunktene OG
|
||||||
|
syv nye `/public/rounds/*`-endepunkter (samme `get_current_user_
|
||||||
|
optional`-mønster som turnering sin offentlige side, ADR-018/026/027)
|
||||||
|
pluss et nytt offentlig WS-endepunkt `/ws/public/rounds/{id}/live`
|
||||||
|
(ulikt det eksisterende PRIVATE `/ws/rounds/{id}/live`, som fortsatt
|
||||||
|
krever ekte autentisert eier/medspiller-sesjon, uendret). Egen, leaner
|
||||||
|
`PublicRoundOut` (aldri `guest_email`, som er PII, aldri `my_*`/
|
||||||
|
`setup_*`, som kun gir mening for eier/deltaker) i stedet for å
|
||||||
|
gjenbruke den fulle `RoundOut` med etterhånds-redigering.
|
||||||
|
Ny `GET /people/{id}` (friends.py, samme lavsensitive felt-sett som
|
||||||
|
`/people/search`, bevisst OPTIONALT autentisert — en offentlig runde
|
||||||
|
må kunne nås via en delt lenke selv av en anonym leser, og
|
||||||
|
vennprofil-siden som leder dit må da fungere anonymt også) og
|
||||||
|
`GET /public/people/{id}/rounds` (rounds.py, lister eierens runder
|
||||||
|
filtrert gjennom `_can_view_round()` per rad — "pågår nå" alltid øverst).
|
||||||
|
**Scratch-verifisert grundig, 191 automatiserte sjekker i tre testløp**
|
||||||
|
(isolert `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
|
||||||
|
API-container, samme mønster som hele prosjektet): et NYTT 34-punkts
|
||||||
|
skript som dekket hele synlighetsmatrisen presist (tre brukere A/B/C +
|
||||||
|
en helt anonym opener) — B/C/anonym nektet privat OG venner-runde FØR
|
||||||
|
vennskap, alle ser offentlig (inkl. anonym), venner-runde fortsatt
|
||||||
|
nektet RETT ETTER vennskap men FØR kategorisering, tilgjengelig
|
||||||
|
UMIDDELBART etter riktig kategorisering, feil kategori (close_family
|
||||||
|
mot golf_friends) fortsatt nektet, PATCH kan endre synlighet i
|
||||||
|
etterkant (bekreftet begge retninger), offentlig+autentisert
|
||||||
|
leaderboard ga BEVIST IDENTISK resultat (kryssjekket), ukjent
|
||||||
|
runde-id ga 404 (ikke 403). PLUSS en full regresjonskjøring av to
|
||||||
|
eksisterende testsuiter fra tidligere runder denne uken (35-punkts
|
||||||
|
co-player-flyt, 122-punkts spillformat-flyt) — begge 100 % grønne,
|
||||||
|
bekrefter at ekstraheringen av de fire delte funksjonene ikke endret
|
||||||
|
noen eksisterende oppførsel.
|
||||||
|
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
|
||||||
|
isolert browser-kontekst for en helt anonym tredje "bruker" ved siden
|
||||||
|
av to ekte innloggede faner): (1) synlighetsvelgeren i opprett-runde-
|
||||||
|
veiviseren fungerte som designet (tre knapper, kategori-multiselect
|
||||||
|
dukker kun opp ved "Venner", advarselstekst ved 0 valgte kategorier) —
|
||||||
|
runde opprettet med `friends`+`golf_friends`, bekreftet via API;
|
||||||
|
(2) rediger-runde-panelet viste korrekt FORHÅNDSUTFYLT synlighet,
|
||||||
|
PATCH til `public` bekreftet lagret; (3) en HELT ANONYM
|
||||||
|
nettleserkontekst (ingen cookies i det hele tatt) kunne se den
|
||||||
|
offentlige runden direkte på `/watch/{id}` (live-puls, leaderboard,
|
||||||
|
riktig eiernavn) OG via `/my-friends/{eier-id}`-profilsiden (navn/
|
||||||
|
avatar/HCP/hjemmeklubb + rundeliste med lenke inn); (4) en ekte andre
|
||||||
|
bruker sendte venneforespørsel, eieren aksepterte og kategoriserte
|
||||||
|
vedkommende som "Golfvenner" VIA DEN FAKTISKE UI-EN (ikke bare API) —
|
||||||
|
satte deretter runden til `friends`+`golf_friends`, bekreftet vennen
|
||||||
|
fikk tilgang UMIDDELBART via profilsiden; (5) **negativ kontroll,
|
||||||
|
samme venn**: byttet runden til `friends`+`close_family` (en kategori
|
||||||
|
vennen IKKE var satt i) — profilsiden viste korrekt INGEN runder
|
||||||
|
lenger, og et direkte `/watch/{id}`-forsøk ga en tydelig "Du har ikke
|
||||||
|
tilgang"-melding (403), atskilt fra en egen "Denne runden finnes
|
||||||
|
ikke"-melding for en ukjent id (404) — begge bekreftet med ekte
|
||||||
|
skjermbilder. Ekte typesjekket + full produksjonsbuild (26 ruter, inkl.
|
||||||
|
de to nye `/my-friends/[id]` og `/watch/[id]`) kjørt før utrulling.
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
|
||||||
|
eksplisitt (viste frem full plan for migrasjon FØR den kjørte, per
|
||||||
|
CLAUDE.md sin ufravikelige regel): migrasjon 032 kjørt mot ekte
|
||||||
|
`teecup_db` (kun additivt — ny kolonne med default, ny tabell, ingen
|
||||||
|
eksisterende rader rørt), `test_isolation.sql` fortsatt 12/12,
|
||||||
|
deretter `docker compose up -d --build teecup_api teecup_frontend`.
|
||||||
|
Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||||
|
upåvirket. Verifisert presist at de nye rutene faktisk når FastAPI
|
||||||
|
(ikke bare at Next.js svarte): et ukjent person-id mot
|
||||||
|
`/public/people/{id}/rounds` over ekte https ga korrekt JSON-formet
|
||||||
|
`404 NOT_FOUND` (ikke en rå Next.js-404-side).
|
||||||
|
**ADR-036 er dermed HELT ferdig, alle tre faser** (venner-kjernen,
|
||||||
|
rundevisibilitet, ekte medspillere) — backend + frontend, live.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
|
|
@ -4650,12 +4802,10 @@ Neste steg:
|
||||||
noterte "naturlig neste steg"-punkter er dermed alle tettet, unntatt
|
noterte "naturlig neste steg"-punkter er dermed alle tettet, unntatt
|
||||||
notifikasjons-/aktivitetsfeed og "Mine runder" for rene påmeldinger
|
notifikasjons-/aktivitetsfeed og "Mine runder" for rene påmeldinger
|
||||||
(begge fortsatt IKKE bygget, se FEATURE_BACKLOG.md).
|
(begge fortsatt IKKE bygget, se FEATURE_BACKLOG.md).
|
||||||
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
6. ~~Oppfølgingspunkt fra PWA-runden: offline-scoreregistrering er ALDRI
|
||||||
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
browser-testet i praksis~~ — **ferdig 2026-07-28** (se status-punkt
|
||||||
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
samme dag, "Turnering-scorekortets offline-flyt (ADR-028) FAKTISK
|
||||||
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
browserverifisert"). Beholdt her kun for historikk.
|
||||||
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
|
||||||
synkes — først da er flyten reelt bevist.
|
|
||||||
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
||||||
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||||
(bevisst utsatt i ADR-025).
|
(bevisst utsatt i ADR-025).
|
||||||
|
|
@ -4673,8 +4823,10 @@ Neste steg:
|
||||||
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
|
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
|
||||||
live; (b) ingen Stableford-poengberegning for frittstående runder (kun
|
live; (b) ingen Stableford-poengberegning for frittstående runder (kun
|
||||||
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand;
|
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand;
|
||||||
(c) rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
(c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
||||||
fortsatt ikke bygget; (d) flere flighter i én frittstående runde
|
fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
|
||||||
|
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
|
||||||
|
faser); (d) flere flighter i én frittstående runde
|
||||||
fortsatt kun drøftet (se
|
fortsatt kun drøftet (se
|
||||||
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
||||||
til rundehendelser ennå.
|
til rundehendelser ennå.
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import storage
|
from .. import storage
|
||||||
from ..auth import CurrentUser, get_current_user
|
from ..auth import CurrentUser, get_current_user, get_current_user_optional
|
||||||
from ..db import plain_connection
|
from ..db import plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from .notifications import create_notification
|
from .notifications import create_notification
|
||||||
|
|
@ -131,6 +131,34 @@ async def search_people(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/people/{user_id}", response_model=PersonMatch)
|
||||||
|
async def get_person(
|
||||||
|
user_id: str,
|
||||||
|
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||||
|
) -> PersonMatch:
|
||||||
|
"""Enkeltperson-oppslag (ADR-036 fase 2, 2026-07-28) -- brukt av
|
||||||
|
vennprofil-siden til å vise navn/avatar/hjemmeklubb/HCP i headeren.
|
||||||
|
Samme felt-sett/sensitivitetsnivå som /people/search (aldri e-post/
|
||||||
|
mobil/fødselsdato) -- derfor OPTIONALT autentisert, ikke
|
||||||
|
`get_current_user`: en runde satt til 'public' (ADR-036 Beslutning B)
|
||||||
|
skal kunne nås av en anonym leser via en delt lenke, og profilsiden som
|
||||||
|
leder dit må da også fungere anonymt, ellers ville "offentlig" runde
|
||||||
|
vært offentlig i praksis, men uoppdagelig uten innlogging."""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"SELECT id::text AS id, first_name, last_name, avatar_key, home_club, "
|
||||||
|
"handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
if row is None or row["first_name"] is None or row["last_name"] is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
|
||||||
|
return PersonMatch(
|
||||||
|
id=row["id"], first_name=row["first_name"], last_name=row["last_name"],
|
||||||
|
avatar_url=_avatar_url(row["avatar_key"]), home_club=row["home_club"],
|
||||||
|
handicap_index=row["handicap_index"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Vennskap -- forespørsel/aksept/fjern
|
# Vennskap -- forespørsel/aksept/fjern
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
6
frontend/app/my-friends/[id]/page.tsx
Normal file
6
frontend/app/my-friends/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { FriendProfile } from "@/components/friend-profile"
|
||||||
|
|
||||||
|
export default async function FriendProfilePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
return <FriendProfile userId={id} />
|
||||||
|
}
|
||||||
6
frontend/app/watch/[id]/page.tsx
Normal file
6
frontend/app/watch/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { WatchRound } from "@/components/watch-round"
|
||||||
|
|
||||||
|
export default async function WatchRoundPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params
|
||||||
|
return <WatchRound roundId={id} />
|
||||||
|
}
|
||||||
242
frontend/components/friend-profile.tsx
Normal file
242
frontend/components/friend-profile.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Vennprofil-side (ADR-036 fase 2, 2026-07-28) -- svar på at det tidligere
|
||||||
|
// ikke fantes NOEN vei til å gå inn på en venns profil og følge en runde
|
||||||
|
// live, kun selve tilgangskontrollen. Viser navn/avatar/hjemmeklubb/HCP
|
||||||
|
// (samme lavsensitive felt-sett som personsøket, GET /people/{id}) og en
|
||||||
|
// liste over personens runder som SPØRREREN har lov til å se
|
||||||
|
// (GET /public/people/{id}/rounds -- backend filtrerer allerede på
|
||||||
|
// visibility_mode/venne-kategorisering, denne siden viser bare det den
|
||||||
|
// får). Bevisst uten venneskap-administrasjon (send/fjern venn) -- det
|
||||||
|
// hører hjemme i /my-friends, denne siden er en ren visning, nåbar for
|
||||||
|
// enhver synlig person, ikke bare venner (en offentlig runde er synlig
|
||||||
|
// for alle, også fremmede/anonyme).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, ChevronRight, MapPin, Radio } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type ApiPerson = {
|
||||||
|
id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
avatar_url: string | null
|
||||||
|
home_club: string | null
|
||||||
|
handicap_index: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiPersonRound = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
course_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
play_format: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
stroke: "Slagspill",
|
||||||
|
match: "Match",
|
||||||
|
skins: "Skins",
|
||||||
|
fourball: "Fourball",
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2)",
|
||||||
|
scramble_4: "Scramble (4)",
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
|
function initials(name: string) {
|
||||||
|
const parts = name.trim().split(/\s+/)
|
||||||
|
const first = parts[0]?.[0] ?? ""
|
||||||
|
const last = parts.length > 1 ? parts[parts.length - 1][0] : ""
|
||||||
|
return (first + last).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name, url, size = "large" }: { name: string; url: string | null; size?: "large" | "small" }) {
|
||||||
|
const dim = size === "large" ? "size-16 text-xl" : "size-10 text-sm"
|
||||||
|
if (url) {
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={url} alt="" className={cn(dim, "shrink-0 rounded-full object-cover")} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-primary/15 font-bold text-foreground")}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundRow({ round }: { round: ApiPersonRound }) {
|
||||||
|
const ongoing = round.completed_at === null
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/watch/${round.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-base font-bold text-foreground">
|
||||||
|
{round.name?.trim() || round.course_name_snapshot}
|
||||||
|
</span>
|
||||||
|
{ongoing && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
||||||
|
<Radio aria-hidden="true" className="size-3" />
|
||||||
|
Pågår nå
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||||
|
{FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FriendProfile({ userId }: { userId: string }) {
|
||||||
|
const [person, setPerson] = useState<ApiPerson | null>(null)
|
||||||
|
const [rounds, setRounds] = useState<ApiPersonRound[] | null>(null)
|
||||||
|
const [viewerId, setViewerId] = useState<string | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
|
||||||
|
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
|
||||||
|
async function load() {
|
||||||
|
const [personRes, roundsRes] = await Promise.all([
|
||||||
|
fetch(`/people/${userId}`, { credentials: "include" }),
|
||||||
|
fetch(`/public/people/${userId}/rounds`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
if (personRes.status === 404) {
|
||||||
|
if (!cancelled) setNotFound(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!personRes.ok || !roundsRes.ok) return
|
||||||
|
const personData: ApiPerson = await personRes.json()
|
||||||
|
const roundsData: ApiPersonRound[] = await roundsRes.json()
|
||||||
|
if (!cancelled) {
|
||||||
|
setPerson(personData)
|
||||||
|
setRounds(roundsData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||||
|
<p className="text-base font-medium text-destructive">Fant ikke denne brukeren.</p>
|
||||||
|
<Link href="/my-friends" className="text-base font-semibold text-primary underline underline-offset-2">
|
||||||
|
Tilbake til venner
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!person || !rounds) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `${person.first_name} ${person.last_name}`.trim()
|
||||||
|
const isSelf = viewerId !== null && viewerId === userId
|
||||||
|
const ongoing = rounds.filter((r) => r.completed_at === null)
|
||||||
|
const finished = rounds.filter((r) => r.completed_at !== null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href="/my-friends"
|
||||||
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
|
<Avatar name={name} url={person.avatar_url} />
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="truncate text-xl font-extrabold tracking-tight text-foreground">{name}</span>
|
||||||
|
{person.home_club && (
|
||||||
|
<span className="flex items-center gap-1 truncate text-sm font-semibold text-muted-foreground">
|
||||||
|
<MapPin aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
|
{person.home_club}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{person.handicap_index !== null && (
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground">HCP {person.handicap_index.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelf && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
Dette er din egen profil. Gå til{" "}
|
||||||
|
<Link href="/my-rounds" className="font-semibold text-primary underline underline-offset-2">
|
||||||
|
Egne runder
|
||||||
|
</Link>{" "}
|
||||||
|
for å se alle rundene dine, inkludert de private.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ongoing.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Pågår nå</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{ongoing.map((r) => (
|
||||||
|
<RoundRow key={r.id} round={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Tidligere runder</h2>
|
||||||
|
{finished.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
{rounds.length === 0
|
||||||
|
? "Ingen runder du har tilgang til å se ennå."
|
||||||
|
: "Ingen fullførte runder du har tilgang til å se ennå."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{finished.map((r) => (
|
||||||
|
<RoundRow key={r.id} round={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -433,13 +433,16 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
<div className="flex items-center gap-4">
|
<Link
|
||||||
|
href={`/my-friends/${friend.user_id}`}
|
||||||
|
className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
<Avatar name={name} />
|
<Avatar name={name} />
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate text-lg font-bold text-foreground">{name}</span>
|
<span className="truncate text-lg font-bold text-foreground">{name}</span>
|
||||||
<span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
|
<span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,28 @@ type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "gree
|
||||||
type SkinsScoring = "net" | "gross"
|
type SkinsScoring = "net" | "gross"
|
||||||
type SkinsTieHandling = "carry" | "split"
|
type SkinsTieHandling = "carry" | "split"
|
||||||
|
|
||||||
|
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||||||
|
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller
|
||||||
|
// (som alltid ser runden uansett). Trygg standard "private".
|
||||||
|
type VisibilityMode = "private" | "public" | "friends"
|
||||||
|
|
||||||
|
// Samme faste sett/rekkefølge som friends.py sin Category og
|
||||||
|
// friends.tsx sin CATEGORY_OPTIONS -- egen, lokal kopi (samme
|
||||||
|
// "liten duplisering fremfor cross-file-import"-mønster som FORMAT_LABELS
|
||||||
|
// ellers i appen).
|
||||||
|
const VISIBILITY_CATEGORY_OPTIONS = [
|
||||||
|
{ code: "spouse", label: "Make" },
|
||||||
|
{ code: "close_family", label: "Nær familie" },
|
||||||
|
{ code: "extended_family", label: "Storfamilie" },
|
||||||
|
{ code: "close_friends", label: "Nære venner" },
|
||||||
|
{ code: "golf_friends", label: "Golfvenner" },
|
||||||
|
{ code: "colleagues", label: "Kollegaer" },
|
||||||
|
{ code: "business", label: "Forretningsforbindelser" },
|
||||||
|
{ code: "classmates", label: "Studiekamerater" },
|
||||||
|
{ code: "acquaintances", label: "Perifere bekjente" },
|
||||||
|
{ code: "other", label: "Ymse" },
|
||||||
|
] as const
|
||||||
|
|
||||||
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
|
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
|
||||||
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
|
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
|
||||||
{ value: "match", label: "Match", desc: "To sider, hull mot hull." },
|
{ value: "match", label: "Match", desc: "To sider, hull mot hull." },
|
||||||
|
|
@ -220,6 +242,8 @@ export function NewRound() {
|
||||||
excludeFromHandicap: boolean
|
excludeFromHandicap: boolean
|
||||||
skinsScoring: SkinsScoring | null
|
skinsScoring: SkinsScoring | null
|
||||||
skinsTieHandling: SkinsTieHandling | null
|
skinsTieHandling: SkinsTieHandling | null
|
||||||
|
visibilityMode: VisibilityMode
|
||||||
|
visibleCategories: string[]
|
||||||
}): Promise<{ id: string }> {
|
}): Promise<{ id: string }> {
|
||||||
if (!courseMeta) throw new Error("Mangler valgt bane")
|
if (!courseMeta) throw new Error("Mangler valgt bane")
|
||||||
const shared = {
|
const shared = {
|
||||||
|
|
@ -234,6 +258,8 @@ export function NewRound() {
|
||||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||||
skins_scoring: payload.skinsScoring,
|
skins_scoring: payload.skinsScoring,
|
||||||
skins_tie_handling: payload.skinsTieHandling,
|
skins_tie_handling: payload.skinsTieHandling,
|
||||||
|
visibility_mode: payload.visibilityMode,
|
||||||
|
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
|
||||||
}
|
}
|
||||||
const body =
|
const body =
|
||||||
courseMeta.source === "teeoff"
|
courseMeta.source === "teeoff"
|
||||||
|
|
@ -1137,6 +1163,8 @@ function ConfirmStep({
|
||||||
excludeFromHandicap: boolean
|
excludeFromHandicap: boolean
|
||||||
skinsScoring: SkinsScoring | null
|
skinsScoring: SkinsScoring | null
|
||||||
skinsTieHandling: SkinsTieHandling | null
|
skinsTieHandling: SkinsTieHandling | null
|
||||||
|
visibilityMode: VisibilityMode
|
||||||
|
visibleCategories: string[]
|
||||||
}) => Promise<{ id: string }>
|
}) => Promise<{ id: string }>
|
||||||
onCreated: (roundId: string) => void
|
onCreated: (roundId: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1154,6 +1182,9 @@ function ConfirmStep({
|
||||||
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
||||||
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
||||||
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
|
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
|
||||||
|
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
|
||||||
|
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
|
||||||
|
const [visibleCategories, setVisibleCategories] = useState<string[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -1193,6 +1224,8 @@ function ConfirmStep({
|
||||||
excludeFromHandicap,
|
excludeFromHandicap,
|
||||||
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
||||||
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
||||||
|
visibilityMode,
|
||||||
|
visibleCategories,
|
||||||
})
|
})
|
||||||
onCreated(created.id)
|
onCreated(created.id)
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1477,6 +1510,77 @@ function ConfirmStep({
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
|
||||||
|
innsyn (venner/offentligheten), aldri en faktisk lagt-til
|
||||||
|
medspiller (som alltid ser runden uansett). Trygg standard
|
||||||
|
"Privat", samme filosofi som RLS-policyenes "se ingenting" ved
|
||||||
|
manglende kontekst ellers i appen. */}
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-base font-semibold text-foreground">Synlighet</legend>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: "private", label: "Privat", desc: "Kun deg og faktiske medspillere." },
|
||||||
|
{ value: "friends", label: "Venner", desc: "Valgte venne-kategorier kan følge live." },
|
||||||
|
{ value: "public", label: "Offentlig", desc: "Alle kan følge live, ingen innlogging." },
|
||||||
|
] as { value: VisibilityMode; label: string; desc: string }[]
|
||||||
|
).map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibilityMode(opt.value)}
|
||||||
|
aria-pressed={visibilityMode === opt.value}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[44px] flex-col items-center gap-0.5 rounded-2xl border px-2 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
visibilityMode === opt.value
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold">{opt.label}</span>
|
||||||
|
<span className="text-xs">{opt.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{visibilityMode === "friends" && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
Hvilke av dine venne-kategorier skal kunne se runden?
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
|
||||||
|
const active = visibleCategories.includes(code)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCategories((prev) =>
|
||||||
|
active ? prev.filter((c) => c !== code) : [...prev, code],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{visibleCategories.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
|
||||||
|
|
@ -218,8 +218,28 @@ type ApiRound = {
|
||||||
sides: ApiSide[]
|
sides: ApiSide[]
|
||||||
setup_complete: boolean
|
setup_complete: boolean
|
||||||
setup_message: string | null
|
setup_message: string | null
|
||||||
|
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||||||
|
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
|
||||||
|
visibility_mode: string
|
||||||
|
visible_categories: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
||||||
|
// sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering
|
||||||
|
// fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen).
|
||||||
|
const VISIBILITY_CATEGORY_OPTIONS = [
|
||||||
|
{ code: "spouse", label: "Make" },
|
||||||
|
{ code: "close_family", label: "Nær familie" },
|
||||||
|
{ code: "extended_family", label: "Storfamilie" },
|
||||||
|
{ code: "close_friends", label: "Nære venner" },
|
||||||
|
{ code: "golf_friends", label: "Golfvenner" },
|
||||||
|
{ code: "colleagues", label: "Kollegaer" },
|
||||||
|
{ code: "business", label: "Forretningsforbindelser" },
|
||||||
|
{ code: "classmates", label: "Studiekamerater" },
|
||||||
|
{ code: "acquaintances", label: "Perifere bekjente" },
|
||||||
|
{ code: "other", label: "Ymse" },
|
||||||
|
] as const
|
||||||
|
|
||||||
// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
|
// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
|
||||||
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
||||||
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
||||||
|
|
@ -2775,16 +2795,28 @@ function EditRoundPanel({
|
||||||
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri
|
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri
|
||||||
// AGS/differensial-matematikken selv).
|
// AGS/differensial-matematikken selv).
|
||||||
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke")
|
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke")
|
||||||
|
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
|
||||||
|
// av fullført-status (samme begrunnelse som playFormat over).
|
||||||
|
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
|
||||||
|
round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private",
|
||||||
|
)
|
||||||
|
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const sameCategories =
|
||||||
|
visibleCategories.length === round.visible_categories.length &&
|
||||||
|
visibleCategories.every((c) => round.visible_categories.includes(c))
|
||||||
|
|
||||||
const hasMetadataChanges =
|
const hasMetadataChanges =
|
||||||
name.trim() !== (round.name ?? "") ||
|
name.trim() !== (round.name ?? "") ||
|
||||||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||||||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||||||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
|
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
|
||||||
playFormat !== (round.play_format === "match" ? "match" : "stroke")
|
playFormat !== (round.play_format === "match" ? "match" : "stroke") ||
|
||||||
|
visibilityMode !== round.visibility_mode ||
|
||||||
|
(visibilityMode === "friends" && !sameCategories)
|
||||||
|
|
||||||
async function saveMetadata() {
|
async function saveMetadata() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -2802,6 +2834,8 @@ function EditRoundPanel({
|
||||||
body.completed_at = fromDatetimeLocalValue(completedAt)
|
body.completed_at = fromDatetimeLocalValue(completedAt)
|
||||||
}
|
}
|
||||||
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
|
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
|
||||||
|
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||||||
|
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||||||
const result = await onPatch(body)
|
const result = await onPatch(body)
|
||||||
if (!result.ok) setError(result.message)
|
if (!result.ok) setError(result.message)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|
@ -2859,6 +2893,69 @@ function EditRoundPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
|
||||||
|
innsyn (venner/offentligheten), aldri en faktisk lagt-til
|
||||||
|
medspiller (som alltid ser runden uansett). */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Synlighet</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: "private" as const, label: "Privat" },
|
||||||
|
{ value: "friends" as const, label: "Venner" },
|
||||||
|
{ value: "public" as const, label: "Offentlig" },
|
||||||
|
]
|
||||||
|
).map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisibilityMode(opt.value)}
|
||||||
|
aria-pressed={visibilityMode === opt.value}
|
||||||
|
className={cn(
|
||||||
|
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||||||
|
visibilityMode === opt.value
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{visibilityMode === "friends" && (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground">
|
||||||
|
Hvilke av dine venne-kategorier skal kunne se runden?
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
|
||||||
|
const active = visibleCategories.includes(code)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCategories((prev) =>
|
||||||
|
active ? prev.filter((c) => c !== code) : [...prev, code],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isCompleted && (
|
{!isCompleted && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
320
frontend/components/watch-round.tsx
Normal file
320
frontend/components/watch-round.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
|
||||||
|
// fase 2, 2026-07-28) -- der en venn (i en synlig kategori) eller alle
|
||||||
|
// (visibility_mode='public') kan følge runden UTEN å være eier/deltaker.
|
||||||
|
// Ferskbygget, IKKE en gjenbruk av round-detail.tsx/round-leaderboard.tsx
|
||||||
|
// (som antar eier-/deltaker-tilgang og full skrive-UI) -- denne siden er
|
||||||
|
// rendyrket lesevisning mot de nye /public/rounds/*-endepunktene, som
|
||||||
|
// fungerer like fint anonymt som innlogget (get_current_user_optional).
|
||||||
|
//
|
||||||
|
// Tre distinkte presentasjoner avhengig av play_format, samme inndeling
|
||||||
|
// som round-leaderboard.tsx sin TWO_SIDED_FORMATS-logikk:
|
||||||
|
// - Slagspill: individuell rangering (brutto til par).
|
||||||
|
// - Skins: skins-tavle (hvem har vunnet flest skins).
|
||||||
|
// - Match/fourball/foursome/greensome/scramble: matchstatus + hull-merker.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Radio, Trophy } from "lucide-react"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type ApiSide = { id: string; label: string | null }
|
||||||
|
|
||||||
|
type ApiParticipant = {
|
||||||
|
id: string
|
||||||
|
display_name: string
|
||||||
|
is_owner: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
course_name_snapshot: string
|
||||||
|
tee_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
play_format: string
|
||||||
|
completed_at: string | null
|
||||||
|
owner_display_name: string
|
||||||
|
participants: ApiParticipant[]
|
||||||
|
sides: ApiSide[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLeaderboardEntry = {
|
||||||
|
participant_id: string
|
||||||
|
display_name: string
|
||||||
|
holes_played: number
|
||||||
|
total_score: number | null
|
||||||
|
score_to_par: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] }
|
||||||
|
|
||||||
|
type ApiFormatResult = {
|
||||||
|
play_format: string
|
||||||
|
ready: boolean
|
||||||
|
match_holes_played: number | null
|
||||||
|
match_holes_remaining: number | null
|
||||||
|
match_is_closed: boolean | null
|
||||||
|
match_is_dormie: boolean | null
|
||||||
|
match_status_text: string | null
|
||||||
|
hole_results: string[] | null
|
||||||
|
skins_won: Record<string, number> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
stroke: "Slagspill",
|
||||||
|
match: "Match",
|
||||||
|
skins: "Skins",
|
||||||
|
fourball: "Fourball",
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2)",
|
||||||
|
scramble_4: "Scramble (4)",
|
||||||
|
}
|
||||||
|
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
|
function formatToPar(value: number): string {
|
||||||
|
if (value === 0) return "E"
|
||||||
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
||||||
|
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||||||
|
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slagspill: individuell rangering ---------------------------------------
|
||||||
|
|
||||||
|
function StrokeLeaderboard({ leaderboard }: { leaderboard: ApiLeaderboard }) {
|
||||||
|
const ranked = [...leaderboard.entries].sort((a, b) => {
|
||||||
|
if (a.score_to_par === null) return 1
|
||||||
|
if (b.score_to_par === null) return -1
|
||||||
|
return a.score_to_par - b.score_to_par
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
{ranked.map((entry, i) => (
|
||||||
|
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
||||||
|
i === 0 && entry.score_to_par !== null ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.score_to_par !== null ? `#${i + 1}` : "–"}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
||||||
|
<span className="shrink-0 text-sm text-muted-foreground">
|
||||||
|
{leaderboard.completed ? "Ferdig" : entry.holes_played > 0 ? `${entry.holes_played} hull` : "Ikke startet"}
|
||||||
|
</span>
|
||||||
|
{entry.score_to_par !== null && (
|
||||||
|
<span className="shrink-0 text-base font-extrabold tabular-nums text-foreground">
|
||||||
|
{formatToPar(entry.score_to_par)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skins-tavle -------------------------------------------------------------
|
||||||
|
|
||||||
|
function SkinsBoard({ participants, skinsWon }: { participants: ApiParticipant[]; skinsWon: Record<string, number> }) {
|
||||||
|
const rows = participants
|
||||||
|
.map((p) => ({ participant: p, skins: skinsWon[p.id] ?? 0 }))
|
||||||
|
.sort((a, b) => b.skins - a.skins)
|
||||||
|
const totalAwarded = Object.values(skinsWon).reduce((sum, v) => sum + v, 0)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Skins-tavle</span>
|
||||||
|
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||||
|
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{rows.map(({ participant, skins }) => (
|
||||||
|
<li key={participant.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{participant.display_name}</span>
|
||||||
|
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Matchstatus (to-sidede formater) ----------------------------------------
|
||||||
|
|
||||||
|
function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSide[] }) {
|
||||||
|
if (!result.ready) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
|
||||||
|
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
|
||||||
|
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
|
||||||
|
</div>
|
||||||
|
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{result.match_holes_played} hull spilt
|
||||||
|
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
|
||||||
|
</span>
|
||||||
|
{result.hole_results && result.hole_results.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-1">
|
||||||
|
{result.hole_results.map((r, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
aria-label={
|
||||||
|
r === "halved" ? `Hull ${i + 1}: delt` : `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||||||
|
r === "a" && "bg-primary text-primary-foreground",
|
||||||
|
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||||||
|
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r === "halved" ? "½" : r.toUpperCase()}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screen ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function WatchRound({ roundId }: { roundId: string }) {
|
||||||
|
const [round, setRound] = useState<ApiRound | null>(null)
|
||||||
|
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
||||||
|
const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/public/rounds/${roundId}/live`)
|
||||||
|
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
||||||
|
return () => socket.close()
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
const roundRes = await fetch(`/public/rounds/${roundId}`, { credentials: "include" })
|
||||||
|
if (roundRes.status === 404) {
|
||||||
|
if (!cancelled) setError("Denne runden finnes ikke.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (roundRes.status === 403) {
|
||||||
|
if (!cancelled) setError("Du har ikke tilgang til å se denne runden.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!roundRes.ok) {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const roundData: ApiRound = await roundRes.json()
|
||||||
|
if (cancelled) return
|
||||||
|
setRound(roundData)
|
||||||
|
|
||||||
|
if (roundData.play_format === "stroke" || roundData.play_format === "skins") {
|
||||||
|
const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" })
|
||||||
|
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
|
||||||
|
}
|
||||||
|
if (TWO_SIDED_FORMATS.has(roundData.play_format) || roundData.play_format === "skins") {
|
||||||
|
const frRes = await fetch(`/public/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||||
|
if (frRes.ok && !cancelled) setFormatResult(await frRes.json())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, refreshKey])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||||
|
<p className="text-base font-medium text-destructive">{error}</p>
|
||||||
|
<Link href="/dashboard" className="text-base font-semibold text-primary underline underline-offset-2">
|
||||||
|
Tilbake til dashbordet
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ongoing = round.completed_at === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
|
||||||
|
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
Følger live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||||
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
{round.name?.trim() || round.course_name_snapshot}
|
||||||
|
</span>
|
||||||
|
{ongoing && (
|
||||||
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
||||||
|
<Radio aria-hidden="true" className="size-3 animate-pulse" />
|
||||||
|
Pågår nå
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||||
|
{FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">Spilt av {round.owner_display_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{TWO_SIDED_FORMATS.has(round.play_format) && formatResult && (
|
||||||
|
<MatchStatus result={formatResult} sides={round.sides} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{round.play_format === "skins" && formatResult?.skins_won && (
|
||||||
|
<SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{round.play_format === "stroke" && leaderboard && <StrokeLeaderboard leaderboard={leaderboard} />}
|
||||||
|
|
||||||
|
{!TWO_SIDED_FORMATS.has(round.play_format) && round.play_format !== "skins" && round.play_format !== "stroke" && (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
Ingen live-visning tilgjengelig for dette formatet ennå.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue