Initial commit v1.1

This commit is contained in:
Rolf
2026-03-18 13:34:41 +01:00
parent cb04bf2ab8
commit b982584244
38 changed files with 3196 additions and 1103 deletions
+14 -14
View File
@@ -13,8 +13,8 @@ type Props = {
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 front = holes.filter((h) => h.holeNumber <= 9)
const back = holes.filter((h) => h.holeNumber > 9)
const hasBoth = front.length > 0 && back.length > 0
function getGross(playerId: string, hole: Hole) {
@@ -24,8 +24,8 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
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)
const ch = players.find((p) => p.userId === playerId)?.courseHandicap ?? 0
return gross - strokesReceived(ch, hole.strokeIndex, totalHoles)
}
function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) {
@@ -47,7 +47,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
</th>
{front.map((h) => (
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
{h.hole_number}
{h.holeNumber}
</th>
))}
{hasBoth && (
@@ -55,7 +55,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
)}
{back.map((h) => (
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
{h.hole_number}
{h.holeNumber}
</th>
))}
{hasBoth && (
@@ -89,25 +89,25 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
</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 frontOut = subtotal(player.userId, front, getGross)
const backIn = subtotal(player.userId, 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)
: subtotal(player.userId, holes, getGross)
const netTotal = subtotal(player.userId, holes, getNet)
const netVsPar = netTotal !== null ? netTotal - parTotal : null
return (
<tr key={player.user_id} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
<tr key={player.userId} 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}
{player.profile?.displayName}
</td>
{front.map((h) => {
const gross = getGross(player.user_id, h)
const gross = getGross(player.userId, h)
const grossVsPar = gross !== null ? gross - h.par : null
return (
<td
@@ -133,7 +133,7 @@ export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props
)}
{back.map((h) => {
const gross = getGross(player.user_id, h)
const gross = getGross(player.userId, h)
const grossVsPar = gross !== null ? gross - h.par : null
return (
<td
+11 -11
View File
@@ -59,9 +59,9 @@ export function HoleView({
</button>
<div className="text-center">
<div className="text-lg font-bold">Hole {hole.hole_number}</div>
<div className="text-lg font-bold">Hole {hole.holeNumber}</div>
<div className="text-sm text-muted-foreground">
Par {hole.par} · SI {hole.stroke_index}
Par {hole.par} · SI {hole.strokeIndex}
</div>
</div>
@@ -77,34 +77,34 @@ export function HoleView({
{/* 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 gross = scores[`${player.userId}-${hole.id}`] ?? null
const ch = player.courseHandicap ?? 0
const received = strokesReceived(ch, hole.strokeIndex, 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}
key={player.userId}
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={player.profile?.displayName ?? '?'}
url={player.profile?.avatarUrl}
/>
{/* Name + running total */}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{player.profile?.display_name}
{player.profile?.displayName}
</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`
{runningTotals[player.userId]
? `${runningTotals[player.userId].holesPlayed} holes · ${formatVsPar(runningTotals[player.userId].net)} net`
: 'No scores yet'}
</div>
</div>
+9 -9
View File
@@ -20,10 +20,10 @@ export function LeaderboardView({ holes, players, scores }: Props) {
let parPlayed = 0
for (const hole of holes) {
const gross = scores[`${player.user_id}-${hole.id}`]
const gross = scores[`${player.userId}-${hole.id}`]
if (gross !== undefined) {
const ch = player.course_handicap ?? 0
const net = gross - strokesReceived(ch, hole.stroke_index, totalHoles)
const ch = player.courseHandicap ?? 0
const net = gross - strokesReceived(ch, hole.strokeIndex, totalHoles)
grossTotal += gross
netTotal += net
parPlayed += hole.par
@@ -57,7 +57,7 @@ export function LeaderboardView({ holes, players, scores }: Props) {
return (
<div
key={player.user_id}
key={player.userId}
className={`flex items-center gap-4 px-4 py-4 ${isLead ? 'bg-primary/5' : ''}`}
>
{/* Position */}
@@ -71,21 +71,21 @@ export function LeaderboardView({ holes, players, scores }: Props) {
{/* Avatar */}
<PlayerAvatar
name={player.profile?.display_name ?? '?'}
url={player.profile?.avatar_url}
name={player.profile?.displayName ?? '?'}
url={player.profile?.avatarUrl}
/>
{/* Name + holes played */}
<div className="flex-1 min-w-0">
<div className="font-semibold truncate">
{player.profile?.display_name}
{player.profile?.displayName}
</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}`}
{player.courseHandicap !== null &&
` · HCP ${player.courseHandicap}`}
</div>
</div>
+3 -3
View File
@@ -24,7 +24,7 @@ export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, on
setStrokes(currentStrokes ?? hole?.par ?? 4)
setEditingNumber(false)
}
}, [open, hole?.id, player?.user_id])
}, [open, hole?.id, player?.userId])
useEffect(() => {
if (editingNumber) inputRef.current?.select()
@@ -65,9 +65,9 @@ export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, on
{/* 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="font-semibold">{player.profile?.displayName}</div>
<div className="text-sm text-muted-foreground">
Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index}
Hole {hole.holeNumber} · Par {par} · SI {hole.strokeIndex}
</div>
</div>
<div
+20 -36
View File
@@ -1,9 +1,7 @@
'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'
@@ -21,7 +19,7 @@ type EntryState = {
}
function buildScoreMap(rows: ScoreRow[]): Record<string, number> {
return Object.fromEntries(rows.map((r) => [`${r.player_id}-${r.hole_id}`, r.strokes]))
return Object.fromEntries(rows.map((r) => [`${r.playerId}-${r.holeId}`, r.strokes]))
}
export function ScorecardView({
@@ -37,7 +35,6 @@ export function ScorecardView({
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')
@@ -47,33 +44,20 @@ export function ScorecardView({
const isActive = round.status === 'active'
const activeHole = holes[activeHoleIdx]
// ── Realtime subscription ──────────────────────────────────
// ── SSE real-time 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()
const es = new EventSource(`/api/rounds/${round.id}/stream`)
return () => {
supabase.removeChannel(channel)
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 ──────────────────────────────
@@ -83,16 +67,16 @@ export function ScorecardView({
let net = 0
let gross = 0
let played = 0
const ch = player.course_handicap ?? 0
const ch = player.courseHandicap ?? 0
for (const hole of holes) {
const g = scores[`${player.user_id}-${hole.id}`]
const g = scores[`${player.userId}-${hole.id}`]
if (g !== undefined) {
gross += g
net += g - strokesReceived(ch, hole.stroke_index, holes.length)
net += g - strokesReceived(ch, hole.strokeIndex, holes.length)
played++
}
}
totals[player.user_id] = { net, gross, holesPlayed: played }
totals[player.userId] = { net, gross, holesPlayed: played }
}
return totals
}, [scores, players, holes])
@@ -102,14 +86,14 @@ export function ScorecardView({
if (!entry) return
setSaving(true)
const key = `${entry.player.user_id}-${entry.hole.id}`
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.user_id, strokes)
const result = await upsertScore(round.id, entry.hole.id, entry.player.userId, strokes)
if (result.error) {
// Revert
@@ -140,7 +124,7 @@ export function ScorecardView({
{round.name ?? round.course?.name ?? 'Scorecard'}
</div>
<div className="text-xs text-muted-foreground">
{round.tee?.name} tees · {round.holes_count} holes
{round.tee?.name} tees · {round.holesCount} holes
{!isActive && (
<span className="ml-1 text-muted-foreground">(completed)</span>
)}
@@ -210,7 +194,7 @@ export function ScorecardView({
hole={entry?.hole ?? null}
player={entry?.player ?? null}
currentStrokes={
entry ? (scores[`${entry.player.user_id}-${entry.hole.id}`] ?? null) : null
entry ? (scores[`${entry.player.userId}-${entry.hole.id}`] ?? null) : null
}
onSave={handleSave}
onClose={() => setEntry(null)}