Initial commit v1.1
This commit is contained in:
@@ -4,14 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## 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.
|
||||
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 app running in Docker Compose with a single PostgreSQL database.
|
||||
|
||||
## Commands
|
||||
|
||||
All commands run inside Docker (Node.js is not installed on the host):
|
||||
|
||||
```bash
|
||||
# Development (with Studio + Mailpit)
|
||||
# Development (with Mailpit for email)
|
||||
docker compose --profile dev up
|
||||
|
||||
# Production
|
||||
@@ -23,70 +23,72 @@ 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
|
||||
# Type-check / build
|
||||
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm run build
|
||||
|
||||
# Generate Drizzle migrations (after schema changes)
|
||||
docker run --rm -v $(pwd):/app -w /app -e DATABASE_URL=... node:22-alpine npx drizzle-kit generate
|
||||
|
||||
# Apply migrations manually
|
||||
docker run --rm -v $(pwd):/app -w /app -e DATABASE_URL=... node:22-alpine npx drizzle-kit migrate
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui · Supabase (self-hosted) · TanStack Query v5 · Zustand · Serwist (PWA)
|
||||
**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui v4 · better-auth · Drizzle ORM · SSE · nodemailer · Serwist (PWA)
|
||||
|
||||
**Routing groups:**
|
||||
- `app/(auth)/` — unauthenticated pages (login, confirm)
|
||||
- `app/(auth)/` — unauthenticated pages (login only)
|
||||
- `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 (`lib/auth.ts`):**
|
||||
- better-auth with `magicLink` plugin (`disableSignUp: true` for invite-only)
|
||||
- `drizzleAdapter` pointing to `lib/db/schema.ts` tables: `user`, `session`, `verification`
|
||||
- Additional field: `handicapIndex` on user
|
||||
- Server-side session: `auth.api.getSession({ headers: await headers() })`
|
||||
- Magic link to invite new users: create user with `db.insert(user)` first, then `auth.api.signInMagicLink()`
|
||||
|
||||
**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()`.
|
||||
**Database (`lib/db/`):**
|
||||
- `index.ts` — `drizzle(postgres(DATABASE_URL))` export
|
||||
- `schema.ts` — all table definitions (camelCase column names)
|
||||
- `migrations/` — generated SQL files applied via `instrumentation.ts` on startup
|
||||
- **All Drizzle field names are camelCase** (`holesCount`, `courseHandicap`, `holeNumber`, `strokeIndex`)
|
||||
|
||||
**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.
|
||||
- `app/api/rounds/[id]/stream/route.ts` — SSE Route Handler polling `scores` table every 3s
|
||||
- Client connects via `new EventSource('/api/rounds/{id}/stream')`
|
||||
- Payload: `ScoreRow[]` with `{ holeId, playerId, strokes }`
|
||||
|
||||
**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.
|
||||
- Lobby: players join/are invited; no scoring
|
||||
- Active: started by round creator; scoring enabled
|
||||
- Completed: closed by creator; read-only
|
||||
|
||||
## Database
|
||||
## Database Schema Key Points
|
||||
|
||||
Migrations live in `supabase/migrations/` and are applied on DB container startup via `/docker-entrypoint-initdb.d/`.
|
||||
- better-auth tables use `text` PKs (nanoid); app tables use `uuid` PKs (`gen_random_uuid()`)
|
||||
- `scores` has unique constraint on `(roundId, holeId, playerId)` — upsert pattern
|
||||
- `roundPlayers` snapshots `handicapIndex` and `courseHandicap` at join time
|
||||
- Migrations run automatically on app startup via `instrumentation.ts`
|
||||
|
||||
- `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
|
||||
## UI Conventions
|
||||
|
||||
**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`.
|
||||
- shadcn/ui v4 uses `@base-ui/react` — **no `asChild` prop on Button**. Use `buttonVariants()` + `<Link>` instead
|
||||
- Net score vs par (WHS handicap-adjusted) is always **primary** display, bold
|
||||
- Raw strokes vs course par is secondary
|
||||
- Score entry defaults to hole par; +/− buttons; tap number for direct input
|
||||
- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user)
|
||||
- Mobile-first: minimum 44×44px tap targets, swipe between holes
|
||||
|
||||
**Handicap calculation (WHS):**
|
||||
`course_handicap = handicap_index × (slope_rating ÷ 113) + (course_rating − par)`
|
||||
Stored as a snapshot on `round_players` at join time.
|
||||
See `lib/handicap.ts` for `strokesReceived()`, `netStrokes()`, `formatVsPar()`, `scoreColorClass()`
|
||||
|
||||
## 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
|
||||
- **Docker Compose** — `app` + `db` (postgres:17-alpine) + `mailpit` (dev profile)
|
||||
- **Traefik** — external reverse proxy; services expose via labels only
|
||||
- **Email dev:** Mailpit (`docker compose --profile dev up`)
|
||||
- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` in `.env`
|
||||
- Copy `.env.example` → `.env` before first run
|
||||
- `BETTER_AUTH_URL` must equal the app's public URL for magic links to work
|
||||
|
||||
Reference in New Issue
Block a user