teecup/frontend/components/session-blind-draw.tsx
Erol Haagenrud 02247a1e5f Update Todos
Bygg DELETE .../matches/{id}/participants/{id}

Scratch-verifisere DELETE-endepunktet

Integrere session-blind-draw.tsx mot ekte API

Finn+fiks synlighetshull: org-admin så ikke egne tilføyde deltakere

Ekte typesjekket frontend-build

Fikse+verifisere 500-krasj på null handicap_index

Rulle ut live

Oppdatere CLAUDE.md/FEATURE_BACKLOG.md

Slette zip (6) etter fullført integrering
Alt levert og live:

Blind draw-skjermen — /tournaments/[id]/sessions/[sessionId], koblet fra Program-siden
Ny DELETE-endepunkt for å angre et deltakervalg før låsing
Synlighetshull tettet — org-admin så ikke lenger sine egne tilføyelser før reveal (own_team_ids() manglet samme owner/admin-utvidelse som skrive-siden fikk forrige runde)
500-krasjen fikset — manglende handicap-indeks gir nå en ren 400 i stedet for å krasje, med use_handicap=false fortsatt upåvirket
Alt scratch-verifisert i flere runder, test_isolation.sql 12/12, ekte typesjekket build, begge containere redeployet, teeoff.no upåvirket. Zip (6) slettet. .md-filene er oppdatert med hele runden.
2026-07-18 17:15:50 +02:00

777 lines
29 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"
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
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} />
) : (
<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 }: { teams: [ApiTeam, ApiTeam]; matches: ApiMatch[] }) {
return (
<div className="mt-6 flex flex-col gap-3">
{matches.map((match) => (
<div
key={match.id}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5"
>
<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>
<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" />
<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" />
</div>
</div>
))}
</div>
)
}
function RevealSide({
team,
participants,
align,
}: {
team: ApiTeam
participants: ApiParticipant[]
align: "left" | "right"
}) {
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 }}
>
<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(".", ",")
}