2026-07-28 19:25:37 +02:00
|
|
|
|
"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
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
match_lead: number | null
|
2026-07-28 19:25:37 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-28 19:25:37 +02:00
|
|
|
|
const FORMAT_LABELS: Record<string, string> = {
|
|
|
|
|
|
stroke: "Slagspill",
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
stableford: "Stableford",
|
2026-07-28 19:25:37 +02:00
|
|
|
|
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
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-07-28 19:25:37 +02:00
|
|
|
|
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">
|
2026-07-28 19:25:37 +02:00
|
|
|
|
<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>
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
<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>
|
2026-07-28 19:25:37 +02:00
|
|
|
|
{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)
|
|
|
|
|
|
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
if (roundData.play_format === "stroke" || roundData.play_format === "stableford" || roundData.play_format === "skins") {
|
2026-07-28 19:25:37 +02:00
|
|
|
|
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">
|
2026-07-28 19:25:37 +02:00
|
|
|
|
<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} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
{(round.play_format === "stroke" || round.play_format === "stableford") && leaderboard && (
|
|
|
|
|
|
<StrokeLeaderboard leaderboard={leaderboard} />
|
|
|
|
|
|
)}
|
2026-07-28 19:25:37 +02:00
|
|
|
|
|
Stableford is now a real spilleform for frittstående runder, alongside Slagspill/Match: selectable at round creation, editable afterward, shown as a badge everywhere the round appears.
"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden".
Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production.
Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
2026-07-29 19:46:17 +02:00
|
|
|
|
{!TWO_SIDED_FORMATS.has(round.play_format) &&
|
|
|
|
|
|
round.play_format !== "skins" &&
|
|
|
|
|
|
round.play_format !== "stroke" &&
|
|
|
|
|
|
round.play_format !== "stableford" && (
|
2026-07-28 19:25:37 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|