Initial commit v1.1

This commit is contained in:
Rolf
2026-03-18 13:34:41 +01:00
parent cb04bf2ab8
commit b982584244
38 changed files with 3196 additions and 1103 deletions
+47 -45
View File
@@ -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