Initial commit v1.1
This commit is contained in:
@@ -2,20 +2,21 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { joinRound } from '@/app/actions/rounds'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type Round = {
|
||||
id: string
|
||||
name: string | null
|
||||
date: string
|
||||
holes_count: number
|
||||
status: string
|
||||
course: { name: string } | null
|
||||
tee: { name: string; color: string } | null
|
||||
type Props = {
|
||||
roundId: string
|
||||
round: {
|
||||
name: string | null
|
||||
date: string
|
||||
holesCount: number
|
||||
course: { name: string }
|
||||
tee: { name: string; color: string }
|
||||
}
|
||||
}
|
||||
|
||||
export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
||||
export function JoinRound({ roundId, round }: Props) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -24,69 +25,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = supabase as any
|
||||
const result = await joinRound(roundId)
|
||||
|
||||
// Get current user's handicap for snapshot
|
||||
const { data: profile } = await db
|
||||
.from('profiles')
|
||||
.select('handicap_index')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
// Get tee data for course handicap
|
||||
const { data: tee } = await db
|
||||
.from('tees')
|
||||
.select('course_rating, slope_rating')
|
||||
.eq('id', round.id) // note: round.tee.id would be better — using server action is cleaner
|
||||
.single()
|
||||
|
||||
const { data: roundData } = await db
|
||||
.from('rounds')
|
||||
.select('tee_id, course:courses(par)')
|
||||
.eq('id', round.id)
|
||||
.single()
|
||||
|
||||
const { data: teeData } = roundData?.tee_id
|
||||
? await db
|
||||
.from('tees')
|
||||
.select('course_rating, slope_rating')
|
||||
.eq('id', roundData.tee_id)
|
||||
.single()
|
||||
: { data: null }
|
||||
|
||||
const handicapIndex = profile?.handicap_index ?? null
|
||||
const courseHandicap =
|
||||
handicapIndex !== null && teeData && roundData?.course
|
||||
? Math.round(
|
||||
handicapIndex * (teeData.slope_rating / 113) +
|
||||
(teeData.course_rating - roundData.course.par),
|
||||
)
|
||||
: null
|
||||
|
||||
const { error: joinError } = await db.from('round_players').insert({
|
||||
round_id: round.id,
|
||||
user_id: userId,
|
||||
role: 'player',
|
||||
handicap_index: handicapIndex,
|
||||
course_handicap: courseHandicap,
|
||||
})
|
||||
|
||||
if (joinError) {
|
||||
setError(joinError.message)
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark invite as accepted
|
||||
await db
|
||||
.from('round_invites')
|
||||
.update({ accepted_at: new Date().toISOString() })
|
||||
.eq('round_id', round.id)
|
||||
.eq('email', (await supabase.auth.getUser()).data.user?.email)
|
||||
|
||||
router.push(`/rounds/${round.id}`)
|
||||
router.push(`/rounds/${roundId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,15 +45,15 @@ export function JoinRound({ round, userId }: { round: Round; userId: string }) {
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-4 space-y-2">
|
||||
<div className="font-semibold">{round.course?.name}</div>
|
||||
<div className="font-semibold">{round.course.name}</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{round.name && <div>{round.name}</div>}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full border"
|
||||
style={{ backgroundColor: round.tee?.color }}
|
||||
style={{ backgroundColor: round.tee.color }}
|
||||
/>
|
||||
{round.tee?.name} tees · {round.holes_count} holes
|
||||
{round.tee.name} tees · {round.holesCount} holes
|
||||
</div>
|
||||
<div>{new Date(round.date).toLocaleDateString()}</div>
|
||||
</div>
|
||||
|
||||
@@ -12,16 +12,16 @@ import Link from 'next/link'
|
||||
type Player = {
|
||||
id: string
|
||||
role: string
|
||||
handicap_index: number | null
|
||||
course_handicap: number | null
|
||||
profile: { id: string; display_name: string; avatar_url: string | null } | null
|
||||
handicapIndex: string | null
|
||||
courseHandicap: number | null
|
||||
profile: { id: string; displayName: string; avatarUrl: string | null } | null
|
||||
}
|
||||
|
||||
type Invite = {
|
||||
id: string
|
||||
email: string
|
||||
accepted_at: string | null
|
||||
created_at: string
|
||||
acceptedAt: Date | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
type Round = {
|
||||
@@ -29,10 +29,10 @@ type Round = {
|
||||
name: string | null
|
||||
date: string
|
||||
status: string
|
||||
holes_count: number
|
||||
created_by: string
|
||||
course: { id: string; name: string; par: number } | null
|
||||
tee: { id: string; name: string; color: string; course_rating: number; slope_rating: number } | null
|
||||
holesCount: number
|
||||
createdBy: string
|
||||
course: { name: string } | null
|
||||
tee: { name: string; color: string } | null
|
||||
}
|
||||
|
||||
export function RoundLobby({
|
||||
@@ -95,7 +95,7 @@ export function RoundLobby({
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.course?.name} · {round.tee?.name} tees ·{' '}
|
||||
{round.holes_count} holes ·{' '}
|
||||
{round.holesCount} holes ·{' '}
|
||||
{new Date(round.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -114,13 +114,13 @@ export function RoundLobby({
|
||||
{players.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-3 rounded-xl border p-3">
|
||||
<Avatar
|
||||
name={p.profile?.display_name ?? '?'}
|
||||
url={p.profile?.avatar_url}
|
||||
name={p.profile?.displayName ?? '?'}
|
||||
url={p.profile?.avatarUrl}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">
|
||||
{p.profile?.display_name}
|
||||
{p.profile?.displayName}
|
||||
</span>
|
||||
{p.profile?.id === currentUserId && (
|
||||
<span className="text-xs text-muted-foreground">(you)</span>
|
||||
@@ -132,8 +132,8 @@ export function RoundLobby({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{p.handicap_index !== null
|
||||
? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}`
|
||||
{p.handicapIndex !== null
|
||||
? `HCP ${p.handicapIndex} · Course HCP ${p.courseHandicap}`
|
||||
: 'No handicap'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user