"Kommende runder" → "Runder" on the dashboard. Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry. Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round. Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping. Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round. Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
234 lines
9.1 KiB
TypeScript
234 lines
9.1 KiB
TypeScript
import Link from "next/link"
|
|
import { CalendarDays, ChevronRight, Flag, Gauge, MapPin, Medal, 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
|
|
// Lowest score-to-par among the viewer's own completed rounds (computed
|
|
// by the caller across the full rounds list, not by the card itself).
|
|
isPersonalBest?: boolean
|
|
}
|
|
|
|
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
|
|
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
|
|
|
|
// 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} />
|
|
{showPersonalBest && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gold/20 px-2.5 py-1 text-xs font-bold text-foreground">
|
|
<Medal aria-hidden="true" className="size-3.5 text-gold" />
|
|
Personlig rekord
|
|
</span>
|
|
)}
|
|
</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="inline-flex items-center gap-1.5 rounded-full bg-info/15 px-2.5 py-0.5 text-sm font-semibold text-foreground">
|
|
<Gauge aria-hidden="true" className="size-4 shrink-0 text-info" />
|
|
<span className="tabular-nums">Hcp spilt til {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>
|
|
)
|
|
}
|