Files
MulliganMates/CLAUDE.md
T
2026-03-18 12:37:17 +01:00

4.1 KiB
Raw Blame History

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):

# 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 <package>

# Add a shadcn/ui component
docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add <component>

# 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