Initial commit v1

This commit is contained in:
Rolf
2026-03-18 12:37:17 +01:00
commit 4897d3003f
55 changed files with 14241 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
'use client'
import { strokesReceived, scoreColorClass, 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>
onTapScore: (player: Player, hole: Hole) => void
isActive: boolean
}
export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) {
const totalHoles = holes.length
const front = holes.filter((h) => h.hole_number <= 9)
const back = holes.filter((h) => h.hole_number > 9)
const hasBoth = front.length > 0 && back.length > 0
function getGross(playerId: string, hole: Hole) {
return scores[`${playerId}-${hole.id}`] ?? null
}
function getNet(playerId: string, hole: Hole) {
const gross = getGross(playerId, hole)
if (gross === null) return null
const ch = players.find((p) => p.user_id === playerId)?.course_handicap ?? 0
return gross - strokesReceived(ch, hole.stroke_index, totalHoles)
}
function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) {
const values = holeSet.map((h) => fn(playerId, h)).filter((v): v is number => v !== null)
return values.length === holeSet.length ? values.reduce((a, b) => a + b, 0) : null
}
const parFront = front.reduce((s, h) => s + h.par, 0)
const parBack = back.reduce((s, h) => s + h.par, 0)
const parTotal = parFront + parBack
return (
<div className="overflow-x-auto">
<table className="min-w-full text-xs border-collapse">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="sticky left-0 z-10 bg-muted/50 px-3 py-2 text-left font-medium min-w-[100px]">
Player
</th>
{front.map((h) => (
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
{h.hole_number}
</th>
))}
{hasBoth && (
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">Out</th>
)}
{back.map((h) => (
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
{h.hole_number}
</th>
))}
{hasBoth && (
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">In</th>
)}
<th className="px-2 py-2 text-center font-semibold w-12 bg-muted">Tot</th>
<th className="px-2 py-2 text-center font-bold w-12 bg-muted">Net</th>
</tr>
{/* Par row */}
<tr className="bg-muted/20 text-muted-foreground border-b">
<td className="sticky left-0 z-10 bg-muted/20 px-3 py-1 text-xs italic">Par</td>
{front.map((h) => (
<td key={h.id} className="px-2 py-1 text-center">
{h.par}
</td>
))}
{hasBoth && (
<td className="px-2 py-1 text-center font-medium bg-muted">{parFront}</td>
)}
{back.map((h) => (
<td key={h.id} className="px-2 py-1 text-center">
{h.par}
</td>
))}
{hasBoth && (
<td className="px-2 py-1 text-center font-medium bg-muted">{parBack}</td>
)}
<td className="px-2 py-1 text-center font-medium bg-muted">{parTotal}</td>
<td className="px-2 py-1 text-center bg-muted" />
</tr>
</thead>
<tbody className="divide-y">
{players.map((player, pi) => {
const frontOut = subtotal(player.user_id, front, getGross)
const backIn = subtotal(player.user_id, back, getGross)
const total =
frontOut !== null && backIn !== null
? frontOut + backIn
: hasBoth
? null
: subtotal(player.user_id, holes, getGross)
const netTotal = subtotal(player.user_id, holes, getNet)
const netVsPar = netTotal !== null ? netTotal - parTotal : null
return (
<tr key={player.user_id} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
<td className="sticky left-0 z-10 bg-background px-3 py-2 font-medium truncate max-w-[100px]">
{player.profile?.display_name}
</td>
{front.map((h) => {
const gross = getGross(player.user_id, h)
const grossVsPar = gross !== null ? gross - h.par : null
return (
<td
key={h.id}
className="px-1 py-1.5 text-center"
onClick={() => isActive && onTapScore(player, h)}
>
<span
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
}`}
>
{gross ?? '·'}
</span>
</td>
)
})}
{hasBoth && (
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
{frontOut ?? '—'}
</td>
)}
{back.map((h) => {
const gross = getGross(player.user_id, h)
const grossVsPar = gross !== null ? gross - h.par : null
return (
<td
key={h.id}
className="px-1 py-1.5 text-center"
onClick={() => isActive && onTapScore(player, h)}
>
<span
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
}`}
>
{gross ?? '·'}
</span>
</td>
)
})}
{hasBoth && (
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
{backIn ?? '—'}
</td>
)}
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
{total ?? '—'}
</td>
<td className="px-2 py-1.5 text-center font-bold bg-muted/30">
{netVsPar !== null ? formatVsPar(netVsPar) : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
'use client'
import { useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { strokesReceived, formatVsPar, scoreColorClass } from '@/lib/handicap'
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
type Props = {
hole: Hole
holeIndex: number
totalHoles: number
players: Player[]
scores: Record<string, number>
runningTotals: Record<string, { net: number; gross: number; holesPlayed: number }>
isActive: boolean
onPrev: () => void
onNext: () => void
onTapScore: (player: Player) => void
}
export function HoleView({
hole,
holeIndex,
totalHoles,
players,
scores,
runningTotals,
isActive,
onPrev,
onNext,
onTapScore,
}: Props) {
const touchStartX = useRef<number>(0)
function handleTouchStart(e: React.TouchEvent) {
touchStartX.current = e.touches[0].clientX
}
function handleTouchEnd(e: React.TouchEvent) {
const diff = touchStartX.current - e.changedTouches[0].clientX
if (diff > 50) onNext()
if (diff < -50) onPrev()
}
return (
<div
className="flex flex-col flex-1"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Hole header */}
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-3">
<button
onClick={onPrev}
disabled={holeIndex === 0}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-muted disabled:opacity-30"
>
<ChevronLeft className="h-5 w-5" />
</button>
<div className="text-center">
<div className="text-lg font-bold">Hole {hole.hole_number}</div>
<div className="text-sm text-muted-foreground">
Par {hole.par} · SI {hole.stroke_index}
</div>
</div>
<button
onClick={onNext}
disabled={holeIndex === totalHoles - 1}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-muted disabled:opacity-30"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
{/* Players */}
<div className="flex-1 divide-y overflow-y-auto">
{players.map((player) => {
const gross = scores[`${player.user_id}-${hole.id}`] ?? null
const ch = player.course_handicap ?? 0
const received = strokesReceived(ch, hole.stroke_index, totalHoles)
const net = gross !== null ? gross - received : null
const grossVsPar = gross !== null ? gross - hole.par : null
const netVsPar = net !== null ? net - hole.par : null
return (
<button
key={player.user_id}
onClick={() => isActive && onTapScore(player)}
disabled={!isActive}
className="flex w-full items-center gap-3 px-4 py-4 text-left transition-colors hover:bg-muted/50 active:bg-muted disabled:cursor-default"
>
{/* Avatar */}
<PlayerAvatar
name={player.profile?.display_name ?? '?'}
url={player.profile?.avatar_url}
/>
{/* Name + running total */}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{player.profile?.display_name}
</div>
<div className="text-xs text-muted-foreground">
{runningTotals[player.user_id]
? `${runningTotals[player.user_id].holesPlayed} holes · ${formatVsPar(runningTotals[player.user_id].net)} net`
: 'No scores yet'}
</div>
</div>
{/* Net score (primary, bold) */}
<div className="flex flex-col items-end gap-0.5">
{net !== null ? (
<>
<span
className={`flex h-9 w-9 items-center justify-center text-base font-bold ${scoreColorClass(netVsPar)}`}
>
{net}
</span>
<span className="text-xs text-muted-foreground">
{gross} gross
</span>
</>
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground text-sm">
</span>
)}
</div>
</button>
)
})}
</div>
{/* Hole progress dots */}
<div className="flex justify-center gap-1 border-t py-3">
{Array.from({ length: totalHoles }, (_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${
i === holeIndex
? 'w-4 bg-primary'
: 'w-1.5 bg-muted-foreground/30'
}`}
/>
))}
</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>
)
}
+135
View File
@@ -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>
)
}
+131
View File
@@ -0,0 +1,131 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
type Props = {
open: boolean
hole: Hole | null
player: Player | null
currentStrokes: number | null
onSave: (strokes: number) => void
onClose: () => void
}
export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, onClose }: Props) {
const [strokes, setStrokes] = useState<number>(currentStrokes ?? hole?.par ?? 4)
const [editingNumber, setEditingNumber] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Reset when a new entry opens
useEffect(() => {
if (open) {
setStrokes(currentStrokes ?? hole?.par ?? 4)
setEditingNumber(false)
}
}, [open, hole?.id, player?.user_id])
useEffect(() => {
if (editingNumber) inputRef.current?.select()
}, [editingNumber])
// Lock body scroll when open
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
if (!hole || !player) return null
const par = hole.par
const vsPar = strokes - par
return (
<>
{/* Backdrop */}
<div
className={`fixed inset-0 z-40 bg-black/50 transition-opacity duration-200 ${
open ? 'opacity-100' : 'pointer-events-none opacity-0'
}`}
onClick={onClose}
/>
{/* Sheet */}
<div
className={`fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background pb-safe transition-transform duration-300 ${
open ? 'translate-y-0' : 'translate-y-full'
}`}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-1">
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
</div>
{/* Player + hole info */}
<div className="flex items-center justify-between px-6 py-3 border-b">
<div>
<div className="font-semibold">{player.profile?.display_name}</div>
<div className="text-sm text-muted-foreground">
Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index}
</div>
</div>
<div
className={`text-sm font-bold ${
vsPar < 0 ? 'text-red-500' : vsPar > 0 ? 'text-muted-foreground' : 'text-foreground'
}`}
>
{vsPar === 0 ? 'Par' : vsPar > 0 ? `+${vsPar}` : vsPar}
</div>
</div>
{/* Score input */}
<div className="flex items-center justify-center gap-8 py-8">
{/* Minus */}
<button
onClick={() => setStrokes(Math.max(1, strokes - 1))}
className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-border text-2xl font-light hover:bg-muted active:scale-95"
>
</button>
{/* Score display / input */}
{editingNumber ? (
<input
ref={inputRef}
type="number"
value={strokes}
min={1}
max={20}
onChange={(e) => setStrokes(Math.max(1, parseInt(e.target.value) || 1))}
onBlur={() => setEditingNumber(false)}
className="h-20 w-20 rounded-xl border-2 border-primary bg-background text-center text-4xl font-bold outline-none"
/>
) : (
<button
onClick={() => setEditingNumber(true)}
className="flex h-20 w-20 items-center justify-center rounded-xl border-2 border-transparent text-5xl font-bold transition-colors hover:border-primary"
>
{strokes}
</button>
)}
{/* Plus */}
<button
onClick={() => setStrokes(Math.min(20, strokes + 1))}
className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-border text-2xl font-light hover:bg-muted active:scale-95"
>
+
</button>
</div>
{/* Save */}
<div className="px-6 pb-8">
<Button className="w-full text-base h-12" onClick={() => onSave(strokes)}>
Save
</Button>
</div>
</div>
</>
)
}
+220
View File
@@ -0,0 +1,220 @@
'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>
)
}