teecup/frontend/components/watch-round.tsx
Erol Haagenrud 968ea79184 Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)

Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx

Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx

Bingo Bango Bongo: format-valg + per-hull picker + poengtabell

Flaggturnering: format-valg + nedtelling/resultatvisning

Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)

Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)

High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)

Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)

Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)

Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00

387 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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",
}
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 (
<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">
<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>
)
}