For match-formater viser den nå spillerens eget resultat, viewer-relativt — "1 UP"/"AS"/"Vunnet 2&1" osv. (aldri en generisk side A/B-tekst), farget grønt ved ledelse og oransje ved etterslep. Vises nå både mens runden pågår og etter fullføring, ikke bare etterpå som før. For vanlig slagspill viser den fortsatt brutto "Til par", men har nå fått et eget "Netto"-felt ved siden av når HCP faktisk spores. Scorekortet (både match- og slagspill-formater, individuell- og delt-ball-grid): en liten blå "−N" vises nå inni den tomme ruten når spilleren/siden faktisk mottar et slag på det hullet — synlig før du fyller ut noe, ikke bare som netto-tall etterpå. Verifisert grundig mot en isolert scratch-backend (både API-svar og full nettleser-gjennomgang i lys/mørk modus, match- og foursome-runder), ingen konsollfeil, ingen migrasjon nødvendig.
239 lines
10 KiB
TypeScript
239 lines
10 KiB
TypeScript
import type React from "react"
|
|
import Link from "next/link"
|
|
import { ChevronRight, Gauge, MapPin, Medal, Trophy } 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
|
|
// Netto til-par (2026-07-29) -- kun slagspill, kun når HCP faktisk
|
|
// spores for spilleren (samme betingelse som differensial-chipen).
|
|
netToPar?: number
|
|
// To-sidede formater (match/fourball/foursome/greensome/scramble,
|
|
// 2026-07-29): VIEWER-relativ løpende status ("1 UP"/"AS"/"Vunnet 2&1"),
|
|
// aldri backend sin bokstavelige side A/B. matchLead sitt fortegn (>0 =
|
|
// spilleren leder) styrer fargen -- unngår å måtte parse teksten.
|
|
playFormat?: string
|
|
matchStatus?: string | null
|
|
matchLead?: number | null
|
|
// 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 }> = {
|
|
active: { label: "Pågår" },
|
|
completed: { label: "Fullført" },
|
|
}
|
|
|
|
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`
|
|
}
|
|
|
|
// Status vises nå som fet, farget tekst (samme "form+farge, aldri farge
|
|
// alene"-prinsipp -- teksten SIER "Pågår"/"Fullført", fargen er kun en
|
|
// forsterkning) i stedet for en pille-badge, for en tettere,
|
|
// nøkkel/verdi-orientert korttype.
|
|
const STATUS_TEXT_CLASS: Record<RoundStatus, string> = {
|
|
active: "text-primary",
|
|
completed: "text-brand-orange",
|
|
}
|
|
|
|
function RoundProgressBar({ played, holes }: { played: number; holes: number }) {
|
|
const pct = holes > 0 ? Math.min(100, Math.round((played / holes) * 100)) : 0
|
|
return (
|
|
<div
|
|
className="h-1.5 w-full overflow-hidden rounded-full bg-border"
|
|
role="progressbar"
|
|
aria-valuenow={played}
|
|
aria-valuemin={0}
|
|
aria-valuemax={holes}
|
|
aria-label={`${played} av ${holes} hull spilt`}
|
|
>
|
|
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Kompakt nøkkel/verdi-celle -- selve rammen for grid-strukturen under
|
|
// tittelen (label liten/dempet over, verdi stor/fet under).
|
|
function InfoCell({
|
|
label,
|
|
value,
|
|
valueClassName,
|
|
}: {
|
|
label: string
|
|
value: React.ReactNode
|
|
valueClassName?: string
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
<span className={cn("text-base font-bold tabular-nums text-foreground", valueClassName)}>{value}</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
|
|
|
|
// To-sidede formater (match/fourball/foursome/greensome/scramble,
|
|
// 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto
|
|
// slagtotal alene sier lite i matchspill. Alltid VIEWER-relativ tekst
|
|
// (aldri backend sin bokstavelige side A/B), samme kilde uansett om
|
|
// runden pågår eller er fullført (bruker bekreftet begge).
|
|
const hasMatchStatus = typeof round.matchStatus === "string" && round.matchStatus.length > 0
|
|
const matchLead = round.matchLead ?? 0
|
|
const matchStatusClassName = matchLead > 0 ? "text-primary" : matchLead < 0 ? "text-brand-orange" : ""
|
|
|
|
// Concise summary so the compact score tile reads clearly for screen readers.
|
|
const scoreSummary = hasMatchStatus
|
|
? `Resultat ${round.matchStatus}.`
|
|
: 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}`
|
|
|
|
const played = round.holesPlayed ?? 0
|
|
// Resultat vises nå BÅDE mens runden pågår og etter fullføring (2026-07-29,
|
|
// bekreftet av bruker) -- ikke lenger gatet til kun fullførte runder.
|
|
const showResultRow = !hasMatchStatus && hasScore
|
|
const toPar = round.toPar as number | undefined
|
|
const toParClassName = toPar === undefined ? "" : toPar > 0 ? "text-brand-orange" : toPar < 0 ? "text-primary" : ""
|
|
const netToPar = round.netToPar
|
|
const netToParClassName = netToPar === undefined ? "" : netToPar > 0 ? "text-brand-orange" : netToPar < 0 ? "text-primary" : ""
|
|
|
|
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 w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-all duration-200 ease-in-out 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 items-start justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col">
|
|
<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>
|
|
{round.name?.trim() && (
|
|
<span className="truncate pl-7 text-sm text-muted-foreground">{round.courseName}</span>
|
|
)}
|
|
</div>
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
|
|
{STATUS_CONFIG[round.status].label}
|
|
</span>
|
|
{round.status === "active" && (
|
|
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
|
{played}/{round.holes} hull spilt
|
|
</span>
|
|
)}
|
|
{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>
|
|
{round.status === "active" && <RoundProgressBar played={played} holes={round.holes} />}
|
|
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-border pt-3">
|
|
<InfoCell label="Tee" value={round.teeName} />
|
|
<InfoCell label="Hull" value={round.holes} />
|
|
<InfoCell label="Dato" value={formatDate(round.date)} />
|
|
<InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} />
|
|
{hasMatchStatus && (
|
|
<InfoCell label="Resultat" value={round.matchStatus} valueClassName={cn("text-lg", matchStatusClassName)} />
|
|
)}
|
|
{showResultRow && (
|
|
<>
|
|
<InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" />
|
|
<InfoCell label="Til par" value={formatToPar(toPar as number)} valueClassName={cn("text-lg", toParClassName)} />
|
|
{netToPar !== undefined && (
|
|
<InfoCell label="Netto" value={formatToPar(netToPar)} valueClassName={cn("text-lg", netToParClassName)} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{differentialText && (
|
|
<span className="inline-flex w-fit 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>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|