diff --git a/032_round_visibility.sql b/032_round_visibility.sql new file mode 100644 index 0000000..54d6a08 --- /dev/null +++ b/032_round_visibility.sql @@ -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; diff --git a/CLAUDE.md b/CLAUDE.md index e13f97e..f06b103 100644 --- a/CLAUDE.md +++ b/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å. diff --git a/app/routers/friends.py b/app/routers/friends.py index 80e4e15..c53171c 100644 --- a/app/routers/friends.py +++ b/app/routers/friends.py @@ -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 # --------------------------------------------------------------------------- diff --git a/app/routers/rounds.py b/app/routers/rounds.py index c182c44..06c0521 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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,32 +2118,25 @@ 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) - participant_row = await conn.fetchrow( - "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2", - participant_id, round_id, - ) - if participant_row is None: - raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") - rows = await conn.fetch( - """ - SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee, - tee_shot_result, approach_result, chip_count, bunker_shot_count, - penalty_strokes, first_putt_distance_bucket, anyway_strokes - FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number - """, - participant_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, + ) + if participant_row is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.") + rows = await conn.fetch( + """ + SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee, + tee_shot_result, approach_result, chip_count, bunker_shot_count, + penalty_strokes, first_putt_distance_bucket, anyway_strokes + FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number + """, + participant_id, + ) strokes_received_by_hole: dict[int, int] | None = None if participant_row["course_handicap_snapshot"] is not None: @@ -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,25 +2189,20 @@ 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) - 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.") - rows = await conn.fetch( - "SELECT hole_number, par, stroke_index, played, score FROM round_hole " - "WHERE round_side_id = $1 ORDER BY hole_number", - side_id, - ) - playing_handicap = await conn.fetchval( - "SELECT playing_handicap FROM round_participant WHERE round_side_id = $1 LIMIT 1", side_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.") + rows = await conn.fetch( + "SELECT hole_number, par, stroke_index, played, score FROM round_hole " + "WHERE round_side_id = $1 ORDER BY hole_number", + side_id, + ) + playing_handicap = await conn.fetchval( + "SELECT playing_handicap FROM round_participant WHERE round_side_id = $1 LIMIT 1", side_id + ) strokes_received_by_hole: dict[int, int] | None = None if playing_handicap is not None: @@ -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,6 +2327,278 @@ 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)] +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", + round_id, + ) + play_format = round_row["play_format"] + + if play_format in _TWO_SIDED_FORMATS: + relative = await _relative_strokes_for_round(conn, round_id, play_format) + if not relative: + return RoundFormatResultOut(play_format=play_format, ready=False) + + engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format] + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + + if engine_format in SIDE_IS_UNIT: + # Delt ball -- én rad per SIDE per hull. "Enheten" ER siden, + # så label = side.label (fallback "Side A"/"Side B" avgjøres + # under, samme sortert-på-id-konvensjon som frontend bruker + # for match_status_text sin bokstavelige "(A)"/"(B)"). + rows = await conn.fetch( + """ + SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score + FROM round_hole rh + JOIN round_side rs ON rs.id = rh.round_side_id + WHERE rs.round_id = $1 + """, + round_id, + ) + side_label_rows = await conn.fetch( + "SELECT id::text AS id, label FROM round_side WHERE round_id = $1", round_id + ) + unit_label = {r["id"]: r["label"] for r in side_label_rows} + unit_to_side: dict[str, str] | None = None + else: + # Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv + # (samme som _relative_strokes_for_round sin nøkkel for + # disse formatene). Henter navn+side-tilhørighet i samme + # spørring (én rundtur, ikke to). + rows = await conn.fetch( + """ + SELECT rp.id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score + FROM round_hole rh + JOIN round_participant rp ON rp.id = rh.round_participant_id + WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL + """, + round_id, + ) + participant_label_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.round_side_id::text AS side_id, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + unit_label = {r["id"]: r["label"] for r in participant_label_rows} + unit_to_side = {r["id"]: r["side_id"] for r in participant_label_rows} + + by_unit_hole: dict[tuple[str, int], list] = {} + stroke_index_by_hole: dict[int, int] = {} + par_by_hole: dict[int, int] = {} + for r in rows: + if r["played"] and r["score"] is not None: + by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"]) + stroke_index_by_hole[r["hole_number"]] = r["stroke_index"] + par_by_hole[r["hole_number"]] = r["par"] + all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None + + if engine_format in SIDE_IS_UNIT: + side_ids = sorted(relative.keys()) + else: + side_ids = sorted({s for s in unit_to_side.values() if s is not None}) + + if len(side_ids) != 2 or all_18_si is None: + return RoundFormatResultOut(play_format=play_format, ready=False) + + strokes_per_hole = { + unit: dict(zip(range(1, 19), allocate_over_played_holes(v, all_18_si, list(range(1, 19))))) + for unit, v in relative.items() + } + side_a_id, side_b_id = side_ids + side_label_of: dict[str, Literal["a", "b"]] = {side_a_id: "a", side_b_id: "b"} + expected_players = _SIDE_PLAYER_COUNT[play_format] + + def units_for_side(side_id: str) -> list[str]: + if engine_format in SIDE_IS_UNIT: + return [side_id] + return [uid for uid, sid in unit_to_side.items() if sid == side_id] + + # Delt ball -- siden ER enheten (nøyaktig én per side), uansett + # hvor mange SPILLERE formatet krever. "expected_players" (2 for + # foursome/greensome, 2 eller 4 for scramble) er PLAYER-antallet, + # ikke UNIT-antallet -- bruk kun for individuell ball, der hver + # spiller er sin egen enhet. + required_units = 1 if engine_format in SIDE_IS_UNIT else expected_players + + def side_net(hole_number: int, side_id: str) -> int | None: + nets = [] + for unit_id in units_for_side(side_id): + scores = by_unit_hole.get((unit_id, hole_number), []) + if len(scores) != 1: + return None + per_hole = strokes_per_hole.get(unit_id) + if per_hole is None: + return None + nets.append(scores[0] - per_hole[hole_number]) + if len(nets) != required_units: + return None + return min(nets) + + hole_results: list[HoleResult] = [] + # Hull-for-hull-forløp (2026-07-28) -- samme sannhetskilde/ + # trunkering som hole_results: kun hull som er FERDIG avgjort + # (begge sider komplette) tas med, i samme rekkefølge. Et + # ufullstendig hull (noen har registrert, andre ikke) vises + # ikke her ennå -- samme "vent til begge er ferdig"-prinsipp + # som resten av match-motoren. + holes: list[FormatHoleOut] = [] + for hole_number in play_order: + net_a = side_net(hole_number, side_a_id) + net_b = side_net(hole_number, side_b_id) + if net_a is None or net_b is None: + break + if net_a < net_b: + hole_results.append(HoleResult.SIDE_A) + elif net_b < net_a: + hole_results.append(HoleResult.SIDE_B) + else: + hole_results.append(HoleResult.HALVED) + + entries: list[FormatHoleEntry] = [] + for side_id, side_net_value in ((side_a_id, net_a), (side_b_id, net_b)): + for unit_id in units_for_side(side_id): + gross = by_unit_hole[(unit_id, hole_number)][0] + strokes = strokes_per_hole[unit_id][hole_number] + net = gross - strokes + entries.append( + FormatHoleEntry( + id=unit_id, + label=unit_label.get(unit_id, "Ukjent"), + side=side_label_of[side_id], + gross=gross, + net=net, + strokes_received=strokes, + # Fourball: kun laveste av de to partnernes + # netto teller for siden -- markerer HVILKEN, + # slik at "hvorfor vant siden" er synlig uten + # egen forklarende tekst (R&A-regelen, samme + # side_net()-funksjon over). + counted=(net == side_net_value), + ) + ) + holes.append( + FormatHoleOut( + hole_number=hole_number, + par=par_by_hole[hole_number], + stroke_index=stroke_index_by_hole[hole_number], + entries=entries, + result=_RESULT_LABEL[hole_results[-1]], + ) + ) + + state = compute_match_state(hole_results, total_holes=round_row["holes_planned"]) + return RoundFormatResultOut( + play_format=play_format, + ready=True, + match_lead=state.lead, + match_holes_played=state.holes_played, + match_holes_remaining=state.holes_remaining, + match_is_closed=state.is_closed, + match_is_dormie=state.is_dormie, + match_status_text=state.describe(), + hole_results=[_RESULT_LABEL[r] for r in hole_results], + holes=holes, + ) + + if play_format == "skins": + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.course_handicap_snapshot, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + hole_rows = await conn.fetch( + """ + SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score + FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id + WHERE rp.round_id = $1 + ORDER BY rh.hole_number + """, + round_id, + ) + strokes_by_participant: dict[str, dict[int, int]] = {} + if round_row["skins_scoring"] == "net": + by_participant_holes: dict[str, list] = {} + for r in hole_rows: + by_participant_holes.setdefault(r["participant_id"], []).append(r) + ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows} + for pid, holes_for_participant in by_participant_holes.items(): + ch = ch_by_participant.get(pid) + if ch is None: + continue + allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes_for_participant]) + strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes_for_participant, allocation)} + + is_net_mode = round_row["skins_scoring"] == "net" + gross_by_hole_number: dict[int, dict[str, int]] = {} + par_by_hole: dict[int, int] = {} + si_by_hole: dict[int, int] = {} + scores_by_hole_number: dict[int, list[tuple[str, int]]] = {} + for r in hole_rows: + par_by_hole[r["hole_number"]] = r["par"] + si_by_hole[r["hole_number"]] = r["stroke_index"] + if not r["played"] or r["score"] is None: + continue + gross_by_hole_number.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] + if is_net_mode: + per_hole = strokes_by_participant.get(r["participant_id"]) + if per_hole is None: + continue + value = r["score"] - per_hole[r["hole_number"]] + else: + value = r["score"] + scores_by_hole_number.setdefault(r["hole_number"], []).append((r["participant_id"], value)) + + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order] + skins_won, hole_log = compute_skins_detail(scores_by_hole, tie_handling=round_row["skins_tie_handling"]) + + # Hull-for-hull-forløp -- kun hull der minst én deltaker faktisk + # har registrert en score tas med (et helt tomt hull har + # ingenting å vise frem). + holes = [] + for hole_number, log_entry in zip(play_order, hole_log): + if not log_entry.values: + continue + gross_this_hole = gross_by_hole_number.get(hole_number, {}) + entries = [ + FormatHoleEntry( + id=pid, + label=label_by_participant.get(pid, "Ukjent"), + gross=gross_this_hole.get(pid), + net=value if is_net_mode else None, + strokes_received=strokes_by_participant.get(pid, {}).get(hole_number) if is_net_mode else None, + ) + for pid, value in log_entry.values.items() + ] + holes.append( + FormatHoleOut( + hole_number=hole_number, + par=par_by_hole[hole_number], + stroke_index=si_by_hole[hole_number], + entries=entries, + winner_ids=list(log_entry.awarded.keys()), + carried=log_entry.carried, + ) + ) + return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won, holes=holes) + + 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, @@ -2189,272 +2606,7 @@ async def get_round_format_result( ) -> RoundFormatResultOut: async with plain_connection() as conn: await _get_accessible_round_or_404(conn, round_id, user.user_id) - round_row = await conn.fetchrow( - "SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned " - "FROM round WHERE id = $1", - round_id, - ) - play_format = round_row["play_format"] - - if play_format in _TWO_SIDED_FORMATS: - relative = await _relative_strokes_for_round(conn, round_id, play_format) - if not relative: - return RoundFormatResultOut(play_format=play_format, ready=False) - - engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format] - play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) - - if engine_format in SIDE_IS_UNIT: - # Delt ball -- én rad per SIDE per hull. "Enheten" ER siden, - # så label = side.label (fallback "Side A"/"Side B" avgjøres - # under, samme sortert-på-id-konvensjon som frontend bruker - # for match_status_text sin bokstavelige "(A)"/"(B)"). - rows = await conn.fetch( - """ - SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score - FROM round_hole rh - JOIN round_side rs ON rs.id = rh.round_side_id - WHERE rs.round_id = $1 - """, - round_id, - ) - side_label_rows = await conn.fetch( - "SELECT id::text AS id, label FROM round_side WHERE round_id = $1", round_id - ) - unit_label = {r["id"]: r["label"] for r in side_label_rows} - unit_to_side: dict[str, str] | None = None - else: - # Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv - # (samme som _relative_strokes_for_round sin nøkkel for - # disse formatene). Henter navn+side-tilhørighet i samme - # spørring (én rundtur, ikke to). - rows = await conn.fetch( - """ - SELECT rp.id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score - FROM round_hole rh - JOIN round_participant rp ON rp.id = rh.round_participant_id - WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL - """, - round_id, - ) - participant_label_rows = await conn.fetch( - """ - SELECT rp.id::text AS id, rp.round_side_id::text AS side_id, - COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label - FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id - WHERE rp.round_id = $1 - """, - round_id, - ) - unit_label = {r["id"]: r["label"] for r in participant_label_rows} - unit_to_side = {r["id"]: r["side_id"] for r in participant_label_rows} - - by_unit_hole: dict[tuple[str, int], list] = {} - stroke_index_by_hole: dict[int, int] = {} - par_by_hole: dict[int, int] = {} - for r in rows: - if r["played"] and r["score"] is not None: - by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"]) - stroke_index_by_hole[r["hole_number"]] = r["stroke_index"] - par_by_hole[r["hole_number"]] = r["par"] - all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None - - if engine_format in SIDE_IS_UNIT: - side_ids = sorted(relative.keys()) - else: - side_ids = sorted({s for s in unit_to_side.values() if s is not None}) - - if len(side_ids) != 2 or all_18_si is None: - return RoundFormatResultOut(play_format=play_format, ready=False) - - strokes_per_hole = { - unit: dict(zip(range(1, 19), allocate_over_played_holes(v, all_18_si, list(range(1, 19))))) - for unit, v in relative.items() - } - side_a_id, side_b_id = side_ids - side_label_of: dict[str, Literal["a", "b"]] = {side_a_id: "a", side_b_id: "b"} - expected_players = _SIDE_PLAYER_COUNT[play_format] - - def units_for_side(side_id: str) -> list[str]: - if engine_format in SIDE_IS_UNIT: - return [side_id] - return [uid for uid, sid in unit_to_side.items() if sid == side_id] - - # Delt ball -- siden ER enheten (nøyaktig én per side), uansett - # hvor mange SPILLERE formatet krever. "expected_players" (2 for - # foursome/greensome, 2 eller 4 for scramble) er PLAYER-antallet, - # ikke UNIT-antallet -- bruk kun for individuell ball, der hver - # spiller er sin egen enhet. - required_units = 1 if engine_format in SIDE_IS_UNIT else expected_players - - def side_net(hole_number: int, side_id: str) -> int | None: - nets = [] - for unit_id in units_for_side(side_id): - scores = by_unit_hole.get((unit_id, hole_number), []) - if len(scores) != 1: - return None - per_hole = strokes_per_hole.get(unit_id) - if per_hole is None: - return None - nets.append(scores[0] - per_hole[hole_number]) - if len(nets) != required_units: - return None - return min(nets) - - hole_results: list[HoleResult] = [] - # Hull-for-hull-forløp (2026-07-28) -- samme sannhetskilde/ - # trunkering som hole_results: kun hull som er FERDIG avgjort - # (begge sider komplette) tas med, i samme rekkefølge. Et - # ufullstendig hull (noen har registrert, andre ikke) vises - # ikke her ennå -- samme "vent til begge er ferdig"-prinsipp - # som resten av match-motoren. - holes: list[FormatHoleOut] = [] - for hole_number in play_order: - net_a = side_net(hole_number, side_a_id) - net_b = side_net(hole_number, side_b_id) - if net_a is None or net_b is None: - break - if net_a < net_b: - hole_results.append(HoleResult.SIDE_A) - elif net_b < net_a: - hole_results.append(HoleResult.SIDE_B) - else: - hole_results.append(HoleResult.HALVED) - - entries: list[FormatHoleEntry] = [] - for side_id, side_net_value in ((side_a_id, net_a), (side_b_id, net_b)): - for unit_id in units_for_side(side_id): - gross = by_unit_hole[(unit_id, hole_number)][0] - strokes = strokes_per_hole[unit_id][hole_number] - net = gross - strokes - entries.append( - FormatHoleEntry( - id=unit_id, - label=unit_label.get(unit_id, "Ukjent"), - side=side_label_of[side_id], - gross=gross, - net=net, - strokes_received=strokes, - # Fourball: kun laveste av de to partnernes - # netto teller for siden -- markerer HVILKEN, - # slik at "hvorfor vant siden" er synlig uten - # egen forklarende tekst (R&A-regelen, samme - # side_net()-funksjon over). - counted=(net == side_net_value), - ) - ) - holes.append( - FormatHoleOut( - hole_number=hole_number, - par=par_by_hole[hole_number], - stroke_index=stroke_index_by_hole[hole_number], - entries=entries, - result=_RESULT_LABEL[hole_results[-1]], - ) - ) - - state = compute_match_state(hole_results, total_holes=round_row["holes_planned"]) - return RoundFormatResultOut( - play_format=play_format, - ready=True, - match_lead=state.lead, - match_holes_played=state.holes_played, - match_holes_remaining=state.holes_remaining, - match_is_closed=state.is_closed, - match_is_dormie=state.is_dormie, - match_status_text=state.describe(), - hole_results=[_RESULT_LABEL[r] for r in hole_results], - holes=holes, - ) - - if play_format == "skins": - participant_rows = await conn.fetch( - """ - SELECT rp.id::text AS id, rp.course_handicap_snapshot, - COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label - FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id - WHERE rp.round_id = $1 - """, - round_id, - ) - label_by_participant = {p["id"]: p["label"] for p in participant_rows} - hole_rows = await conn.fetch( - """ - SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score - FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id - WHERE rp.round_id = $1 - ORDER BY rh.hole_number - """, - round_id, - ) - strokes_by_participant: dict[str, dict[int, int]] = {} - if round_row["skins_scoring"] == "net": - by_participant_holes: dict[str, list] = {} - for r in hole_rows: - by_participant_holes.setdefault(r["participant_id"], []).append(r) - ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows} - for pid, holes_for_participant in by_participant_holes.items(): - ch = ch_by_participant.get(pid) - if ch is None: - continue - allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes_for_participant]) - strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes_for_participant, allocation)} - - is_net_mode = round_row["skins_scoring"] == "net" - gross_by_hole_number: dict[int, dict[str, int]] = {} - par_by_hole: dict[int, int] = {} - si_by_hole: dict[int, int] = {} - scores_by_hole_number: dict[int, list[tuple[str, int]]] = {} - for r in hole_rows: - par_by_hole[r["hole_number"]] = r["par"] - si_by_hole[r["hole_number"]] = r["stroke_index"] - if not r["played"] or r["score"] is None: - continue - gross_by_hole_number.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] - if is_net_mode: - per_hole = strokes_by_participant.get(r["participant_id"]) - if per_hole is None: - continue - value = r["score"] - per_hole[r["hole_number"]] - else: - value = r["score"] - scores_by_hole_number.setdefault(r["hole_number"], []).append((r["participant_id"], value)) - - play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) - scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order] - skins_won, hole_log = compute_skins_detail(scores_by_hole, tie_handling=round_row["skins_tie_handling"]) - - # Hull-for-hull-forløp -- kun hull der minst én deltaker faktisk - # har registrert en score tas med (et helt tomt hull har - # ingenting å vise frem). - holes = [] - for hole_number, log_entry in zip(play_order, hole_log): - if not log_entry.values: - continue - gross_this_hole = gross_by_hole_number.get(hole_number, {}) - entries = [ - FormatHoleEntry( - id=pid, - label=label_by_participant.get(pid, "Ukjent"), - gross=gross_this_hole.get(pid), - net=value if is_net_mode else None, - strokes_received=strokes_by_participant.get(pid, {}).get(hole_number) if is_net_mode else None, - ) - for pid, value in log_entry.values.items() - ] - holes.append( - FormatHoleOut( - hole_number=hole_number, - par=par_by_hole[hole_number], - stroke_index=si_by_hole[hole_number], - entries=entries, - winner_ids=list(log_entry.awarded.keys()), - carried=log_entry.carried, - ) - ) - return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won, holes=holes) - - return RoundFormatResultOut(play_format=play_format, ready=True) + return await _build_format_result(conn, round_id) class LeaderboardHoleOut(BaseModel): @@ -2495,89 +2647,88 @@ 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) - round_row = await conn.fetchrow( - "SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id - ) - participant_rows = await conn.fetch( - """ - SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot, - au.display_name AS linked_display_name - 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, - ) +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 + ) + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot, + au.display_name AS linked_display_name + 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, + ) - entries: list[LeaderboardEntryOut] = [] - for p in participant_rows: - hole_rows = await conn.fetch( - "SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number", - p["id"], + entries: list[LeaderboardEntryOut] = [] + for p in participant_rows: + hole_rows = await conn.fetch( + "SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number", + p["id"], + ) + played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None] + holes_played = len(played_holes) + total_score = sum(h["score"] for h in played_holes) if holes_played else None + total_par = sum(h["par"] for h in played_holes) if holes_played else None + score_to_par = total_score - total_par if total_score is not None else None + + # Beregnes uavhengig av om noen hull faktisk er spilt ennå -- + # brukt BÅDE for net_score_to_par/total_points under OG for + # strokes_received per hull i den rå hull-listen som returneres. + strokes_received_by_hole: dict[int, int] = {} + if p["course_handicap_snapshot"] is not None: + allocation = allocate_strokes_by_index( + p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows] ) - played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None] - holes_played = len(played_holes) - total_score = sum(h["score"] for h in played_holes) if holes_played else None - total_par = sum(h["par"] for h in played_holes) if holes_played else None - score_to_par = total_score - total_par if total_score is not None else None + strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)} - # Beregnes uavhengig av om noen hull faktisk er spilt ennå -- - # brukt BÅDE for net_score_to_par/total_points under OG for - # strokes_received per hull i den rå hull-listen som returneres. - strokes_received_by_hole: dict[int, int] = {} - if p["course_handicap_snapshot"] is not None: - allocation = allocate_strokes_by_index( - p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows] - ) - strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)} - - net_score_to_par = None - total_points = None - if total_score is not None and strokes_received_by_hole: - strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) - net_score_to_par = score_to_par - strokes_received_total - # Samme stableford-formel som round-scorecard.tsx sin - # stablefordPoints(): 2 poeng for netto par, +/-1 per slag, - # gulvet på 0. - total_points = sum( - max(0, h["par"] - (h["score"] - strokes_received_by_hole[h["hole_number"]]) + 2) - for h in played_holes - ) - - # Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier - # OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et - # viewer-relativt "Deg" ville vært feil for de andre som ser på. - display_name = p["guest_name"] or p["linked_display_name"] or "Medspiller" - entries.append( - LeaderboardEntryOut( - participant_id=p["id"], - display_name=display_name, - is_owner=p["is_owner"], - holes_played=holes_played, - total_score=total_score, - score_to_par=score_to_par, - net_score_to_par=net_score_to_par, - total_points=total_points, - holes=[ - LeaderboardHoleOut( - hole_number=h["hole_number"], - par=h["par"], - stroke_index=h["stroke_index"], - played=h["played"], - score=h["score"], - strokes_received=strokes_received_by_hole.get(h["hole_number"]), - ) - for h in hole_rows - ], - ) + net_score_to_par = None + total_points = None + if total_score is not None and strokes_received_by_hole: + strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) + net_score_to_par = score_to_par - strokes_received_total + # Samme stableford-formel som round-scorecard.tsx sin + # stablefordPoints(): 2 poeng for netto par, +/-1 per slag, + # gulvet på 0. + total_points = sum( + max(0, h["par"] - (h["score"] - strokes_received_by_hole[h["hole_number"]]) + 2) + for h in played_holes ) - entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par)) + # Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier + # OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et + # viewer-relativt "Deg" ville vært feil for de andre som ser på. + display_name = p["guest_name"] or p["linked_display_name"] or "Medspiller" + entries.append( + LeaderboardEntryOut( + participant_id=p["id"], + display_name=display_name, + is_owner=p["is_owner"], + holes_played=holes_played, + total_score=total_score, + score_to_par=score_to_par, + net_score_to_par=net_score_to_par, + total_points=total_points, + holes=[ + LeaderboardHoleOut( + hole_number=h["hole_number"], + par=h["par"], + stroke_index=h["stroke_index"], + played=h["played"], + score=h["score"], + strokes_received=strokes_received_by_hole.get(h["hole_number"]), + ) + for h in hole_rows + ], + ) + ) + + entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par)) return LeaderboardOut( holes_planned=round_row["holes_planned"], @@ -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 + ] diff --git a/frontend/app/my-friends/[id]/page.tsx b/frontend/app/my-friends/[id]/page.tsx new file mode 100644 index 0000000..5e29ff4 --- /dev/null +++ b/frontend/app/my-friends/[id]/page.tsx @@ -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 +} diff --git a/frontend/app/watch/[id]/page.tsx b/frontend/app/watch/[id]/page.tsx new file mode 100644 index 0000000..734a640 --- /dev/null +++ b/frontend/app/watch/[id]/page.tsx @@ -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 +} diff --git a/frontend/components/friend-profile.tsx b/frontend/components/friend-profile.tsx new file mode 100644 index 0000000..d388d0a --- /dev/null +++ b/frontend/components/friend-profile.tsx @@ -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 = { + 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 + } + return ( + + ) +} + +function RoundRow({ round }: { round: ApiPersonRound }) { + const ongoing = round.completed_at === null + return ( + +
+ + + {round.name?.trim() || round.course_name_snapshot} + + {ongoing && ( + + + )} + + + {round.name?.trim() ? `${round.course_name_snapshot} · ` : ""} + {FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))} + +
+