# 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