teecup/frontend/components/round-card.tsx

200 lines
7.2 KiB
TypeScript

import Link from "next/link"
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Users } from "lucide-react"
import { cn } from "@/lib/utils"
export type RoundStatus = "active" | "completed"
export type Round = {
id: string
courseName: string
status: RoundStatus
teeName: string
holes: 9 | 18
date: string
playerCount: number
// Progress for rounds still in play.
holesPlayed?: number
// Owner's score, present once at least one hole is recorded.
totalScore?: number
toPar?: number
// Handicap differential, only when a completed round counted (one decimal).
differential?: number | null
}
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
active: {
label: "Pågår",
dot: "bg-primary",
badge: "bg-primary/15 text-foreground",
},
completed: {
label: "Fullført",
dot: "bg-brand-orange",
badge: "bg-brand-orange/12 text-foreground",
},
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
day: "numeric",
month: "short",
year: "numeric",
})
function formatDate(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return dateFormatter.format(parsed)
}
// "+10", "-2" or "E" for level par. The sign carries the meaning (never color alone).
function formatToPar(toPar: number) {
if (toPar === 0) return "E"
return toPar > 0 ? `+${toPar}` : `${toPar}`
}
function toParDescription(toPar: number) {
if (toPar === 0) return "på par"
return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par`
}
function RoundStatusBadge({ status }: { status: RoundStatus }) {
const config = STATUS_CONFIG[status]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold",
config.badge,
)}
>
<span className={cn("size-2 rounded-full", config.dot)} aria-hidden="true" />
{config.label}
</span>
)
}
// Prominent right-side tile: final score for completed/scored rounds,
// hole progress for rounds still in play.
function ScoreTile({ round }: { round: Round }) {
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
if (round.status === "active" || !hasScore) {
const played = round.holesPlayed ?? 0
const pct = round.holes > 0 ? Math.min(100, Math.round((played / round.holes) * 100)) : 0
return (
<div className="flex w-24 shrink-0 flex-col items-center gap-1.5 rounded-2xl bg-secondary px-3 py-3 sm:w-28">
<span className="text-2xl font-extrabold leading-none tabular-nums text-foreground">
{played}
<span className="text-base font-bold text-muted-foreground">/{round.holes}</span>
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
hull spilt
</span>
<div
className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-border"
role="progressbar"
aria-valuenow={played}
aria-valuemin={0}
aria-valuemax={round.holes}
aria-label={`${played} av ${round.holes} hull spilt`}
>
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
</div>
</div>
)
}
const toPar = round.toPar as number
const overPar = toPar > 0
const underPar = toPar < 0
return (
<div className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-2xl bg-secondary px-3 py-3 sm:w-28">
<span className="text-3xl font-extrabold leading-none tabular-nums text-foreground">
{round.totalScore}
</span>
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-base font-bold tabular-nums",
underPar && "bg-primary/20 text-foreground",
overPar && "bg-brand-orange/20 text-foreground",
!underPar && !overPar && "bg-muted text-foreground",
)}
>
{formatToPar(toPar)}
</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
til par
</span>
</div>
)
}
export function RoundCard({ round }: { round: Round }) {
const playerLabel = round.playerCount === 1 ? "spiller" : "spillere"
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
const showDifferential =
round.status === "completed" &&
typeof round.differential === "number" &&
round.differential !== null
const differentialText =
showDifferential && round.differential != null
? round.differential.toFixed(1).replace(".", ",")
: null
// Concise summary so the compact score tile reads clearly for screen readers.
const scoreSummary =
round.status === "completed" && hasScore
? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.`
: `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.`
const ariaLabel = `${round.courseName}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
return (
<Link
href={`/my-rounds/${round.id}`}
aria-label={ariaLabel}
className="group flex min-h-[88px] w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:p-5"
>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.courseName}</span>
</h3>
<RoundStatusBadge status={round.status} />
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>
{round.teeName} · {round.holes} hull
</span>
</span>
<span className="flex items-center gap-1.5">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">{formatDate(round.date)}</span>
</span>
<span className="flex items-center gap-1.5">
<Users aria-hidden="true" className="size-4 shrink-0" />
<span>
{round.playerCount} {playerLabel}
</span>
</span>
{differentialText && (
<span className="flex items-center gap-1.5">
<Gauge aria-hidden="true" className="size-4 shrink-0" />
<span className="tabular-nums">Diff {differentialText}</span>
</span>
)}
</div>
</div>
<ScoreTile round={round} />
<ChevronRight
aria-hidden="true"
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
/>
</Link>
)
}