Web companion dashboard for Project Phoenix, an open-source companion for Phoenix-compatible fitness machines. View workouts, build routines and training cycles, analyze biomechanics, and replay 50Hz session telemetry synced from the Kotlin Multiplatform mobile app.
- Real-time Dashboard — Workout stats, streak tracking, recovery score, goal progress rings
- Workout History — Searchable/filterable workout log synced from mobile app
- Session Detail — Deep dive into individual workouts with exercise breakdown
- Performance Analytics — Volume trends, PR tracking, muscle group distribution
- Biomechanics Analysis — Velocity zones, ROM trends, asymmetry detection
- Training Load Gauge — Acute:chronic workload ratio monitoring
- Muscle Heatmap — Visual muscle group coverage
- Force Curves — Per-cable forces and method-aware signed cable-work proxy power; unverified watts remain unavailable
- Telemetry Playback — Rep-by-rep motion replay canvas; measured receipt timing is distinct from unverified legacy timing
- Timeline Navigation — Scrub through workout timeline
- Fatigue Summary — Per-set fatigue analysis
- Quality Badges — Rep quality indicators
Power summaries accept PAIRED_CABLE_WORK_V1, UNAVAILABLE and LEGACY_UNKNOWN_V0. Only the first projects mean/peak watts; genuine zero and signed negative values are preserved. Historical collapsed curves retain known cable forces, but unknown position/velocity cannot establish phase or work. This is not muscle/body power or physical measurement validation.
Deploy 20261002100000_paired_rep_power_contract.sql with the coordinated sync handlers before new mobile uploads; regenerate types from the migrated local stack. Inferno raw-curve gating is unchanged. The performance review is at /analytics?tab=performance; software checks and synthetic localhost UI examples are not physical pilots.
- Visual Routine Editor — Drag-and-drop exercise ordering
- Superset Support — Group exercises with rest period configuration
- AMRAP & PR-scaling — Advanced workout modifiers
- Community Sharing — Publish to routine library
- Multi-week Periodization — Build structured training blocks
- Day-by-Day Planning — Assign routines to specific days
- Cycle Activation — Single active cycle enforcement
- Routine Library — Browse and clone community routines
- Vote System — Upvote/downvote community content
- Leaderboards — Compete on volume, PRs, streaks
- Challenges — Time-limited competitions
- Strava — Import activities (pull-only)
- Fitbit — Wearable import (coming soon; pending developer-program approval)
- Garmin Connect — Webhook activity import (coming soon; pending Garmin developer program / GCPP approval)
- Hevy — Workout history migration
- Liftosaur — Program and log migration
- Strong — CSV workout import
- Apple Health / Google Health Connect — mobile-only cards (sync from the app, not portal OAuth)
Single matrix matching src/lib/pricing.ts and src/lib/tierMatrix.ts (read by src/app/routes/index.tsx). Ember is not sold leaderboards.
| Tier | Monthly | Annual | Access |
|---|---|---|---|
| FREE | $0 | $0 | Landing, pricing; signed-in users are a conversion funnel (dashboard upgrade wall), not a local-history SKU |
| EMBER | $5 | $49/yr | Cloud sync, dashboard, history, session detail, goals, recovery |
| FLAME | $15 | $149/yr | Analytics, community, routines, cycles, integrations (Strava/Hevy/Liftosaur), leaderboards, challenges, compare, session replay (rep-by-rep; force curves are INFERNO) |
| INFERNO | $25 | $249/yr | Advanced biomechanics (force, VBT, ROM, SRA, form). Purchasable; inner INFERNO gates stay. |
Route gates read their tiers from FEATURE_MIN_TIER in src/lib/tierMatrix.ts. Route gates are UX only; what the server enforces is:
- EMBER (server-enforced): cloud sync (
mobile-sync-push,mobile-sync-pull) and browser writes to workout sessions, records and local profiles (RLS). - FLAME (server-enforced): browser INSERT/UPDATE on shared routines/cycles, votes, comments, saved items, follows, challenge participation, integrations, the sync queue, and portal routine/cycle authoring (
routines,routine_exercises,training_cycles,cycle_days) via RLS; theimport_shared_routine/import_shared_cycleRPCs;initiate-oauth, the integration sync Edge Functions and leaderboards (compute-rankings). Deleting what you published or joined (comments, shares, votes, follows, saved items, challenge participation) carries no tier check in RLS, and every portal mutation that removes one of those rows is a realDELETE— including comment removal, which is a hard delete with a row check, not a soft-deleteUPDATE. So a downgrade never blocks removal, at the API or in the mutations. The community and challenge screens are still FLAME-gated, so a downgraded user has no portal page to remove from (follow-up: a withdraw-only view for lapsed plans). Routines and cycles pushed from the mobile app stay EMBER (service-role push). - INFERNO (server-enforced): the force-curve and biomechanics data —
rep_telemetry(and thetelemetry_pointsview over it),vbt_assessments,session_phase_statistics,exercise_signatures— is readable only by an INFERNO subscriber (RLS,20260920003800_inferno_read_policies.sql). A FLAME user reads zero rows from them, so session replay degrades to rep-by-rep playback built fromrep_summariesand says "Force curves require Inferno". The GDPR export is unaffected:export-user-datareads with the service role, so any tier can still export every telemetry row they own. - Browser-only gates: analytics, compare and session replay itself are computed from data the user can already read, so their gates are UX only.
Fitbit and Garmin Connect stay comingSoon in the UI until developer-program approval. Do not treat README as a Connect un-block.
| Layer | Technology |
|---|---|
| Build | Vite 7 |
| Framework | React 19, TypeScript 5.7 |
| Styling | Tailwind CSS v4 |
| Components | shadcn/ui (33 component files, 20 Radix imports) |
| State | Zustand 5 (client), TanStack Query 5 (server) |
| Visualization | Recharts 3, @visx, ECharts 6 |
| Animation | Motion (formerly Framer Motion, reduced-motion) |
| Validation | Zod 4 |
| Backend | Supabase (PostgreSQL, Auth, Realtime, Storage) |
| Edge Functions | Deno (ls supabase/functions; each has a [functions.*] block in supabase/config.toml) |
| Payments | Paddle (Merchant of Record) |
| Monitoring | Sentry (cookie-consent-gated) |
| Testing | Vitest 4, Playwright 1.58 |
| Linting | Biome 2.4 |
# Install dependencies
npm install
# Start dev server (localhost:5173)
npm run dev
# Production build
npm run build
# Run tests
npm test
# Sync tests (mocked Edge Functions)
npm run test:sync
# Edge Function type-check and Deno handler tests
npm run check:edge-functions
npm run test:edge
# Type checking (scripts/typecheck.mjs runs tsc -p --noEmit on the app, node,
# test, and e2e tsconfigs; fails on errors not in typecheck-baseline.json)
npm run typecheck
# Re-record the pre-existing type errors after fixing some (review the diff)
npm run typecheck:baseline
# E2E tests
npm run test:e2e
# Regenerate Supabase types from the migrated local DB (what CI checks)
npm run supabase -- start
npm run supabase -- db reset --no-seed
npm run gen:types:local
# Database tests (pgTAP) against the local stack
npm run test:dbThis repo includes WORKFLOW.md for OpenAI Symphony,
which can poll Linear and launch isolated Codex implementation runs for active
issues. Before running it, set LINEAR_API_KEY and confirm that
tracker.project_slug in WORKFLOW.md (already set) points at your Linear
project. See
docs/runbooks/symphony.md for the full setup and
operating checklist.
Copy .env.example to .env.local and confirm every value in it — that file
is the list, and it carries a comment per variable.
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_PADDLE_CLIENT_TOKEN=your-paddle-token
VITE_PADDLE_ENVIRONMENT=sandbox
VITE_SENTRY_DSN=
# ... Paddle price IDs for each tierOnly VITE_-prefixed values reach the browser bundle. Edge Function secrets
(PADDLE_API_KEY, CRON_SECRET, SYNC_LWW_ENABLED, SUPABASE_PUBLIC_URL,
APP_URL, the provider client secrets, …) are set in Supabase Dashboard →
Edge Functions → Secrets. CLAUDE.md → "Environment Variables" explains what
each one is for.
Bidirectional sync with Project Phoenix mobile app (Kotlin Multiplatform):
Mobile (SQLite) → POST mobile-sync-push → Supabase DB
↓
Broadcast sync_complete
↓
Portal ← useRealtimeSync hook ← Channel sync:{userId}
- Push: Workouts →
mobile-sync-pushEdge upsert (realtime broadcast on privatesync:{userId}invalidates portal cache) - Pull: Parity-based — the device sends the ids it already holds and the server returns the rest, plus rows changed since
lastSync - 2 min. Cursor-based, 75 entities/page (max 300). There is no timestamp-only pull mode.rep_telemetryis not pulled — session replay restores telemetry in the portal only. - Conflict Resolution:
client_updated_atis the last-write-wins key andupdated_atis the server-owned pull cursor; they are not interchangeable. Sessions and routines are last-push-wins unlessSYNC_LWW_ENABLEDis set (a hosted, cold-start flag); training cycles always go throughmerge_training_cycles_from_push, which preserves portal-only configuration and refuses a stale structure. Routine deletes (deletedRoutineIds) are tombstoned, so a stale device cannot resurrect them. Cycle resurrection is stopped by clockeddeletedCyclesand the tombstone gate; legacydeletedCycleIdsdo not tombstone. SeeCLAUDE.md→ "The mobile sync contract".
Dark theme by default. Color tokens live in src/styles/theme.css (:root for dark, :root[data-theme='light'] for light):
| Token | Dark | Usage |
|---|---|---|
--background |
#06060a |
App background |
--primary / --phoenix-ember |
#ff6b35 |
Primary accent |
--destructive |
#ff5252 |
Alerts |
--accent |
#f59e0b |
Highlights |
--success |
#00e676 |
Success states |
The light theme overrides those tokens: --background #fbfbfc, --primary #c2410c, --destructive #b91c1c, --accent #a16207, --success #047857. --phoenix-ember stays #ff6b35 in both themes.
Hosted as a Cloudflare Worker with static assets (wrangler.toml: the phoenix-portal Worker serves dist/ with single-page-application fallback). Workers Builds runs npx wrangler deploy on every push to main; npm run deploy does the same by hand. Other static hosts are possible but not the supported path.
- Project Phoenix Mobile — Kotlin Multiplatform app (iOS/Android)
MIT
This project exists because a community of lifters wanted their training data and tools to keep working, no matter what.