Purpose: Step-by-step feature-based build order for spec-driven development with Claude Code. Each step references the relevant docs, lists its dependencies, and defines its "done" criteria.
Key Constraint: Solo developer using AI coding agents heavily. Steps are scoped so each is a self-contained prompt-friendly unit with clear inputs, outputs, and validation criteria.
Stack: Tauri v2 + React + TypeScript + Rust + Supabase. One React app serves all platforms (Android, iOS, web browser). Bun as package manager and runtime.
Critical Architecture Decision: Phase 0 builds a browser-only React app against Supabase. Phase 1 wraps it in Tauri and adds the Rust/SQLite backend. This means the React app must be designed from day one to work through a data adapter that can switch between Supabase (browser) and Tauri commands (native).
Design System: "Iron & Ember" — Industrial Brutalism aesthetic. Dark-only, zero border-radius, tonal depth via surface layering, dual-font (Space Grotesk + Inter). Full spec in DESIGN.md.
┌──────────────────────────────────┐
│ STEP 1: Project Scaffold │
│ React + Vite + TanStack │
└──────────┬───────────────────────┘
│
┌──────────┴───────────────────────┐
│ STEP 1.5: Design System │
│ "Iron & Ember" + shadcn overrides│
└──────────┬───────────────────────┘
│
┌────────┴────────┐
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ STEP 2: │ │ STEP 3: │
│ Domain Types │ │ Supabase Setup │
│ + Zod │ │ (PARALLEL) │
│ │ │ │
└──────┬───────┘ │ 3a. Project + │
│ │ Auth │
│ │ 3b. Schema + │
│ │ Migrations │
│ │ 3c. RLS Policies │
│ └────────┬─────────┘
│ │
└────────┬───────────┘
▼
┌──────────────┐
│ STEP 4: │
│ Data Adapter │
│ + Supabase │
│ Adapter │
└──────┬───────┘
│
┌────────┼────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ STEP 5: │ │ STEP 6: │
│ Exercise │ │ Active │
│ Dictionary│ │ Workout │
│ + 1RMs │ │ Logging │
└─────┬─────┘ └─────┬─────┘
│ │
└────────┬─────────┘
▼
┌──────────────┐
│ STEP 7: │
│ Workout │
│ History │
└──────┬───────┘
│
══════════════════════╪═══════════════════════
Phase 0 Complete │ Browser MVP working
══════════════════════╪═══════════════════════
│
┌──────────────┐
│ STEP 8: │
│ Tauri Shell │
│ + Rust/SQLite│
│ + Tauri │
│ Adapter │
└──────┬───────┘
│
┌──────────────┐
│ STEP 9: │
│ Sync Engine │
│ + Rest Timer │
│ (Rust) │
└──────┬───────┘
│
══════════════════════╪═══════════════════════
Phase 1 Complete │ GO / NO-GO on Tauri
══════════════════════╪═══════════════════════
│
┌────────────┼───────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ STEP 10: │ │ STEP 11: │ │ STEP 12: │
│ Session │ │ Program │ │ Program │
│ Templates │ │ Structure │ │ Builder │
│ + SetSchm │ │ Blocks/ │ │ (DnD UI) │
│ │ │ Weeks │ │ │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└──────────────┼──────────────┘
▼
┌───────────┐
│ STEP 13: │
│ Programmed│
│ Workout │
│ Logging │
└─────┬─────┘
│
══════════════════════╪═══════════════════════
Phase 2 Complete │ Programs + Templates
══════════════════════╪═══════════════════════
│
┌────────────┼───────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ STEP 14: │ │ STEP 15: │ │ STEP 16: │
│ Progress │ │ Notif. │ │ Share │
│ + PR │ │ System │ │ Links │
│ Analytics │ │ │ │ │
└───────────┘ └───────────┘ └─────┬─────┘
│
┌──────┴──────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ STEP 17: │ │ STEP 18: │
│ Account- │ │ Coach │
│ ability │ │ Write │
│ Groups │ │ Access │
└───────────┘ └─────┬─────┘
│
═════════════════════════════════╪═════════════════════
Phase 3-4 Complete │ Social + Analytics
═════════════════════════════════╪═════════════════════
│
┌────────┼────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ STEP 19: │ │ STEP 20: │
│ Runtime │ │ Docker & │
│ Backend │ │ Self-Host │
│ Config │ │ │
└─────┬─────┘ └───────────┘
│
═══════════════════════╪═══════════════════════════════
Phase 5 Complete │ Ready for Play Store release
═══════════════════════╪═══════════════════════════════
│
┌────────────────┼───────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ STEP 21: │ │ STEP 22: │ │ STEP 26: │
│ Chat Data │ │ Supabase │ │ Retention │
│ Layer │ │ Realtime │ │ + Archive │
│ │ │ (AFTER 21)│ │ (AFTER 21)│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└───────┬────────┘ │
▼ │
┌──────────────┐ │
│ STEP 23: │ │
│ Chat UI │ │
│ (biggest) │ │
└──────┬───────┘ │
│ │
┌────────┼────────┐ │
▼ ▼ │
┌───────────┐ ┌───────────┐ │
│ STEP 24: │ │ STEP 25: │ │
│ Workout │ │ Video + │ │
│ Sharing │ │ Image │ │
│ in Chat │ │ Sharing │ │
└───────────┘ └───────────┘ │
│
══════════════════════════╪═══════════════╪════
Phase 6 Complete │ Chat & Media working
══════════════════════════════════════════════
│
┌──────────────┐
│ STEP 27: │
│ Display │
│ Broadcast │
│ Infra │
└──────┬───────┘
│
┌──────────────┐
│ STEP 28: │
│ Display │
│ Route + │
│ Board/Focus │
└──────┬───────┘
│
┌──────────────┐
│ STEP 29: │
│ Idle Mode │
└──────────────┘
══════════════════════════════════════════════════
Phase 7 Complete Remote Display working
══════════════════════════════════════════════════
Note on ordering: Steps 27–29 are shown after Phase 6 for dependency graph clarity, but the remote display has no dependency on chat (Phase 6). The only hard dependencies are Step 6 (active workout logging) and Step 11 (program structure for idle mode schedule). The display could be built as early as Phase 2 completion if desired. Broadcast channel setup is independent between chat and display — they use separate channel names.
Note on ordering: Step 19 and Step 20 are shown after Phase 3-4 for dependency graph clarity, but Step 19 can be executed at any point after Phase 1 (Step 9). It modifies the Supabase client initialization and adds a settings screen — both of which exist by Step 9. Step 20 (Docker) can be done at any point after Step 3 (Supabase schema exists). Neither step depends on Steps 10-18. The only hard requirement is that Step 19 must be complete before any Play Store or public distribution.
| Track A: React App (browser) | Track B: Supabase | Track C: Tauri + Rust |
|---|---|---|
| Steps 1–1.5–2, 4–7 | Step 3 (< 1 day) | Steps 8–9 |
Supabase setup (Track B) is a console-click + migration exercise. The real work is Track A (building the app) and Track C (wrapping it in Tauri with offline support).
Critical path to browser MVP: Steps 1 → 1.5 → 2/3 → 4 → 5 → 6 → 7 (~13 days) Critical path to Tauri GO/NO-GO: Steps 1–7 → 8 → 9 (~18.5 days) Critical path to programmed workouts: Steps 1–9 → 10 → 11 → 12 → 13 (~27 days)
Dependencies: None
Priority: P0
Docs: 07-architecture.md §High-Level Architecture
- Vite + React 19 + TypeScript project (scaffolded with
bun create vite) - TanStack Router with file-based routing via
@tanstack/router-pluginVite plugin - TanStack Query provider
- Zustand store skeleton
- shadcn/ui setup (Radix + Vega preset) + Tailwind CSS 4 (CSS-first, no
tailwind.config.ts) - ESLint + Prettier configuration
- Environment variable setup (Supabase URL, publishable key)
ardent-forge/
├── src/
│ ├── routes/
│ │ ├── __root.tsx # Root layout
│ │ └── index.tsx # Today screen (empty shell)
│ ├── components/
│ │ └── ui/ # shadcn/ui components
│ ├── lib/
│ │ ├── supabase.ts # Supabase client
│ │ ├── data-adapter.ts # Interface (empty)
│ │ └── utils.ts
│ ├── domain/
│ │ └── types/ # Built in Step 2
│ ├── hooks/
│ ├── stores/
│ │ └── active-workout.ts # Zustand skeleton
│ ├── main.tsx
│ └── index.css # Tailwind v4 + theme tokens
├── src-tauri/ # Created in Step 8 (not now)
├── supabase/ # CLI + migrations
├── package.json
├── vite.config.ts
├── tsconfig.json
├── components.json # shadcn/ui config
└── .env.local # VITE_SUPABASE_URL, VITE_SUPABASE_PUB_KEY
| Library | Purpose |
|---|---|
| React 19 | UI framework |
| Vite (latest stable) | Build tool |
| TanStack Router | File-based routing |
| TanStack Query | Server state management |
| Zustand | Client state (active workout) |
| shadcn/ui (Radix) | Component library |
| Tailwind CSS 4 | Styling (CSS-first config) |
| Zod | Runtime validation |
| React Hook Form | Form handling |
| dnd-kit | Drag and drop (used in Step 12) |
| Recharts | Charts (used in Step 14) |
| Bun | Package manager + runtime |
-
bun run devlaunches app in browser - TanStack Router renders empty index route
- shadcn/ui Button component renders correctly
- TanStack Query provider wraps app
- Zustand store creates and reads a test value
- Environment variables load correctly
- ESLint + Prettier pass on all files
- Production build succeeds (
bun run build)
Dependencies: Step 1 (project scaffold with shadcn/ui installed), DESIGN.md
Priority: P0
Docs: DESIGN.md (full design system spec)
Estimated effort: 1 day
Why this step exists: The DESIGN.md spec requires full overrides of shadcn/ui defaults — zero border-radius, no shadows, underline inputs, custom color tokens, dual-font system, and a dark-only theme. Doing this before any feature UI work prevents compounding style debt across Steps 4–18.
- Tailwind CSS 4 theme tokens mapped from DESIGN.md color palette
- Dual-font setup: Space Grotesk (headlines) + Inter (body)
- Material Symbols Outlined icon setup (alongside Lucide for shadcn internals)
- Global style overrides: scrollbar, no-line rule, frosted glass utilities
- shadcn component overrides: buttons, cards, inputs, badges, dialogs, navigation
- Responsive layout shell: mobile bottom nav + wide viewport sidebar
- All route shells for navigation targets
Map every token from DESIGN.md §2 into CSS custom properties and register them with Tailwind's @theme inline directive in src/index.css.
Surface Hierarchy — "The Milled Block":
| Token | Hex | Role |
|---|---|---|
surface-pit |
#0E0E0E |
Deepest recess. Nav trays, sidebar bg. |
surface-anvil |
#131313 |
Primary canvas. Default page background. |
surface-charcoal |
#1C1B1B |
Alternating row stripes in data tables. |
surface-iron |
#201F1F |
Card backgrounds, content sections. |
surface-gunmetal |
#2A2A2A |
Active form fields, elevated modules. |
surface-steel |
#353534 |
Timers, active set cards, scrollbar. |
surface-slag |
#393939 |
Floating overlays, surface highlights. |
Primary — "Molten" Accent:
| Token | Hex | Role |
|---|---|---|
ember |
#FFB59C |
Text accents, active underlines, icons. |
forge |
#FB5C1C |
High-impact CTA backgrounds. |
on-ember |
#5C1900 |
Text on primary surfaces. |
on-forge |
#511500 |
Text on CTA backgrounds. |
Secondary, Tertiary, Error, Text: Full mapping in DESIGN.md §2. All tokens mapped to both Iron & Ember names and shadcn compatibility variables (--background, --primary, --card, etc.).
Critical overrides:
| Property | Value | Rationale |
|---|---|---|
--radius |
0px |
The Hard Edge Rule — no rounded corners |
--border |
Ghost | 15% opacity outline-variant only |
| Font display | Space Grotesk | Headlines, numbers, readouts |
| Font body | Inter | Body text, labels, data tables |
Fonts loaded via Google Fonts @import in CSS. Tailwind tokens: --font-display (Space Grotesk), --font-body (Inter).
Usage: font-display for headlines/numbers/readouts, font-body for body/labels/data.
Custom utility classes for typography scale: .text-readout (3.5rem Space Grotesk), .text-industrial (uppercase, 5% letter-spacing).
bun add material-symbolsMaterial Symbols Outlined for app-level icons. Lucide remains for shadcn component internals only.
Create src/components/icon.tsx — wrapper component with name, size, fill props and correct fontVariationSettings.
Key icons from DESIGN.md: fitness_center, timer, menu_book, inventory_2, cloud_done, precision_manufacturing, grid_view, construction, monitoring, library_books, settings, check_circle, open_with, drag_indicator, add.
Override every shadcn component in src/components/ui/ to match DESIGN.md spec:
Button variants:
| Variant | Background | Text | Notes |
|---|---|---|---|
default |
#FB5C1C (forge) |
#511500 |
High-contrast CTA |
molten |
Molten gradient (135deg) | #511500 |
New variant — hero CTA |
secondary |
#334A55 (deep-slate) |
#A0B9C5 |
Supporting actions |
ghost |
Transparent | #FFB59C |
ALL-CAPS text-only |
destructive |
#93000A |
#FFDAD6 |
Destructive actions |
All: 0px radius, no shadows, no transitions. Active: filter: brightness(1.25).
Input fields: Convert to underline-only (no boxed borders). Default: no border, surface-gunmetal bg. Focus: 2px bottom bar in ember. Error: error text with surface-steel bg.
Cards: 0px radius, surface-iron bg, no borders (no-line rule), no shadows. Optional .milled-edge for top-edge definition.
Badges: Flat rectangles, 0px radius, ALL-CAPS. COMPLETE: forge bg. PENDING: surface-steel bg.
Dialogs/Sheets: 0px radius, surface-iron or surface-gunmetal bg, heat-blur overlay.
Tables: ALL-CAPS headers in label-medium Inter. Alternating rows surface-charcoal/surface-anvil. Ghost borders only for accessibility. Header vocabulary: SET, PRESCRIBED, ACTUAL, VARIANCE, STATUS.
Mobile (< 768px) — Bottom navigation:
| Tab | Icon | Route |
|---|---|---|
| FORGE | construction |
/ |
| TRACKER | timer |
/tracker |
| LIBRARY | library_books |
/library |
| VAULT | monitoring |
/vault |
Background: surface-pit. Active: ember. Touch targets ≥ 48px. Fixed bottom with heat-blur. Labels: ALL-CAPS label-small.
Large screen (≥ 1024px) -- Left sidebar:
| Item | Icon | Route |
|---|---|---|
| DASHBOARD | grid_view |
/ |
| PROGRAM BUILDER | precision_manufacturing |
/builder |
| ANALYTICS | monitoring |
/vault |
| LIBRARY | library_books |
/library |
| SETTINGS | settings |
/settings |
Background: surface-pit. Collapsed: 64px icon-only. Expanded: 240px icon+text.
Create empty route files for all navigation targets:
src/routes/
├── __root.tsx # Root layout with responsive nav
├── index.tsx # FORGE / Dashboard
├── tracker.tsx # Active workout (empty shell)
├── library.tsx # Program library (empty shell)
├── vault.tsx # Analytics / 1RM (empty shell)
├── builder.tsx # Program builder — wide viewport (empty shell)
└── settings.tsx # Settings (empty shell)
- Scrollbar: 4px narrow,
surface-steelthumb,forgehover - Heat-blur:
rgba(19,19,19,0.8)+backdrop-filter: blur(20px)utility class - Molten gradient:
linear-gradient(135deg, #FFB59C 0%, #FB5C1C 100%)utility class - Industrial grid:
radial-gradient(circle, #201f1f 1px, transparent 1px)at 30px for large screen backgrounds - Milled edge:
box-shadow: inset 0 1px 0 0 rgba(255,255,255,0.05)utility class - Hard tap:
button:active { filter: brightness(1.25) }— no transitions
-
bun run devrenders app with Iron & Ember color scheme - Space Grotesk renders on headlines, Inter on body text
- Material Symbols icons render (test:
<Icon name="fitness_center" />) - All
border-radiusis 0px across shadcn components - Button variants match spec: default (forge), molten (gradient), secondary (slate), ghost (text-only)
- Input fields use underline-only style (no boxed borders)
- Cards use tonal layering (no shadows, no line borders)
- Mobile: bottom nav renders with 4 tabs, active state highlights in ember
- Large screen: sidebar renders with 5 items, collapse/expand works
- Responsive breakpoint switches nav correctly (< 768px bottom, ≥ 1024px sidebar)
- Touch targets ≥ 48px on mobile nav
- Scrollbar styled (narrow, molten hover)
- No light mode — dark only
- Frosted glass (heat-blur) effect works on sticky elements
- Production build succeeds
Dependencies: Step 1 (project structure exists)
Priority: P0
Docs: 05-domain-model.md (full entity definitions), 06-invariants.md (constraints), 09-state-machines.md (valid transitions)
Canonical TypeScript types and Zod validation schemas in src/domain/. These are the source of truth for the entire application — Rust structs and Supabase schemas are derived from these.
| File | Contents | Source Doc |
|---|---|---|
units.ts |
Weight, Distance, Duration, Pace, NumberRange, OneRepMax | 05-domain-model.md §Value Objects |
exercise.ts |
Exercise, ExerciseCategory, MovementPattern, MuscleGroup, Equipment | 05-domain-model.md §Exercise |
set-scheme.ts |
SetScheme (12-variant union), LoadSpec (7-variant union) | 05-domain-model.md §SetScheme |
session.ts |
SessionTemplate, ActivityGroup, Activity, GroupType, ScoringType, SessionCategory (includes EVENT) | 05-domain-model.md §Session Template |
program.ts |
Program, Block, BlockWeek, ScheduledSession, ProgramSource, BlockType | 05-domain-model.md §Program |
workout-log.ts |
WorkoutLog, LoggedActivityGroup, LoggedActivity, LoggedSet, SetType | 05-domain-model.md §WorkoutLog |
user.ts |
UserProfile, OneRepMaxHistory | 05-domain-model.md §UserProfile |
sharing.ts |
AccountabilityGroup, GroupMember, GroupInvite, DirectConnection, ShareLink | 02-prd-sharing.md §Data Model |
event.ts |
EventMetadata, EventRequirement, EventItem, Zod schemas | 04-prd-events.md §Data Model, 05-domain-model.md §Event Entities |
- All types compile with no errors
- SetScheme discriminated union covers all 12 variants
- LoadSpec discriminated union covers all 7 variants
- Zod schemas validate correct data and reject invalid data
- Unit tests for: SS-2 (percentage range), SS-3 (rep ladder), SS-4 (number range)
- Unit tests for SetScheme type-field consistency (SS-1)
- Data adapter interface defined with all methods
-
domain/directory has zero React or framework dependencies
Dependencies: Step 2 (types for schema alignment). Can be done in parallel with Step 2.
Priority: P0
Docs: 08-erd.md §Remote Schema, 06-invariants.md §Sync Invariants
Supabase project configuration and database schema. Uses the new publishable key (sb_publishable_...) instead of legacy anon JWT key.
- Supabase project created
- Email/password auth enabled
- Project URL and publishable key in
.env.local @supabase/supabase-jsclient installed and connected- Supabase CLI initialized and linked for migration management
Phase 0 tables created via migrations: exercises, workout_logs, logged_activity_groups, logged_activities, logged_sets, user_profiles, one_rep_max_history.
Note: The
event_itemstable andevent_metadatacolumns onsession_templates/workout_logsare added as a Phase 2 migration in Step 13.5a, not in the initial Phase 0 schema.
RLS enabled with simple user isolation: user_id = auth.uid(). Indices for key query patterns. Exercise dictionary seeded with 50+ common exercises.
- Supabase project exists with Auth enabled
-
.env.localhas correct project URL and publishable key - All Phase 0 tables created via migrations
- RLS enabled and tested
- Unauthenticated requests rejected
- Exercise dictionary seeded with 50+ common exercises
- Indices created for key queries
- Supabase client connects from React app
Dependencies: Step 2 (types), Step 3 (Supabase schema), Step 1.5 (design system for auth screens)
Priority: P0
Docs: 07-architecture.md §Data Layer, 10-user-flows.md §Flow 9, DESIGN.md
Supabase adapter implementing the data adapter interface, plus basic auth screens styled with Iron & Ember.
Implements every method from the data adapter interface using @supabase/supabase-js:
| Method | Supabase Call |
|---|---|
getExercises |
supabase.from('exercises').select().order('name') |
saveWorkoutLog |
supabase.from('workout_logs').upsert(log) |
saveLoggedSet |
supabase.from('logged_sets').upsert(set) |
getUserProfile |
supabase.from('user_profiles').select().eq('id', userId).single() |
Wrap adapter calls in TanStack Query hooks:
| Hook | Query Key | Adapter Method |
|---|---|---|
useExercises |
['exercises', filters] |
getExercises |
useWorkoutHistory |
['workouts', userId] |
getWorkoutLogs |
useWorkoutLog |
['workout', id] |
getWorkoutLog |
useUserProfile |
['profile', userId] |
getUserProfile |
useSaveSet |
mutation | saveLoggedSet |
useSaveWorkout |
mutation | saveWorkoutLog |
| Screen | Components | Design Notes (Iron & Ember) |
|---|---|---|
| Sign In | Email + password form, Google OAuth button | Underline inputs, forge CTA button |
| Sign Up | Email + password form | surface-iron card, 0px radius |
| Forgot Password | Email input, send reset | Minimal, surface-anvil background |
Auth screens use the industrial vocabulary: "AUTHENTICATE", "ACCESS FORGE", not "Welcome back!"
- Supabase auth listener wraps app
- Unauthenticated → show auth screen
- Authenticated → show main app with Iron & Ember layout
- Sign out clears session
- User can sign up, sign in, sign out
- Auth screens match Iron & Ember design (underline inputs, forge buttons, industrial copy)
- Supabase adapter implements all data adapter methods
- TanStack Query hooks fetch and cache data correctly
- Exercises query returns seeded exercises
- Saving a workout log persists to Supabase and is retrievable
- RLS prevents accessing other users' data
- Optimistic updates work for set logging mutations
- Error states handled (network error, auth error)
Dependencies: Step 4 (data adapter working)
Priority: P0
Docs: 01-prd-core.md §FR-5, 05-domain-model.md §Exercise + §UserProfile, DESIGN.md §4 Data Tables
Exercise search, filtering, custom exercise creation, and 1RM tracking. All UI uses Iron & Ember design tokens.
- Search by name and aliases (debounced, 200ms)
- Filter by category, muscle group, movement pattern — use flat
surface-steelbadges for filter chips - "Recently used" exercises shown first (query by
logged_activities) - Create custom exercise option at bottom of results
- Exercise metadata (category, muscles, equipment) in
label-mediumInter, ALL-CAPS - 1RM history chart — line chart using
arc(#86CFFF) for primary data line,emberfor secondary (Recharts with Iron & Ember palette) - "UPDATE 1RM" button in
forgeCTA style - Per-exercise workout history (last N sessions with this exercise)
- Profile screen section showing current 1RMs — large numbers in
text-readout(Space Grotesk 3.5rem) - Update 1RM: underline input for weight, mark tested vs estimated
- Historical entries preserved (insert-only, never modified per PR-2)
- 1RM changes cascade to all percentage-based calculations
- Name (required), category, movement pattern, muscle groups, equipment
- Marked as
is_custom = true - Available in search immediately after creation
- Exercise search returns results within 200ms
- Search by name and aliases works (e.g., "bench" finds "Barbell Bench Press")
- Filters by category and muscle group work
- Recently used exercises appear first
- Custom exercise creation works
- 1RM entry saves to
one_rep_max_history - 1RM history displayed as line chart with
arccolor - Profile screen shows all current 1RMs in
text-readoutscale - Exercise detail shows per-exercise workout history
Dependencies: Step 5 (exercise dictionary for adding exercises)
Priority: P0
Docs: 01-prd-core.md §UC-1 + §UC-3 + §UC-4 + §UC-5 + §FR-1 + §FR-2 + §FR-3, 09-state-machines.md §Active Workout + §Set Logging + §Circuit Execution, 10-user-flows.md §Flow 3 + §Flow 4 + §Flow 5 + §Flow 6, DESIGN.md §4 Data Tables + §5 Layout
The most important screen in the app. Active workout logging for all workout types: barbell sets, cardio, rucking, SE circuits. This screen is data-dense per the density philosophy -- uses body-small and label-medium Inter.
| State Field | Type | Purpose |
|---|---|---|
workoutLog |
WorkoutLog | null |
Current workout (null = no active workout) |
loggedGroups |
LoggedActivityGroup[] |
All activity groups |
elapsedSeconds |
number |
Session timer |
restTimer |
{ remaining: number, total: number } | null |
Rest countdown |
undoAction |
{ setId: string, expiresAt: number } | null |
10-second undo |
- "EXECUTE WORKOUT" button on Today screen (molten gradient CTA)
- Creates
WorkoutLogwithstartedAt = now() - Saves to database immediately (crash recovery)
- Navigates to active workout screen
- Starts elapsed timer — displayed in
text-readoutSpace Grotesk
- Tap "+ ADD EXERCISE" → exercise search (from Step 5)
- Set logging table with ALL-CAPS column headers: SET, PRESCRIBED, ACTUAL, STATUS
- Enter weight and reps, tap
check_circleicon to confirm - Set saved to database immediately
- Next set row pre-fills from previous set values
- Rest timer starts automatically (default: 2 min, configurable)
- Rest timer displayed in
surface-steelcard with countdown in Space Grotesk - COMPLETE status badge: flat
forgerectangle
- Select cardio modality (run, cycle, swim, row) — icon chips
- Running timer display in
text-readout - Manual entry: distance and/or duration after completion (underline inputs)
- Optional: heart rate, intensity level
- Enter ruck load weight (underline input)
- Running timer
- After completion: distance, optional elevation gain
- Pace auto-calculated from duration and distance
Circuit execution mode per 09-state-machines.md §Circuit Execution:
- Show circuit overview (exercises, target reps, rounds) — data table format
- Step through: exercise → confirm reps → inter-exercise rest → next exercise
- Between rounds: inter-round rest with countdown in
surface-steelcard - Summary after all rounds complete
- "FINISH" button sets
completedAt - Show workout summary — duration in
text-readout, exercises, total sets, volume - Navigate back to Today screen
- Active workout with no
completedAtdetected on app launch - Prompt: "RESUME SESSION?" with RESUME/DISCARD options
- Resume restores full state from database
| Metric | Target |
|---|---|
| Set confirmation to visual feedback | < 100ms |
| Touch targets | ≥ 48px |
| Taps to confirm a pre-filled set | ≤ 2 |
| Taps to log an ad-hoc set | ≤ 4 |
- User can start an empty workout, add exercises, log sets, finish
- Weight × reps logging works with checkmark confirmation
- Previous set values pre-fill next set row
- Rest timer starts after set confirmation with countdown display
- Rest timer can be skipped or adjusted mid-countdown
- Undo available for 10 seconds after confirming a set
- Cardio logging: duration + distance entry with pace calculation
- Ruck logging: load weight + duration + distance + optional elevation
- SE circuit mode: step through exercises with rest timers between
- Elapsed session timer runs in
text-readoutSpace Grotesk - "FINISH" shows summary with duration, exercises, volume
- Set type classification works (working, warmup, drop, backoff)
- Only one active workout at a time (L-8)
- Crash recovery prompt on relaunch with incomplete workout
- All data persists to Supabase on every set confirmation
- UI matches Iron & Ember: tonal layering, no borders, industrial vocabulary
Dependencies: Step 6 (workouts exist to view)
Priority: P0
Docs: 01-prd-core.md §FR-6, DESIGN.md §4 Data Tables + §5 Layout
History list, workout detail view, and per-exercise history. The VAULT screen houses analytics in later steps, but history is accessible from FORGE (dashboard).
- Reverse chronological list of completed workouts
- Each entry on
surface-ironcard: date, duration, exercise names, set count - Alternating rows with
surface-charcoalfor density - Virtualized list for performance (large histories)
- Tap to view full detail
- Full workout reconstruction: exercises → sets → reps/weight
- Data table with SET / ACTUAL / STATUS columns (ALL-CAPS headers)
- Program context shown if applicable (block, week, day)
- Notes and perceived difficulty
- Duration and volume totals in
text-readoutscale
- Navigate from exercise detail screen
- Last N sessions with this exercise
- Set-by-set comparison across sessions — alternating row stripes
- Volume trend: horizontal load bars (
emberonsurface-steeltrack), not circular charts
- History list shows all completed workouts in reverse chronological order
- Workout detail shows full set-by-set breakdown
- Per-exercise history shows last 10+ sessions
- Volume trend renders as horizontal load bars (not circular)
- Virtualized list performs well with 100+ workouts
- Delete workout available with confirmation dialog
- All UI uses Iron & Ember tonal layering and typography
Checkpoint: The React app runs in the browser with Iron & Ember design system. Users can sign up, search exercises, set 1RMs, log any workout type (barbell, cardio, ruck, circuit), view history, and see per-exercise trends. Data is stored in Supabase. Take your phone to the gym and log a workout in the mobile browser.
Dependencies: Step 7 (browser app is functional)
Priority: P0
Docs: 07-architecture.md §Rust Backend Responsibilities
Tauri v2 shell wrapping the React app, Rust backend with SQLite for offline-first operation, and the Tauri data adapter. Core CRUD commands, adapter switching, and SQLite migrations are all in place. Remaining gaps: no integration test suite comparing Tauri vs Supabase adapter parity, Android APK build/install not verified, offline-first behavior not validated end-to-end.
Design note: Bundle Space Grotesk, Inter, and Material Symbols font files locally for Tauri builds to avoid Google Fonts network dependency. The heat-blur frosted glass effect may need testing in Tauri WebView — backdrop-filter support varies.
bun create tauri-appin existing project- Tauri v2 configuration (
tauri.conf.json) - Android target initialization (
tauri android init) - Verify React app renders inside Tauri WebView with Iron & Ember styling intact
- Add
sqlxwith SQLite feature to Cargo dependencies - Create SQLite database file in Tauri app data directory
- Migration system for schema creation
- Create all Phase 0 tables (mirroring Supabase schema from
08-erd.md)
Typed Rust functions invokable from React via invoke():
| Command | Purpose |
|---|---|
save_workout_log |
Insert/update workout to SQLite |
get_workout_logs |
Query workout history |
get_workout_log |
Get single workout with all sets |
save_logged_set |
Save individual set |
get_exercises |
Query exercise dictionary |
create_exercise |
Insert custom exercise |
get_user_profile |
Get profile + 1RMs |
save_one_rep_max |
Record new 1RM |
delete_workout_log |
Remove workout |
Implements the same data adapter interface as the Supabase adapter, but calls Tauri commands.
import { isTauri } from '@tauri-apps/api/core'
export const adapter = isTauri() ? tauriAdapter : supabaseAdapterAll existing TanStack Query hooks use the adapter — switching is transparent.
- Build Android APK (
tauri android build) - Sideload on personal device
- GO / NO-GO: Log a workout at the gym. Does it feel right?
- React app renders inside Tauri WebView (Android)
- Iron & Ember styling renders correctly in WebView (fonts, colors, heat-blur)
- SQLite database creates all tables on first launch
- All Tauri commands work: CRUD for workouts, exercises, profile
- Tauri adapter passes the same functional tests as Supabase adapter
- Adapter switching works: Tauri mode uses SQLite, browser uses Supabase
- Android APK builds and installs
- Existing workout logging flow works identically in Tauri mode
- Data persists across app restarts (SQLite)
- App works with airplane mode (offline-first validated)
- Fonts bundled locally (no Google Fonts dependency in native builds)
Dependencies: Step 8 (Tauri shell with SQLite working)
Priority: P0
Docs: 07-architecture.md §Sync Data Flow, 09-state-machines.md §Sync State Machine, 06-invariants.md §Sync Invariants
Rest timer fully implemented in Rust with notification support. Sync architecture present with push queue, pull subscription, and last-write-wins conflict resolution. Critical gap: the pull/upsert path is stubbed -- remote changes from Supabase are detected and conflict-resolved but not written to local SQLite (sync/pull.rs). Force-pull command also returns an error. Push (local to remote) works.
- Push: local SQLite changes → Supabase (on workout complete or periodic)
- Pull: Supabase realtime subscription → local SQLite
- Conflict resolution: last-write-wins by
updated_at - Offline queue: changes queued when offline, flushed on reconnect
- Auth awareness: sync only when authenticated
| State | Meaning | UI Indicator (Iron & Ember) |
|---|---|---|
offline |
No auth or no network | No indicator (app works normally) |
syncing |
Push or pull in progress | cloud_done icon pulsing in ember |
synced |
All caught up | cloud_done icon solid in steel-blue |
error |
Sync failed | error icon in error color, toast with retry |
The rest timer must survive screen lock and WebView backgrounding:
- React calls
invoke('start_rest_timer', { seconds: 150 }) - Rust starts async timer
- Rust emits
timer_tickevent every second - Rust emits
timer_expiredevent + triggers platform notification - React subscribes to events for UI countdown display in
surface-steelcard, Space Grotesk
- Use
tauri-plugin-notificationfor cross-platform alerts - Short chime + vibration when rest timer expires
- Notification channel:
rest_timer(high importance on Android)
- Workout logged offline (airplane mode) → go online → data appears in Supabase
- Workout logged on web (Supabase) → appears on Tauri app via sync (pull upsert stubbed)
- Conflict: same workout edited on both → last-write-wins correctly (logic wired, untestable without upsert)
- Sync state indicator uses correct Iron & Ember icons and colors
- Rest timer runs in Rust, counts down in React UI (Space Grotesk)
- Rest timer survives screen lock on Android (not verified)
- Timer expiry triggers notification with sound/vibration
- Timer can be skipped or adjusted from React UI
GO / NO-GO Checkpoint: The app works on Android with offline support. Rest timer survives screen lock. Sync works bidirectionally. If the mobile experience is acceptable → continue. If not → pivot (React app becomes web-only, rebuild mobile in native framework).
Dependencies: Step 9 (Tauri + sync working)
Priority: P0
Docs: 05-domain-model.md §SessionTemplate + §SetScheme, 10-user-flows.md §Flow 7 §SetScheme Editor, DESIGN.md §4
Session template creation and editing with the full SetScheme system (all 12 types). SetScheme editor dynamically renders correct fields per type with Zod validation. Templates save with nested activity groups and activities.
Create program-related tables: session_templates, activity_groups, activities. Add corresponding SQLite tables and Tauri commands.
The most complex form in the app. A type selector (12 options) that dynamically shows the correct fields. All form fields use underline-only inputs per Iron & Ember spec.
| SetScheme Type | Fields Shown |
|---|---|
| FixedSets | Sets, reps, weight, rest, AMRAP toggle |
| PercentageSets | Sets, reps, %1RM, rest, AMRAP toggle |
| WorkToMax | Target rep range, optional warmup scheme |
| CardioSteadyState | Duration or distance, intensity, modality |
| CardioInterval | Work duration/distance, rest, rounds, modality |
| RuckMarch | Duration or distance, load, pace target |
| EMOM | Reps per minute, total minutes |
| AMRAPTimed | Time cap |
| DescendingReps | Rep ladder input (e.g., 21, 15, 9) |
| ForReps | Target reps, optional load |
| TimedHold | Duration, sets, rest |
| PercentageOfMaxReps | Percentage slider |
- Name the session (underline input, Space Grotesk)
- Add activity groups (straight, circuit, superset, interval) —
secondarybuttons - Within each group: add exercises, configure set scheme per exercise
- Configure group-level settings (rounds, rest between activities)
- Save template —
forgeCTA
- SetScheme editor renders correct fields for all 12 types
- Switching type clears irrelevant fields
- Zod validation runs on save, shows inline errors in
errorcolor - Session template saves with nested activity groups and activities
- Session template loads and displays correctly
- Edit existing template works
- Templates sync via Supabase
- All form inputs use Iron & Ember underline style
Dependencies: Step 10 (session templates exist to schedule)
Priority: P0
Docs: 05-domain-model.md §Program, 06-invariants.md §Program Invariants
Program hierarchy: Program → Blocks → Weeks → Scheduled Sessions. Full CRUD, ordinal integrity, TB seed data, and single-active-program activation.
Create programs, blocks, block_weeks, scheduled_sessions tables. Add SQLite equivalents and Tauri commands.
- Create program with name, source, duration
- Add blocks with ordinal ordering (P-1)
- Add weeks to blocks (P-2)
- Schedule sessions to days within weeks (P-3)
created_byfield for coach-created programs
Pre-build session templates and program structures for common TB programs:
| Program | Blocks | Sessions |
|---|---|---|
| TB Operator (3-week) | 1 block, 3 weeks | 3×/week: squat, bench, WPU at 70/80/90% |
| TB Operator I/A | Perpetual (no fixed duration) | Floating sessions with self-regulation |
| TB Fighter | 1 block, 3 weeks | 2×/week: bench, squat, DL |
| Base Building (SE) | 1 block, 5 weeks | SE circuits + LSS |
| Base Building (Strength) | 1 block, 5 weeks | Strength + LSS |
- User selects "ACTIVATE" on a program (forge CTA)
- System tracks current position (block index, week number, next session)
- Only one active program at a time
- Program creates with blocks and weeks
- Block ordinal integrity enforced (P-1)
- Sessions scheduled to days within weeks
- TB Operator template loads correctly from seed data
- Program activation tracks current position
- Only one active program at a time
- All program data syncs correctly
Dependencies: Step 11 (program data structure exists)
Priority: P1
Docs: 10-user-flows.md §Flow 7, DESIGN.md §5 Layout (Large screen)
Visual drag-and-drop program builder using dnd-kit. Large screen multi-column layout with sidebar block list. Mobile fallback with simplified list-based editor. Copy-week, session picker, and program preview with calculated weights.
- Add/remove/reorder blocks (dnd-kit) —
drag_indicatoricon for handles - Set block type (standard, deload, peak) — flat
surface-steelbadges - Set duration (weeks)
- Visual week grid showing days on
surface-ironcards - Drag session templates onto days
- Copy week to fill a block quickly
- Assign existing session templates to days
- Create new session template inline
- Preview session content (exercises, set schemes) in
surface-charcoalpanel
- Read-only view of entire program structure
- Week-by-week, session-by-session breakdown in data table format
- Working weights shown based on current 1RMs — Space Grotesk numbers
- Drag-and-drop reordering of blocks works
- Drag session templates onto week days
- Copy week fills block quickly
- Program preview shows full structure with calculated weights
- Mobile: simplified list-based editor (no drag-drop)
- Saved programs appear in library
- Large screen layout uses sidebar + multi-column grid per DESIGN.md
Dependencies: Step 11 (programs exist), Step 6 (logging infrastructure)
Priority: P0
Docs: 01-prd-core.md §UC-2, 10-user-flows.md §Flow 3, DESIGN.md §4 Data Tables
The "Today's Workout" flow: load prescribed session, calculate weights from 1RMs, pre-fill all sets, log with prescribed-vs-actual tracking. Program position advancement on completion.
- If active program → show "TODAY'S SESSION" card on
surface-iron - Display: session name in Space Grotesk, exercise list, set/rep/weight summary
- "EXECUTE SESSION" button (molten gradient CTA)
- Load user's 1RMs for all exercises in the session
- Apply percentage:
weight = floor(1RM * percentage) - Round to nearest plate-loadable weight (per PR-3: within 5lb/2.5kg)
- Pre-fill all set rows with calculated weights and prescribed reps
- All sets appear pre-populated in data table: SET / PRESCRIBED / ACTUAL columns
- User taps
check_circleto confirm (2-tap logging) - User can edit any value before confirming (deviation from prescription)
- AMRAP sets show "5+" notation
- Prescribed values stored alongside actual values in LoggedSet
- VARIANCE column shows deviation: green (
arc) if matched/exceeded,errorcolor if under
- After completing the workout, advance to next session
- Track: current block, current week, next session day label
- Deload week awareness — visual indicator using
steel-bluebadge
- Today screen shows "TODAY'S SESSION" when program is active
- Percentage calculations resolve to plate-rounded weights
- All sets pre-filled with prescribed values
- Confirming a pre-filled set takes 1 tap (checkmark)
- Deviations recorded as actual ≠ prescribed
- AMRAP sets handled with "5+" and actual reps logged
- Workout links to program context (block, week, day)
- Program position advances after workout completion
- Plate calculator available (visual plate loading guide)
- SET / PRESCRIBED / ACTUAL / VARIANCE table matches Iron & Ember spec
Dependencies: Step 10 (session templates exist), Step 6 (workout logging exists)
Priority: P1
Docs: 04-prd-events.md, 05-domain-model.md §Event Entities, 06-invariants.md §Event Invariants, 08-erd.md §Event Tables
Event session type with packing lists and freeform requirements. Events are sessions with category: EVENT that display event metadata and a checkable packing list instead of exercises and sets.
Add event_metadata nullable JSON column to session_templates and workout_logs tables. Create event_items table with polymorphic FK (session_template_id XOR workout_log_id), CHECK constraints per 08-erd.md §Event Tables.
Migration includes:
event_metadatacolumn onsession_templates(TEXT, nullable)event_metadatacolumn onworkout_logs(TEXT, nullable)event_itemstable with CHECK constraint for FK exclusivity- Partial indices on
event_items(session_template_id)andevent_items(workout_log_id) - RLS policies for
event_items(user_id = auth.uid())
Add to src/domain/:
| Type | File | Contents |
|---|---|---|
| EventMetadata | event.ts |
EventMetadata value object, EventRequirement value object |
| EventItem | event.ts |
EventItem entity type |
| Zod schemas | event.ts |
eventMetadataSchema, eventRequirementSchema, eventItemSchema |
Update session.ts: Add EVENT to the SessionCategory enum and update the SessionTemplate type to include optional eventMetadata field.
Update workout-log.ts: Add optional eventMetadata field to WorkoutLog type.
Add to the data adapter interface:
| Method | Purpose |
|---|---|
getEventItems(parentId, parentType) |
Fetch packing list for a template or log |
saveEventItem(item) |
Create or update a packing list item |
deleteEventItem(itemId) |
Remove an item from the list |
toggleEventItemPacked(itemId) |
Toggle isPacked on a single item |
reorderEventItems(items) |
Batch update sort_order values |
Implement in both Supabase adapter (browser mode) and Tauri adapter (native mode, when Step 8 is complete).
Event creation form accessible from:
- Program builder (Step 12) -- adding an event session to a block week
- Quick-log -- creating a standalone event workout log
- Clone -- duplicating an event template with isPacked reset
Form sections per 10-user-flows.md §Flow 10:
- Event name (required, underline input)
- Date/time (optional, date + time pickers)
- Location (optional, underline input + coordinates toggle)
- Event URL (optional, underline input)
- Requirements (expandable section, key-value-unit-notes form)
- Packing list (expandable section, name-category-quantity-notes form)
All text uses industrial vocabulary: "ADD REQUIREMENT", "ADD ITEM", "SAVE EVENT"
Event detail screen per 10-user-flows.md §Flow 11:
- Event header with name, date, location, countdown badge
- Location as tappable map link (when coordinates present, opens platform maps)
- Event URL as tappable external link
- Requirements displayed as key-value list in
surface-steelcard - Packing list as categorized checklist with:
- Items grouped by
categoryvalue - Collapsible category sections
- Single-tap toggle for isPacked (< 100ms feedback)
- Progress bar per category and overall (
emberonsurface-steeltrack) - Drag-and-drop reorder within categories (dnd-kit)
- Items grouped by
- Events display with distinct visual treatment in program timeline (⚑ icon,
surface-steelcard withemberaccent) - Event date shown alongside the program week/day label
- Countdown badge visible on Today screen when next event is within 30 days
Per 11-notification-design.md §Type 4:
- New
event_remindersnotification channel (Android) - Configurable reminder intervals (default: 7 days, 3 days, 1 day before)
- Notification includes packing progress when items exist
- Respects quiet hours
- "VIEW EVENT" action navigates to event detail
-
EVENTis a valid SessionCategory in domain types - event_metadata column exists on session_templates and workout_logs
- event_items table exists with CHECK constraints and indices
- Zod schemas validate EventMetadata, EventRequirement, EventItem
- Data adapter supports getEventItems, saveEventItem, deleteEventItem, toggleEventItemPacked, reorderEventItems
- Event creation form renders with all fields (name, date, location, URL, requirements, packing list)
- Event detail screen displays metadata, requirements, and packing list
- Single-tap packing toggle works with < 100ms feedback
- Progress indicator updates on pack/unpack
- Drag-and-drop reorder works for packing list items
- Events display correctly in program timeline with ⚑ icon
- Clone operation resets isPacked to false on all items
- Event countdown notification fires at configured intervals
- Location renders as tappable map link when coordinates present
- RLS policies enforce user_id isolation on event_items
- All text uses industrial vocabulary (no emoji, no exclamation marks)
Checkpoint: Users can create programs, build session templates with all 12 SetScheme types, follow structured multi-week periodized programs with percentage-based loading, and log workouts with prescribed-vs-actual tracking. The TB template library provides ready-made programs. All UI follows Iron & Ember design system.
Dependencies: Step 7 (workout history exists)
Priority: P1
Docs: 05-domain-model.md §Domain Events (PersonalRecordSet), 11-notification-design.md §Type 3, DESIGN.md §4 Progress & Metrics
The VAULT screen with progress charts (Recharts), horizontal volume tracking bars, large metric readouts, and automatic PR detection with in-app molten gradient celebration banner and platform notifications.
- Line chart: 1RM over time per exercise (Recharts)
- Chart palette per DESIGN.md:
arc(#86CFFF) primary data line,ember(#FFB59C) secondary,steel-blue(#B1CAD7) tertiary - Filter by exercise, date range
- Show tested vs estimated markers
- Weekly tonnage by exercise or muscle group
- Horizontal load bars (
emberonsurface-steeltrack) — no circular progress rings per DESIGN.md - Large metric readouts (12.4T volume, 94% adherence) in Space Grotesk
text-readoutscale
After workout completion, scan logged sets for new bests:
- New 1RM (heaviest single)
- New 3RM, 5RM (heaviest set at rep count)
- New max reps at a given weight
- Distance/duration PRs for cardio
- PR notification styled with molten gradient banner
- Notification: "NEW PR: SQUAT — 275LB × 5" (industrial vocabulary)
- PR history list in exercise detail
- 1RM trend chart renders with
arc/ember/steel-bluepalette - Volume tracking shows weekly tonnage as horizontal load bars
- Large metrics in Space Grotesk
text-readout - PR detection runs after every workout completion
- PR notification fires for new bests
- PR history visible in exercise detail
- VAULT screen renders well on all screen sizes
- No circular progress rings -- horizontal bars only
Dependencies: Step 9 (Rust backend for background delivery)
Priority: P1
Docs: 11-notification-design.md (complete spec)
Three notification types: rest timer alerts, session reminders (configurable, disabled by default), and PR celebrations. Notification settings UI with per-type toggles and quiet hours. Android notification channels registered. Industrial vocabulary throughout.
- Optional, per-program
- Configurable time (default: 30 min before typical training time)
- Only fires when: active program + session today + not yet completed
- Content: session name + exercise summary
- Actions: "EXECUTE" / "LATER" (industrial vocabulary)
| Channel | ID | Importance |
|---|---|---|
| Rest Timer | rest_timer |
High (already exists from Step 9) |
| Workout Reminders | workout_reminders |
Default |
| Personal Records | personal_records |
Default |
- Default: 10 PM – 6 AM
- Rest timer exempt (user is actively working out)
- Never: "You missed your workout", "Don't skip leg day", streaks
- Always: neutral, actionable, informational — commands, not conversations
- Session reminder fires at configured time when workout is due
- "EXECUTE" action opens pre-filled workout
- Quiet hours prevent non-timer notifications
- All notification text passes shame-free review
- All notification text uses industrial vocabulary (no exclamation points, no emoji)
- Notification settings screen with per-type toggles and quiet hours
Dependencies: Step 7 (workouts exist to share), Step 11 (programs exist to share)
Priority: P1
Docs: 02-prd-sharing.md §Feature 1
Share link generation with 12-character cryptographic tokens. Public /s/$token route for unauthenticated viewing of programs and workout logs. Clone-to-library for authenticated users. Revocation support (deactivated links return 404).
- "SHARE" button (
secondarystyle) on program detail and workout detail screens - Generate random 12-character alphanumeric token
- Store in
share_linkstable with entity type and entity ID - Display copyable URL:
https://ardentforge.app/s/{token}
- Public route
/s/:token— no auth required to view - Fetch shared entity via token lookup (bypasses RLS)
- Read-only display of program structure or workout log, styled with Iron & Ember
- "CLONE TO LIBRARY" button (forge CTA, requires auth)
- Deep copy: program + blocks + weeks + sessions + templates
- Owned by the cloning user (
user_id= their ID)
- Author can view active share links
- Author can revoke (deactivate) any link
- "SHARE" button generates a working link
- Shared program viewable without authentication
- Shared workout log viewable without authentication
- "CLONE" copies program to authenticated user's account
- Author can revoke share links
- Revoked links return 404
- Shared view renders with Iron & Ember styling
Dependencies: Step 16 (sharing infrastructure), Step 9 (sync for cross-user data)
Priority: P2
Docs: 02-prd-sharing.md §Feature 2 + §Feature 3, 06-invariants.md §Sharing Invariants
Accountability groups with role-based visibility (coach/member), invite codes (AF-XXXXXXXX format), activity feeds, and direct peer connections with per-direction write access. Full RLS policy expansion. Group size limits enforced (max 20 members, 3 coaches, 5 groups per user). Data retention settings per group.
Create accountability_groups, group_members, group_invites, direct_connections tables.
Update RLS policies from simple user_id = auth.uid() to include group membership and connection checks. Key policy changes per 02-prd-sharing.md §RLS Policy Changes:
- Workout logs: readable by group peers and connections
- Exercise maxes: readable by group peers (coach needs this)
- Programs: readable by group members
- Workout logs: writable ONLY by owner (never by coach)
- Create group (creator becomes coach)
- Generate invite code (
AF-{8 alphanumeric}) - Join group via code or link
- Leave group
- Coach: remove member, revoke invite
- Chronological list of group members' recent workouts
- Entry: member name, date, session name, exercise summary — data table on
surface-ironcards - Tap to expand full set-by-set detail
- Private fields excluded: perceived difficulty, bodyweight, notes
- Search by email or username
- Send connection request → notification → accept/decline
- Symmetric read access when active
- Optional write access (per-direction toggle)
- Group creation with invite code works
- Joining via code adds user as MEMBER
- Coach sees all members' workout logs
- Members see each other's logs but not coach's logs
- Activity feed shows group workouts chronologically
- Private fields (difficulty, bodyweight, notes) excluded from group view
- Direct connection request/accept flow works
- Connection provides mutual log visibility
- RLS policies correctly enforce all access rules
- Group size limits enforced (SH-4)
- Leave group works, data retained per retention setting
Dependencies: Step 17 (groups exist with role-based access)
Priority: P2
Docs: 02-prd-sharing.md §Feature 2 (Coach Workflow), 06-invariants.md SH-1 through SH-3
Coach write access via RLS policies: create/edit programs and sessions for group members, update member 1RMs. SH-3 enforced (coach cannot modify workout logs). Per-direction write access on direct connections. Member override preserved (SH-2).
- Coach opens group → selects member → "CREATE PROGRAM"
- Standard program builder (from Step 12) with
user_id = memberandcreated_by = coach - Member receives notification: "COACH ASSIGNED PROGRAM: [name]"
A coach can assign a program they already own to a group member. This covers the common workflow where the coach builds a program before the member has signed up -- the program lives under the coach's account until the member joins the group, at which point the coach reassigns it.
The operation is a cascade update of user_id on the program and all child records (program blocks, program weeks, session templates, scheduled sessions) from the coach's ID to the member's ID. The created_by field remains unchanged (still the coach). After assignment, standard coach write permissions from Step 18a-18e apply -- the coach continues to edit the program, the member logs against it, and the member-always-wins rule (SH-2) governs conflicts.
UI surface: In the coach's group member view, alongside "CREATE PROGRAM" add "ASSIGN EXISTING PROGRAM." Coach selects from their own programs, confirms the target member, and the reassignment executes. Member receives notification: "COACH ASSIGNED PROGRAM: [name]."
RLS requirement: The acting user must be a coach in a group containing the target member. This is the same permission boundary used by 18a for new program creation -- no new RLS policy needed, just an additional allowed operation under the existing coach write policy.
Adapter method:
| Method | Params | Behavior |
|---|---|---|
assignProgramToMember |
programId, targetUserId |
Update user_id on program and all child records (blocks, weeks, session templates, scheduled sessions) from coach to member. Validate coach role in shared group before executing. |
Tauri command:
| Command | Params | Behavior |
|---|---|---|
assign_program_to_member |
program_id, target_user_id |
Cascade update user_id within a single SQLite transaction. Validate group membership locally. |
- Coach can modify upcoming scheduled sessions for a member
- Changes reflected when member opens "TODAY'S SESSION"
- Member notification on changes
- Coach can view and update member's 1RMs
- Needed to calibrate percentage-based programs
- Member can always modify or delete coach-created programs
- Member's edits take precedence (SH-2: member always wins)
- Optional write access on direct connections
- Per-direction: User A can grant write to User B independently
- Same permissions as coach write (programs/templates/sessions/1RMs)
- Coach can create program owned by member
- Coach can assign an existing program they own to a group member (cascade updates user_id on program + children)
- Coach can edit member's upcoming sessions
- Coach can update member's 1RMs
- Member receives notification on coach changes
- Member can modify/delete coach-created programs
- Coach CANNOT modify member's workout logs (SH-3)
- Connection write access works per-direction
- RLS policies correctly scope coach write to programs/templates/sessions only
Dependencies: Step 4 (Supabase adapter + auth), Step 8 (Tauri shell + SQLite). Can be done in parallel with Steps 10-18.
Priority: P0 (required before Play Store release)
Docs: 03-prd-hosting.md, 07-architecture.md §Configuration Layer, 06-invariants.md §Configuration Invariants
Runtime configuration system with lazy Supabase client initialization. Config store (browser: localStorage, Tauri: SQLite app_config table). Setup screen at /setup for first launch. Connection validator with three-state status. Backend change in Settings with data wipe confirmation for Tauri. Comprehensive test suite (16 cases).
Define a ConfigStore interface with methods: getConfig, setConfig, clearConfig, hasConfig. Two implementations follow the existing adapter pattern.
| Implementation | Storage | Read Timing |
|---|---|---|
| Browser | localStorage key ardentforge:config |
Synchronous |
| Tauri | SQLite app_config table (key-value, local-only) |
Async via invoke('get_app_config') |
The app_config table is created in the existing SQLite migration set but is excluded from the sync engine's table list. It has two columns: key (TEXT PRIMARY KEY) and value (TEXT, JSON string).
New Tauri commands for Step 8's Rust backend:
| Command | Purpose |
|---|---|
get_app_config |
Read a config key from app_config |
set_app_config |
Write a config key to app_config |
clear_app_config |
Delete a config key |
wipe_synced_data |
Drop and recreate all synced tables (for backend change) |
Refactor src/lib/supabase.ts from eager module-level initialization to a lazy factory.
Current behavior: createClient(import.meta.env.VITE_SUPABASE_URL, ...) runs at import time.
New behavior: A getSupabaseClient() function reads from the config store on first call, constructs the client, and caches it. Returns null if no config exists. On config change, the cached client is discarded.
The Supabase adapter and all TanStack Query hooks that reference the client are updated to use the factory. The sync engine (Rust) also reads the config store for its Supabase connection.
A validation function that takes a URL and publishable key, attempts a lightweight request against the target Supabase instance, and returns a result indicating success, connection failure, or missing schema.
Validation steps: first, hit the REST API root to confirm the instance is reachable and the key is accepted. Second, attempt a simple query against a known table (e.g., SELECT 1 FROM exercises LIMIT 1) to confirm the schema is present. The second step distinguishes between "valid Supabase but no Ardent Forge schema" and "fully configured."
A new route at /setup with the Iron & Ember design system. This screen is shown only when no valid configuration exists (first launch with failed defaults, or after config is cleared).
| Element | Design |
|---|---|
| Heading | "CONFIGURE BACKEND" in text-industrial |
| URL field | Underline input, placeholder "Supabase URL" |
| Key field | Underline input, placeholder "Publishable Key" |
| Validate button | forge CTA: "CONNECT" |
| Status | Inline feedback: connecting spinner, success checkmark, error message |
| Help link | "Self-hosting? See the setup guide" → links to GitHub docs |
Add a "Backend" section to the existing Settings route. Shows current Supabase URL (truncated, with copy button). "CHANGE BACKEND" button opens an edit form identical to the setup screen, but with a confirmation dialog warning about data reset (Tauri only, per CF-3).
| Platform | Change Behavior |
|---|---|
| Browser | Clear auth session → re-validate → persist → redirect to sign-in |
| Tauri | Confirm dialog → wipe synced SQLite tables → clear auth → re-validate → persist → redirect to sign-in |
Add a config check to the TanStack Router root layout. Before the existing auth guard runs, check configStore.hasConfig(). If false, redirect to /setup. This ensures no part of the app attempts to use a Supabase client before one can be constructed.
- Config store reads and writes correctly (browser: localStorage, Tauri: SQLite)
- Supabase client initializes lazily from config store
- App with no env vars and no stored config shows setup screen on launch
- App with valid bundled defaults skips setup screen (smart default flow)
- Connection validator distinguishes: reachable + schema present, reachable + no schema, unreachable
- Setup screen validates and persists config, then proceeds to auth
- Settings page shows current backend URL
- Changing backend in browser mode: clears auth, re-validates, persists, redirects to sign-in
- Changing backend in Tauri mode: shows confirmation, wipes synced tables, clears auth, persists
-
app_configtable is never included in sync operations - Existing workout logging flow works identically after refactor
- All existing tests pass (client initialization change is transparent to consumers)
Dependencies: Step 3 (Supabase migrations exist), Step 19 (runtime config for mobile users connecting to self-hosted instances). Can be done in parallel with Steps 10-18.
Priority: P1
Docs: 03-prd-hosting.md §Docker Composition
Full Docker Compose stack (9 containers: Postgres, Kong, GoTrue, PostgREST, Realtime, Studio, migration runner, web app via nginx, Caddy for TLS). Key generation script, .env.example, Caddyfile, multi-stage web app Dockerfile, idempotent migration runner, and comprehensive self-hosting documentation covering both Docker and Supabase Cloud paths.
Docker Compose configuration for one-command self-hosted deployment, plus self-hosting documentation.
A docker-compose.yml at the repository root that provisions the full stack. Based on Supabase's official self-hosted Compose file with two additions: a migration init container and a web app container.
| Container | Source | Notes |
|---|---|---|
| Supabase stack | Official supabase/docker |
Postgres, Kong, GoTrue, PostgREST, Realtime, Studio |
| Migration runner | supabase/cli |
Runs supabase db push against local Postgres using service_role key, then exits |
| Web app | Multi-stage Dockerfile (Bun build → nginx) | Serves Vite production build, build-time env vars from Compose .env |
| Caddy | caddy:alpine |
Reverse proxy, automatic TLS via Let's Encrypt |
Create .env.example with all required variables, default values where safe, and comments explaining each. Include a shell script (scripts/generate-keys.sh) that generates the JWT secret and derives the anon and service_role keys using the Supabase key generation algorithm.
Multi-stage build:
| Stage | Base | Purpose |
|---|---|---|
| Build | oven/bun:latest |
bun install + bun run build with env vars |
| Serve | nginx:alpine |
Copy build output to nginx html dir, custom nginx.conf for SPA routing |
The nginx config handles SPA fallback (all routes → index.html) and sets appropriate cache headers (hashed assets: immutable, index.html: no-cache).
A lightweight container that waits for Postgres to be healthy (via pg_isready), then applies all migrations from supabase/migrations/. Uses the service_role key from the Compose .env. Exits with code 0 on success, allowing dependent containers to start.
On subsequent runs, already-applied migrations are skipped (Supabase CLI tracks applied migrations in a schema_migrations table).
A Caddyfile that routes traffic:
| Path | Target |
|---|---|
/ and static assets |
Web app container (nginx) |
/rest/v1/*, /auth/v1/*, /realtime/v1/* |
Kong (Supabase API gateway) |
/studio/* (optional) |
Supabase Studio |
Caddy handles TLS automatically via Let's Encrypt using the SITE_URL from .env.
A docs/self-hosting.md file (or a section in the main README) covering both deployment paths.
Docker path: Prerequisites, clone, configure .env, generate keys, docker compose up -d, verify. Includes a troubleshooting section for common issues (port conflicts, DNS, TLS).
Supabase Cloud path: Prerequisites, create project, link, push schema, deploy web app, configure mobile app. Shorter since Supabase handles infrastructure.
Both paths end with: "To connect the Play Store app, open Settings → Backend and enter your instance URL and publishable key."
Add health check endpoints to the Docker containers so docker compose ps shows meaningful status. The web app container health check is a simple HTTP GET to /. The migration runner has no health check (it exits).
-
docker compose up -dstarts all containers from a clean state - Migration runner applies schema and exits cleanly
- Web app loads at
SITE_URLwith Iron & Ember design - User can register, sign in, and log a workout via the web interface
- Play Store app connects to Docker-hosted instance after configuring URL in Settings
- Supabase Studio accessible at
SITE_URL/studio(when enabled) -
docker compose down && docker compose up -dis idempotent (data persists, migrations don't re-run) -
.env.exampledocuments all variables with comments -
scripts/generate-keys.shproduces valid JWT secret and derived keys - Self-hosting docs cover both Docker and Supabase Cloud paths
- Caddy handles TLS automatically
Dependencies: Step 4 (data adapter pattern), Step 9 (sync engine for offline message queueing), Step 17 (accountability groups + direct connections for relationship checks)
Priority: P2
Docs: 12-prd-chat.md §Data Model + §Social Model Integration, 06-invariants.md §Chat Invariants, 08-erd.md
Database tables, RLS policies, data adapter extension, and Zod schemas for the chat domain. No UI in this step -- this is pure data plumbing.
Create four new tables via migrations: conversations, conversation_participants, messages, media_attachments. All column definitions per 12-prd-chat.md §Data Model.
Key constraints:
| Table | Constraint | Invariant |
|---|---|---|
conversations |
CHECK (type IN ('direct', 'group')) | -- |
conversation_participants |
UNIQUE (conversation_id, user_id) | CH-2 |
conversation_participants |
Unique partial index on participant pair for direct conversations | CH-2 |
messages |
CHECK (message_type IN ('text', 'workout', 'media', 'system')) | -- |
messages |
INDEX on (conversation_id, created_at) | CH-5 |
media_attachments |
CHECK (provider IN ('cloudflare_stream', 'supabase_storage')) | -- |
media_attachments |
CHECK (status IN ('processing', 'ready', 'failed')) | -- |
All four tables get Row Level Security enabled. Policies enforce CH-1 (conversation access requires participation):
| Table | Operation | Policy |
|---|---|---|
conversations |
SELECT | User has active (non-departed) row in conversation_participants |
conversation_participants |
SELECT | User is a participant in the same conversation |
conversation_participants |
INSERT | User is adding themselves, or is a participant adding to a group they're in |
messages |
SELECT | User participates in the message's conversation |
messages |
INSERT | User participates in conversation AND left_at IS NULL |
media_attachments |
SELECT | Inherits from parent message's conversation participation check |
media_attachments |
INSERT | User participates in the parent message's conversation |
Create matching tables in the SQLite migration set. The messages table gains an additional sync_status column (TEXT, default 'synced', CHECK IN ('pending', 'synced', 'failed')) that exists only in SQLite, not in Postgres. This column drives the offline message queueing state machine.
New Tauri commands:
| Command | Purpose |
|---|---|
create_conversation |
Insert conversation + initial participants |
get_conversations |
Query user's conversations, ordered by updated_at |
get_conversation |
Get single conversation with participants |
send_message |
Insert message to SQLite with sync_status pending |
get_messages |
Query messages for a conversation, paginated |
update_last_read |
Set last_read_at for a participant |
get_unread_counts |
Count unread messages per conversation |
leave_conversation |
Set left_at on participant record |
save_media_attachment |
Insert or update media attachment metadata |
toggle_archive |
Set is_archived on participant record |
Extend the data adapter interface with a ChatAdapter facet. This follows the same pattern as the existing data adapter -- interface definition in src/domain/, Supabase and Tauri implementations in their respective adapter directories.
| Method | Return Type | Description |
|---|---|---|
createConversation |
Conversation |
Create direct or group conversation |
getConversations |
Conversation[] |
All user's conversations, sorted by recency |
getConversation |
Conversation |
Single conversation with participants |
findDirectConversation |
Conversation | null |
Find existing direct conversation with a user |
sendMessage |
Message |
Persist a new message |
getMessages |
Message[] |
Paginated messages for a conversation |
getMessagesSince |
Message[] |
Messages newer than a timestamp (catch-up) |
updateLastRead |
void |
Update read cursor for current user |
getUnreadCounts |
Map<string, number> |
Unread counts per conversation |
addParticipant |
ConversationParticipant |
Add user to group conversation |
leaveConversation |
void |
Set left_at on current user's participation |
toggleArchive |
void |
Toggle is_archived flag |
saveMediaAttachment |
MediaAttachment |
Create or update media attachment record |
New schema files in src/domain/:
| File | Contents | Source Doc |
|---|---|---|
conversation.ts |
Conversation, ConversationType, ConversationParticipant | 12-prd-chat.md §Data Model |
message.ts |
Message, MessageType, WorkoutSnapshot (value object), SyncStatus (local-only) | 12-prd-chat.md §Messaging |
media.ts |
MediaAttachment, MediaProvider, MediaType, MediaStatus | 12-prd-chat.md §Data Model |
The WorkoutSnapshot schema reuses existing exercise, set scheme, and logged set schemas but wraps them in a self-contained structure suitable for JSON serialization into the message content field.
Extend the Rust sync engine to include the four chat tables in the sync boundary. Messages use the sync_status column for offline queueing:
- On send while offline: insert to SQLite with
sync_status = 'pending' - On connectivity restored: push pending messages to Supabase, set
sync_status = 'synced'with server-assigned timestamp - On push failure after retries: set
sync_status = 'failed'
The sync engine treats messages as append-only (CH-7). No update or delete sync needed for messages in the initial release.
Media attachment records sync metadata only (URLs, status, thumbnail URL). Binary files are never synced to SQLite (CH-6).
- All four tables created via Supabase migration
- RLS policies enforce conversation membership on all operations
- Unauthenticated requests to chat tables rejected
- User can only read messages in conversations they participate in
- Direct conversation uniqueness constraint prevents duplicates
- SQLite tables mirror Postgres schema (plus
sync_statuson messages) - All Tauri commands work: CRUD for conversations, messages, participants
- Chat adapter interface defined with all methods
- Supabase adapter implements all chat methods
- Tauri adapter implements all chat methods via Tauri commands
- Zod schemas validate conversation, message, and media attachment types
- WorkoutSnapshot schema serializes/deserializes from existing workout types
- Sync engine pushes pending messages on reconnect
- Sync engine pulls new messages on reconnect (catch-up query)
-
domain/chat types have zero React or framework dependencies
Dependencies: Step 21 (chat tables and adapter exist)
Priority: P2
Docs: 12-prd-chat.md §Message Ordering and Delivery, 07-architecture.md §Supabase Realtime Integration
Real-time message delivery via Supabase Realtime Broadcast. Typing indicators. Catch-up-then-subscribe reconnection pattern.
Each conversation maps to a private Supabase Realtime Broadcast channel. The channel topic is the conversation ID. The app subscribes to channels for all active conversations when the chat feature is entered, and unsubscribes when the user leaves chat.
Channel lifecycle:
| Event | Action |
|---|---|
| User opens chat list | Subscribe to channels for all conversations with unread or recent activity |
| User opens a conversation | Subscribe to that conversation's channel (if not already subscribed) |
| User leaves chat | Unsubscribe from all channels (conserve connections) |
| App backgrounds | Unsubscribe from all channels |
| App foregrounds | Run catch-up query, then re-subscribe |
| New conversation created | Subscribe to the new channel |
| User leaves conversation | Unsubscribe from that channel |
When a user sends a message, two things happen simultaneously:
- The message is inserted into the
messagestable via the chat adapter (persistence). - A Broadcast event is sent on the conversation's channel with the message payload (real-time notification).
Connected participants receive the Broadcast event and append the message to their local conversation view. The Broadcast event is a lightweight notification -- recipients verify the message exists in their local store (or fetch it) rather than relying solely on the Broadcast payload.
Event payload:
| Field | Type | Description |
|---|---|---|
type |
string | 'message' |
message_id |
string | UUID of the new message |
conversation_id |
string | UUID of the conversation |
sender_id |
string | UUID of the sender |
message_type |
string | 'text', 'workout', 'media', 'system' |
preview |
string | First 100 characters of content (for notifications) |
created_at |
string | Server timestamp (ISO 8601) |
Typing indicators are ephemeral Broadcast events on the same channel. They are never persisted.
| Field | Type | Description |
|---|---|---|
type |
string | 'typing' |
user_id |
string | UUID of the typing user |
user_name |
string | Display name for rendering |
The sender emits a typing event on each keystroke, debounced to at most once per 2 seconds. Recipients display the typing indicator for 3 seconds after the last received typing event, then clear it. No typing indicator is shown for the user's own input.
On app foreground or reconnection:
- For each conversation the user participates in, query
getMessagesSince(conversation_id, last_read_at). - Insert any missed messages into the local store.
- Update unread counts.
- Subscribe to Broadcast channels for live updates.
This ordering ensures no messages are missed during the gap between offline and subscription. The catch-up query uses the last_read_at cursor from conversation_participants to avoid re-fetching already-seen messages.
| Hook | Query Key | Adapter Method / Realtime |
|---|---|---|
useConversations |
['conversations'] |
getConversations |
useConversation |
['conversation', id] |
getConversation |
useMessages |
['messages', conversationId, cursor] |
getMessages (infinite query) |
useUnreadCounts |
['unread-counts'] |
getUnreadCounts |
useSendMessage |
mutation, invalidates ['messages', convId] |
sendMessage + Broadcast |
useRealtimeMessages |
-- (side effect) | Broadcast subscription, appends to ['messages'] cache |
The useMessages hook uses TanStack Query's infinite query pattern for cursor-based pagination (load older messages on scroll-up). New messages from Realtime are appended to the newest page.
In Tauri mode, the Realtime subscription can run either in the WebView (via the Supabase JS client, same as browser mode) or in the Rust backend via a native WebSocket client. The simpler approach -- using the WebView's Supabase client -- is recommended for the initial release. The Rust-native approach is only needed if WebView backgrounding kills the WebSocket connection on Android; if so, the Rust backend can maintain the connection and forward events to the WebView via Tauri events.
Test the WebView approach first. If WebSocket connections drop on Android background, add a Rust-side keepalive as a follow-up.
- Broadcast channel subscribes per conversation on chat entry
- Sending a message triggers both DB insert and Broadcast event
- Remote messages appear in < 500ms for connected participants
- Typing indicator shows when remote user is composing
- Typing indicator clears after 3 seconds of inactivity
- Catch-up query fetches missed messages on reconnect
- No duplicate messages after catch-up + subscription overlap
- Channel subscriptions cleaned up on chat exit and app background
- Unread counts update in real time when messages arrive
- TanStack Query infinite query loads older messages on scroll-up
- Works in both browser mode and Tauri WebView
Dependencies: Step 22 (Realtime working), Step 1.5 (Iron & Ember design system)
Priority: P2
Docs: 12-prd-chat.md §Conversation Types + §Messaging + §Blocking, 10-user-flows.md §Chat Flows, DESIGN.md
The most time-intensive chat step. Conversation list, conversation detail with virtualized message list, compose bar, system messages, unread indicators, and blocking. All UI follows Iron & Ember: dark-only, zero border-radius, tonal surface layering.
Add a fifth tab to the bottom navigation (mobile) and sidebar (wide viewport):
| Icon | Label | Route |
|---|---|---|
chat (Material Symbol) |
COMMS | /comms |
Update the nav from 4 items to 5. Mobile bottom nav touch targets remain >= 48px. Large screen sidebar adds the item below VAULT.
Displays all conversations the user participates in, sorted by most recent activity (updated_at descending).
| Element | Design |
|---|---|
| Conversation row | surface-iron card, zero border-radius |
| Avatar / group icon | 40px circle on surface-steel -- initials for direct, group icon for group |
| Title | Space Grotesk text-label-large -- other user's name or group title |
| Last message preview | Inter body-small, truncated to one line, text-secondary color |
| Timestamp | Inter label-small, right-aligned, relative time ("3m", "2h", "Yesterday") |
| Unread indicator | ember dot (8px circle) next to timestamp, title rendered bold |
| Empty state | "NO ACTIVE CHANNELS" centered, text-secondary, with "START CONVERSATION" CTA |
- "NEW" button (top-right,
forgeCTA) opens a contact picker - Contact picker shows friends and group members from existing connections
- Single selection → create direct conversation (or navigate to existing per CH-2)
- Multi-selection (3+ users) → create ad-hoc group with title prompt
- Group conversations linked to a group entity are created from the group detail screen, not from the contact picker
The core chat view. A virtualized message list with a compose bar at the bottom.
Header:
| Element | Design |
|---|---|
| Back button | arrow_back icon, navigates to conversation list |
| Title | Space Grotesk text-label-large -- conversation name |
| Participant count | Inter label-small, text-secondary -- "(4 members)" for groups |
| Menu | more_vert icon → actions (leave, archive, block for direct) |
Message list:
| Element | Design |
|---|---|
| Own messages | Right-aligned bubbles on surface-steel background |
| Other messages | Left-aligned bubbles on surface-iron background |
| Sender name | Inter label-small in ember above the bubble (group conversations only) |
| Timestamp | Inter label-small, text-secondary, shown on first message of a time cluster (5-min gap) |
| System messages | Centered, no bubble, Inter body-small in text-secondary |
| Pending indicator | schedule Material Symbol next to own pending messages |
| Date separator | Centered date label on surface-charcoal -- "TODAY", "YESTERDAY", or formatted date |
The message list uses virtualization (e.g., @tanstack/react-virtual) for performance with large conversation histories. Scroll position anchors to the bottom on new messages. Scrolling up loads older messages via the infinite query (Step 22e).
Compose bar:
| Element | Design |
|---|---|
| Container | Sticky bottom, surface-anvil background, zero border-radius |
| Text input | Underline input, placeholder "Message..." in text-secondary |
| Send button | send Material Symbol, ember color when input has content |
| Attachment button | attach_file Material Symbol, opens attachment picker |
| Typing indicator | Above compose bar: "[Name] is typing..." animated dots |
Triggered by the attachment button in the compose bar. Presents options relevant to the current conversation:
| Option | Icon | Action |
|---|---|---|
| Video | videocam |
Opens device camera/gallery for video (<=60s, <=50 MB) |
| Photo | photo_camera |
Opens device camera/gallery for image (<=10 MB) |
| Workout | fitness_center |
Opens workout picker (from Step 24) |
| File | description |
Opens file picker for documents (<= 25 MB) |
The picker is a bottom sheet (mobile) or dropdown (large screen), styled with surface-steel cards.
- Block action available from conversation header menu (direct conversations) or from a user's profile
- Block confirmation dialog: "Block [Name]? Their messages will be hidden in all conversations."
- Blocked user's messages filtered client-side (not deleted from DB)
- Blocked indicator in direct conversation: "This conversation is blocked. Unblock to resume."
- Unblock available from Settings or from the blocked conversation
- Participant list viewable from conversation header (tap participant count or from menu)
- Add participant: "ADD MEMBER" button (group conversations only), opens contact picker filtered to friends not already in the conversation
- Leave confirmation dialog: "Leave this conversation? You won't receive new messages."
- System messages generated for join/leave events
- COMMS tab appears in bottom nav (mobile) and sidebar (wide viewport) with
chaticon - Conversation list shows all conversations sorted by recency
- Unread indicator (ember dot + bold) shows for conversations with new messages
- Empty state renders when user has no conversations
- New direct conversation creates or navigates to existing (CH-2)
- New ad-hoc group conversation creates with title prompt
- Message bubbles render: own (right,
surface-steel), other (left,surface-iron) - System messages render centered without bubble
- Sender names shown in group conversations, hidden in direct
- Timestamps cluster correctly (5-minute gap rule)
- Date separators show between day boundaries
- Pending messages show clock icon, re-sort on sync
- Compose bar: send button, attachment button, underline input
- Typing indicator appears and clears correctly
- Message list virtualized -- smooth scrolling with 500+ messages
- Scroll-up loads older messages (infinite query)
- Block/unblock works, blocked messages hidden
- Group participant list viewable
- Add participant and leave conversation work
- Touch targets >= 48px on all interactive elements
- All UI follows Iron & Ember: zero border-radius, tonal layering, industrial vocabulary
Dependencies: Step 23 (chat UI exists), Step 6 (workout log display components)
Priority: P2
Docs: 12-prd-chat.md §Workout Sharing, 10-user-flows.md §Flow: Share a Workout to Chat
Share workout logs, programs, and templates into conversations as frozen snapshots. Render shared workouts as cards within chat bubbles.
A function that takes a WorkoutLog, Program, or SessionTemplate and produces a self-contained WorkoutSnapshot JSON payload. The snapshot includes all data needed to render the workout card without any database lookups: exercise names, sets with reps/weight/percentage, rest periods, notes, and duration.
| Source Entity | Snapshot Contents |
|---|---|
| WorkoutLog | Date, duration, exercises with logged sets (actual values), notes |
| Program | Name, block/week structure, session names, exercise lists |
| SessionTemplate | Name, activity groups, exercises, set schemes with resolved parameters |
The snapshot never includes the source entity's ID or user ID. It is a value object with no identity or foreign key references.
When the user taps the "Workout" option in the attachment picker:
- Show a list of recent workout logs (last 20), programs, and templates
- Segmented control: "LOGS" / "PROGRAMS" / "TEMPLATES" -- flat
surface-steelbadges - Each item shows: name/date, exercise summary, duration (for logs)
- Tap to select → confirmation → message created with
message_type = 'workout'
A "SHARE" button on workout log detail, program detail, and session template detail screens. Tapping opens a conversation picker (list of user's conversations). Select conversation → snapshot created → message sent.
Workout-type messages render as a card within the chat bubble:
| Element | Design |
|---|---|
| Card container | surface-charcoal inside the message bubble, zero border-radius |
| Header | Space Grotesk text-label-large: "WORKOUT LOG" / "PROGRAM" / "TEMPLATE" |
| Title / date | Exercise names or program name, date if workout log |
| Summary | Inter body-small: set count, volume, duration |
| Expand action | "VIEW DETAILS" text button in ember -- expands to full set-by-set breakdown |
The expanded view reuses existing workout display components from Step 6, rendered inline within the chat conversation. The card collapses on a second tap.
- Workout snapshot serializes WorkoutLog, Program, and SessionTemplate
- Snapshot contains all rendering data -- no DB lookups needed to display
- Workout picker shows recent logs, programs, and templates
- Selecting a workout creates a message with
message_type = 'workout' - Share-from-detail flow works on workout log, program, and template screens
- Workout card renders inside chat bubble with correct Iron & Ember styling
- "VIEW DETAILS" expands to full set-by-set breakdown
- Workout cards display correctly offline (snapshot is self-contained)
- Snapshot does not include source entity ID or user ID (privacy)
Dependencies: Step 23 (chat UI with attachment picker exists)
Priority: P2
Docs: 12-prd-chat.md §Media Sharing + §Video Platform Integration, 07-architecture.md §Media Provider Interface + §Edge Functions
Cloudflare Stream integration for video, Supabase Storage for images. Client-side upload with progress, inline playback, and signed URL access control.
- Create Cloudflare Stream account
- Generate API token with Stream write permissions
- Store API token as a Supabase Vault secret (accessible from Edge Functions)
- Configure webhook URL pointing to the
chat-media-webhookEdge Function
Define a MediaProvider interface in src/domain/ that abstracts the video platform:
| Method | Return Type | Description |
|---|---|---|
getUploadUrl(metadata) |
{ url, assetId } |
Get a direct creator upload URL for the client |
getPlaybackUrl(assetId) |
string |
Get the HLS manifest URL for playback |
getSignedUrl(assetId, ttl) |
string |
Get a time-limited signed playback URL |
deleteAsset(assetId) |
void |
Delete a video asset from the provider |
The initial implementation is CloudflareStreamProvider. The interface allows swapping to Mux later without changing the chat layer.
An authenticated Supabase Edge Function that:
- Verifies the caller is authenticated
- Validates upload parameters (max duration: 60s, max size: 50 MB)
- Calls the Cloudflare Stream API to create a direct creator upload URL (TUS endpoint)
- Returns the upload URL and asset ID to the client
The function reads the Stream API token from Supabase Vault. The client never sees the API token.
A webhook receiver for Cloudflare Stream transcoding events:
| Stream Event | Action |
|---|---|
ready.to.stream |
Update media_attachments record: status → 'ready', set thumbnail URL |
encoding.failed |
Update media_attachments record: status → 'failed' |
The webhook validates the incoming request using a shared secret. On status update, a Broadcast event is sent on the conversation's channel to notify connected participants that the video is playable.
- User selects or records a video via the attachment picker (Step 23e)
- Client validates constraints: duration <= 60s, size <= 50 MB, format is MP4/MOV/WebM
- Client calls
chat-media-upload-urlEdge Function → receives TUS upload URL + asset ID - Client creates a
messagesrecord withmessage_type = 'media'and amedia_attachmentsrecord withstatus = 'processing' - Client uploads video to Cloudflare Stream via TUS protocol (resumable, shows progress)
- Upload progress displayed as a horizontal progress bar in
emberonsurface-steeltrack inside the message bubble - When transcoding completes (webhook → DB update → Broadcast event), the message bubble updates to show a playable thumbnail
Upload error handling:
| Error | Behavior |
|---|---|
| File too large | Reject before upload with inline error: "Video exceeds 50 MB limit" |
| Duration too long | Reject before upload: "Video exceeds 60 second limit" |
| Upload fails midway | Retry up to 3 times (TUS is resumable); show "RETRY" button |
| Transcoding fails | Show error icon: "Processing failed. Tap to retry." |
| Transcoding timeout | After 5 minutes with no webhook, set status to 'failed' |
- User selects or captures an image via attachment picker
- Client validates: size <= 10 MB, format is JPEG/PNG/WebP/HEIC
- Client uploads to Supabase Storage bucket
chat-imagesusing the Supabase client - On upload complete, create message with
message_type = 'media'andmedia_attachmentsrecord withstatus = 'ready'(no transcoding needed)
Supabase Storage RLS on the chat-images bucket: authenticated users can upload; download is allowed for users who participate in the message's conversation (enforced via signed URLs).
- User selects a file via the attachment picker (Step 23e)
- Client validates constraints: size <= 25 MB, extension is in the allowed list (PDF, DOC, DOCX, XLS, XLSX, CSV, TXT, ZIP), blocked extensions (exe, bat, sh, cmd, ps1, msi, app, dmg, jar, com, scr, vbs, wsf) are rejected
- Client uploads to Supabase Storage bucket
chat-filesusing the Supabase client - On upload complete, create message with
message_type = 'file'and amedia_attachmentsrecord withstatus = 'ready',original_filename, andmime_typepopulated - File card renders immediately in the conversation on success
Upload error handling:
| Error | Behavior |
|---|---|
| File too large | Reject before upload with inline error: "File exceeds 25 MB limit" |
| Blocked extension | Reject before upload: "File type not allowed" |
| Format not in allowlist | Reject before upload: "Unsupported file type" |
| Upload fails | Retry up to 3 times; show "RETRY" button on persistent failure |
Supabase Storage RLS on the chat-files bucket: authenticated users can upload; download requires participation in the message's conversation (enforced via signed URLs).
| Media Status | Rendering |
|---|---|
| Processing | Pulsing placeholder rectangle on surface-charcoal, "PROCESSING..." label |
| Ready (video) | Thumbnail image with play_circle overlay icon in ember, tap to play |
| Ready (image) | Inline image preview (max-width 280px), tap to expand full-screen |
| Ready (file) | File card: document-type icon, original filename, file size, "DOWNLOAD" button in ember |
| Failed | error icon with "FAILED" label, "RETRY" text button in ember |
Video playback: open in an inline player within the conversation. The player uses Cloudflare Stream's embedded player or hls.js with the HLS manifest URL. Player controls match Iron & Ember palette. Full-screen toggle available.
When a participant taps to play a video, the client requests a signed playback URL from a Supabase Edge Function. The Edge Function:
- Verifies the caller participates in the message's conversation
- Calls Cloudflare Stream to generate a signed URL with 1-hour TTL
- Returns the signed URL to the client
The signed URL is cached client-side for its TTL to avoid redundant Edge Function calls on repeated playback.
- Cloudflare Stream account configured with API token in Supabase Vault
-
chat-media-upload-urlEdge Function returns valid TUS upload URL -
chat-media-webhookEdge Function processes transcoding events - Video upload from mobile: select/record → progress bar → playable thumbnail
- Video upload constraints enforced (60s, 50 MB)
- Image upload to Supabase Storage works
- Image constraints enforced (10 MB)
- Processing state renders pulsing placeholder
- Ready state renders playable thumbnail (video) or inline preview (image)
- Failed state renders with retry option
- Video plays inline with hls.js or Cloudflare player
- Signed URL access control prevents unauthorized playback
- Media uploads only work online -- offline attempt shows clear message
- Thumbnails cached locally for offline display
- Media provider interface defined; Cloudflare implementation is swappable
- All media UI follows Iron & Ember palette
- File upload to Supabase Storage
chat-filesbucket works - File constraints enforced (25 MB, allowed formats, blocked extensions)
- File card renders with document-type icon, original filename, size, and download button
- File upload only works online -- offline attempt shows clear message
-
original_filenameandmime_typestored inmedia_attachmentsrecord - File messages render with
message_type = 'file'
Dependencies: Step 21 (chat tables exist)
Priority: P3
Docs: 12-prd-chat.md §Message Retention
Automated 90-day message cleanup with per-conversation opt-in archiving. Can be built in parallel with Steps 22-25 since it only depends on the data layer.
A Supabase Edge Function (chat-retention-cleanup) or pg_cron job that runs daily and:
- Identifies messages older than 90 days
- For each candidate message, checks whether any participant in the message's conversation has
is_archived = true - If no participant has archived → delete the message and its
media_attachments - For deleted media attachments with
provider = 'cloudflare_stream', call the Cloudflare Stream API to delete the video asset - For deleted media attachments with
provider = 'supabase_storage', delete the file from the appropriate bucket (chat-imagesfor images,chat-filesfor document files)
The function runs with service_role permissions (bypasses RLS). It processes messages in batches (100 per iteration) to avoid long-running transactions.
A secondary check for media attachments whose parent message no longer exists (edge case from race conditions or manual deletion):
- Query
media_attachmentswhere no matchingmessagesrow exists - Delete from Cloudflare Stream / Supabase Storage
- Delete the orphan
media_attachmentsrecord
In the conversation header menu (the more_vert dropdown from Step 23d):
| Action | Label | Behavior |
|---|---|---|
| Archive | "ARCHIVE CONVERSATION" | Sets is_archived = true, toast: "Messages will be preserved" |
| Unarchive | "REMOVE ARCHIVE" | Sets is_archived = false, toast: "90-day retention applies" |
The archive state is per-user, per-conversation. One user archiving does not affect another user's retention. The archive toggle is a simple boolean flip on the conversation_participants record.
Archived conversations display an archive Material Symbol badge next to the title in the conversation list.
Add a brief explanation in Settings → Data section: "Chat messages are automatically deleted after 90 days. Archive a conversation to keep its messages indefinitely."
- Retention cleanup runs daily (Edge Function or pg_cron)
- Messages older than 90 days are deleted when no participant has archived
- Archived conversations are exempt from retention cleanup
- Cloudflare Stream assets deleted when their message is deleted
- Supabase Storage images deleted when their message is deleted
- Supabase Storage document files deleted when their message is deleted
- Orphan media attachments cleaned up
- Archive toggle works from conversation menu
- Archive badge shows in conversation list
- Retention explanation in Settings → Data
- Retention function is idempotent (safe to run multiple times)
Checkpoint: Users can send text messages in direct and group conversations. Workouts can be shared as frozen snapshots with inline card rendering. Videos (<= 60s lift critique clips) and images can be shared with inline playback. Messages auto-delete after 90 days unless archived. All chat respects the existing social model: friend connections, group membership, and coach/member relationships. Chat visibility is independent of group member list visibility.
Dependencies: Step 6 (active workout logging), Step 1.5 (Iron & Ember design system)
Priority: P2
Docs: 13-prd-remote-display.md §Broadcast Channel Design + §Snapshot Publishing
The data pipeline: workout snapshot construction, Broadcast channel publishing from the phone, and the display route's subscription and state management.
Define a DisplaySnapshot type (Zod schema) containing all fields from 13-prd-remote-display.md §What Gets Pushed. This is a projection of the active workout state — not the full workout log. It includes user_id, display_name, session metadata, current exercise, set array for the current exercise, and rest timer state.
Add is_visible to the Zod schema. This field is checked before publishing; if false, no snapshot is emitted.
Integrate snapshot publishing into the active workout Zustand store. On each state-changing action (set confirmed, exercise transitioned, rest timer started, rest timer expired, workout started, workout completed), build a DisplaySnapshot from the current store state and publish it to the display Broadcast channel.
Publishing triggers:
| Action | Event Type | Notes |
|---|---|---|
| Workout started | workout_snapshot |
Initial snapshot with first exercise |
| Set confirmed | workout_snapshot |
Updated set completion status |
| Exercise transitioned | workout_snapshot |
New current exercise and fresh set array |
| Rest timer started | workout_snapshot |
Timer state: running, remaining seconds, started_at |
| Rest timer expired | workout_snapshot |
Timer state: idle |
| Workout completed | session_ended |
Payload: { user_id } only |
| Workout abandoned | session_ended |
Same as completed |
| Focus requested | focus |
Payload: { user_id } |
| Unfocus requested | unfocus |
Payload: {} |
Add display_visible boolean to user profile (default: true). Add a toggle in the Settings screen under a "Remote Display" section. When false, the snapshot publisher skips all Broadcast publishes.
Adapter method:
| Method | Description |
|---|---|
updateDisplayVisibility(userId, visible) |
Sets the display_visible flag on the user profile |
The channel name is display. All phone clients publish to this single channel. All display clients subscribe to this single channel. Events are discriminated by event type (workout_snapshot, session_ended, focus, unfocus) and by user_id within the payload.
No RLS applies to Broadcast channels — they are ephemeral pub/sub. The privacy boundary is enforced at the publisher (phone checks display_visible before emitting).
-
DisplaySnapshotZod schema validates all required fields - Active workout store publishes snapshot on every state-changing action
-
session_endedevent fires on workout completion and abandonment -
focusandunfocusevents publish from phone - Display visibility toggle exists in Settings
- Opted-out users produce zero Broadcast events
- Snapshot includes correct timer state (running with started_at, or idle)
- Publishing does not degrade active workout UI performance (async, fire-and-forget)
Dependencies: Step 27 (Broadcast publishing working)
Priority: P2
Docs: 13-prd-remote-display.md §Display Modes + §Display UI + §Iron & Ember Design Callouts
The /display route: a full-viewport, no-chrome web page that subscribes to the display Broadcast channel and renders active workout sessions. This is the most visually demanding step — the display must be readable from across a gym on a 1080p TV.
Add /display route to TanStack Router. This route renders outside the main app layout — no navigation bar, no sidebar, no header. Full viewport, surface-anvil background, single status footer.
The route:
- Subscribes to the
displayBroadcast channel on mount - Maintains an in-memory map of active sessions (keyed by
user_id) - Tracks current display mode: idle, board, or focused (and which user_id is focused)
- Cleans up subscription on unmount
The display maintains a Map<string, DisplaySnapshot> in component state (or a dedicated Zustand store). Incoming workout_snapshot events upsert into this map. session_ended events remove the entry. The display mode is derived from this map:
| Condition | Mode |
|---|---|
| Map is empty | Idle |
| Map has entries + no focus command active | Board |
| Focus command active + focused user in map | Focused |
| Focus command active + focused user NOT in map | Board (focus cleared) |
Responsive grid of session cards. Grid columns adapt to session count:
| Active Sessions | Grid Layout |
|---|---|
| 1 | Single card, centered, expanded |
| 2 | 2 columns |
| 3-4 | 2 columns, 2 rows |
| 5+ | Pages of up to 4 cards, auto-cycling every 10 seconds |
Each card renders on surface-iron, zero border-radius, containing:
| Element | Typography | Color |
|---|---|---|
| Display name | Space Grotesk text-label-large, ALL-CAPS |
text-primary |
| Session type | Inter label-medium, ALL-CAPS |
text-secondary |
| Elapsed time | Space Grotesk monospace | text-secondary |
| Current exercise | Inter body-medium |
text-primary |
| Exercise progress | Inter label-small |
text-secondary |
| Set progress | Compact: completed/total with arc checkmarks |
— |
| Rest timer | Space Grotesk, ember text, surface-steel background |
ember |
Full-screen workout display for a single user. Larger typography than board cards. Full set table with PRESCRIBED / ACTUAL / STATUS columns. Large rest timer countdown.
| Element | Scale | Notes |
|---|---|---|
| User name + session | Space Grotesk, 2rem+ | Header bar |
| Current exercise | Space Grotesk, 2.5rem+ | Centered, prominent |
| Set table | Inter body-medium, generous row height |
Alternating surface-charcoal rows |
| Current set row | ember left border |
Visual indicator of active set |
| Rest timer | Space Grotesk text-readout (3.5rem+) |
ember text, centered, pulsing when < 10s |
| Elapsed time | Space Grotesk, 1.5rem | Header, text-secondary |
The display does not receive per-second timer updates. When a snapshot arrives with rest_timer.state = 'running', the display calculates remaining time from rest_timer.started_at and rest_timer.total_seconds, then runs a local setInterval (or requestAnimationFrame) countdown. When a snapshot arrives with rest_timer.state = 'idle', the display clears the local timer.
This produces a smooth countdown visual with zero additional Broadcast traffic.
If the WebSocket disconnects, the display:
- Shows "Reconnecting..." in the status footer
- Attempts automatic reconnection (Supabase client handles this)
- On reconnection, publishes a
display_helloevent to the channel - Active phones respond by re-publishing their current snapshot
- Display rebuilds its session map from incoming snapshots
The display_hello → re-publish pattern ensures the display catches up after any disconnection without requiring server-side state persistence.
Add a button to the active workout screen (phone). Placement: in the workout header or action menu. Material Symbol: cast or tv. Label: "PUSH TO DISPLAY" / "RETURN TO BOARD".
The button publishes a focus event with the user's user_id. Tapping again publishes an unfocus event. Button state reflects whether the user is currently focused.
Persistent footer at bottom of display viewport. surface-pit background, Inter label-small.
| State | Display |
|---|---|
| Connected | ● Connected (green dot) |
| Reconnecting | ○ Reconnecting... (amber dot, pulsing) |
| Active sessions count | N active sessions or Focused: [Name] |
-
/displayroute renders full-viewport with no app chrome - Display subscribes to
displayBroadcast channel on mount - Board view shows cards for all active visible sessions
- Grid layout adapts from 1 to 4 sessions per page
- Board auto-cycles through pages when 5+ sessions active (10-second interval)
- Focused view renders full set table and large timer
- Focus/unfocus triggered from phone updates display mode
- Timer interpolation produces smooth countdown (± 1 second accuracy)
- Display auto-reconnects and rebuilds state after network drop
- "Push to Display" button exists in active workout UI
- Connection status footer shows current state
- All typography readable at 3+ meters on 1080p display
- Iron & Ember design tokens applied throughout (zero border-radius, tonal surfaces, dual fonts)
- No database queries from the display route
Dependencies: Step 28 (display route working), Step 11 (program structure for schedule data), Step 3 (Supabase project for Edge Function deployment)
Priority: P2
Docs: 13-prd-remote-display.md §Idle Mode + §Idle Mode Data
The ambient display state when no workouts are active. Clock, today's schedule, and countdown to next session.
Full-viewport idle display:
| Element | Typography | Position |
|---|---|---|
| Clock | Space Grotesk, minimum 8rem, text-primary |
Centered, upper half |
| Date | Inter body-large, text-secondary |
Below clock |
| Schedule section header | Space Grotesk text-label-large, ALL-CAPS, "TODAY'S SESSIONS" |
Left-aligned, lower section |
| Schedule entries | Inter body-medium: display name, session type (ALL-CAPS), scheduled time |
Rows in surface-iron cards |
| Next-up countdown | Space Grotesk, ember text, surface-steel badge |
Below schedule |
| Status footer | Same as board/focused modes | Bottom |
Clock updates every second via local setInterval. No Broadcast event needed for clock ticking.
A Supabase Edge Function runs on a cron schedule (every 60 seconds). It queries today's remaining scheduled sessions for all users and publishes an idle_state event to the display Broadcast channel. The Edge Function uses the service_role key (server-side only, never exposed to clients) to query across users.
The Edge Function:
- Runs via
pg_cronor Supabase's scheduled function invocation - Queries
scheduled_sessionsjoined with user profiles for today's date - Filters to sessions not yet completed
- Publishes the
idle_stateevent payload to thedisplaychannel - Runs independently of any client being connected
| Field | Type | Description |
|---|---|---|
server_time |
ISO timestamp | Current time for clock sync correction |
scheduled_sessions |
Array | { display_name, session_name, session_type, scheduled_time } for each remaining session today |
next_session |
Object or null | The soonest upcoming session: { display_name, session_name, starts_in_seconds } |
| Transition | Animation |
|---|---|
| Idle → Board | Fade clock out, fade cards in (300ms) |
| Board → Idle | Fade cards out, fade clock in (300ms) |
| Board → Focused | Cards shrink to zero, focused view expands from center (400ms) |
| Focused → Board | Reverse of above (400ms) |
Use CSS transitions or Framer Motion. Keep animations subtle — the display should feel calm, not flashy.
- Idle mode renders large clock updating every second
- Today's scheduled sessions displayed when idle state data available
- Next-session countdown badge renders with
emberaccent - Idle mode transitions to board when first session starts
- Board transitions to idle when last session ends
- Idle state published by Edge Function on 60-second cron interval
- Edge Function queries today's remaining sessions across all users
- Clock is readable from 5+ meters on 1080p
- Transitions between modes are smooth (300-400ms)
- Display remains stable for hours without memory leaks or drift
Checkpoint: A full-viewport /display route runs on any browser-capable screen in the gym. Active workout sessions appear as cards (board mode) or expand to full-screen (focused mode) with live set tracking and smooth rest timer countdowns. The idle screen shows the clock and today's scheduled sessions. The display requires no authentication and no database queries — pure Broadcast subscription.
- Log a barbell workout in mobile browser → data persists in Supabase
- This validates the end-to-end pipeline. Do it ASAP.
- Verify Iron & Ember design renders correctly on actual mobile device
- Log same workout in Tauri Android APK → data in local SQLite
- Verify offline → go online → data appears in Supabase
- Verify fonts load from local bundle (no network dependency)
- Create TB Operator program → activate → log Day 1 with pre-filled sets
- Verify prescribed vs actual tracking works
- Verify PRESCRIBED / ACTUAL / VARIANCE columns display correctly
- User A logs workout → User B (in same group) sees it in activity feed within 5s
- Coach creates program for member → member activates → logs workout → coach sees completion
- Coach builds program under own account → member signs up and joins group → coach assigns program to member → member sees program in their library → member logs workout → coach sees completion
- User A sends text message to User B → B sees it in < 1 second
- User A sends message while offline → clock icon shown → message delivers on reconnect
- Verify Iron & Ember chat bubble styling on actual mobile device
- User A logs a workout → shares to conversation with User B → B sees workout card → B taps "VIEW DETAILS" → full set-by-set breakdown renders inline
- User A records a 30-second squat video → shares to group chat → video uploads with progress bar → transcoding completes → all group members can play the video inline
| Step | Priority | Est. Effort | Can Parallel With |
|---|---|---|---|
| 1. Project Scaffold | P0 | 0.5 day | — |
| 1.5. Design System (Iron & Ember) | P0 | 1 day | — |
| 2. Domain Types + Zod | P0 | 1.5 days | 3 (Supabase setup) |
| 3. Supabase Setup | P0 | 0.5 day | Step 2 |
| 4. Data Adapter + Auth | P0 | 2 days | — |
| 5. Exercise Dictionary + 1RMs | P0 | 2 days | — |
| 6. Active Workout Logging | P0 | 4 days | — |
| 7. Workout History | P0 | 1.5 days | — |
| Phase 0 subtotal | ~13 days | ||
| 8. Tauri Shell + Rust/SQLite | P0 | 3 days | — |
| 9. Sync Engine + Rest Timer | P0 | 2.5 days | — |
| Phase 1 subtotal | ~5.5 days | ||
| 10. Session Templates + SetScheme | P0 | 3 days | — |
| 11. Program Structure | P0 | 2 days | — |
| 12. Program Builder (DnD) | P1 | 3 days | — |
| 13. Programmed Workout Logging | P0 | 2.5 days | — |
| 13.5. Events & Packing Lists | P1 | 2 days | — |
| Phase 2 subtotal | ~12.5 days | ||
| 14. Progress Analytics + PR | P1 | 2.5 days | 15 |
| 15. Notification System | P1 | 1.5 days | 14 |
| 16. Share Links | P1 | 1.5 days | 14, 15 |
| 17. Accountability Groups | P2 | 3 days | — |
| 18. Coach Write Access | P2 | 2 days | — |
| Phase 3-4 subtotal | ~11 days | ||
| 19. Runtime Backend Config | P0 | 2 days | Steps 10-18 |
| 20. Docker & Self-Hosting | P1 | 2 days | Steps 10-18 (after Step 19 for mobile config) |
| Phase 5 subtotal | ~4 days | ||
| 21. Chat Data Layer | P2 | 3 days | -- |
| 22. Supabase Realtime Integration | P2 | 2 days | -- |
| 23. Chat UI | P2 | 5 days | -- |
| 24. Workout Sharing in Chat | P2 | 2 days | 25 |
| 25. Video + Image Sharing | P2 | 4 days | 24 |
| 26. Message Retention + Archiving | P3 | 1 day | 22, 23, 24, 25 |
| Phase 6 subtotal | ~17 days | ||
| 27. Display Broadcast Infrastructure | P2 | 2 days | Step 6 |
| 28. Display Route + Board/Focused | P2 | 3 days | Step 27 |
| 29. Idle Mode + Edge Function | P2 | 1.5 days | Step 28, Step 11 |
| Phase 7 subtotal | ~6.5 days | ||
| Total | ~69.5 days |
Critical path to browser MVP: Steps 1 → 1.5 → 2/3 → 4 → 5 → 6 → 7 = ~13 days Critical path to Tauri GO/NO-GO: + Steps 8 → 9 = ~18.5 days Critical path to programmed workouts: + Steps 10 → 11 → 13 = ~26 days Critical path to Play Store release: All phases + Step 19 = minimum viable for distribution. Critical path to text chat MVP: + Steps 21 → 22 → 23 = ~36 days Critical path to full chat with video: + Steps 24 + 25 = ~42 days (24 and 25 can parallel) Retention can ship any time after Step 21. Critical path to remote display: Steps 1–13 + Step 27 → 28 → 29 = ~6 additional days after Phase 2 Can be parallelized with: Phase 3-4 (analytics, notifications, sharing), Phase 5 (hosting), Phase 6 (chat)
At community scale (< 50 users), Ardent Forge stays well within Supabase's free tier:
| Resource | Free Tier | Ardent Forge Est. Usage (with chat) |
|---|---|---|
| Database | 500 MB | < 100 MB (messages add ~50 MB/year at 10 active users) |
| Auth | 50K MAU | < 50 users |
| Realtime | 200 concurrent | < 20 (chat subscriptions) |
| Edge Functions | 500K invocations | < 1K/month (media upload URLs + webhooks) |
| Storage | 1 GB | < 500 MB (chat images and files; video on Cloudflare) |
Video note: Video storage and delivery is handled by Cloudflare Stream, not Supabase Storage. At 10 active users sharing ~20 clips per week, Cloudflare Stream costs are under $5/month on the Starter Bundle.
Ardent Forge uses the new Supabase publishable key (sb_publishable_...) instead of the legacy anon JWT key. The publishable key is sent in the apikey header by the Supabase client library. Benefits: independently rotatable, no JWT secret coupling, shorter and easier to manage. Legacy anon key still works but is deprecated.
Self-hosted Supabase uses the same API surface as Supabase Cloud. The Ardent Forge app does not distinguish between them -- both are accessed via the same Supabase JS client with the same URL and publishable key pattern.
The main operational difference is that self-hosted Supabase uses a locally-derived JWT secret and key pair rather than Supabase Cloud's managed keys. The migration runner and key generation script handle this automatically.
Supabase derives the anon (publishable) and service_role keys from a JWT secret. The scripts/generate-keys.sh helper generates a random JWT secret and produces both keys using the standard Supabase algorithm (HS256-signed JWTs with role claims). This matches what Supabase Cloud does internally when you create a project.
Complex types (SetScheme, LoadSpec, Weight, prescribed values) are stored as JSONB in Postgres / JSON text in SQLite. This means:
- No JOINs needed for nested structures within a single entity
- Validation happens at the application layer (Zod schemas), not database
- Migration-friendly: adding a new SetScheme variant is a code change, not a schema migration
| SQLite | Postgres | Conversion |
|---|---|---|
| TEXT (UUID) | UUID | Same format |
| INTEGER (Unix timestamp) | TIMESTAMPTZ | Convert on sync |
| TEXT (JSON string) | JSONB | Parse/stringify on sync |
| INTEGER (0/1) | BOOLEAN | Convert on sync |
| TEXT (enum) | TEXT | Same format |
| # | Decision | Rationale |
|---|---|---|
| 1 | Phase 0 is browser-only against Supabase | Validates data model and UX before committing to Tauri |
| 2 | Data adapter pattern from day one | Switching between Supabase and Tauri/SQLite is transparent |
| 3 | SetScheme as discriminated union, not generic schema | Each workout type gets first-class field validation |
| 4 | JSON columns for complex nested types | Avoids explosion of junction tables for SetScheme variants |
| 5 | 1RM history is insert-only | Audit trail for progression, never lose historical data |
| 6 | Pre-fill + confirm pattern for programmed logging | Minimizes taps (2 per set) while allowing deviation |
| 7 | Rest timer in Rust, not JavaScript | Survives WebView backgrounding on mobile |
| 8 | RLS expansion deferred to Steps 17-18 | Simple user_id = auth.uid() for Phases 0-2, complexity only when needed |
| 9 | Coach creates programs owned by member | Member always controls their data, coach access is revocable |
| 10 | Same React app for all platforms | Eliminates duplication between web and native |
| 11 | Iron & Ember design system before feature work | Full shadcn overrides upfront prevent style debt across 14 feature steps |
| 12 | Publishable key over legacy anon key | Independently rotatable, no JWT secret coupling, Supabase recommended |
| 13 | Bun over npm | Faster installs, native TypeScript, simpler toolchain |
| 14 | TanStack Router (not Start) for Tauri compatibility | Start requires a server for SSR; Tauri runs in a serverless WebView |
| 15 | Material Symbols + Lucide dual icon strategy | Material Symbols for app icons (fitness-specific), Lucide for shadcn internals |
| 16 | Fonts bundled locally in Tauri builds | No Google Fonts network dependency in native offline-first builds |
| 17 | Runtime config over build-time config | Single APK works against any Supabase instance; self-hosters don't need to build from source |
| 18 | Config in localStorage / SQLite, not Supabase | Config must be readable before Supabase client exists; chicken-and-egg |
| 19 | Backend change wipes local SQLite | Cross-instance data in one SQLite database corrupts sync invariants |
| 20 | Docker uses Supabase official self-hosted stack | Maintained upstream, well-documented, matches the Supabase Cloud API surface |
| 21 | Migration runner as init container | service_role key stays inside Docker network; client app never has DDL permissions |
| 22 | Caddy over nginx for reverse proxy | Automatic TLS with zero config; simpler for self-hosters who aren't sysadmins |
| 23 | Multi-stage web app build | No Node/Bun runtime in production; smaller image; nginx is battle-tested for static serving |
| 24 | Supabase Realtime Broadcast for chat delivery | Aligns with existing stack; avoids new vendor; sufficient for expected scale |
| 25 | Cloudflare Stream for video hosting | Simpler pricing than Mux; free encoding; swappable via MediaProvider interface |
| 26 | 90-day message retention with opt-in archiving | Balances storage cost and privacy; archive flag is per-participant per-conversation |
| 27 | Frozen workout snapshots for chat sharing | Avoids permission edge cases; self-contained for offline display; no live reference needed |
| 28 | Chat participation supersedes group member_visibility | Cannot hide identity within a conversation; joining is voluntary consent |
| 29 | Push notifications deferred | Platform complexity (APNs/FCM) not justified for friends-and-family scale |
| 30 | Messages are append-only (no edit/delete) | Simplifies sync to append-only replication with no conflict resolution for mutations |
| 31 | Cloudflare Stream signed URLs for access control | Server-side signing via Edge Function; client never sees API token; 1-hour TTL |
| 32 | Images on Supabase Storage, video on Cloudflare | Images are small static files (no transcoding); avoids a second external provider for images |
| 33 | WebView Realtime over Rust-native WebSocket | Simpler; reuses existing Supabase JS client; only move to Rust if Android background kills WS |
| 34 | TUS protocol for video uploads | Resumable; handles mobile network interruptions gracefully; Cloudflare Stream native support |
| 35 | Supabase Storage for generic file sharing | Avoids a third external provider; document types are low-risk; 25 MB cap and allowlist enforce safety; inline preview deferred |