143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
"use client"
|
||
|
||
// "Spillere og runde", read-only tilskuer-variant (ADR-044, 2026-08-06).
|
||
// Bevisst IKKE et forsøk på å gjenbruke round-detail.tsx sin PlayerList
|
||
// via en canManage=false-prop -- den er en lokal, ikke-eksportert funksjon
|
||
// tett koblet til eier-autentiserte handlere (onPatchParticipant m.fl.),
|
||
// og selv canManage dekker i dag ikke alt konsekvent der (Fullfør runde-
|
||
// knappen er kun gated på !completed). Denne komponenten inneholder rett
|
||
// og slett INGEN handlings-kode (ingen +Medspiller, ingen Rediger/Fullfør/
|
||
// Slett) -- ingen risiko for at en eier-handling glipper gjennom.
|
||
|
||
import Link from "next/link"
|
||
import { Users } from "lucide-react"
|
||
|
||
export type ApiPublicParticipant = {
|
||
id: string
|
||
display_name: string
|
||
is_owner: boolean
|
||
tee_name_snapshot: string
|
||
handicap_index_snapshot: number | null
|
||
course_handicap_snapshot: number | null
|
||
round_side_id: string | null
|
||
playing_handicap: number | null
|
||
}
|
||
|
||
export type ApiLeaderboardEntry = {
|
||
participant_id: string
|
||
display_name: string
|
||
holes_played: number
|
||
total_score: number | null
|
||
score_to_par: number | null
|
||
}
|
||
|
||
export type ApiFlightSummary = {
|
||
round_id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
played_at: string
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participant_count: number
|
||
is_anchor: boolean
|
||
}
|
||
|
||
function formatToPar(value: number): string {
|
||
if (value === 0) return "E"
|
||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||
}
|
||
|
||
function ParticipantCard({ participant, progress }: { participant: ApiPublicParticipant; progress: ApiLeaderboardEntry | null }) {
|
||
// Tildelte slag: allowance-justert spillehandicap der satt, ellers
|
||
// course handicap -- samme prioritet som eiersidens PlayerList bruker
|
||
// for "Tildelte slag".
|
||
const strokesGiven = participant.playing_handicap ?? participant.course_handicap_snapshot
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-base font-bold text-foreground">{participant.display_name}</span>
|
||
{participant.is_owner && (
|
||
<span className="rounded-full border border-border px-2 py-0.5 text-xs font-semibold text-muted-foreground">
|
||
Eier
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Utslag: <span className="font-semibold text-foreground">{participant.tee_name_snapshot}</span>
|
||
{participant.handicap_index_snapshot !== null && (
|
||
<>
|
||
{" · "}HCP: <span className="font-semibold text-foreground">{participant.handicap_index_snapshot.toFixed(1)}</span>
|
||
</>
|
||
)}
|
||
{strokesGiven !== null && (
|
||
<>
|
||
{" · "}Tildelte slag: <span className="font-semibold text-foreground">{strokesGiven}</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
{progress && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Så langt: <span className="font-semibold text-foreground">{progress.holes_played} hull</span>
|
||
{progress.total_score !== null && (
|
||
<>
|
||
{" · "}
|
||
<span className="font-semibold text-foreground">{progress.total_score} slag</span>
|
||
</>
|
||
)}
|
||
{progress.score_to_par !== null && (
|
||
<>
|
||
{" · "}
|
||
<span className="font-semibold text-foreground">{formatToPar(progress.score_to_par)} til par</span>
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function WatchPlayers({
|
||
participants,
|
||
leaderboardEntries,
|
||
flightGroup,
|
||
}: {
|
||
participants: ApiPublicParticipant[]
|
||
leaderboardEntries: ApiLeaderboardEntry[]
|
||
flightGroup: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null
|
||
}) {
|
||
const progressByParticipant = new Map(leaderboardEntries.map((e) => [e.participant_id, e]))
|
||
const otherFlights = (flightGroup?.flights ?? []).filter((f) => !f.is_anchor)
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4" aria-label="Spillere og runde">
|
||
{otherFlights.length > 0 && (
|
||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
|
||
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||
<Users aria-hidden="true" className="size-4 text-primary" />
|
||
Flere flighter deler dette leaderboardet
|
||
</span>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{otherFlights.map((f) => (
|
||
<li key={f.round_id}>
|
||
<Link
|
||
href={`/watch/${f.round_id}`}
|
||
className="flex min-h-11 items-center justify-between gap-2 px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<span>{f.name || "Flight"}</span>
|
||
<span className="text-xs font-medium text-muted-foreground">{f.participant_count} spillere</span>
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{participants.map((p) => (
|
||
<ParticipantCard key={p.id} participant={p} progress={progressByParticipant.get(p.id) ?? null} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|