teecup/frontend/components/watch-round.tsx

390 lines
16 KiB
TypeScript
Raw Normal View History

"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_lead: number | null
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
// High-low-high (2026-07-30) -- literal poengsum, IKKE match-play
// "X UP" (match_status_text settes aldri for dette formatet).
hlh_points_a: number | null
hlh_points_b: number | null
}
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx/round-
// leaderboard.tsx) -- ingen navn her (vises andre steder på siden), kun
// fargesonene + den sentrerte statuspillen, for full visuell konsistens.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
const FORMAT_LABELS: Record<string, string> = {
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 TWO_SIDED_FORMATS = new Set([
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
])
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)
: result.hlh_points_a !== null && result.hlh_points_b !== null
? `${result.hlh_points_a}-${result.hlh_points_b}`
: null
const lead = result.match_lead ?? 0
const holesPlayed = result.match_holes_played ?? 0
const holesRemaining = result.match_holes_remaining ?? 0
const totalHoles = holesPlayed + holesRemaining
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return (
2026-07-29 12:44:51 +02:00
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 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>
<div
className="grid h-20 w-full overflow-hidden rounded-2xl"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
<div className="flex h-full items-center justify-center px-3">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{statusText ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{statusText}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
</div>
{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 === "stableford" || 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">
2026-07-29 12:44:51 +02:00
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<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" || round.play_format === "stableford") && leaderboard && (
<StrokeLeaderboard leaderboard={leaderboard} />
)}
{!TWO_SIDED_FORMATS.has(round.play_format) &&
round.play_format !== "skins" &&
round.play_format !== "stroke" &&
round.play_format !== "stableford" && (
<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>
)
}