"use client" // Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036 // fase 2, 2026-07-28). Ferskbygget, IKKE en gjenbruk av round-detail.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). // // Score-fanen (denne filen): kompakt status + "Spillere og runde" // (deltakerliste, HCP/utslag/tildelte slag, "så langt", flight- // gruppering) -- se watch-players.tsx. Selve matchstatus/skins-tavle/ // leaderboard flyttet til /watch/[id]/leaderboard (ADR-044, 2026-08-06), // som gjenbruker den ALLEREDE eksisterende round-leaderboard.tsx uendret // (kun `publicMode`-bryteren), for full formatparitet med eiersiden. import { useEffect, useState } from "react" import Link from "next/link" import dynamic from "next/dynamic" import { Radio, Trophy } from "lucide-react" import { WatchTabs } from "@/components/watch-tabs" import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players" import { RoundMessages } from "@/components/round-messages" import type { FlagMapEntry } from "@/components/flag-map-overview" const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false }) // Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- // tilskuer-varianten, mot /public/rounds/{id}/flag-map. Tilgang er // allerede gatet av _get_viewable_round_or_404 (samme regel som resten // av denne siden, styrt av round.visibility_mode) -- selve INNHOLDET // (alle flagg vs. ingenting) styres i tillegg av round.flag_map_visible // server-side (`visible_to_all` i responsen), en tilskuer har jo ikke // noe "eget flagg" å falle tilbake til når bryteren er av. type ApiFlagMapPublic = { visible_to_all: boolean; flags: FlagMapEntry[] } function WatchFlagMap({ roundId, refreshKey }: { roundId: string; refreshKey: number }) { const [data, setData] = useState(null) const [expanded, setExpanded] = useState(false) useEffect(() => { let cancelled = false fetch(`/public/rounds/${roundId}/flag-map`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((json: { visible_to_all: boolean; flags: Array> } | null) => { if (cancelled || !json) return setData({ visible_to_all: json.visible_to_all, flags: json.flags.map((f) => ({ participantId: f.participant_id as string, displayName: f.display_name as string, lap: f.lap as number, holeNumber: f.hole_number as number, lat: f.lat as number, lng: f.lng as number, onGreen: f.on_green as boolean, distanceToPinCm: f.distance_to_pin_cm as number | null, })), }) }) .catch(() => {}) return () => { cancelled = true } }, [roundId, refreshKey]) // Ingen synlig seksjon i det hele tatt når bryteren er av OG tilskueren // (naturligvis) ikke har noe eget flagg -- ikke en tom/forvirrende boks. if (!data || (!data.visible_to_all && data.flags.length === 0)) return null return (
{expanded && (
)}
) } type ApiSide = { id: string; label: string | null } 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 owner_user_id: string participants: ApiPublicParticipant[] sides: ApiSide[] } type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] } const FORMAT_LABELS: Record = { stroke: "Slagspill", stableford: "Stableford", match: "Match", skins: "Skins", fourball: "Fourball", foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", chapman: "Chapman", copenhagen: "Københavner", bbb: "Bingo Bango Bongo", flag: "Flaggturnering", shamble: "Shamble", money_ball: "Money Ball", high_low_high: "High-low-high", scramble_solo: "Scramble mot enkeltspiller", scramble_solo_match: "Scramble mot enkeltspiller (matchspill)", } const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) export function WatchRound({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) const [leaderboard, setLeaderboard] = useState(null) const [flightGroup, setFlightGroup] = useState<{ flight_group_id: string | null; flights: ApiFlightSummary[] } | null>(null) const [error, setError] = useState(null) const [refreshKey, setRefreshKey] = useState(0) // Innlogget bruker (kan være anonym -- se moduldoc). Kun brukt til // RoundMessages sin slett-knapp-synlighet; "" er trygt som fallback // siden ingen ekte author_user_id/eier-id noensinne er en tom streng. const [viewerId, setViewerId] = useState(null) useEffect(() => { let cancelled = false fetch("/auth/me", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: { id: string } | null) => { if (!cancelled && data) setViewerId(data.id) }) .catch(() => {}) return () => { cancelled = true } }, []) useEffect(() => { 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) const [lbRes, flightRes] = await Promise.all([ fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" }), fetch(`/public/rounds/${roundId}/flight-group`, { credentials: "include" }), ]) if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json()) if (flightRes.ok && !cancelled) setFlightGroup(await flightRes.json()) } void load() return () => { cancelled = true } }, [roundId, refreshKey]) // Anker-scroll til kommentarseksjonen (ADR-047) -- se samme mønster/ // begrunnelse i round-detail.tsx. useEffect(() => { if (!round) return if (window.location.hash !== "#kommentarer") return document.getElementById("kommentarer")?.scrollIntoView({ block: "start" }) }, [round]) if (error) { return (

{error}

Tilbake til dashbordet
) } if (!round) { return (
) } 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))} Spilt av {round.owner_display_name}
{round.play_format === "flag" && } {/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes aldri her, kun på round-detail.tsx sin egen "Score"-fane. En person runden er delt med hadde dermed ingen vei til å se eller delta i samtalen. `GET/POST /rounds/{id}/messages` er allerede synlighets-gatet (_can_view_round, ikke eier/deltaker-only), så samme endepunkt gjenbrukes uendret -- ingen ny backend-kode. */} {/* Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat. */}
) }