commit 4897d3003fcb5d18ce3fe173d77bad2063c4af65 Author: Rolf Date: Wed Mar 18 12:37:17 2026 +0100 Initial commit v1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6d8f857 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Is + +MulliganMates is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Next.js + Supabase app running in Docker Compose. + +## Commands + +All commands run inside Docker (Node.js is not installed on the host): + +```bash +# Development (with Studio + Mailpit) +docker compose --profile dev up + +# Production +docker compose up -d + +# Install a new npm package +docker run --rm -v $(pwd):/app -w /app node:22-alpine npm install + +# Add a shadcn/ui component +docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add + +# Type-check +docker run --rm -v $(pwd):/app -w /app node:22-alpine npm run build +``` + +## Architecture + +**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui · Supabase (self-hosted) · TanStack Query v5 · Zustand · Serwist (PWA) + +**Routing groups:** +- `app/(auth)/` — unauthenticated pages (login, confirm) +- `app/(app)/` — authenticated app shell with bottom navigation + +**Supabase clients:** +- `lib/supabase/client.ts` — browser client (use in `'use client'` components) +- `lib/supabase/server.ts` — server client + admin client (Server Components, Server Actions, Route Handlers) +- Always use `supabase.auth.getUser()` server-side, never `getSession()` — getUser() cryptographically verifies the JWT + +**Auth flow:** +- Invite-only. New users are invited via `supabase.auth.admin.inviteUserByEmail()` (uses service role key). +- Magic link → `/auth/confirm` route handler exchanges the PKCE code for a session. +- `middleware.ts` guards all `/(app)` routes; redirects unauthenticated users to `/login`. +- Admin email is set via `ADMIN_EMAIL` env var; `lib/admin.ts` exposes `isAdminEmail()`. + +**Real-time scores:** +- Supabase Postgres Changes subscriptions on the `scores` table, filtered by `round_id`. +- TanStack Query cache is invalidated on change events. +- Score entry uses UPSERT — `unique(round_id, hole_id, player_id)` constraint. + +**Round lifecycle:** `lobby → active → completed` +- Lobby: players can be invited and join; no scoring yet. +- Active: started by round creator; scoring enabled; no new players. +- Completed: closed by round creator; read-only. + +## Database + +Migrations live in `supabase/migrations/` and are applied on DB container startup via `/docker-entrypoint-initdb.d/`. + +- `00001_schema.sql` — tables, triggers (auto-create profile, enforce course par sum, update scores.updated_at) +- `00002_rls.sql` — Row Level Security policies for all tables + +**Key RLS rules:** +- Scores can be inserted/updated by any round member, but only when `round.status = 'active'` +- `is_round_member(round_id)` helper function used throughout RLS policies +- `service_role` key bypasses RLS — only use server-side, never expose to client + +**Course par validation:** A DB trigger fires on `holes` INSERT/UPDATE; once 9 or 18 holes exist for a course, it asserts `SUM(holes.par) = courses.par`. + +**Handicap calculation (WHS):** +`course_handicap = handicap_index × (slope_rating ÷ 113) + (course_rating − par)` +Stored as a snapshot on `round_players` at join time. + +## Infrastructure + +- **Docker Compose** — all services defined in `docker-compose.yml` +- **Traefik** — external reverse proxy; services expose themselves via labels only (no port mappings) +- `studio` and `mailpit` services are in the `dev` profile — not started in production +- **Email dev:** Mailpit catches all outgoing email (`SMTP_HOST=mailpit`) +- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` to Resend/Mailgun/etc. in `.env` +- Copy `.env.example` → `.env` and fill all values before first run + +## Key Conventions + +- Net score vs par (WHS handicap-adjusted) is always the **primary** display, in bold +- Raw strokes vs course par is secondary +- Score entry defaults to hole par; +/− buttons; tap number for picker +- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user only) +- Mobile-first: minimum 44×44px tap targets, swipe between holes, high-contrast for sunlight diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa72b4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:22-alpine AS base + +# ── Dependencies ────────────────────────────────────────────── +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Builder ─────────────────────────────────────────────────── +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Disable Next.js telemetry +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ── Runner ──────────────────────────────────────────────────── +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/(app)/courses/new/page.tsx b/app/(app)/courses/new/page.tsx new file mode 100644 index 0000000..53a3700 --- /dev/null +++ b/app/(app)/courses/new/page.tsx @@ -0,0 +1,366 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { createCourse } from '@/app/actions/courses' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ChevronLeft } from 'lucide-react' + +const TEE_COLORS = [ + { label: 'Yellow', value: '#EAB308' }, + { label: 'Red', value: '#DC2626' }, + { label: 'White', value: '#F9FAFB' }, + { label: 'Blue', value: '#2563EB' }, + { label: 'Black', value: '#111827' }, + { label: 'Green', value: '#16A34A' }, + { label: 'Gold', value: '#D97706' }, +] + +type Tee = { name: string; color: string; course_rating: string; slope_rating: string } +type Hole = { hole_number: number; par: number; stroke_index: number } + +function defaultHoles(count: number): Hole[] { + return Array.from({ length: count }, (_, i) => ({ + hole_number: i + 1, + par: 4, + stroke_index: i + 1, + })) +} + +export default function NewCoursePage() { + const router = useRouter() + const searchParams = useSearchParams() + const returnTo = searchParams.get('returnTo') ?? '/rounds/new' + + const [step, setStep] = useState(1) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + // Step 1 + const [name, setName] = useState('') + const [par, setPar] = useState('72') + const [holesCount, setHolesCount] = useState<9 | 18>(18) + + // Step 2 + const [tees, setTees] = useState([]) + const [teeForm, setTeeForm] = useState({ + name: '', + color: '#2563EB', + course_rating: '', + slope_rating: '', + }) + + // Step 3 + const [holes, setHoles] = useState(defaultHoles(18)) + + const parSum = holes.slice(0, holesCount).reduce((s, h) => s + h.par, 0) + const parTarget = parseInt(par) || 0 + const parValid = parSum === parTarget + + function addTee() { + if (!teeForm.name || !teeForm.course_rating || !teeForm.slope_rating) return + setTees([...tees, teeForm]) + setTeeForm({ name: '', color: '#2563EB', course_rating: '', slope_rating: '' }) + } + + function removeTee(i: number) { + setTees(tees.filter((_, idx) => idx !== i)) + } + + function updateHole(index: number, field: 'par' | 'stroke_index', value: number) { + setHoles(holes.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + } + + async function handleSubmit() { + setLoading(true) + setError(null) + + const result = await createCourse({ + name, + par: parseInt(par), + tees: tees.map((t) => ({ + name: t.name, + color: t.color, + course_rating: parseFloat(t.course_rating), + slope_rating: parseInt(t.slope_rating), + })), + holes: holes.slice(0, holesCount).map((h) => ({ + hole_number: h.hole_number, + par: h.par, + stroke_index: h.stroke_index, + })), + }) + + if (result.error) { + setError(result.error) + setLoading(false) + return + } + + router.push(`${returnTo}?course_id=${result.courseId}`) + } + + const steps = ['Course', 'Tees', 'Holes'] + + return ( +
+ {/* Header */} +
+ +

New course

+
+ {steps.map((s, i) => ( +
+ ))} +
+
+ +
+ {/* Step 1: Course basics */} + {step === 1 && ( +
+

Course details

+ +
+ + setName(e.target.value)} + placeholder="e.g. Augusta National" + autoFocus + /> +
+ +
+ +
+ {([9, 18] as const).map((n) => ( + + ))} +
+
+ +
+ + setPar(e.target.value)} + min={27} + max={90} + /> +
+ + +
+ )} + + {/* Step 2: Tees */} + {step === 2 && ( +
+

Tees

+ + {/* Existing tees */} + {tees.length > 0 && ( +
+ {tees.map((t, i) => ( +
+
+
+
+
{t.name}
+
+ Rating {t.course_rating} · Slope {t.slope_rating} +
+
+
+ +
+ ))} +
+ )} + + {/* Add tee form */} +
+

Add tee

+ +
+ +
+ {TEE_COLORS.map((c) => ( +
+
+ +
+
+ + setTeeForm({ ...teeForm, name: e.target.value })} + placeholder="e.g. White" + /> +
+
+ + setTeeForm({ ...teeForm, course_rating: e.target.value })} + placeholder="71.5" + step="0.1" + /> +
+
+ + setTeeForm({ ...teeForm, slope_rating: e.target.value })} + placeholder="125" + /> +
+
+ + +
+ + +
+ )} + + {/* Step 3: Holes */} + {step === 3 && ( +
+
+

Holes

+ + Par sum: {parSum} / {parTarget} + +
+ +
+ + + + + + + + + + {holes.slice(0, holesCount).map((hole, i) => ( + + + + + + ))} + +
HoleParSI
{hole.hole_number} +
+ {[3, 4, 5].map((p) => ( + + ))} +
+
+ updateHole(i, 'stroke_index', parseInt(e.target.value) || 1)} + min={1} + max={holesCount} + className="h-8 w-14 rounded border border-border bg-background px-2 text-sm" + /> +
+
+ + {error &&

{error}

} + + +
+ )} +
+
+ ) +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..2b3dad1 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -0,0 +1,98 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export default async function DashboardPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect('/login') + + type RoundRow = { + round: { + id: string + name: string | null + date: string + status: string + holes_count: number + course: { name: string } | null + tee: { name: string; color: string } | null + } | null + } + + // Fetch rounds the user is participating in + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: rounds } = (await (supabase as any) + .from('round_players') + .select( + ` + round:rounds ( + id, name, date, status, holes_count, + course:courses (name), + tee:tees (name, color) + ) + `, + ) + .eq('user_id', user.id) + .in('round.status', ['lobby', 'active']) + .order('round(date)', { ascending: false })) as { data: RoundRow[] | null } + + return ( +
+
+

Rounds

+ + New round + +
+ + {!rounds?.length && ( +

+ No active rounds. Start one! +

+ )} + +
+ {rounds?.map(({ round }) => { + if (!round) return null + return ( + +
+ {round.course?.name} + +
+
+ {round.name && {round.name} · } + {round.tee?.name} tees · {round.holes_count} holes ·{' '} + {new Date(round.date).toLocaleDateString()} +
+ + ) + })} +
+
+ ) +} + +function StatusBadge({ status }: { status: string }) { + const styles = { + lobby: 'bg-yellow-100 text-yellow-800', + active: 'bg-green-100 text-green-800', + completed: 'bg-gray-100 text-gray-600', + } + return ( + + {status} + + ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..58f4086 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { BottomNav } from '@/components/bottom-nav' + +export default async function AppLayout({ + children, +}: { + children: React.ReactNode +}) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect('/login') + + return ( +
+
{children}
+ +
+ ) +} diff --git a/app/(app)/rounds/[id]/join/page.tsx b/app/(app)/rounds/[id]/join/page.tsx new file mode 100644 index 0000000..7d2878e --- /dev/null +++ b/app/(app)/rounds/[id]/join/page.tsx @@ -0,0 +1,43 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect, notFound } from 'next/navigation' +import { JoinRound } from '@/components/round/join-round' + +export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect(`/login`) + + const { data: round } = await db + .from('rounds') + .select( + ` + id, name, date, holes_count, status, created_by, + course:courses (name), + tee:tees (name, color), + round_players (user_id) + `, + ) + .eq('id', id) + .single() + + if (!round) notFound() + + // Already a member — go straight to the round + const alreadyJoined = round.round_players?.some( + (p: { user_id: string }) => p.user_id === user.id, + ) + if (alreadyJoined) redirect(`/rounds/${id}`) + + // Round must be in lobby to join + if (round.status !== 'lobby') { + redirect(`/dashboard`) + } + + return +} diff --git a/app/(app)/rounds/[id]/page.tsx b/app/(app)/rounds/[id]/page.tsx new file mode 100644 index 0000000..6b34fd4 --- /dev/null +++ b/app/(app)/rounds/[id]/page.tsx @@ -0,0 +1,63 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect, notFound } from 'next/navigation' +import { RoundLobby } from '@/components/round/round-lobby' + +export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: round } = await db + .from('rounds') + .select( + ` + id, name, date, status, holes_count, created_by, + course:courses (id, name, par), + tee:tees (id, name, color, course_rating, slope_rating) + `, + ) + .eq('id', id) + .single() + + if (!round) notFound() + + const { data: players } = await db + .from('round_players') + .select( + ` + id, role, handicap_index, course_handicap, joined_at, + profile:profiles (id, display_name, avatar_url) + `, + ) + .eq('round_id', id) + .order('joined_at') + + const { data: invites } = await db + .from('round_invites') + .select('id, email, accepted_at, created_at') + .eq('round_id', id) + .is('accepted_at', null) + .order('created_at') + + const isCreator = round.created_by === user.id + + if (round.status === 'active' || round.status === 'completed') { + redirect(`/rounds/${id}/scorecard`) + } + + return ( + + ) +} diff --git a/app/(app)/rounds/[id]/scorecard/page.tsx b/app/(app)/rounds/[id]/scorecard/page.tsx new file mode 100644 index 0000000..7c98f6c --- /dev/null +++ b/app/(app)/rounds/[id]/scorecard/page.tsx @@ -0,0 +1,104 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect, notFound } from 'next/navigation' +import { ScorecardView } from '@/components/scorecard/scorecard-view' + +export type Hole = { + id: string + hole_number: number + par: number + stroke_index: number +} + +export type Player = { + user_id: string + course_handicap: number | null + handicap_index: number | null + profile: { id: string; display_name: string; avatar_url: string | null } | null +} + +export type ScoreRow = { + hole_id: string + player_id: string + strokes: number +} + +export type RoundInfo = { + id: string + name: string | null + date: string + status: string + holes_count: number + created_by: string + course: { name: string } | null + tee: { name: string; color: string } | null +} + +export default async function ScorecardPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: round } = await db + .from('rounds') + .select( + `id, name, date, status, holes_count, created_by, + course:courses (name), + tee:tees (name, color)`, + ) + .eq('id', id) + .single() + + if (!round) notFound() + if (round.status === 'lobby') redirect(`/rounds/${id}`) + + // Holes ordered by hole_number, limited to holes_count + const { data: holes } = await db + .from('holes') + .select('id, hole_number, par, stroke_index') + .eq('course_id', await getCourseId(db, id)) + .order('hole_number') + .limit(round.holes_count) + + const { data: players } = await db + .from('round_players') + .select( + `user_id, course_handicap, handicap_index, + profile:profiles (id, display_name, avatar_url)`, + ) + .eq('round_id', id) + .order('joined_at') + + const { data: scores } = await db + .from('scores') + .select('hole_id, player_id, strokes') + .eq('round_id', id) + + return ( + + ) +} + +async function getCourseId(db: any, roundId: string): Promise { + const { data } = await db + .from('rounds') + .select('course_id') + .eq('id', roundId) + .single() + return data?.course_id ?? '' +} diff --git a/app/(app)/rounds/new/page.tsx b/app/(app)/rounds/new/page.tsx new file mode 100644 index 0000000..afc5168 --- /dev/null +++ b/app/(app)/rounds/new/page.tsx @@ -0,0 +1,28 @@ +import { createClient } from '@/lib/supabase/server' +import { NewRoundWizard } from '@/components/round/new-round-wizard' + +type Tee = { + id: string + name: string + color: string + course_rating: number + slope_rating: number +} + +type Course = { + id: string + name: string + par: number + tees: Tee[] +} + +export default async function NewRoundPage() { + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: courses } = await (supabase as any) + .from('courses') + .select('id, name, par, tees(id, name, color, course_rating, slope_rating)') + .order('name') + + return +} diff --git a/app/(auth)/confirm/route.ts b/app/(auth)/confirm/route.ts new file mode 100644 index 0000000..4ea43ad --- /dev/null +++ b/app/(auth)/confirm/route.ts @@ -0,0 +1,19 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse, type NextRequest } from 'next/server' + +export async function GET(request: NextRequest) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + const next = searchParams.get('next') ?? '/dashboard' + + if (code) { + const supabase = await createClient() + const { error } = await supabase.auth.exchangeCodeForSession(code) + + if (!error) { + return NextResponse.redirect(`${origin}${next}`) + } + } + + return NextResponse.redirect(`${origin}/login?error=auth_failed`) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e856f27 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useState } from 'react' +import { createClient } from '@/lib/supabase/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError(null) + + const supabase = createClient() + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${window.location.origin}/auth/confirm`, + shouldCreateUser: false, // invite-only: only existing users can log in + }, + }) + + if (error) { + setError(error.message) + } else { + setSubmitted(true) + } + setLoading(false) + } + + if (submitted) { + return ( +
+
+
+

Check your email

+

+ We sent a magic link to {email}. Tap the link to + sign in. +

+
+
+ ) + } + + return ( +
+
+
+
+

MulliganMates

+

Sign in to track your round

+
+ +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + autoFocus + /> +
+ + {error &&

{error}

} + + +
+
+
+ ) +} diff --git a/app/actions/courses.ts b/app/actions/courses.ts new file mode 100644 index 0000000..824b999 --- /dev/null +++ b/app/actions/courses.ts @@ -0,0 +1,62 @@ +'use server' + +import { z } from 'zod' +import { createClient } from '@/lib/supabase/server' +import { revalidatePath } from 'next/cache' + +const TeeInput = z.object({ + name: z.string().min(1, 'Tee name required'), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), + course_rating: z.coerce.number().min(50).max(80), + slope_rating: z.coerce.number().int().min(55).max(155), +}) + +const HoleInput = z.object({ + hole_number: z.number().int().min(1).max(18), + par: z.number().int().min(3).max(5), + stroke_index: z.number().int().min(1).max(18), +}) + +export const CreateCourseSchema = z.object({ + name: z.string().min(1, 'Course name required').max(100), + par: z.number().int().min(27).max(90), + tees: z.array(TeeInput).min(1, 'At least one tee required'), + holes: z.array(HoleInput).min(9), +}) + +export type CreateCourseInput = z.infer + +export async function createCourse(data: CreateCourseInput) { + const parsed = CreateCourseSchema.safeParse(data) + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } + } + + const supabase = await createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + const { data: course, error: courseError } = await db + .from('courses') + .insert({ name: parsed.data.name, par: parsed.data.par, created_by: user.id }) + .select('id') + .single() + if (courseError) return { error: courseError.message } + + const { error: teesError } = await db + .from('tees') + .insert(parsed.data.tees.map((t) => ({ ...t, course_id: course.id }))) + if (teesError) return { error: teesError.message } + + const { error: holesError } = await db + .from('holes') + .insert(parsed.data.holes.map((h) => ({ ...h, course_id: course.id }))) + if (holesError) return { error: holesError.message } + + revalidatePath('/rounds/new') + return { courseId: course.id as string } +} diff --git a/app/actions/invites.ts b/app/actions/invites.ts new file mode 100644 index 0000000..162a731 --- /dev/null +++ b/app/actions/invites.ts @@ -0,0 +1,50 @@ +'use server' + +import { z } from 'zod' +import { createClient, createAdminClient } from '@/lib/supabase/server' + +const InviteSchema = z.object({ + roundId: z.string().uuid(), + email: z.string().email(), +}) + +export async function inviteFromLobby(roundId: string, email: string) { + const parsed = InviteSchema.safeParse({ roundId, email }) + if (!parsed.success) return { error: 'Invalid email address' } + + const supabase = await createClient() + const adminClient = await createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + const { data: round } = await db + .from('rounds') + .select('status, created_by') + .eq('id', parsed.data.roundId) + .single() + + if (!round) return { error: 'Round not found' } + if (round.created_by !== user.id) return { error: 'Only the round creator can invite players' } + if (round.status !== 'lobby') return { error: 'Cannot invite after the round has started' } + + const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${parsed.data.roundId}/join`)}` + + const { error: inviteError } = await (adminClient as any).auth.admin.inviteUserByEmail( + parsed.data.email, + { redirectTo }, + ) + if (inviteError) return { error: inviteError.message } + + await db.from('round_invites').insert({ + round_id: parsed.data.roundId, + email: parsed.data.email, + invited_by: user.id, + }) + + return { success: true } +} diff --git a/app/actions/rounds.ts b/app/actions/rounds.ts new file mode 100644 index 0000000..957454a --- /dev/null +++ b/app/actions/rounds.ts @@ -0,0 +1,129 @@ +'use server' + +import { z } from 'zod' +import { createClient, createAdminClient } from '@/lib/supabase/server' +import { revalidatePath } from 'next/cache' + +const CreateRoundSchema = z.object({ + course_id: z.string().uuid(), + tee_id: z.string().uuid(), + name: z.string().max(100).optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + holes_count: z.union([z.literal(9), z.literal(18)]), + emails: z.array(z.string().email()).default([]), +}) + +export type CreateRoundInput = z.infer + +export async function createRoundWithInvites(data: CreateRoundInput) { + const parsed = CreateRoundSchema.safeParse(data) + if (!parsed.success) { + return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } + } + + const supabase = await createClient() + const adminClient = await createAdminClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const adminDb = adminClient as any + + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + // Snapshot handicap and compute course handicap + const { data: profile } = await db + .from('profiles') + .select('handicap_index') + .eq('id', user.id) + .single() + + const { data: tee } = await db + .from('tees') + .select('course_rating, slope_rating') + .eq('id', parsed.data.tee_id) + .single() + + const { data: course } = await db + .from('courses') + .select('par') + .eq('id', parsed.data.course_id) + .single() + + const handicapIndex: number | null = profile?.handicap_index ?? null + const courseHandicap = + handicapIndex !== null && tee && course + ? Math.round(handicapIndex * (tee.slope_rating / 113) + (tee.course_rating - course.par)) + : null + + // Create round + const { data: round, error: roundError } = await db + .from('rounds') + .insert({ + course_id: parsed.data.course_id, + tee_id: parsed.data.tee_id, + name: parsed.data.name || null, + date: parsed.data.date, + holes_count: parsed.data.holes_count, + created_by: user.id, + status: 'lobby', + }) + .select('id') + .single() + if (roundError) return { error: roundError.message } + + // Add creator as admin player + await db.from('round_players').insert({ + round_id: round.id, + user_id: user.id, + role: 'admin', + handicap_index: handicapIndex, + course_handicap: courseHandicap, + }) + + // Send invites + const failedInvites: string[] = [] + for (const email of parsed.data.emails) { + const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${round.id}/join`)}` + const { error } = await adminDb.auth.admin.inviteUserByEmail(email, { redirectTo }) + if (error) { + failedInvites.push(email) + } else { + await db.from('round_invites').insert({ + round_id: round.id, + email, + invited_by: user.id, + }) + } + } + + return { + roundId: round.id as string, + failedInvites, + } +} + +export async function startRound(roundId: string) { + const parsed = z.string().uuid().safeParse(roundId) + if (!parsed.success) return { error: 'Invalid round ID' } + + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + const { error } = await (supabase as any) + .from('rounds') + .update({ status: 'active' }) + .eq('id', parsed.data) + .eq('created_by', user.id) + .eq('status', 'lobby') + + if (error) return { error: error.message } + + revalidatePath(`/rounds/${roundId}`) + return { success: true } +} diff --git a/app/actions/scores.ts b/app/actions/scores.ts new file mode 100644 index 0000000..ba19fff --- /dev/null +++ b/app/actions/scores.ts @@ -0,0 +1,45 @@ +'use server' + +import { z } from 'zod' +import { createClient } from '@/lib/supabase/server' + +const UpsertScoreSchema = z.object({ + roundId: z.string().uuid(), + holeId: z.string().uuid(), + playerId: z.string().uuid(), + strokes: z.number().int().min(1).max(20), +}) + +export async function upsertScore( + roundId: string, + holeId: string, + playerId: string, + strokes: number, +) { + const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes }) + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' } + + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) return { error: 'Not authenticated' } + + // Verify round is active and user is a member (RLS also enforces this) + const { error } = await (supabase as any) + .from('scores') + .upsert( + { + round_id: parsed.data.roundId, + hole_id: parsed.data.holeId, + player_id: parsed.data.playerId, + strokes: parsed.data.strokes, + updated_by: user.id, + updated_at: new Date().toISOString(), + }, + { onConflict: 'round_id,hole_id,player_id' }, + ) + + if (error) return { error: error.message } + return { success: true } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a8da733 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,129 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..a46b0ac --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from 'next' + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'MulliganMates', + short_name: 'MulliganMates', + description: 'Social golf score tracking', + start_url: '/dashboard', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#16a34a', + orientation: 'portrait', + icons: [ + { + src: '/icons/icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icons/icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/icons/icon-512-maskable.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + } +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..295f8fd --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/components/bottom-nav.tsx b/components/bottom-nav.tsx new file mode 100644 index 0000000..cdd231e --- /dev/null +++ b/components/bottom-nav.tsx @@ -0,0 +1,45 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Flag, Clock, User, ShieldCheck } from 'lucide-react' +import { cn } from '@/lib/utils' + +const navItems = [ + { href: '/dashboard', label: 'Rounds', icon: Flag }, + { href: '/history', label: 'History', icon: Clock }, + { href: '/profile', label: 'Profile', icon: User }, +] + +export function BottomNav({ isAdmin = false }: { isAdmin?: boolean }) { + const pathname = usePathname() + + const items = isAdmin + ? [...navItems, { href: '/admin', label: 'Admin', icon: ShieldCheck }] + : navItems + + return ( + + ) +} diff --git a/components/round/join-round.tsx b/components/round/join-round.tsx new file mode 100644 index 0000000..7f3010b --- /dev/null +++ b/components/round/join-round.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' +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 +} + +export function JoinRound({ round, userId }: { round: Round; userId: string }) { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleJoin() { + setLoading(true) + setError(null) + + const supabase = createClient() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = supabase as any + + // 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) + 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}`) + } + + return ( +
+
+
+
+

You're invited!

+
+ +
+
{round.course?.name}
+
+ {round.name &&
{round.name}
} +
+
+ {round.tee?.name} tees · {round.holes_count} holes +
+
{new Date(round.date).toLocaleDateString()}
+
+
+ + {error &&

{error}

} + + +
+
+ ) +} diff --git a/components/round/new-round-wizard.tsx b/components/round/new-round-wizard.tsx new file mode 100644 index 0000000..b4015db --- /dev/null +++ b/components/round/new-round-wizard.tsx @@ -0,0 +1,312 @@ +'use client' + +import { useState } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { createRoundWithInvites } from '@/app/actions/rounds' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ChevronLeft, Plus, X } from 'lucide-react' + +type Tee = { + id: string + name: string + color: string + course_rating: number + slope_rating: number +} + +type Course = { + id: string + name: string + par: number + tees: Tee[] +} + +type WizardData = { + courseId: string + teeId: string + date: string + holesCount: 9 | 18 + name: string + emails: string[] +} + +export function NewRoundWizard({ courses }: { courses: Course[] }) { + const router = useRouter() + const searchParams = useSearchParams() + const preselectedCourseId = searchParams.get('course_id') ?? '' + + const [step, setStep] = useState(preselectedCourseId ? 2 : 1) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [emailInput, setEmailInput] = useState('') + + const [data, setData] = useState({ + courseId: preselectedCourseId, + teeId: '', + date: new Date().toISOString().split('T')[0], + holesCount: 18, + name: '', + emails: [], + }) + + const selectedCourse = courses.find((c) => c.id === data.courseId) + const steps = ['Course', 'Tee', 'Details', 'Invite'] + + function addEmail() { + const email = emailInput.trim().toLowerCase() + if (!email || data.emails.includes(email)) return + setData({ ...data, emails: [...data.emails, email] }) + setEmailInput('') + } + + function removeEmail(email: string) { + setData({ ...data, emails: data.emails.filter((e) => e !== email) }) + } + + async function handleCreate() { + setLoading(true) + setError(null) + + const result = await createRoundWithInvites({ + course_id: data.courseId, + tee_id: data.teeId, + date: data.date, + holes_count: data.holesCount, + name: data.name || undefined, + emails: data.emails, + }) + + if ('error' in result && result.error) { + setError(result.error) + setLoading(false) + return + } + + if (result.failedInvites?.length) { + setError(`Round created. Could not invite: ${result.failedInvites.join(', ')}`) + } + + router.push(`/rounds/${result.roundId}`) + } + + return ( +
+ {/* Header */} +
+ +

New round

+
+ {steps.map((s, i) => ( +
+ ))} +
+
+ +
+ {/* Step 1: Course selection */} + {step === 1 && ( +
+

Select course

+ + {courses.length === 0 ? ( +

+ No courses yet.{' '} + + Create one first. + +

+ ) : ( +
+ {courses.map((course) => ( + + ))} +
+ )} + + + + Add new course + +
+ )} + + {/* Step 2: Tee selection */} + {step === 2 && selectedCourse && ( +
+

Select tees

+

{selectedCourse.name}

+ +
+ {selectedCourse.tees.map((tee) => ( + + ))} +
+
+ )} + + {/* Step 3: Round details */} + {step === 3 && ( +
+

Round details

+ +
+ +
+ {([9, 18] as const).map((n) => ( + + ))} +
+
+ +
+ + setData({ ...data, date: e.target.value })} + /> +
+ +
+ + setData({ ...data, name: e.target.value })} + placeholder="e.g. Sunday morning four-ball" + /> +
+ + +
+ )} + + {/* Step 4: Invite players */} + {step === 4 && ( +
+

Invite players

+

+ Each player will receive a magic link by email.{' '} + You can skip this and invite later from the lobby. +

+ + {/* Email input */} +
+ setEmailInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addEmail()} + placeholder="player@example.com" + className="flex-1" + /> + +
+ + {/* Email list */} + {data.emails.length > 0 && ( +
+ {data.emails.map((email) => ( +
+ {email} + +
+ ))} +
+ )} + + {error &&

{error}

} + + +
+ )} +
+
+ ) +} diff --git a/components/round/round-lobby.tsx b/components/round/round-lobby.tsx new file mode 100644 index 0000000..b385589 --- /dev/null +++ b/components/round/round-lobby.tsx @@ -0,0 +1,233 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { startRound } from '@/app/actions/rounds' +import { inviteFromLobby } from '@/app/actions/invites' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ChevronLeft, Plus, X } from 'lucide-react' +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 +} + +type Invite = { + id: string + email: string + accepted_at: string | null + created_at: string +} + +type Round = { + id: string + 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 +} + +export function RoundLobby({ + round, + players, + pendingInvites, + currentUserId, + isCreator, +}: { + round: Round + players: Player[] + pendingInvites: Invite[] + currentUserId: string + isCreator: boolean +}) { + const router = useRouter() + const [emailInput, setEmailInput] = useState('') + const [inviteError, setInviteError] = useState(null) + const [inviting, setInviting] = useState(false) + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(null) + + async function handleInvite() { + const email = emailInput.trim().toLowerCase() + if (!email) return + setInviting(true) + setInviteError(null) + const result = await inviteFromLobby(round.id, email) + if (result.error) { + setInviteError(result.error) + } else { + setEmailInput('') + router.refresh() + } + setInviting(false) + } + + async function handleStart() { + setStarting(true) + setStartError(null) + const result = await startRound(round.id) + if (result.error) { + setStartError(result.error) + setStarting(false) + } else { + router.refresh() + } + } + + return ( +
+ {/* Header */} +
+ + + +
+

+ {round.name ?? round.course?.name ?? 'Round'} +

+

+ {round.course?.name} · {round.tee?.name} tees ·{' '} + {round.holes_count} holes ·{' '} + {new Date(round.date).toLocaleDateString()} +

+
+ + Lobby + +
+ +
+ {/* Players */} +
+

+ Players ({players.length}) +

+
+ {players.map((p) => ( +
+ +
+
+ + {p.profile?.display_name} + + {p.profile?.id === currentUserId && ( + (you) + )} + {p.role === 'admin' && ( + + creator + + )} +
+
+ {p.handicap_index !== null + ? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}` + : 'No handicap'} +
+
+
+ ))} +
+
+ + {/* Pending invites */} + {pendingInvites.length > 0 && ( +
+

+ Awaiting ({pendingInvites.length}) +

+ {pendingInvites.map((invite) => ( +
+ + {invite.email} +
+ ))} +
+ )} + + {/* Invite form (creator only, lobby only) */} + {isCreator && round.status === 'lobby' && ( +
+

Invite player

+
+ setEmailInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleInvite()} + placeholder="player@example.com" + className="flex-1" + /> + +
+ {inviteError && ( +

{inviteError}

+ )} +
+ )} + + {/* Start round button */} + {isCreator && round.status === 'lobby' && ( +
+ {startError && ( +

{startError}

+ )} + +
+ )} +
+
+ ) +} + +function Avatar({ name, url }: { name: string; url?: string | null }) { + const initials = name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) + + if (url) { + return ( + {name} + ) + } + + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/full-grid.tsx b/components/scorecard/full-grid.tsx new file mode 100644 index 0000000..065612f --- /dev/null +++ b/components/scorecard/full-grid.tsx @@ -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 + 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 ( +
+ + + + + {front.map((h) => ( + + ))} + {hasBoth && ( + + )} + {back.map((h) => ( + + ))} + {hasBoth && ( + + )} + + + + {/* Par row */} + + + {front.map((h) => ( + + ))} + {hasBoth && ( + + )} + {back.map((h) => ( + + ))} + {hasBoth && ( + + )} + + + + + {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 ( + + + + {front.map((h) => { + const gross = getGross(player.user_id, h) + const grossVsPar = gross !== null ? gross - h.par : null + return ( + + ) + })} + + {hasBoth && ( + + )} + + {back.map((h) => { + const gross = getGross(player.user_id, h) + const grossVsPar = gross !== null ? gross - h.par : null + return ( + + ) + })} + + {hasBoth && ( + + )} + + + + + ) + })} + +
+ Player + + {h.hole_number} + Out + {h.hole_number} + InTotNet
Par + {h.par} + {parFront} + {h.par} + {parBack}{parTotal} +
+ {player.profile?.display_name} + isActive && onTapScore(player, h)} + > + + {gross ?? '·'} + + + {frontOut ?? '—'} + isActive && onTapScore(player, h)} + > + + {gross ?? '·'} + + + {backIn ?? '—'} + + {total ?? '—'} + + {netVsPar !== null ? formatVsPar(netVsPar) : '—'} +
+
+ ) +} diff --git a/components/scorecard/hole-view.tsx b/components/scorecard/hole-view.tsx new file mode 100644 index 0000000..0d72191 --- /dev/null +++ b/components/scorecard/hole-view.tsx @@ -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 + runningTotals: Record + 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(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 ( +
+ {/* Hole header */} +
+ + +
+
Hole {hole.hole_number}
+
+ Par {hole.par} · SI {hole.stroke_index} +
+
+ + +
+ + {/* Players */} +
+ {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 ( + + ) + })} +
+ + {/* Hole progress dots */} +
+ {Array.from({ length: totalHoles }, (_, i) => ( +
+ ))} +
+
+ ) +} + +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 {name} + } + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/leaderboard-view.tsx b/components/scorecard/leaderboard-view.tsx new file mode 100644 index 0000000..f41afa7 --- /dev/null +++ b/components/scorecard/leaderboard-view.tsx @@ -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 +} + +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 ( +
+ {standings.map(({ player, grossTotal, netVsPar, grossVsPar, holesPlayed }, i) => { + const isLead = i === 0 && netVsPar !== null + + return ( +
+ {/* Position */} +
+ {holesPlayed > 0 ? i + 1 : '—'} +
+ + {/* Avatar */} + + + {/* Name + holes played */} +
+
+ {player.profile?.display_name} +
+
+ {holesPlayed > 0 + ? `${holesPlayed} of ${totalHoles} holes` + : 'No scores yet'} + {player.course_handicap !== null && + ` · HCP ${player.course_handicap}`} +
+
+ + {/* Net score (primary, bold) */} +
+ + {netVsPar !== null ? formatVsPar(netVsPar) : '—'} + + {grossVsPar !== null && ( + + {grossTotal} gross + + )} +
+
+ ) + })} +
+ ) +} + +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 {name} + } + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/score-entry-sheet.tsx b/components/scorecard/score-entry-sheet.tsx new file mode 100644 index 0000000..6e2e78b --- /dev/null +++ b/components/scorecard/score-entry-sheet.tsx @@ -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(currentStrokes ?? hole?.par ?? 4) + const [editingNumber, setEditingNumber] = useState(false) + const inputRef = useRef(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 */} +
+ + {/* Sheet */} +
+ {/* Handle */} +
+
+
+ + {/* Player + hole info */} +
+
+
{player.profile?.display_name}
+
+ Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index} +
+
+
0 ? 'text-muted-foreground' : 'text-foreground' + }`} + > + {vsPar === 0 ? 'Par' : vsPar > 0 ? `+${vsPar}` : vsPar} +
+
+ + {/* Score input */} +
+ {/* Minus */} + + + {/* Score display / input */} + {editingNumber ? ( + 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" + /> + ) : ( + + )} + + {/* Plus */} + +
+ + {/* Save */} +
+ +
+
+ + ) +} diff --git a/components/scorecard/scorecard-view.tsx b/components/scorecard/scorecard-view.tsx new file mode 100644 index 0000000..27b59f1 --- /dev/null +++ b/components/scorecard/scorecard-view.tsx @@ -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 { + 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>(buildScoreMap(initialScores)) + const [activeHoleIdx, setActiveHoleIdx] = useState(0) + const [view, setView] = useState('hole') + const [entry, setEntry] = useState(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 = {} + 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 ( +
+ {/* Header */} +
+ + + +
+
+ {round.name ?? round.course?.name ?? 'Scorecard'} +
+
+ {round.tee?.name} tees · {round.holes_count} holes + {!isActive && ( + (completed) + )} +
+
+ {saving && ( + Saving… + )} +
+ + {/* View tabs */} +
+ {(['hole', 'grid', 'leaderboard'] as View[]).map((v) => ( + + ))} +
+ + {/* Main content */} +
+ {view === 'hole' && activeHole && ( + setActiveHoleIdx((i) => Math.max(0, i - 1))} + onNext={() => setActiveHoleIdx((i) => Math.min(holes.length - 1, i + 1))} + onTapScore={(player) => openEntry(player)} + /> + )} + + {view === 'grid' && ( +
+ openEntry(player, hole)} + /> +
+ )} + + {view === 'leaderboard' && ( +
+ +
+ )} +
+ + {/* Score entry sheet */} + setEntry(null)} + /> +
+ ) +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..ded01b2 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +"use client" + +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +