221 lines
6.8 KiB
TypeScript
221 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import { createClient } from '@/lib/supabase/client'
|
|
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.player_id}-${r.hole_id}`, r.strokes]))
|
|
}
|
|
|
|
export function ScorecardView({
|
|
round,
|
|
holes,
|
|
players,
|
|
initialScores,
|
|
currentUserId,
|
|
}: {
|
|
round: RoundInfo
|
|
holes: Hole[]
|
|
players: Player[]
|
|
initialScores: ScoreRow[]
|
|
currentUserId: string
|
|
}) {
|
|
const router = useRouter()
|
|
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]
|
|
|
|
// ── Realtime subscription ──────────────────────────────────
|
|
useEffect(() => {
|
|
const supabase = createClient()
|
|
const channel = supabase
|
|
.channel(`scores:${round.id}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{
|
|
event: '*',
|
|
schema: 'public',
|
|
table: 'scores',
|
|
filter: `round_id=eq.${round.id}`,
|
|
},
|
|
(payload) => {
|
|
if (payload.eventType === 'DELETE') return
|
|
const row = payload.new as ScoreRow
|
|
setScores((prev) => ({
|
|
...prev,
|
|
[`${row.player_id}-${row.hole_id}`]: row.strokes,
|
|
}))
|
|
},
|
|
)
|
|
.subscribe()
|
|
|
|
return () => {
|
|
supabase.removeChannel(channel)
|
|
}
|
|
}, [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.course_handicap ?? 0
|
|
for (const hole of holes) {
|
|
const g = scores[`${player.user_id}-${hole.id}`]
|
|
if (g !== undefined) {
|
|
gross += g
|
|
net += g - strokesReceived(ch, hole.stroke_index, holes.length)
|
|
played++
|
|
}
|
|
}
|
|
totals[player.user_id] = { 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.user_id}-${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.user_id, 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.holes_count} 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.user_id}-${entry.hole.id}`] ?? null) : null
|
|
}
|
|
onSave={handleSave}
|
|
onClose={() => setEntry(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|