teecup/frontend/components/session-blind-draw.tsx

835 lines
31 KiB
TypeScript
Raw Normal View History

"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Lock, PartyPopper, Plus, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py)
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
type HoleConfig = "full_18" | "front_9" | "back_9"
type ScoringMode = "stroke" | "hole_result"
type ApiTeam = { id: string; name: string; color: string | null }
type ApiRosterEntry = {
id: string
player_id: string
display_name: string
handicap_index_snapshot: number | null
is_captain: boolean
}
type ApiTee = {
id: string
name: string
gender: "m" | "f" | "x" | null
course_rating: number
slope_rating: number
par: number
}
type ApiSession = {
id: string
name: string | null
format: Format
hole_config: HoleConfig
course_id: string
points_per_match: number
scoring_mode: ScoringMode
locked_team_ids: string[]
}
type ApiParticipant = {
id: string
team_side: "a" | "b"
team_roster_id: string
player_name: string
tee_id: string
tee_name: string
}
type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
status_text: string | null
points_side_a: number | null
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null
participants: ApiParticipant[]
}
// --- Labels ------------------------------------------------------------------
const FORMAT_LABELS: Record<Format, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
}
const HOLE_LABELS: Record<HoleConfig, string> = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" }
const SCORING_LABELS: Record<ScoringMode, string> = { stroke: "Slag for slag", hole_result: "Kun hullresultat" }
function slotsPerSide(format: Format): number {
if (format === "singles") return 1
if (format === "scramble_4") return 4
return 2
}
// --- Component ---------------------------------------------------------------
export function SessionBlindDraw({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [session, setSession] = useState<ApiSession | null>(null)
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({})
const [tees, setTees] = useState<ApiTee[]>([])
const [matches, setMatches] = useState<ApiMatch[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmingLock, setConfirmingLock] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [sessionsRes, teamsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
])
if (!sessionsRes.ok || !teamsRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json()
const foundSession = sessionsData.find((s) => s.id === sessionId)
if (!foundSession) throw new Error("session not found")
const teamsData: ApiTeam[] = await teamsRes.json()
if (teamsData.length !== 2) throw new Error("expected 2 teams")
const [rosterARes, rosterBRes, teesRes, matchesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
])
if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed")
if (cancelled) return
setSession(foundSession)
setTeams([teamsData[0], teamsData[1]])
setRosters({
[teamsData[0].id]: await rosterARes.json(),
[teamsData[1].id]: await rosterBRes.json(),
})
setTees(await teesRes.json())
setMatches(await matchesRes.json())
} catch {
if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, sessionId])
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches],
)
const bothLocked = (session?.locked_team_ids.length ?? 0) >= 2
async function refetchMatches() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" })
if (res.ok) setMatches(await res.json())
}
async function addMatch() {
if (!teams) return
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
})
if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json()
setMatches((prev) => [...prev, created])
} catch {
setError("Klarte ikke å legge til match. Prøv igjen.")
}
}
async function addParticipant(matchId: string, teamSide: "a" | "b", teamRosterId: string, teeId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ team_side: teamSide, team_roster_id: teamRosterId, tee_id: teeId }),
})
if (!res.ok) throw new Error(`add participant: ${res.status}`)
const created: ApiParticipant = await res.json()
setMatches((prev) =>
prev.map((m) => (m.id === matchId ? { ...m, participants: [...m.participants, created] } : m)),
)
} catch {
setError("Klarte ikke å legge til spilleren. Kanskje laget allerede har låst, eller du mangler tilgang.")
}
}
async function removeParticipant(matchId: string, participantId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants/${participantId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) throw new Error(`remove participant: ${res.status}`)
setMatches((prev) =>
prev.map((m) =>
m.id === matchId
? { ...m, participants: m.participants.filter((p) => p.id !== participantId) }
: m,
),
)
} catch {
setError("Klarte ikke å fjerne spilleren. Prøv igjen.")
}
}
async function lockTeam(teamId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/lock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ team_id: teamId }),
})
if (!res.ok) throw new Error(`lock: ${res.status}`)
setSession((prev) =>
prev ? { ...prev, locked_team_ids: [...prev.locked_team_ids, teamId] } : prev,
)
// Motstanderens deltakere er skjult server-side inntil begge har låst
// (ADR-013) -- hent matchene på nytt for å få den avslørte visningen
// hvis dette var det andre laget som nettopp låste.
await refetchMatches()
} catch {
setError("Klarte ikke å låse oppstillingen. Prøv igjen.")
} finally {
setConfirmingLock(null)
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (loading || !session || !teams) {
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-5xl flex-1 items-center justify-center px-5 py-6">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : (
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
)}
</main>
</div>
)
}
const perSide = slotsPerSide(session.format)
function usedPlayerIds(teamId: string, exceptMatchId: string): Set<string> {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
const used = new Set<string>()
for (const m of matches) {
if (m.id === exceptMatchId) continue
for (const p of m.participants) {
if (p.team_side === side) used.add(p.team_roster_id)
}
}
return used
}
function teamIsComplete(teamId: string): boolean {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
if (matches.length === 0) return false
return matches.every((m) => m.participants.filter((p) => p.team_side === side).length >= perSide)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
<span className="truncate text-sm text-muted-foreground">
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]}
</span>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-4 flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
<StatusBanner teams={teams} lockedTeamIds={session.locked_team_ids} bothLocked={bothLocked} />
{bothLocked ? (
<RevealedView
teams={teams}
matches={sortedMatches}
scorecardHrefFor={(matchId) =>
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
}
/>
) : (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{teams.map((team, teamIndex) => (
<TeamColumn
key={team.id}
team={team}
side={teamIndex === 0 ? "a" : "b"}
roster={rosters[team.id] ?? []}
tees={tees}
matches={sortedMatches}
perSide={perSide}
locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant}
onRemoveParticipant={removeParticipant}
onAddMatch={addMatch}
complete={teamIsComplete(team.id)}
confirming={confirmingLock === team.id}
onRequestLock={() => setConfirmingLock(team.id)}
onCancelLock={() => setConfirmingLock(null)}
onConfirmLock={() => lockTeam(team.id)}
/>
))}
</div>
)}
</main>
</div>
)
}
// --- Status banner -------------------------------------------------------------
function StatusBanner({
teams,
lockedTeamIds,
bothLocked,
}: {
teams: [ApiTeam, ApiTeam]
lockedTeamIds: string[]
bothLocked: boolean
}) {
if (bothLocked) {
return (
<div className="flex items-center gap-3 rounded-3xl bg-primary px-5 py-4 text-primary-foreground shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
<PartyPopper aria-hidden="true" className="size-6" />
</div>
<div className="flex flex-col">
<span className="text-base font-extrabold tracking-tight">Oppstilling avslørt!</span>
<span className="text-sm text-primary-foreground/80">
Begge lag har låst. Her er paringene for økten.
</span>
</div>
</div>
)
}
const lockedCount = lockedTeamIds.length
if (lockedCount === 1) {
const lockedTeam = lockedTeamIds.includes(teams[0].id) ? teams[0] : teams[1]
const otherTeam = lockedTeam.id === teams[0].id ? teams[1] : teams[0]
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<Clock aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
{lockedTeam.name} har låst
</span>
<span className="text-sm text-muted-foreground">
Venter at {otherTeam.name} låser sin oppstilling.
</span>
</div>
</div>
)
}
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<EyeOff aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground">Begge lag setter opp i skjul</span>
<span className="text-sm text-muted-foreground text-pretty">
Ingen ser motstanderens valg før begge har låst oppstillingen.
</span>
</div>
</div>
)
}
// --- Team column ---------------------------------------------------------------
function TeamColumn({
team,
side,
roster,
tees,
matches,
perSide,
locked,
usedPlayerIds,
onAddParticipant,
onRemoveParticipant,
onAddMatch,
complete,
confirming,
onRequestLock,
onCancelLock,
onConfirmLock,
}: {
team: ApiTeam
side: "a" | "b"
roster: ApiRosterEntry[]
tees: ApiTee[]
matches: ApiMatch[]
perSide: number
locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: (matchId: string, side: "a" | "b", teamRosterId: string, teeId: string) => void
onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: () => void
complete: boolean
confirming: boolean
onRequestLock: () => void
onCancelLock: () => void
onConfirmLock: () => void
}) {
const color = team.color ?? "#64748b"
return (
<section
className="flex flex-col gap-3 rounded-3xl border border-border bg-card/40 p-4 shadow-sm shadow-black/5 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: color }}
aria-label={`Oppstilling for ${team.name}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<span aria-hidden="true" className="size-4 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{team.name}</h2>
</div>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Users aria-hidden="true" className="size-4" />
{matches.length} {matches.length === 1 ? "match" : "matcher"}
</span>
</div>
{locked && (
<div className="inline-flex w-fit items-center gap-1.5 rounded-full bg-secondary px-3 py-1 text-xs font-bold text-secondary-foreground">
<Lock aria-hidden="true" className="size-3.5" />
Låst
</div>
)}
<div className="flex flex-col gap-3">
{matches.map((match) => {
const sideParticipants = match.participants.filter((p) => p.team_side === side)
const disabled = usedPlayerIds(team.id, match.id)
return (
<div key={match.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{sideParticipants.map((p) => (
<div
key={p.id}
className="flex items-center justify-between gap-3 rounded-xl bg-secondary/60 px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<div className="flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-card px-2.5 py-1 text-xs font-semibold text-foreground">
{p.tee_name}
</span>
{!locked && (
<button
type="button"
onClick={() => onRemoveParticipant(match.id, p.id)}
aria-label={`Fjern ${p.player_name}`}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
))}
{!locked && sideParticipants.length < perSide && (
<AddSlotForm
roster={roster}
tees={tees}
disabledPlayerIds={disabled}
onAdd={(teamRosterId, teeId) => onAddParticipant(match.id, side, teamRosterId, teeId)}
/>
)}
{perSide > 1 && (
<p className="text-xs text-muted-foreground">{perSide} spillere per side i dette formatet</p>
)}
</div>
</div>
)
})}
</div>
{!locked && (
<button
type="button"
onClick={onAddMatch}
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Legg til match
</button>
)}
{!locked && (
<div className="mt-1">
{confirming ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/60 p-4">
<p className="text-sm font-medium text-foreground text-pretty">
Er du sikker? Du kan ikke endre oppstillingen etter at du har låst.
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancelLock} className="h-11 flex-1 rounded-2xl font-semibold">
Avbryt
</Button>
<Button type="button" onClick={onConfirmLock} className="h-11 flex-1 rounded-2xl font-bold">
<Lock aria-hidden="true" className="size-4" />
Lås
</Button>
</div>
</div>
) : (
<Button
type="button"
onClick={onRequestLock}
disabled={!complete}
className="h-12 w-full rounded-2xl text-base font-bold shadow-sm"
>
<Lock aria-hidden="true" className="size-5" />
Lås oppstilling
</Button>
)}
{!complete && !confirming && (
<p className="mt-2 text-center text-xs text-muted-foreground text-pretty">
Velg spiller og tee for alle plasser før du kan låse.
</p>
)}
</div>
)}
</section>
)
}
// --- Add-slot inline form --------------------------------------------------
function AddSlotForm({
roster,
tees,
disabledPlayerIds,
onAdd,
}: {
roster: ApiRosterEntry[]
tees: ApiTee[]
disabledPlayerIds: Set<string>
onAdd: (teamRosterId: string, teeId: string) => void
}) {
const [rosterId, setRosterId] = useState("")
const [teeId, setTeeId] = useState("")
function handleAdd() {
if (!rosterId || !teeId) return
onAdd(rosterId, teeId)
setRosterId("")
setTeeId("")
}
return (
<div className="grid grid-cols-[1fr_1fr_auto] gap-2 rounded-xl border border-dashed border-border bg-background/20 p-2">
<label className="flex flex-col gap-1">
<span className="sr-only">Velg spiller</span>
<select
value={rosterId}
onChange={(e) => setRosterId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg spiller</option>
{roster.map((p) => (
<option key={p.id} value={p.id} disabled={disabledPlayerIds.has(p.id)}>
{p.display_name}
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
{disabledPlayerIds.has(p.id) ? " — valgt" : ""}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="sr-only">Velg tee</span>
<select
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg tee</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{teeLabel(t)}
</option>
))}
</select>
</label>
<Button
type="button"
size="icon"
disabled={!rosterId || !teeId}
onClick={handleAdd}
className="h-11 w-11 shrink-0 rounded-lg"
aria-label="Legg til spiller"
>
<Plus aria-hidden="true" className="size-4" />
</Button>
</div>
)
}
// --- Revealed view ---------------------------------------------------------
function RevealedView({
teams,
matches,
scorecardHrefFor,
}: {
teams: [ApiTeam, ApiTeam]
matches: ApiMatch[]
scorecardHrefFor: (matchId: string) => string
}) {
return (
<div className="mt-6 flex flex-col gap-3">
{matches.map((match) => {
// Fargekoding etter ledende lag (ADR-020) -- umiddelbar visuell
// status i stedet for kun tekst, samme mønster som TV-dekning av
// Ryder Cup. Ingen farge når leading_side er null (ikke startet/AS).
const leadingTeam =
match.leading_side === "a" ? teams[0] : match.leading_side === "b" ? teams[1] : null
const leadColor = leadingTeam?.color ?? null
const decided = match.points_side_a !== null
return (
<Link
key={match.id}
href={scorecardHrefFor(match.id)}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
{match.status_text ? (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide",
decided ? "text-white" : "text-foreground",
)}
style={{ backgroundColor: leadColor ? `${leadColor}${decided ? "" : "26"}` : undefined }}
>
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
)}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2 border-t border-border pt-3 sm:gap-3">
<RevealSide
team={teams[0]}
participants={match.participants.filter((p) => p.team_side === "a")}
align="left"
leading={match.leading_side === "a"}
/>
<div className="flex items-center justify-center">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide text-secondary-foreground">
vs
</span>
</div>
<RevealSide
team={teams[1]}
participants={match.participants.filter((p) => p.team_side === "b")}
align="right"
leading={match.leading_side === "b"}
/>
</div>
</Link>
)
})}
</div>
)
}
function RevealSide({
team,
participants,
align,
leading,
}: {
team: ApiTeam
participants: ApiParticipant[]
align: "left" | "right"
leading: boolean
}) {
const right = align === "right"
const color = team.color ?? "#64748b"
return (
<div
className={cn("flex flex-col gap-2 rounded-2xl bg-background/50 p-3", right ? "items-end text-right" : "items-start text-left")}
style={{
...(right ? { borderRightWidth: 4, borderRightColor: color } : { borderLeftWidth: 4, borderLeftColor: color }),
backgroundColor: leading ? `${color}14` : undefined,
}}
>
<div className={cn("flex items-center gap-2", right && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{team.name}</span>
</div>
{participants.map((p) => (
<div key={p.id} className={cn("flex flex-col", right ? "items-end" : "items-start")}>
<span className="text-sm font-semibold text-foreground text-pretty">{p.player_name}</span>
<span className="text-xs text-muted-foreground">{p.tee_name}</span>
</div>
))}
</div>
)
}
// --- Small helpers ---------------------------------------------------------
function MetaBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{children}
</span>
)
}
function teeLabel(tee: ApiTee) {
const genderMap: Record<"m" | "f" | "x", string> = { m: "H", f: "D", x: "N" }
const g = tee.gender ? ` ${genderMap[tee.gender]}` : ""
return `${tee.name}${g}`
}
function formatHcp(value: number | null) {
if (value === null) return ""
return value.toFixed(1).replace(".", ",")
}
function formatTime(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}