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.
320 lines
13 KiB
TypeScript
320 lines
13 KiB
TypeScript
"use client"
|
||
|
||
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
|
||
// fase 2, 2026-07-28) -- der en venn (i en synlig kategori) eller alle
|
||
// (visibility_mode='public') kan følge runden UTEN å være eier/deltaker.
|
||
// Ferskbygget, IKKE en gjenbruk av round-detail.tsx/round-leaderboard.tsx
|
||
// (som antar eier-/deltaker-tilgang og full skrive-UI) -- denne siden er
|
||
// rendyrket lesevisning mot de nye /public/rounds/*-endepunktene, som
|
||
// fungerer like fint anonymt som innlogget (get_current_user_optional).
|
||
//
|
||
// Tre distinkte presentasjoner avhengig av play_format, samme inndeling
|
||
// som round-leaderboard.tsx sin TWO_SIDED_FORMATS-logikk:
|
||
// - Slagspill: individuell rangering (brutto til par).
|
||
// - Skins: skins-tavle (hvem har vunnet flest skins).
|
||
// - Match/fourball/foursome/greensome/scramble: matchstatus + hull-merker.
|
||
|
||
import { useEffect, useState } from "react"
|
||
import Link from "next/link"
|
||
import { Radio, Trophy } from "lucide-react"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
type ApiSide = { id: string; label: string | null }
|
||
|
||
type ApiParticipant = {
|
||
id: string
|
||
display_name: string
|
||
is_owner: boolean
|
||
}
|
||
|
||
type ApiRound = {
|
||
id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
tee_name_snapshot: string
|
||
played_at: string
|
||
play_format: string
|
||
completed_at: string | null
|
||
owner_display_name: string
|
||
participants: ApiParticipant[]
|
||
sides: ApiSide[]
|
||
}
|
||
|
||
type ApiLeaderboardEntry = {
|
||
participant_id: string
|
||
display_name: string
|
||
holes_played: number
|
||
total_score: number | null
|
||
score_to_par: number | null
|
||
}
|
||
|
||
type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] }
|
||
|
||
type ApiFormatResult = {
|
||
play_format: string
|
||
ready: boolean
|
||
match_holes_played: number | null
|
||
match_holes_remaining: number | null
|
||
match_is_closed: boolean | null
|
||
match_is_dormie: boolean | null
|
||
match_status_text: string | null
|
||
hole_results: string[] | null
|
||
skins_won: Record<string, number> | null
|
||
}
|
||
|
||
const FORMAT_LABELS: Record<string, string> = {
|
||
stroke: "Slagspill",
|
||
match: "Match",
|
||
skins: "Skins",
|
||
fourball: "Fourball",
|
||
foursome: "Foursome",
|
||
greensome: "Greensome",
|
||
scramble_2: "Scramble (2)",
|
||
scramble_4: "Scramble (4)",
|
||
}
|
||
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||
|
||
function formatToPar(value: number): string {
|
||
if (value === 0) return "E"
|
||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||
}
|
||
|
||
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
||
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||
}
|
||
|
||
// --- Slagspill: individuell rangering ---------------------------------------
|
||
|
||
function StrokeLeaderboard({ leaderboard }: { leaderboard: ApiLeaderboard }) {
|
||
const ranked = [...leaderboard.entries].sort((a, b) => {
|
||
if (a.score_to_par === null) return 1
|
||
if (b.score_to_par === null) return -1
|
||
return a.score_to_par - b.score_to_par
|
||
})
|
||
return (
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||
{ranked.map((entry, i) => (
|
||
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
|
||
<span
|
||
className={cn(
|
||
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
||
i === 0 && entry.score_to_par !== null ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{entry.score_to_par !== null ? `#${i + 1}` : "–"}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
||
<span className="shrink-0 text-sm text-muted-foreground">
|
||
{leaderboard.completed ? "Ferdig" : entry.holes_played > 0 ? `${entry.holes_played} hull` : "Ikke startet"}
|
||
</span>
|
||
{entry.score_to_par !== null && (
|
||
<span className="shrink-0 text-base font-extrabold tabular-nums text-foreground">
|
||
{formatToPar(entry.score_to_par)}
|
||
</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
// --- Skins-tavle -------------------------------------------------------------
|
||
|
||
function SkinsBoard({ participants, skinsWon }: { participants: ApiParticipant[]; skinsWon: Record<string, number> }) {
|
||
const rows = participants
|
||
.map((p) => ({ participant: p, skins: skinsWon[p.id] ?? 0 }))
|
||
.sort((a, b) => b.skins - a.skins)
|
||
const totalAwarded = Object.values(skinsWon).reduce((sum, v) => sum + v, 0)
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">Skins-tavle</span>
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
|
||
</span>
|
||
</div>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map(({ participant, skins }) => (
|
||
<li key={participant.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{participant.display_name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Matchstatus (to-sidede formater) ----------------------------------------
|
||
|
||
function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSide[] }) {
|
||
if (!result.ready) {
|
||
return (
|
||
<div className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
|
||
</div>
|
||
)
|
||
}
|
||
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
|
||
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
|
||
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
|
||
</div>
|
||
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
|
||
<span className="text-sm text-muted-foreground">
|
||
{result.match_holes_played} hull spilt
|
||
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
|
||
</span>
|
||
{result.hole_results && result.hole_results.length > 0 && (
|
||
<ul className="flex flex-wrap gap-1">
|
||
{result.hole_results.map((r, i) => (
|
||
<li
|
||
key={i}
|
||
aria-label={
|
||
r === "halved" ? `Hull ${i + 1}: delt` : `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
|
||
}
|
||
className={cn(
|
||
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||
r === "a" && "bg-primary text-primary-foreground",
|
||
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{r === "halved" ? "½" : r.toUpperCase()}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Screen ------------------------------------------------------------------
|
||
|
||
export function WatchRound({ roundId }: { roundId: string }) {
|
||
const [round, setRound] = useState<ApiRound | null>(null)
|
||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
||
const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [refreshKey, setRefreshKey] = useState(0)
|
||
|
||
useEffect(() => {
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/public/rounds/${roundId}/live`)
|
||
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
||
return () => socket.close()
|
||
}, [roundId])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
async function load() {
|
||
const roundRes = await fetch(`/public/rounds/${roundId}`, { credentials: "include" })
|
||
if (roundRes.status === 404) {
|
||
if (!cancelled) setError("Denne runden finnes ikke.")
|
||
return
|
||
}
|
||
if (roundRes.status === 403) {
|
||
if (!cancelled) setError("Du har ikke tilgang til å se denne runden.")
|
||
return
|
||
}
|
||
if (!roundRes.ok) {
|
||
if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
||
return
|
||
}
|
||
const roundData: ApiRound = await roundRes.json()
|
||
if (cancelled) return
|
||
setRound(roundData)
|
||
|
||
if (roundData.play_format === "stroke" || roundData.play_format === "skins") {
|
||
const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" })
|
||
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
|
||
}
|
||
if (TWO_SIDED_FORMATS.has(roundData.play_format) || roundData.play_format === "skins") {
|
||
const frRes = await fetch(`/public/rounds/${roundId}/format-result`, { credentials: "include" })
|
||
if (frRes.ok && !cancelled) setFormatResult(await frRes.json())
|
||
}
|
||
}
|
||
void load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, refreshKey])
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||
<p className="text-base font-medium text-destructive">{error}</p>
|
||
<Link href="/dashboard" className="text-base font-semibold text-primary underline underline-offset-2">
|
||
Tilbake til dashbordet
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!round) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const ongoing = round.completed_at === null
|
||
|
||
return (
|
||
<div className="min-h-dvh bg-background">
|
||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
|
||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||
Følger live
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||
{round.name?.trim() || round.course_name_snapshot}
|
||
</span>
|
||
{ongoing && (
|
||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
||
<Radio aria-hidden="true" className="size-3 animate-pulse" />
|
||
Pågår nå
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="text-sm font-semibold text-muted-foreground">
|
||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||
{FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">Spilt av {round.owner_display_name}</span>
|
||
</div>
|
||
|
||
{TWO_SIDED_FORMATS.has(round.play_format) && formatResult && (
|
||
<MatchStatus result={formatResult} sides={round.sides} />
|
||
)}
|
||
|
||
{round.play_format === "skins" && formatResult?.skins_won && (
|
||
<SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} />
|
||
)}
|
||
|
||
{round.play_format === "stroke" && leaderboard && <StrokeLeaderboard leaderboard={leaderboard} />}
|
||
|
||
{!TWO_SIDED_FORMATS.has(round.play_format) && round.play_format !== "skins" && round.play_format !== "stroke" && (
|
||
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
Ingen live-visning tilgjengelig for dette formatet ennå.
|
||
</p>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|