Taltastaturet for Slag viser nå Eagle/Birdie/Par/Bogey/Dobbel bogey under hvert tall, regnet ut fra hullets par — samme prinsipp som videoen, ingen hoderegning nødvendig.
Automatisk fremdrift: når en spillers registrering er "god nok" (slag, eller slag+putter avhengig av statistikknivå), viser knappen nederst enten "Neste: {navn}" (bytter aktiv spiller på samme hull) eller "Neste hull" (er hen den siste, går videre og starter på spiller 1 igjen) — ingen manuell skrolling opp til spillerlisten for å bytte spiller.
Bevisst forskjell fra GameBook: ikke en full skjermovertakende veiviser (det ville vært et større og mer risikofylt bygg uten visuell testing) — samme side, men med den friksjonsreduserende logikken lagt inn. Test gjerne selv, spesielt hvordan det føles å gå gjennom begge spillerne på et hull etter hverandre.
224 lines
8.4 KiB
TypeScript
224 lines
8.4 KiB
TypeScript
import Link from "next/link"
|
|
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Trophy, Users } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type RoundStatus = "active" | "completed"
|
|
|
|
export type Round = {
|
|
id: string
|
|
name?: string | null
|
|
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 displayTitle = round.name?.trim() || round.courseName
|
|
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
|
|
|
|
return (
|
|
// Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve
|
|
// lenken til rundesiden; en leaderboard-lenke kan derfor ikke ligge
|
|
// NESTET inni den (ugyldig HTML, samme klasse feil som tidligere
|
|
// nestede-form-/knapp-feller i prosjektet). Hovedinnholdet er fortsatt
|
|
// én stor lenke til rundesiden, leaderboard-lenken en egen, separat
|
|
// rad under.
|
|
<div className="flex flex-col gap-2">
|
|
<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">{displayTitle}</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">
|
|
{round.name?.trim() && <span className="truncate">{round.courseName}</span>}
|
|
<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>
|
|
|
|
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn
|
|
én deltaker. Vises for BÅDE pågående og fullførte runder (en
|
|
fullført rundes sluttstilling er fortsatt nyttig å se). */}
|
|
{round.playerCount > 1 && (
|
|
<Link
|
|
href={`/my-rounds/${round.id}/leaderboard`}
|
|
className="flex min-h-11 items-center gap-1.5 self-start rounded-xl px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
|
|
>
|
|
<Trophy aria-hidden="true" className="size-4" />
|
|
Se leaderboard
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|