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:
Erol Haagenrud 2026-07-28 19:25:37 +02:00
parent 071874e8c8
commit 7631ef60e6
12 changed files with 1774 additions and 408 deletions

39
032_round_visibility.sql Normal file
View 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
View file

@ -4462,6 +4462,158 @@ Ferdig og verifisert:
migrasjon (ren frontend), `docker compose up -d --build migrasjon (ren frontend), `docker compose up -d --build
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
→ 200, `teeoff.no` upåvirket. → 200, `teeoff.no` upåvirket.
- **Turnering-scorekortets offline-flyt (den OPPRINNELIGE ADR-028, session-
scorecard.tsx) FAKTISK browserverifisert, samme dag (2026-07-28):**
eneste reelt gjenstående punkt fra forrige runde — offline-koden i
turnering-scorekortet er over ett år gammel (funksjonelt), men var ALDRI
browser-testet, kun kodegjennomgang/build (se opprinnelig ADR-028-notat).
Bygget en full isolert scratch-turnering fra bunnen via API for å nå
frem til selve scorekortet (organisasjon → turnering → to lag → to
spillere rostret som kapteiner → egendefinert bane med 18 hull + tee →
økt (`format=singles`, `scoring_mode=stroke`) → match → to
match-deltakere → begge lag låst) -- ingen slik full turnering-scaffold
fantes fra før i noe testskript denne uken (frittstående runder trenger
ikke dette apparatet i det hele tatt, ADR-033 Beslutning A), måtte bygges
fra grunnen ved å lese `tournaments.py`/`matches.py`/`courses.py` sine
faktiske endepunkt-kontrakter direkte.
**Verifisert identisk mønster som frittstående runder samme dag:** ekte
DevTools-nettverksemulering (`navigator.onLine` bekreftet `false`),
registrerte et slag (5, Spiller B, hull 1) offline -- bekreftet ÉN
kø-oppføring i ekte IndexedDB (`POST .../hole-scores`), "1 endring
venter"-banner, "Lagret lokalt · venter på synk"-tekst under
tallvelgeren, hull-navigasjonens sjekkmerke. Koblet til nett igjen --
bekreftet AUTOMATISK synk (ingen manuelt trykk på "Synkroniser nå"
nødvendig), kø tom etterpå, "Bogey"-etiketten dukket opp (bevis på at
`refetchScorecard()` faktisk hentet det avledede resultatet fra
serveren), OG et direkte API-kall mot `.../scorecard` som bekreftet
serveren faktisk hadde `gross_strokes=5` lagret. Ingen konsollfeil.
**Ingen kodeendring i denne runden** — ren verifisering av allerede
levert funksjonalitet, ingen utrulling nødvendig.
- **Undersøkt: grensesnitt for å følge en venns runde live — BEKREFTET AT
DET IKKE FINNES (2026-07-28), ren undersøkelse, ingen kode skrevet.**
Brukeren spurte om en spiller kan gå inn på en venns profil og følge en
pågående frittstående runde live, gitt at rettighetene er gitt. Lest
direkte i koden (ikke antatt): (1) `frontend/components/friends.tsx` har
INGEN lenke/rute til en vennprofil-side i det hele tatt — kun
send/aksepter/avvis-knapper og kategorisering, ingen `/friends/[id]`-
eller lignende rute finnes noe sted i `frontend/app/`. (2) `round`-
tabellen har INGEN `visibility`-kolonne (bekreftet med grep over ALLE
migrasjoner — `visibility` finnes kun på `tournament`, fra
`009_landing_pages_and_visibility.sql`); `020_personal_rounds.sql` sin
egen kommentar (linje 39-42) slår eksplisitt fast v1-avgrensningen: en
runde er kun synlig for `owner_user_id`. (3) `app/routers/rounds.py` sin
`_get_accessible_round_or_404` (linje 1220-1240, lest direkte) gir
tilgang KUN til eieren ELLER en lenket `round_participant.user_id`
(ADR-036 fase 3) — ingen sjekk mot `friendship`-tabellen noe sted,
bekreftet med `grep -n -i "friend" app/routers/rounds.py` → null treff.
(4) `GET /ws/rounds/{id}/live` bruker NØYAKTIG samme
`_get_accessible_round_or_404`-sjekk FØR `websocket.accept()` (samme
fil, linje ~2763) — en venn som ikke er lenket deltaker får
websocketen lukket med kode 4403, ulikt turnering-live (ADR-027), som
bevisst TILLATER anonym tilgang. Ingen forberedt/påbegynt kode for
"følg venns runde" funnet noe sted (grep etter "follow"/"følg"/"friend"
i både `rounds.py` og `friends.py` — null relevante treff).
**Konklusjon: dette er nøyaktig ADR-036 fase 2 (rundevisibilitet
public/private/friends), som lenge har stått notert som IKKE bygget i
FEATURE_BACKLOG.md/CLAUDE.md** — bekreftet nå med presis kode-evidens i
stedet for bare et notat om at det mangler. Ingen ny beslutning tatt,
ingen kode skrevet — dette var en ren undersøkelse på brukerens
eksplisitte forespørsel. Se FEATURE_BACKLOG.md for ADR-036 fase 2 sitt
design (public/private/friends, eksplisitt gruppevalg).
- **ADR-036 fase 2 (rundevisibilitet) BYGGET, GRUNDIG SCRATCH-/
BROWSERVERIFISERT OG LIVE (2026-07-28), samme dag:** direkte oppfølging
av undersøkelsen over — brukeren bekreftet eksplisitt retningen fra
Beslutning B (allerede fullt designet i ADR-036, ikke funnet opp på
nytt): tre nivåer (`public`/`private`/`friends`), der `friends` krever
et EKSPLISITT kategori-valg (ikke "alle venner").
**Migrasjon `032_round_visibility.sql`:** `round.visibility_mode`
(`public`/`private`/`friends`, default `private`), ny tabell
`round_visible_category` (samme faste kategori-sett som
`friend_categorization`, migrasjon 025, bevisst duplisert CHECK fremfor
delt ENUM — samme pragmatiske mønster som resten av skjemaet).
**Kjernestykket, `app/routers/rounds.py`:** ny `_can_view_round()`
eier ELLER lenket medspiller ser alltid; ellers `public`→alle (også
anonyme), `private`→ingen, `friends`→krever et AKSEPTERT vennskap MED
eieren OG at EIERENS kategorisering av viewer (retningen er bevisst
omvendt av hva man skulle tro — det er eieren som begrenser, basert på
egen gruppering) treffer minst én av rundens synlige kategorier.
**Reelt funn under selve designarbeidet:** `_can_view_round()` viste
seg å være en STRIKT SUPERSETT av den eksisterende
`_get_accessible_round_or_404()` sin logikk (dens to første grener ER
nøyaktig eier+medspiller-sjekken) — i stedet for å bygge en helt
parallell endepunkt-familie fra bunnen, ble fire eksisterende
autentiserte lese-endepunkters KROPP ekstrahert til delte
hjelpefunksjoner (`_build_participant_holes`/`_build_side_holes`/
`_build_leaderboard`/`_build_format_result` — mekanisk gjort med et
Python-script for presis inndenting fremfor manuell redigering av
~270+95 linjer, verifisert med `ast.parse` + full regresjonskjøring
etterpå), gjenbrukt av BÅDE de originale autentiserte endepunktene OG
syv nye `/public/rounds/*`-endepunkter (samme `get_current_user_
optional`-mønster som turnering sin offentlige side, ADR-018/026/027)
pluss et nytt offentlig WS-endepunkt `/ws/public/rounds/{id}/live`
(ulikt det eksisterende PRIVATE `/ws/rounds/{id}/live`, som fortsatt
krever ekte autentisert eier/medspiller-sesjon, uendret). Egen, leaner
`PublicRoundOut` (aldri `guest_email`, som er PII, aldri `my_*`/
`setup_*`, som kun gir mening for eier/deltaker) i stedet for å
gjenbruke den fulle `RoundOut` med etterhånds-redigering.
Ny `GET /people/{id}` (friends.py, samme lavsensitive felt-sett som
`/people/search`, bevisst OPTIONALT autentisert — en offentlig runde
må kunne nås via en delt lenke selv av en anonym leser, og
vennprofil-siden som leder dit må da fungere anonymt også) og
`GET /public/people/{id}/rounds` (rounds.py, lister eierens runder
filtrert gjennom `_can_view_round()` per rad — "pågår nå" alltid øverst).
**Scratch-verifisert grundig, 191 automatiserte sjekker i tre testløp**
(isolert `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
API-container, samme mønster som hele prosjektet): et NYTT 34-punkts
skript som dekket hele synlighetsmatrisen presist (tre brukere A/B/C +
en helt anonym opener) — B/C/anonym nektet privat OG venner-runde FØR
vennskap, alle ser offentlig (inkl. anonym), venner-runde fortsatt
nektet RETT ETTER vennskap men FØR kategorisering, tilgjengelig
UMIDDELBART etter riktig kategorisering, feil kategori (close_family
mot golf_friends) fortsatt nektet, PATCH kan endre synlighet i
etterkant (bekreftet begge retninger), offentlig+autentisert
leaderboard ga BEVIST IDENTISK resultat (kryssjekket), ukjent
runde-id ga 404 (ikke 403). PLUSS en full regresjonskjøring av to
eksisterende testsuiter fra tidligere runder denne uken (35-punkts
co-player-flyt, 122-punkts spillformat-flyt) — begge 100 % grønne,
bekrefter at ekstraheringen av de fire delte funksjonene ikke endret
noen eksisterende oppførsel.
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
isolert browser-kontekst for en helt anonym tredje "bruker" ved siden
av to ekte innloggede faner): (1) synlighetsvelgeren i opprett-runde-
veiviseren fungerte som designet (tre knapper, kategori-multiselect
dukker kun opp ved "Venner", advarselstekst ved 0 valgte kategorier) —
runde opprettet med `friends`+`golf_friends`, bekreftet via API;
(2) rediger-runde-panelet viste korrekt FORHÅNDSUTFYLT synlighet,
PATCH til `public` bekreftet lagret; (3) en HELT ANONYM
nettleserkontekst (ingen cookies i det hele tatt) kunne se den
offentlige runden direkte på `/watch/{id}` (live-puls, leaderboard,
riktig eiernavn) OG via `/my-friends/{eier-id}`-profilsiden (navn/
avatar/HCP/hjemmeklubb + rundeliste med lenke inn); (4) en ekte andre
bruker sendte venneforespørsel, eieren aksepterte og kategoriserte
vedkommende som "Golfvenner" VIA DEN FAKTISKE UI-EN (ikke bare API) —
satte deretter runden til `friends`+`golf_friends`, bekreftet vennen
fikk tilgang UMIDDELBART via profilsiden; (5) **negativ kontroll,
samme venn**: byttet runden til `friends`+`close_family` (en kategori
vennen IKKE var satt i) — profilsiden viste korrekt INGEN runder
lenger, og et direkte `/watch/{id}`-forsøk ga en tydelig "Du har ikke
tilgang"-melding (403), atskilt fra en egen "Denne runden finnes
ikke"-melding for en ukjent id (404) — begge bekreftet med ekte
skjermbilder. Ekte typesjekket + full produksjonsbuild (26 ruter, inkl.
de to nye `/my-friends/[id]` og `/watch/[id]`) kjørt før utrulling.
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
eksplisitt (viste frem full plan for migrasjon FØR den kjørte, per
CLAUDE.md sin ufravikelige regel): migrasjon 032 kjørt mot ekte
`teecup_db` (kun additivt — ny kolonne med default, ny tabell, ingen
eksisterende rader rørt), `test_isolation.sql` fortsatt 12/12,
deretter `docker compose up -d --build teecup_api teecup_frontend`.
Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket. Verifisert presist at de nye rutene faktisk når FastAPI
(ikke bare at Next.js svarte): et ukjent person-id mot
`/public/people/{id}/rounds` over ekte https ga korrekt JSON-formet
`404 NOT_FOUND` (ikke en rå Next.js-404-side).
**ADR-036 er dermed HELT ferdig, alle tre faser** (venner-kjernen,
rundevisibilitet, ekte medspillere) — backend + frontend, live.
Neste steg: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
@ -4650,12 +4802,10 @@ Neste steg:
noterte "naturlig neste steg"-punkter er dermed alle tettet, unntatt noterte "naturlig neste steg"-punkter er dermed alle tettet, unntatt
notifikasjons-/aktivitetsfeed og "Mine runder" for rene påmeldinger notifikasjons-/aktivitetsfeed og "Mine runder" for rene påmeldinger
(begge fortsatt IKKE bygget, se FEATURE_BACKLOG.md). (begge fortsatt IKKE bygget, se FEATURE_BACKLOG.md).
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens 6. ~~Oppfølgingspunkt fra PWA-runden: offline-scoreregistrering er ALDRI
forespørsel: offline-scoreregistrering er ALDRI browser-testet i browser-testet i praksis~~ — **ferdig 2026-07-28** (se status-punkt
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker samme dag, "Turnering-scorekortets offline-flyt (ADR-028) FAKTISK
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter, browserverifisert"). Beholdt her kun for historikk.
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
synkes — først da er flyten reelt bevist.
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for 7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
(bevisst utsatt i ADR-025). (bevisst utsatt i ADR-025).
@ -4673,8 +4823,10 @@ Neste steg:
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
live; (b) ingen Stableford-poengberegning for frittstående runder (kun live; (b) ingen Stableford-poengberegning for frittstående runder (kun
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand; rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand;
(c) rundedeling/visibility (ADR-036 fase 2, public/private/friends) (c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
fortsatt ikke bygget; (d) flere flighter i én frittstående runde fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
faser); (d) flere flighter i én frittstående runde
fortsatt kun drøftet (se fortsatt kun drøftet (se
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
til rundehendelser ennå. til rundehendelser ennå.

View file

@ -22,7 +22,7 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from .. import storage from .. import storage
from ..auth import CurrentUser, get_current_user from ..auth import CurrentUser, get_current_user, get_current_user_optional
from ..db import plain_connection from ..db import plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from .notifications import create_notification from .notifications import create_notification
@ -131,6 +131,34 @@ async def search_people(
] ]
@router.get("/people/{user_id}", response_model=PersonMatch)
async def get_person(
user_id: str,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> PersonMatch:
"""Enkeltperson-oppslag (ADR-036 fase 2, 2026-07-28) -- brukt av
vennprofil-siden til å vise navn/avatar/hjemmeklubb/HCP i headeren.
Samme felt-sett/sensitivitetsnivå som /people/search (aldri e-post/
mobil/fødselsdato) -- derfor OPTIONALT autentisert, ikke
`get_current_user`: en runde satt til 'public' (ADR-036 Beslutning B)
skal kunne nås av en anonym leser via en delt lenke, og profilsiden som
leder dit da også fungere anonymt, ellers ville "offentlig" runde
vært offentlig i praksis, men uoppdagelig uten innlogging."""
async with plain_connection() as conn:
row = await conn.fetchrow(
"SELECT id::text AS id, first_name, last_name, avatar_key, home_club, "
"handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
user_id,
)
if row is None or row["first_name"] is None or row["last_name"] is None:
raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
return PersonMatch(
id=row["id"], first_name=row["first_name"], last_name=row["last_name"],
avatar_url=_avatar_url(row["avatar_key"]), home_club=row["home_club"],
handicap_index=row["handicap_index"],
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vennskap -- forespørsel/aksept/fjern # Vennskap -- forespørsel/aksept/fjern
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

File diff suppressed because it is too large Load diff

View 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} />
}

View 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} />
}

View 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
</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. 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 </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>
)
}

View file

@ -433,13 +433,16 @@ function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: (
return ( return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex items-center gap-4"> <Link
href={`/my-friends/${friend.user_id}`}
className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar name={name} /> <Avatar name={name} />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-lg font-bold text-foreground">{name}</span> <span className="truncate text-lg font-bold text-foreground">{name}</span>
<span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span> <span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
</div> </div>
</div> </Link>
{categories.length > 0 && ( {categories.length > 0 && (
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}> <ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>

View file

@ -79,6 +79,28 @@ type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "gree
type SkinsScoring = "net" | "gross" type SkinsScoring = "net" | "gross"
type SkinsTieHandling = "carry" | "split" type SkinsTieHandling = "carry" | "split"
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller
// (som alltid ser runden uansett). Trygg standard "private".
type VisibilityMode = "private" | "public" | "friends"
// Samme faste sett/rekkefølge som friends.py sin Category og
// friends.tsx sin CATEGORY_OPTIONS -- egen, lokal kopi (samme
// "liten duplisering fremfor cross-file-import"-mønster som FORMAT_LABELS
// ellers i appen).
const VISIBILITY_CATEGORY_OPTIONS = [
{ code: "spouse", label: "Make" },
{ code: "close_family", label: "Nær familie" },
{ code: "extended_family", label: "Storfamilie" },
{ code: "close_friends", label: "Nære venner" },
{ code: "golf_friends", label: "Golfvenner" },
{ code: "colleagues", label: "Kollegaer" },
{ code: "business", label: "Forretningsforbindelser" },
{ code: "classmates", label: "Studiekamerater" },
{ code: "acquaintances", label: "Perifere bekjente" },
{ code: "other", label: "Ymse" },
] as const
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [ const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." }, { value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
{ value: "match", label: "Match", desc: "To sider, hull mot hull." }, { value: "match", label: "Match", desc: "To sider, hull mot hull." },
@ -220,6 +242,8 @@ export function NewRound() {
excludeFromHandicap: boolean excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null skinsTieHandling: SkinsTieHandling | null
visibilityMode: VisibilityMode
visibleCategories: string[]
}): Promise<{ id: string }> { }): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane") if (!courseMeta) throw new Error("Mangler valgt bane")
const shared = { const shared = {
@ -234,6 +258,8 @@ export function NewRound() {
exclude_owner_from_handicap: payload.excludeFromHandicap, exclude_owner_from_handicap: payload.excludeFromHandicap,
skins_scoring: payload.skinsScoring, skins_scoring: payload.skinsScoring,
skins_tie_handling: payload.skinsTieHandling, skins_tie_handling: payload.skinsTieHandling,
visibility_mode: payload.visibilityMode,
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
} }
const body = const body =
courseMeta.source === "teeoff" courseMeta.source === "teeoff"
@ -1137,6 +1163,8 @@ function ConfirmStep({
excludeFromHandicap: boolean excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null skinsTieHandling: SkinsTieHandling | null
visibilityMode: VisibilityMode
visibleCategories: string[]
}) => Promise<{ id: string }> }) => Promise<{ id: string }>
onCreated: (roundId: string) => void onCreated: (roundId: string) => void
}) { }) {
@ -1154,6 +1182,9 @@ function ConfirmStep({
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare. // play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net") const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry") const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
const [visibleCategories, setVisibleCategories] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1193,6 +1224,8 @@ function ConfirmStep({
excludeFromHandicap, excludeFromHandicap,
skinsScoring: playFormat === "skins" ? skinsScoring : null, skinsScoring: playFormat === "skins" ? skinsScoring : null,
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null, skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
visibilityMode,
visibleCategories,
}) })
onCreated(created.id) onCreated(created.id)
} catch { } catch {
@ -1477,6 +1510,77 @@ function ConfirmStep({
</div> </div>
</fieldset> </fieldset>
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
innsyn (venner/offentligheten), aldri en faktisk lagt-til
medspiller (som alltid ser runden uansett). Trygg standard
"Privat", samme filosofi som RLS-policyenes "se ingenting" ved
manglende kontekst ellers i appen. */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Synlighet</legend>
<div className="grid grid-cols-3 gap-2">
{(
[
{ value: "private", label: "Privat", desc: "Kun deg og faktiske medspillere." },
{ value: "friends", label: "Venner", desc: "Valgte venne-kategorier kan følge live." },
{ value: "public", label: "Offentlig", desc: "Alle kan følge live, ingen innlogging." },
] as { value: VisibilityMode; label: string; desc: string }[]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setVisibilityMode(opt.value)}
aria-pressed={visibilityMode === opt.value}
className={cn(
"flex min-h-[44px] flex-col items-center gap-0.5 rounded-2xl border px-2 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
visibilityMode === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="text-sm font-bold">{opt.label}</span>
<span className="text-xs">{opt.desc}</span>
</button>
))}
</div>
{visibilityMode === "friends" && (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
<p className="text-sm font-semibold text-foreground">
Hvilke av dine venne-kategorier skal kunne se runden?
</p>
<div className="flex flex-wrap gap-2">
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
const active = visibleCategories.includes(code)
return (
<button
key={code}
type="button"
onClick={() =>
setVisibleCategories((prev) =>
active ? prev.filter((c) => c !== code) : [...prev, code],
)
}
aria-pressed={active}
className={cn(
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
)
})}
</div>
{visibleCategories.length === 0 && (
<p className="text-xs text-muted-foreground">
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
</p>
)}
</div>
)}
</fieldset>
<Button <Button
type="button" type="button"
size="lg" size="lg"

View file

@ -218,8 +218,28 @@ type ApiRound = {
sides: ApiSide[] sides: ApiSide[]
setup_complete: boolean setup_complete: boolean
setup_message: string | null setup_message: string | null
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
visibility_mode: string
visible_categories: string[]
} }
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
// sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering
// fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen).
const VISIBILITY_CATEGORY_OPTIONS = [
{ code: "spouse", label: "Make" },
{ code: "close_family", label: "Nær familie" },
{ code: "extended_family", label: "Storfamilie" },
{ code: "close_friends", label: "Nære venner" },
{ code: "golf_friends", label: "Golfvenner" },
{ code: "colleagues", label: "Kollegaer" },
{ code: "business", label: "Forretningsforbindelser" },
{ code: "classmates", label: "Studiekamerater" },
{ code: "acquaintances", label: "Perifere bekjente" },
{ code: "other", label: "Ymse" },
] as const
// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py // ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for // (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid // å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
@ -2775,16 +2795,28 @@ function EditRoundPanel({
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri // ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri
// AGS/differensial-matematikken selv). // AGS/differensial-matematikken selv).
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke") const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke")
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
// av fullført-status (samme begrunnelse som playFormat over).
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private",
)
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [showChangeCourse, setShowChangeCourse] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const sameCategories =
visibleCategories.length === round.visible_categories.length &&
visibleCategories.every((c) => round.visible_categories.includes(c))
const hasMetadataChanges = const hasMetadataChanges =
name.trim() !== (round.name ?? "") || name.trim() !== (round.name ?? "") ||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt || toDatetimeLocalValue(round.started_at) !== startedAt ||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) || (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
playFormat !== (round.play_format === "match" ? "match" : "stroke") playFormat !== (round.play_format === "match" ? "match" : "stroke") ||
visibilityMode !== round.visibility_mode ||
(visibilityMode === "friends" && !sameCategories)
async function saveMetadata() { async function saveMetadata() {
setSaving(true) setSaving(true)
@ -2802,6 +2834,8 @@ function EditRoundPanel({
body.completed_at = fromDatetimeLocalValue(completedAt) body.completed_at = fromDatetimeLocalValue(completedAt)
} }
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
const result = await onPatch(body) const result = await onPatch(body)
if (!result.ok) setError(result.message) if (!result.ok) setError(result.message)
setSaving(false) setSaving(false)
@ -2859,6 +2893,69 @@ function EditRoundPanel({
</div> </div>
</div> </div>
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
innsyn (venner/offentligheten), aldri en faktisk lagt-til
medspiller (som alltid ser runden uansett). */}
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Synlighet</span>
<div className="flex gap-2">
{(
[
{ value: "private" as const, label: "Privat" },
{ value: "friends" as const, label: "Venner" },
{ value: "public" as const, label: "Offentlig" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setVisibilityMode(opt.value)}
aria-pressed={visibilityMode === opt.value}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
visibilityMode === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
{visibilityMode === "friends" && (
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
<p className="text-xs font-semibold text-muted-foreground">
Hvilke av dine venne-kategorier skal kunne se runden?
</p>
<div className="flex flex-wrap gap-2">
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
const active = visibleCategories.includes(code)
return (
<button
key={code}
type="button"
onClick={() =>
setVisibleCategories((prev) =>
active ? prev.filter((c) => c !== code) : [...prev, code],
)
}
aria-pressed={active}
className={cn(
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
)
})}
</div>
</div>
)}
</div>
{!isCompleted && ( {!isCompleted && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View 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 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
</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