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
|
||||
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
||||
→ 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:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
|
|
@ -4650,12 +4802,10 @@ Neste steg:
|
|||
noterte "naturlig neste steg"-punkter er dermed alle tettet, unntatt
|
||||
notifikasjons-/aktivitetsfeed og "Mine runder" for rene påmeldinger
|
||||
(begge fortsatt IKKE bygget, se FEATURE_BACKLOG.md).
|
||||
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
||||
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
||||
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
||||
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
||||
synkes — først da er flyten reelt bevist.
|
||||
6. ~~Oppfølgingspunkt fra PWA-runden: offline-scoreregistrering er ALDRI
|
||||
browser-testet i praksis~~ — **ferdig 2026-07-28** (se status-punkt
|
||||
samme dag, "Turnering-scorekortets offline-flyt (ADR-028) FAKTISK
|
||||
browserverifisert"). Beholdt her kun for historikk.
|
||||
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
||||
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||
(bevisst utsatt i ADR-025).
|
||||
|
|
@ -4673,8 +4823,10 @@ Neste steg:
|
|||
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
|
||||
live; (b) ingen Stableford-poengberegning for frittstående runder (kun
|
||||
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand;
|
||||
(c) rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
||||
fortsatt ikke bygget; (d) flere flighter i én frittstående runde
|
||||
(c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
||||
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
|
||||
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
||||
til rundehendelser ennå.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Query
|
|||
from pydantic import BaseModel
|
||||
|
||||
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 ..errors import app_error, translate_db_errors
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSock
|
|||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from .. import teeoff_client
|
||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
|
||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||
from ..db import plain_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
||||
|
|
@ -65,6 +65,15 @@ from handicap_engine import (
|
|||
|
||||
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
||||
|
||||
# Samme faste kategori-sett som friends.py sin Category (ADR-036 fase 1),
|
||||
# duplisert her fremfor importert for å unngå en tvers-router-avhengighet
|
||||
# for én enkelt type -- samme pragmatiske valg som skjemaets doble CHECK.
|
||||
_FriendCategory = Literal[
|
||||
"spouse", "close_family", "extended_family", "close_friends",
|
||||
"golf_friends", "colleagues", "business", "classmates",
|
||||
"acquaintances", "other",
|
||||
]
|
||||
|
||||
router = APIRouter(tags=["rounds"])
|
||||
|
||||
|
||||
|
|
@ -615,6 +624,15 @@ class RoundCreate(BaseModel):
|
|||
# Beslutning D. Brukeren som setter opp runden velger begge uavhengig.
|
||||
skins_scoring: Literal["net", "gross"] | None = None
|
||||
skins_tie_handling: Literal["carry", "split"] | None = None
|
||||
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||||
# innsyn (venner/offentligheten). Trygg standard 'private', samme
|
||||
# filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
|
||||
# En lagt-til medspiller ser ALLTID runden uansett, uendret av dette.
|
||||
visibility_mode: Literal["public", "private", "friends"] = "private"
|
||||
# KUN meningsfullt (og bør være ikke-tom) når visibility_mode='friends'
|
||||
# -- hvilke av EGNE private kategorier (se friends.py sin Category) som
|
||||
# får se runden. Tomt/utelatt for public/private.
|
||||
visible_categories: list[_FriendCategory] = []
|
||||
|
||||
|
||||
class RoundParticipantOut(BaseModel):
|
||||
|
|
@ -689,6 +707,12 @@ class RoundOut(BaseModel):
|
|||
my_holes_played: int
|
||||
my_total_score: int | None
|
||||
my_score_to_par: int | None
|
||||
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- kun eieren (eller en
|
||||
# medspiller, som ser dette som ren informasjon) trenger å se/endre
|
||||
# disse -- eksponert her siden dette ER den autentiserte eier-/
|
||||
# medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen.
|
||||
visibility_mode: str
|
||||
visible_categories: list[str]
|
||||
|
||||
|
||||
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||
|
|
@ -696,11 +720,14 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
"""
|
||||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||
started_at, completed_at
|
||||
started_at, completed_at, visibility_mode
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
visible_category_rows = await conn.fetch(
|
||||
"SELECT category FROM round_visible_category WHERE round_id = $1", round_id
|
||||
)
|
||||
participant_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
|
|
@ -764,6 +791,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
my_holes_played=my_holes_played,
|
||||
my_total_score=my_total_score,
|
||||
my_score_to_par=my_score_to_par,
|
||||
visibility_mode=round_row["visibility_mode"],
|
||||
visible_categories=[r["category"] for r in visible_category_rows],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -881,8 +910,8 @@ async def create_round(
|
|||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned, started_at, name, play_format,
|
||||
skins_scoring, skins_tie_handling)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
skins_scoring, skins_tie_handling, visibility_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -900,6 +929,7 @@ async def create_round(
|
|||
body.play_format,
|
||||
body.skins_scoring,
|
||||
body.skins_tie_handling,
|
||||
body.visibility_mode,
|
||||
)
|
||||
round_id = round_row["id"]
|
||||
|
||||
|
|
@ -912,6 +942,14 @@ async def create_round(
|
|||
individual_round_hole=body.play_format not in SIDE_IS_UNIT,
|
||||
)
|
||||
|
||||
if body.visibility_mode == "friends" and body.visible_categories:
|
||||
for category in sorted(set(body.visible_categories)):
|
||||
await conn.execute(
|
||||
"INSERT INTO round_visible_category (round_id, category) VALUES ($1, $2)",
|
||||
round_id,
|
||||
category,
|
||||
)
|
||||
|
||||
return await _load_round_out(conn, round_id, user.user_id)
|
||||
|
||||
|
||||
|
|
@ -1240,6 +1278,81 @@ async def _get_accessible_round_or_404(conn, round_id: str, user_id: str):
|
|||
raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tredjeparts leseadgang (ADR-036 fase 2, 2026-07-28) -- venner/offentlig-
|
||||
# heten sitt innsyn i en runde de IKKE selv er eier eller deltaker på.
|
||||
# Bevisst ADSKILT fra _get_accessible_round_or_404 over: den dekker eier +
|
||||
# faktisk medspiller (som alltid har full lese-/skrivetilgang uansett
|
||||
# visibility_mode), denne dekker KUN lesing for alle andre, styrt av
|
||||
# visibility_mode + (for 'friends') eierens private kategorisering av
|
||||
# viewer. `viewer_user_id=None` er en gyldig, forventet verdi (anonym
|
||||
# leser) -- gir tilgang KUN når visibility_mode='public'.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT owner_user_id::text AS owner_user_id, visibility_mode FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
if viewer_user_id is not None and row["owner_user_id"] == viewer_user_id:
|
||||
return True
|
||||
if viewer_user_id is not None:
|
||||
is_participant = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM round_participant WHERE round_id = $1 AND user_id = $2)",
|
||||
round_id,
|
||||
viewer_user_id,
|
||||
)
|
||||
if is_participant:
|
||||
return True
|
||||
if row["visibility_mode"] == "public":
|
||||
return True
|
||||
if row["visibility_mode"] == "private":
|
||||
return False
|
||||
# 'friends' -- krever et AKSEPTERT vennskap MED eieren OG at EIERENS
|
||||
# kategorisering av viewer (ikke omvendt -- det er eieren som
|
||||
# begrenser, basert på egen gruppering, ADR-036 Beslutning B) treffer
|
||||
# minst én av rundens synlige kategorier.
|
||||
if viewer_user_id is None:
|
||||
return False
|
||||
owner_id = row["owner_user_id"]
|
||||
is_friend = await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM friendship
|
||||
WHERE status = 'accepted'
|
||||
AND ((requester_user_id = $1 AND addressee_user_id = $2)
|
||||
OR (requester_user_id = $2 AND addressee_user_id = $1))
|
||||
)
|
||||
""",
|
||||
owner_id,
|
||||
viewer_user_id,
|
||||
)
|
||||
if not is_friend:
|
||||
return False
|
||||
return await conn.fetchval(
|
||||
"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM friend_categorization fc
|
||||
JOIN round_visible_category rvc ON rvc.category = fc.category
|
||||
WHERE fc.owner_user_id = $1 AND fc.friend_user_id = $2 AND rvc.round_id = $3
|
||||
)
|
||||
""",
|
||||
owner_id,
|
||||
viewer_user_id,
|
||||
round_id,
|
||||
)
|
||||
|
||||
|
||||
async def _get_viewable_round_or_404(conn, round_id: str, viewer_user_id: str | None) -> None:
|
||||
exists = await conn.fetchval("SELECT 1 FROM round WHERE id = $1", round_id)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||
if not await _can_view_round(conn, round_id, viewer_user_id):
|
||||
raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
|
||||
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
|
||||
|
|
@ -1337,6 +1450,15 @@ class RoundUpdate(BaseModel):
|
|||
# /complete allerede har satt.
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, endrer
|
||||
# aldri HCP-beregningen, kan derfor justeres uansett fullført-status,
|
||||
# samme begrunnelse som start_hole/started_at over.
|
||||
# visible_categories er en FULL ERSTATNING av settet når sendt (samme
|
||||
# "erstatt hele settet"-kontrakt som PUT /friends/{id}/categories) --
|
||||
# utelatt/None rører ikke eksisterende kategorier, tom liste [] tømmer
|
||||
# dem.
|
||||
visibility_mode: Literal["public", "private", "friends"] | None = None
|
||||
visible_categories: list[_FriendCategory] | None = None
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||
|
|
@ -1434,6 +1556,16 @@ async def update_round(
|
|||
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
||||
if body.completed_at is not None:
|
||||
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
||||
if body.visibility_mode is not None:
|
||||
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
|
||||
if body.visible_categories is not None:
|
||||
await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id)
|
||||
for category in sorted(set(body.visible_categories)):
|
||||
await conn.execute(
|
||||
"INSERT INTO round_visible_category (round_id, category) VALUES ($1, $2)",
|
||||
round_id,
|
||||
category,
|
||||
)
|
||||
|
||||
if resolved is not None and participants is not None:
|
||||
await conn.execute(
|
||||
|
|
@ -1986,17 +2118,10 @@ class RoundHoleOut(BaseModel):
|
|||
strokes_received: int | None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/holes",
|
||||
response_model=list[RoundHoleOut],
|
||||
)
|
||||
async def list_holes(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[RoundHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
async def _build_participant_holes(conn, round_id: str, participant_id: str) -> list[RoundHoleOut]:
|
||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||
/public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
|
||||
kun autorisasjonssjekken FØR dette kalles skiller de to."""
|
||||
participant_row = await conn.fetchrow(
|
||||
"SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
|
||||
participant_id, round_id,
|
||||
|
|
@ -2029,6 +2154,20 @@ async def list_holes(
|
|||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/holes",
|
||||
response_model=list[RoundHoleOut],
|
||||
)
|
||||
async def list_holes(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[RoundHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
return await _build_participant_holes(conn, round_id, participant_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delt-ball-scoring (ADR-039 Beslutning C) -- foursome/greensome/scramble
|
||||
# lagrer ÉN score PER SIDE per hull, ikke per spiller. Ingen av de andre
|
||||
|
|
@ -2050,14 +2189,9 @@ class RoundSideHoleOut(BaseModel):
|
|||
strokes_received: int | None = None
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
||||
async def list_side_holes(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[RoundSideHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]:
|
||||
"""Delt mellom den autentiserte GET og /public/rounds (ADR-036 fase 2)
|
||||
-- se _build_participant_holes over for samme begrunnelse."""
|
||||
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
|
|
@ -2084,6 +2218,17 @@ async def list_side_holes(
|
|||
]
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
||||
async def list_side_holes(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[RoundSideHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
return await _build_side_holes(conn, round_id, side_id)
|
||||
|
||||
|
||||
class SideHoleUpdate(BaseModel):
|
||||
played: bool = True
|
||||
score: int | None = Field(default=None, ge=1, le=20)
|
||||
|
|
@ -2182,13 +2327,10 @@ def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
|
|||
return [((start_hole - 1 + i) % 18) + 1 for i in range(holes_planned)]
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
|
||||
async def get_round_format_result(
|
||||
round_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundFormatResultOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
|
||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||
/public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
|
||||
kun autorisasjonssjekken FØR dette kalles skiller de to."""
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned "
|
||||
"FROM round WHERE id = $1",
|
||||
|
|
@ -2457,6 +2599,16 @@ async def get_round_format_result(
|
|||
return RoundFormatResultOut(play_format=play_format, ready=True)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
|
||||
async def get_round_format_result(
|
||||
round_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundFormatResultOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
return await _build_format_result(conn, round_id)
|
||||
|
||||
|
||||
class LeaderboardHoleOut(BaseModel):
|
||||
hole_number: int
|
||||
par: int
|
||||
|
|
@ -2495,10 +2647,9 @@ class LeaderboardOut(BaseModel):
|
|||
entries: list[LeaderboardEntryOut]
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
|
||||
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||
/public/rounds-varianten (ADR-036 fase 2)."""
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
|
||||
)
|
||||
|
|
@ -2586,6 +2737,13 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current
|
|||
)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
|
||||
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
return await _build_leaderboard(conn, round_id)
|
||||
|
||||
|
||||
class HoleUpdate(BaseModel):
|
||||
played: bool = True
|
||||
score: int | None = Field(default=None, ge=1, le=20)
|
||||
|
|
@ -2748,8 +2906,10 @@ def _course_handicap_from_row(p) -> int:
|
|||
# ---------------------------------------------------------------------------
|
||||
# Sanntid (2026-07-26, oppfølging av ADR-027) -- "noe endret seg, hent på
|
||||
# nytt"-signal for round-detail.tsx/round-stats.tsx/round-scorecard.tsx.
|
||||
# Ulikt turnering-live (ADR-027) er en runde ALDRI anonymt tilgjengelig --
|
||||
# krever alltid en ekte, autorisert sesjon (eier ELLER lenket medspiller).
|
||||
# Denne PRIVATE varianten krever alltid en ekte, autorisert sesjon (eier
|
||||
# ELLER lenket medspiller) -- en anonym/tredjeparts-venn kan ikke bruke
|
||||
# denne, se `/ws/public/rounds/{round_id}/live` under (ADR-036 fase 2,
|
||||
# 2026-07-28) for den offentlige/venn-gatede varianten.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.websocket("/ws/rounds/{round_id}/live")
|
||||
|
|
@ -2775,3 +2935,212 @@ async def round_live_ws(websocket: WebSocket, round_id: str) -> None:
|
|||
pass
|
||||
finally:
|
||||
sockets.discard(websocket)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tredjeparts/offentlig lesevisning (ADR-036 fase 2, 2026-07-28) -- en venn
|
||||
# (i en synlig kategori) eller alle (visibility_mode='public') kan følge en
|
||||
# runde live UTEN å være eier/deltaker. Samme "egen /public-prefiks,
|
||||
# get_current_user_optional"-mønster som turneringers offentlige side
|
||||
# (ADR-018/026/027) -- egne, leaner response-modeller (aldri guest_email,
|
||||
# som er PII, og aldri my_*/setup_*, som kun gir mening for eier/deltaker),
|
||||
# men gjenbruker de SAMME underliggende dataleserne
|
||||
# (_build_participant_holes/_build_side_holes/_build_leaderboard/
|
||||
# _build_format_result) som de autentiserte endepunktene over, for å unngå
|
||||
# to implementasjoner av samme forretningslogikk.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PublicRoundParticipantOut(BaseModel):
|
||||
id: str
|
||||
display_name: str
|
||||
is_owner: bool
|
||||
gender: str
|
||||
tee_name_snapshot: str
|
||||
handicap_index_snapshot: float | None
|
||||
course_handicap_snapshot: int | None
|
||||
round_side_id: str | None
|
||||
playing_handicap: int | None
|
||||
|
||||
|
||||
class PublicRoundOut(BaseModel):
|
||||
id: str
|
||||
name: str | None
|
||||
course_name_snapshot: str
|
||||
tee_name_snapshot: str
|
||||
played_at: date
|
||||
start_hole: int
|
||||
holes_planned: int
|
||||
play_format: str
|
||||
completed_at: str | None
|
||||
owner_display_name: str
|
||||
participants: list[PublicRoundParticipantOut]
|
||||
sides: list[RoundSideOut]
|
||||
|
||||
|
||||
async def _build_public_round_out(conn, round_id: str) -> PublicRoundOut:
|
||||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS id, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, play_format, completed_at,
|
||||
owner_user_id::text AS owner_user_id
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
owner_display_name = await conn.fetchval(
|
||||
"SELECT display_name FROM app_user WHERE id = $1", round_row["owner_user_id"]
|
||||
)
|
||||
participant_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rp.id::text AS id,
|
||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.round_side_id::text AS round_side_id,
|
||||
rp.playing_handicap
|
||||
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,
|
||||
)
|
||||
side_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
|
||||
)
|
||||
return PublicRoundOut(
|
||||
id=round_row["id"],
|
||||
name=round_row["name"],
|
||||
course_name_snapshot=round_row["course_name_snapshot"],
|
||||
tee_name_snapshot=round_row["tee_name_snapshot"],
|
||||
played_at=round_row["played_at"],
|
||||
start_hole=round_row["start_hole"],
|
||||
holes_planned=round_row["holes_planned"],
|
||||
play_format=round_row["play_format"],
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
owner_display_name=owner_display_name or "Ukjent",
|
||||
participants=[PublicRoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
sides=[RoundSideOut(**dict(r)) for r in side_rows],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}", response_model=PublicRoundOut)
|
||||
async def get_public_round(
|
||||
round_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> PublicRoundOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_public_round_out(conn, round_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/public/rounds/{round_id}/participants/{participant_id}/holes",
|
||||
response_model=list[RoundHoleOut],
|
||||
)
|
||||
async def get_public_participant_holes(
|
||||
round_id: str,
|
||||
participant_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[RoundHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_participant_holes(conn, round_id, participant_id)
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
||||
async def get_public_side_holes(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[RoundSideHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_side_holes(conn, round_id, side_id)
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
|
||||
async def get_public_leaderboard(
|
||||
round_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> LeaderboardOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_leaderboard(conn, round_id)
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
|
||||
async def get_public_format_result(
|
||||
round_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> RoundFormatResultOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
return await _build_format_result(conn, round_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/public/rounds/{round_id}/live")
|
||||
async def public_round_live_ws(websocket: WebSocket, round_id: str) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
async with plain_connection() as conn:
|
||||
try:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
except HTTPException as exc:
|
||||
await websocket.close(code=4404 if exc.status_code == 404 else 4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
sockets = live_sockets_for_round(round_id)
|
||||
sockets.add(websocket)
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
sockets.discard(websocket)
|
||||
|
||||
|
||||
class PublicPersonRoundOut(BaseModel):
|
||||
id: str
|
||||
name: str | None
|
||||
course_name_snapshot: str
|
||||
played_at: date
|
||||
completed_at: str | None
|
||||
play_format: str
|
||||
|
||||
|
||||
@router.get("/public/people/{user_id}/rounds", response_model=list[PublicPersonRoundOut])
|
||||
async def list_public_person_rounds(
|
||||
user_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[PublicPersonRoundOut]:
|
||||
"""Brukes av vennprofil-siden (ADR-036 fase 2) -- lister EIERENS runder
|
||||
som SPØRREREN har lov til å se (public alltid, friends kun med riktig
|
||||
kategori-match, private aldri) -- egen lenket medspiller-runde vises
|
||||
ikke her (det er "Mine runder", en annen sak). Pågående
|
||||
(completed_at IS NULL) alltid øverst, deretter nyeste fullførte."""
|
||||
async with plain_connection() as conn:
|
||||
person_exists = await conn.fetchval("SELECT 1 FROM app_user WHERE id = $1", user_id)
|
||||
if person_exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id::text AS id, name, course_name_snapshot, played_at, completed_at, play_format
|
||||
FROM round WHERE owner_user_id = $1
|
||||
ORDER BY (completed_at IS NULL) DESC, played_at DESC, created_at DESC
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
viewer_id = user.user_id if user else None
|
||||
visible = [r for r in rows if await _can_view_round(conn, r["id"], viewer_id)]
|
||||
return [
|
||||
PublicPersonRoundOut(
|
||||
id=r["id"],
|
||||
name=r["name"],
|
||||
course_name_snapshot=r["course_name_snapshot"],
|
||||
played_at=r["played_at"],
|
||||
completed_at=r["completed_at"].isoformat() if r["completed_at"] else None,
|
||||
play_format=r["play_format"],
|
||||
)
|
||||
for r in visible
|
||||
]
|
||||
|
|
|
|||
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 (
|
||||
<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} />
|
||||
<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-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<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 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 }[] = [
|
||||
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
|
||||
{ value: "match", label: "Match", desc: "To sider, hull mot hull." },
|
||||
|
|
@ -220,6 +242,8 @@ export function NewRound() {
|
|||
excludeFromHandicap: boolean
|
||||
skinsScoring: SkinsScoring | null
|
||||
skinsTieHandling: SkinsTieHandling | null
|
||||
visibilityMode: VisibilityMode
|
||||
visibleCategories: string[]
|
||||
}): Promise<{ id: string }> {
|
||||
if (!courseMeta) throw new Error("Mangler valgt bane")
|
||||
const shared = {
|
||||
|
|
@ -234,6 +258,8 @@ export function NewRound() {
|
|||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
skins_scoring: payload.skinsScoring,
|
||||
skins_tie_handling: payload.skinsTieHandling,
|
||||
visibility_mode: payload.visibilityMode,
|
||||
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
|
||||
}
|
||||
const body =
|
||||
courseMeta.source === "teeoff"
|
||||
|
|
@ -1137,6 +1163,8 @@ function ConfirmStep({
|
|||
excludeFromHandicap: boolean
|
||||
skinsScoring: SkinsScoring | null
|
||||
skinsTieHandling: SkinsTieHandling | null
|
||||
visibilityMode: VisibilityMode
|
||||
visibleCategories: string[]
|
||||
}) => Promise<{ id: string }>
|
||||
onCreated: (roundId: string) => void
|
||||
}) {
|
||||
|
|
@ -1154,6 +1182,9 @@ function ConfirmStep({
|
|||
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
||||
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -1193,6 +1224,8 @@ function ConfirmStep({
|
|||
excludeFromHandicap,
|
||||
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
||||
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
||||
visibilityMode,
|
||||
visibleCategories,
|
||||
})
|
||||
onCreated(created.id)
|
||||
} catch {
|
||||
|
|
@ -1477,6 +1510,77 @@ function ConfirmStep({
|
|||
</div>
|
||||
</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
|
||||
type="button"
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -218,8 +218,28 @@ type ApiRound = {
|
|||
sides: ApiSide[]
|
||||
setup_complete: boolean
|
||||
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
|
||||
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
||||
// å 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
|
||||
// AGS/differensial-matematikken selv).
|
||||
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 [showChangeCourse, setShowChangeCourse] = useState(false)
|
||||
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 =
|
||||
name.trim() !== (round.name ?? "") ||
|
||||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||||
(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() {
|
||||
setSaving(true)
|
||||
|
|
@ -2802,6 +2834,8 @@ function EditRoundPanel({
|
|||
body.completed_at = fromDatetimeLocalValue(completedAt)
|
||||
}
|
||||
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)
|
||||
if (!result.ok) setError(result.message)
|
||||
setSaving(false)
|
||||
|
|
@ -2859,6 +2893,69 @@ function EditRoundPanel({
|
|||
</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 && (
|
||||
<>
|
||||
<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