Social training platform for groups of friends preparing for the same endurance event. PacePact generates an AI-powered training plan for the group, automatically marks sessions complete via Strava webhooks, and keeps everyone accountable on a shared live leaderboard.
- Group training plans — Claude generates a structured plan in the background; the group is created immediately and the user is notified via the bell when the plan is ready
- Strava auto-sync — sessions are marked complete when you log a matching Strava activity; matched by activity type and weekly schedule, not exact date
- Brick session support — any two complementary activities (ride + run) on the same day auto-complete a brick session; a 50% progress bar shows while the first leg is pending, with a manual assign fallback
- Live leaderboard — Supabase Realtime keeps scores updated for everyone in the group simultaneously
- Group chat — per-group message board with real-time updates via Supabase Realtime
- In-app notifications — real-time bell in the nav; activity confirmations are always on, message notifications are opt-in per user
- Invite links — 8-character invite codes let friends join a group in one click
- Group admin controls — group creator can kick + ban members, rotate the invite link, lock invites to freeze membership, and transfer admin rights to another member; all surfaced via a
•••dropdown on the group page - Leave group — non-creator members can leave a group at any time; creators must transfer admin rights first
- Points system — base points per session, plus bonuses for completing early, exceeding targets, and maintaining a streak
- Week view — training weeks show date ranges, past weeks are visually locked (green = complete, grey = missed), active week sorted to top
- Rest day handling — rest sessions are excluded from the session grid; a note shows how many rest days are recommended for the week
- Magic link auth — passwordless sign-in via email OTP using Resend; styled email template matches the app
- Strava connect / disconnect — connect Strava on the profile page; disconnect deauthorizes the Strava token and clears stored credentials
- Payments — Stripe Embedded Checkout for group creation and member cap increases; gated by
NEXT_PUBLIC_PAYMENTS_ENABLEDfeature flag (off by default)
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router) |
| Database | Supabase (Postgres + Auth + Realtime) |
| Hosting | Railway |
| AI | Anthropic Claude (claude-sonnet-4-20250514) |
| External API | Strava API v3 (OAuth + Webhooks) |
| Styling | Tailwind CSS |
| Resend | |
| Payments | Stripe (Embedded Checkout) |
# 1. Clone and install
git clone https://github.com/your-org/pacepact.git
cd pacepact
npm install
# 2. Configure environment
cp .env.local.example .env.local
# Fill in all values — see docs/setup.md
# 3. Apply database schema
# Run migrations in supabase/migrations/ in your Supabase SQL editor
# 4. Start dev server
npm run devSee docs/setup.md for the full setup walkthrough including Supabase, Strava API, and Railway deployment.
pacepact/
├── app/
│ ├── (auth)/ # Login + signup pages (magic link / OTP)
│ ├── (app)/ # Authenticated app routes
│ │ ├── groups/ # All groups overview
│ │ ├── group/
│ │ │ ├── new/ # Create group + generate plan
│ │ │ └── [groupId]/ # Group home, full plan, members
│ │ ├── profile/ # Strava connect/disconnect, account info
│ │ └── join/[inviteCode]/# Invite link landing page
│ ├── api/
│ │ ├── auth/otp/send/ # Send magic link email via Resend
│ │ ├── strava/
│ │ │ ├── callback/ # OAuth token exchange
│ │ │ ├── disconnect/ # Deauthorize Strava + clear tokens
│ │ │ └── webhook/ # Strava webhook receiver
│ │ ├── groups/
│ │ │ ├── generate-plan/# Claude plan generation
│ │ │ └── [groupId]/ # PATCH edit/rotate invite/lock; DELETE group
│ │ │ └── members/
│ │ │ ├── me/ # DELETE leave group (non-creator members)
│ │ │ └── [userId]/ # DELETE kick+ban; PATCH transfer creator
│ │ ├── stripe/
│ │ │ ├── checkout/ # Create Stripe Checkout Session (create_group / update_members_cap)
│ │ │ └── webhook/ # Stripe webhook receiver (checkout.session.completed)
│ │ ├── activities/
│ │ │ └── assign/ # Assign parked brick leg to standalone session
│ │ ├── notifications/
│ │ │ └── read-all/ # Mark all notifications as read
│ │ └── user/
│ │ ├── profile/ # PATCH update display name + notification prefs
│ │ └── delete/ # Delete authenticated user account
│ └── auth/callback/ # Auth callback (handles PKCE + implicit flow)
├── components/
│ ├── leaderboard/ # Real-time leaderboard table
│ ├── training/ # Week view + session cards
│ ├── groups/ # Group cards, create form, actions menu, message board, plan generating banner
│ ├── notifications/ # NotificationBell — real-time bell icon with dropdown
│ ├── profile/ # Delete account, disconnect Strava, notification settings
│ └── ui/ # Shared primitives (nav, theme toggle)
├── lib/
│ ├── supabase/ # Browser + server clients; `requireAuth()` shared auth helper
│ ├── strava/ # OAuth, webhook processing, activity matching
│ ├── claude/ # Plan generation prompt + parsing
│ ├── resend/ # Magic link email template
│ ├── stripe/ # Lazy Stripe client initialisation
│ ├── payments/ # Price calculation (members × weeks × rate)
│ ├── groups/ # Session fan-out helper, createGroup() shared fn
│ ├── points/ # Points calculation
│ └── utils/ # Week status, date formatting helpers
├── types/index.ts # Shared TypeScript types
├── supabase/
│ ├── schema.sql # Full DB schema + RLS policies
│ └── migrations/ # Incremental migrations (e.g. messages table)
├── docs/
│ ├── setup.md # Full setup guide
│ └── stripe-setup.md # Stripe keys, webhook registration, pricing, test cards
npm run dev # Start dev server on http://localhost:3000
npm run build # Production build
npm run lint # ESLint
npm test # Jest unit + integration tests
npm run test:watch # Watch modeUnit tests cover core business logic (points calculator, activity matcher, plan parser, week status, date formatting, webhook notification insertion, join-gate evaluation) plus the background plan generation route (group created immediately with plan_status: 'generating', after() scheduled, background success and failure paths). Integration tests cover API routes with Supabase mocked — Strava webhook, auth OTP, notification read-all, profile preference updates, brick leg assignment, and group admin actions (kick, transfer creator, rotate invite, lock invites).
npm test # Run all tests
npm test -- --testPathPatterns=calculator # Run specific file
npm test -- --coverage # With coverage reportPacePact is designed to deploy on Railway. See docs/setup.md for the full deployment guide including Strava webhook registration.
| Action | Points |
|---|---|
| Complete any scheduled session | +10 |
| Session scheduled on Monday or Tuesday | +2 |
| Activity exceeds target by >10% | +3 |
| 7-day streak (session every day) | +5 |
PacePact uses passwordless magic link authentication:
- User enters their email on
/login - The server calls
supabase.auth.admin.generateLink()and sends the link via Resend - User clicks the link →
/auth/callback(client component) - The callback page handles both flows:
- PKCE (
?code=query param): exchanges code for session - Implicit (
#access_token=in URL hash): callssetSession()directly — the hash is never sent to the server, so this must be handled client-side
- PKCE (
- Profile is upserted, user is redirected to
/groups
When a Strava webhook arrives, PacePact matches the activity to a planned session by:
- Finding incomplete sessions for that user scheduled in the same calendar week as the activity
- Filtering by activity type (run, ride, swim, etc.)
- Checking the activity meets ≥ 85% of any distance or duration target
- Matching to the earliest qualifying session in the week
Multiple activities in the same week can each match a different session — completing two runs on the same day will mark off two run sessions if both are scheduled that week.
A duplicate webhook delivery for the same activity (Strava retries on non-200, and occasionally re-emits) is treated as idempotent: groups already credited or already holding a parked leg with that strava_activity_id are skipped, so a single Strava activity can never mark off a second session.
In any week that contains a pending brick session, every incoming run or ride is held in brick_activity_parts and shown as a 50% progress bar on the brick session card. There are two resolution paths:
- Auto-complete: when a complementary leg (opposite type — ride + run) arrives on the same calendar date, stats from both legs are combined and validated against the brick target (85% threshold). The brick is marked complete automatically, and any other activities parked earlier that week are released and matched to remaining standalone sessions.
- Manual assign: the user selects which standalone session to credit the parked leg to. If only one matching session exists in the week, a single button appears; if multiple exist, a labelled list lets the user pick the right one.
See .env.local.example for all required variables.