"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 | null } 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 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 ( ) } // --- Skins-tavle ------------------------------------------------------------- function SkinsBoard({ participants, skinsWon }: { participants: ApiParticipant[]; skinsWon: Record }) { 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 (
Skins-tavle {totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
    {rows.map(({ participant, skins }) => (
  • {participant.display_name} {skins}
  • ))}
) } // --- Matchstatus (to-sidede formater) ---------------------------------------- function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSide[] }) { if (!result.ready) { return (
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
) } const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null return (
Matchstatus {result.match_is_closed && Avgjort} {result.match_is_dormie && !result.match_is_closed && Dormie}
{statusText && {statusText}} {result.match_holes_played} hull spilt {result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`} {result.hole_results && result.hole_results.length > 0 && (
    {result.hole_results.map((r, i) => (
  • {r === "halved" ? "½" : r.toUpperCase()}
  • ))}
)}
) } // --- Screen ------------------------------------------------------------------ export function WatchRound({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) const [leaderboard, setLeaderboard] = useState(null) const [formatResult, setFormatResult] = useState(null) const [error, setError] = useState(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 (

{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}
{TWO_SIDED_FORMATS.has(round.play_format) && formatResult && ( )} {round.play_format === "skins" && formatResult?.skins_won && ( )} {round.play_format === "stroke" && leaderboard && } {!TWO_SIDED_FORMATS.has(round.play_format) && round.play_format !== "skins" && round.play_format !== "stroke" && (

Ingen live-visning tilgjengelig for dette formatet ennå.

)}
) }