Files
MulliganMates/components/scorecard/scorecard-view.tsx
T
2026-03-18 13:34:41 +01:00

205 lines
6.3 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { upsertScore } from '@/app/actions/scores'
import { strokesReceived } from '@/lib/handicap'
import { HoleView } from './hole-view'
import { FullGrid } from './full-grid'
import { LeaderboardView } from './leaderboard-view'
import { ScoreEntrySheet } from './score-entry-sheet'
import { ChevronLeft } from 'lucide-react'
import type { Hole, Player, ScoreRow, RoundInfo } from '@/app/(app)/rounds/[id]/scorecard/page'
type View = 'hole' | 'grid' | 'leaderboard'
type EntryState = {
player: Player
hole: Hole
}
function buildScoreMap(rows: ScoreRow[]): Record<string, number> {
return Object.fromEntries(rows.map((r) => [`${r.playerId}-${r.holeId}`, r.strokes]))
}
export function ScorecardView({
round,
holes,
players,
initialScores,
currentUserId,
}: {
round: RoundInfo
holes: Hole[]
players: Player[]
initialScores: ScoreRow[]
currentUserId: string
}) {
const [scores, setScores] = useState<Record<string, number>>(buildScoreMap(initialScores))
const [activeHoleIdx, setActiveHoleIdx] = useState(0)
const [view, setView] = useState<View>('hole')
const [entry, setEntry] = useState<EntryState | null>(null)
const [saving, setSaving] = useState(false)
const isActive = round.status === 'active'
const activeHole = holes[activeHoleIdx]
// ── SSE real-time subscription ─────────────────────────────
useEffect(() => {
const es = new EventSource(`/api/rounds/${round.id}/stream`)
es.onmessage = (event) => {
try {
const rows: ScoreRow[] = JSON.parse(event.data)
setScores(buildScoreMap(rows))
} catch {
// ignore parse errors
}
}
return () => es.close()
}, [round.id])
// ── Running totals per player ──────────────────────────────
const runningTotals = useMemo(() => {
const totals: Record<string, { net: number; gross: number; holesPlayed: number }> = {}
for (const player of players) {
let net = 0
let gross = 0
let played = 0
const ch = player.courseHandicap ?? 0
for (const hole of holes) {
const g = scores[`${player.userId}-${hole.id}`]
if (g !== undefined) {
gross += g
net += g - strokesReceived(ch, hole.strokeIndex, holes.length)
played++
}
}
totals[player.userId] = { net, gross, holesPlayed: played }
}
return totals
}, [scores, players, holes])
// ── Score save with optimistic update ─────────────────────
async function handleSave(strokes: number) {
if (!entry) return
setSaving(true)
const key = `${entry.player.userId}-${entry.hole.id}`
const prev = scores[key]
// Optimistic update
setScores((s) => ({ ...s, [key]: strokes }))
setEntry(null)
const result = await upsertScore(round.id, entry.hole.id, entry.player.userId, strokes)
if (result.error) {
// Revert
setScores((s) => {
const next = { ...s }
if (prev === undefined) delete next[key]
else next[key] = prev
return next
})
}
setSaving(false)
}
function openEntry(player: Player, hole?: Hole) {
if (!isActive) return
setEntry({ player, hole: hole ?? activeHole })
}
return (
<div className="flex h-screen flex-col">
{/* Header */}
<div className="flex items-center gap-3 border-b px-4 py-3 shrink-0">
<Link href={`/rounds/${round.id}`}>
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="flex-1 min-w-0">
<div className="font-semibold truncate">
{round.name ?? round.course?.name ?? 'Scorecard'}
</div>
<div className="text-xs text-muted-foreground">
{round.tee?.name} tees · {round.holesCount} holes
{!isActive && (
<span className="ml-1 text-muted-foreground">(completed)</span>
)}
</div>
</div>
{saving && (
<span className="text-xs text-muted-foreground animate-pulse">Saving</span>
)}
</div>
{/* View tabs */}
<div className="flex border-b shrink-0">
{(['hole', 'grid', 'leaderboard'] as View[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`flex-1 py-2 text-xs font-medium capitalize transition-colors ${
view === v
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{v === 'grid' ? 'Full card' : v === 'leaderboard' ? 'Leaderboard' : 'Hole'}
</button>
))}
</div>
{/* Main content */}
<div className="flex-1 overflow-hidden flex flex-col">
{view === 'hole' && activeHole && (
<HoleView
hole={activeHole}
holeIndex={activeHoleIdx}
totalHoles={holes.length}
players={players}
scores={scores}
runningTotals={runningTotals}
isActive={isActive}
onPrev={() => setActiveHoleIdx((i) => Math.max(0, i - 1))}
onNext={() => setActiveHoleIdx((i) => Math.min(holes.length - 1, i + 1))}
onTapScore={(player) => openEntry(player)}
/>
)}
{view === 'grid' && (
<div className="overflow-auto flex-1">
<FullGrid
holes={holes}
players={players}
scores={scores}
isActive={isActive}
onTapScore={(player, hole) => openEntry(player, hole)}
/>
</div>
)}
{view === 'leaderboard' && (
<div className="overflow-y-auto flex-1">
<LeaderboardView holes={holes} players={players} scores={scores} />
</div>
)}
</div>
{/* Score entry sheet */}
<ScoreEntrySheet
open={entry !== null}
hole={entry?.hole ?? null}
player={entry?.player ?? null}
currentStrokes={
entry ? (scores[`${entry.player.userId}-${entry.hole.id}`] ?? null) : null
}
onSave={handleSave}
onClose={() => setEntry(null)}
/>
</div>
)
}