Initial commit v1
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { strokesReceived, formatVsPar } from '@/lib/handicap'
|
||||
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type Props = {
|
||||
holes: Hole[]
|
||||
players: Player[]
|
||||
scores: Record<string, number>
|
||||
}
|
||||
|
||||
export function LeaderboardView({ holes, players, scores }: Props) {
|
||||
const totalHoles = holes.length
|
||||
|
||||
const standings = players
|
||||
.map((player) => {
|
||||
let grossTotal = 0
|
||||
let netTotal = 0
|
||||
let holesPlayed = 0
|
||||
let parPlayed = 0
|
||||
|
||||
for (const hole of holes) {
|
||||
const gross = scores[`${player.user_id}-${hole.id}`]
|
||||
if (gross !== undefined) {
|
||||
const ch = player.course_handicap ?? 0
|
||||
const net = gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
||||
grossTotal += gross
|
||||
netTotal += net
|
||||
parPlayed += hole.par
|
||||
holesPlayed++
|
||||
}
|
||||
}
|
||||
|
||||
const netVsPar = holesPlayed > 0 ? netTotal - parPlayed : null
|
||||
const grossVsPar = holesPlayed > 0 ? grossTotal - parPlayed : null
|
||||
|
||||
return {
|
||||
player,
|
||||
grossTotal,
|
||||
netTotal,
|
||||
holesPlayed,
|
||||
netVsPar,
|
||||
grossVsPar,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.netVsPar === null && b.netVsPar === null) return 0
|
||||
if (a.netVsPar === null) return 1
|
||||
if (b.netVsPar === null) return -1
|
||||
return a.netVsPar - b.netVsPar || a.grossVsPar! - b.grossVsPar!
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{standings.map(({ player, grossTotal, netVsPar, grossVsPar, holesPlayed }, i) => {
|
||||
const isLead = i === 0 && netVsPar !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.user_id}
|
||||
className={`flex items-center gap-4 px-4 py-4 ${isLead ? 'bg-primary/5' : ''}`}
|
||||
>
|
||||
{/* Position */}
|
||||
<div
|
||||
className={`w-7 text-center text-sm font-bold ${
|
||||
i === 0 ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{holesPlayed > 0 ? i + 1 : '—'}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<PlayerAvatar
|
||||
name={player.profile?.display_name ?? '?'}
|
||||
url={player.profile?.avatar_url}
|
||||
/>
|
||||
|
||||
{/* Name + holes played */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{player.profile?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{holesPlayed > 0
|
||||
? `${holesPlayed} of ${totalHoles} holes`
|
||||
: 'No scores yet'}
|
||||
{player.course_handicap !== null &&
|
||||
` · HCP ${player.course_handicap}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net score (primary, bold) */}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
netVsPar === null
|
||||
? 'text-muted-foreground'
|
||||
: netVsPar < 0
|
||||
? 'text-red-500'
|
||||
: netVsPar === 0
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{netVsPar !== null ? formatVsPar(netVsPar) : '—'}
|
||||
</span>
|
||||
{grossVsPar !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{grossTotal} gross
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerAvatar({ name, url }: { name: string; url?: string | null }) {
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
if (url) {
|
||||
return <img src={url} alt={name} className="h-10 w-10 rounded-full object-cover shrink-0" />
|
||||
}
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user