# How to Build a Fitness Tracking with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a fitness tracking app in Lovable where users log workouts with dynamic sets and reps inputs, track body metrics over time, visualize progress with Recharts line charts, and automatically detect personal records via Postgres aggregate functions — complete with reusable workout templates and a weekly training calendar.

## Before you start

- Lovable account (free tier sufficient for this build)
- Supabase project with SUPABASE_URL and SUPABASE_ANON_KEY available
- A list of exercises to seed the exercises reference table (e.g. Bench Press, Squat, Deadlift)
- Basic understanding of sets, reps, and progressive overload concepts
- Familiarity with how fitness apps like Strong or Hevy work (for UX reference)

## Step-by-step guide

### 1. Create the fitness tracking schema

Ask Lovable to create all four tables and the personal records function. The schema is the foundation for all other features.

```
Create a fitness tracking schema in Supabase.

Tables:
- exercises: id, name (text unique), muscle_group ('chest' | 'back' | 'legs' | 'shoulders' | 'arms' | 'core' | 'cardio'), is_custom (bool default false), created_by (references auth.users, nullable for global exercises)
- workouts: id, user_id (references auth.users), name, notes, started_at (timestamptz), finished_at (timestamptz), created_at
- workout_exercises: id, workout_id (references workouts), exercise_id (references exercises), order_index (int), notes (text)
- workout_sets: id, workout_exercise_id (references workout_exercises), set_number (int), weight_kg (decimal), reps (int), is_warmup (bool default false), rpe (int, 1-10, nullable), created_at
- body_metrics: id, user_id (references auth.users), date (date), weight_kg (decimal), body_fat_pct (decimal, nullable), notes (text), created_at
- workout_templates: id, user_id (references auth.users), name, template_data (jsonb), created_at

Create a Postgres function get_personal_records(p_user_id uuid) that returns a table of (exercise_id uuid, exercise_name text, max_weight_kg decimal, achieved_at timestamptz). Query workout_sets joined through workout_exercises → workouts filtered by user_id, group by exercise, return MAX(weight_kg) and the timestamp of that max.

RLS: all tables scope to auth.uid() = user_id. exercises are globally readable (SELECT for all authenticated users), INSERT only for the creating user.
```

> Pro tip: Add a UNIQUE constraint on body_metrics(user_id, date) to prevent duplicate entries for the same day. This also enables INSERT ... ON CONFLICT (user_id, date) DO UPDATE for easy upsert when the user edits today's metrics.

**Expected result:** All six tables are created. The get_personal_records function returns rows for each exercise logged by the user. TypeScript types are generated. Supabase Auth is set up.

### 2. Build the dynamic workout logging form

The workout log form is the core of the app. It uses useFieldArray from react-hook-form to manage the nested set/rep structure dynamically. Ask Lovable to build it from this specification.

```
Build a workout logging form at src/pages/LogWorkout.tsx.

Form structure using react-hook-form with zod validation:
- Workout name Input (text, required)
- Start time (auto-filled, editable DateTimePicker)
- Dynamic exercises array (useFieldArray):
  - Each exercise row has:
    - Exercise Combobox (searchable Select from exercises table, filtered by muscle group tabs)
    - Notes Input (optional)
    - Dynamic sets array nested inside each exercise (useFieldArray):
      - Set number (auto-incremented, read-only)
      - Weight Input (number, kg, required)
      - Reps Input (number, required)
      - RPE Select (optional, 6-10)
      - Warmup Checkbox
      - Delete set Button (X icon)
    - 'Add Set' Button appending a new set row with defaults from the previous set
  - 'Remove Exercise' Button
- 'Add Exercise' Button appending a new exercise row
- 'Save Workout' Button at the bottom

On submit:
1. INSERT into workouts, get the workout id
2. For each exercise, INSERT into workout_exercises with order_index
3. For each exercise's sets, INSERT all workout_sets rows in a single batch call
4. After saving, call get_personal_records and check if any new set exceeded the previous PR — if so, show a confetti animation and Toast with the PR details
5. Navigate to /workouts/{id} after successful save
```

**Expected result:** The form renders with dynamic add/remove for both exercises and sets. Submitting creates all rows in the correct tables. PR detection fires after save. Navigation to the workout detail page works.

### 3. Build the progress charts

Ask Lovable to build the exercise progress page where users see their strength progression over time with Recharts line charts.

```
Build a progress page at src/pages/Progress.tsx.

Requirements:
- Exercise selector: a Combobox at the top that lists all exercises the current user has logged, grouped by muscle group
- When an exercise is selected, fetch all workout_sets for that exercise joined through workout_exercises → workouts WHERE user_id = auth.uid(), ordered by workouts.started_at
- Render two Recharts charts side by side (stacked on mobile):
  - Chart 1: LineChart of max weight per session (X = date, Y = weight_kg). Show a gold star data point on the session where the all-time PR was achieved.
  - Chart 2: LineChart of total volume per session (X = date, Y = SUM(weight_kg * reps) for all sets in that session)
- Below charts: a DataTable of all logged sets for that exercise. Columns: Date, Workout Name, Sets x Reps @ Weight (formatted as '3 x 8 @ 80kg'), Volume, PR badge if it was a PR session.
- Add a date range filter (last 30 days / 90 days / all time) that filters both charts and table
- Show the current PR as a stat Card above the charts: 'All-time PR: 100kg on Jan 15'
```

**Expected result:** Selecting an exercise shows the two progress charts and history table. The PR badge appears on the correct row. Date range filter updates both charts. The PR stat card shows the correct value.

### 4. Build the body metrics tracker

Ask Lovable to add the body metrics page for weight and body composition tracking with a trend line chart.

```
Build a body metrics page at src/pages/BodyMetrics.tsx.

Requirements:
- 'Log Today's Metrics' Card at the top with a simple form:
  - Date (DatePicker, default today)
  - Weight Input (decimal, kg or lbs with unit toggle stored in user preferences)
  - Body Fat % Input (decimal, optional)
  - Notes Textarea (optional)
  - Save Button that does supabase.from('body_metrics').upsert() using (user_id, date) conflict target
- Below the form, a Recharts ComposedChart:
  - Primary line: weight over time (last 90 days)
  - Secondary line (if data exists): body_fat_pct as a dashed line on a secondary Y-axis
  - Show a 7-day rolling average line in a lighter color
  - Tooltip showing both values on hover
- Stats row: Starting Weight, Current Weight, Change (formatted with up/down arrow), Lowest Weight
- History DataTable: columns Date, Weight, Body Fat %, Notes. Allow row click to edit.
```

**Expected result:** Logging metrics upserts correctly — logging twice on the same date updates rather than duplicates. The chart shows weight trend with rolling average. The stats cards update dynamically.

### 5. Add workout templates and calendar heatmap

Templates let users save and reuse workout structures. The calendar heatmap shows training frequency at a glance. Ask Lovable to add both to the app.

```
Add two features:

1. Workout templates:
   - On the LogWorkout page, add a 'Load Template' Button at the top that opens a Sheet
   - The Sheet shows a list of saved templates (Cards with template name and exercise count)
   - Clicking a template populates the form with all exercises and default sets from template_data jsonb
   - After logging a workout, add a 'Save as Template' option in a menu. It stores the exercise structure (without weights/reps) as jsonb in workout_templates

2. Training calendar heatmap at src/pages/Calendar.tsx:
   - Show the last 52 weeks as a grid of day squares (like GitHub contribution graph)
   - Each day square is colored by workout count: 0=gray, 1=light green, 2=medium green, 3+=dark green
   - Hovering a day shows a Tooltip with the workout name(s) that day
   - Fetch all workouts for the user, group by date(started_at), count per day
   - Below the heatmap: streak stats — current streak (consecutive days with workouts), longest streak, total workouts this year
```

**Expected result:** Loading a template fills the log form with the template's exercise structure. The calendar heatmap renders 52 weeks with correct coloring. Streak stats calculate correctly from the workouts data.

## Complete code example

File: `src/components/WorkoutForm.tsx`

```typescript
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Trash2, Plus } from 'lucide-react'

const setSchema = z.object({
  weight_kg: z.coerce.number().min(0),
  reps: z.coerce.number().min(1).max(999),
  is_warmup: z.boolean().default(false),
})

const exerciseSchema = z.object({
  exercise_id: z.string().uuid(),
  sets: z.array(setSchema).min(1),
})

const workoutSchema = z.object({
  name: z.string().min(1, 'Workout name required'),
  exercises: z.array(exerciseSchema).min(1, 'Add at least one exercise'),
})

type WorkoutFormValues = z.infer<typeof workoutSchema>

export function WorkoutForm({ onSubmit }: { onSubmit: (data: WorkoutFormValues) => Promise<void> }) {
  const form = useForm<WorkoutFormValues>({
    resolver: zodResolver(workoutSchema),
    defaultValues: { name: '', exercises: [] },
  })

  const { fields: exerciseFields, append: addExercise, remove: removeExercise } = useFieldArray({
    control: form.control,
    name: 'exercises',
  })

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
      <Input placeholder="Workout name" {...form.register('name')} />
      {exerciseFields.map((exercise, exIdx) => (
        <ExerciseRow
          key={exercise.id}
          exIdx={exIdx}
          control={form.control}
          register={form.register}
          onRemove={() => removeExercise(exIdx)}
        />
      ))}
      <Button type="button" variant="outline" onClick={() => addExercise({ exercise_id: '', sets: [{ weight_kg: 0, reps: 5, is_warmup: false }] })}>
        <Plus className="mr-2 h-4 w-4" /> Add Exercise
      </Button>
      <Button type="submit" className="w-full">Save Workout</Button>
    </form>
  )
}

function ExerciseRow({ exIdx, control, register, onRemove }: any) {
  const { fields: setFields, append: addSet, remove: removeSet } = useFieldArray({
    control,
    name: `exercises.${exIdx}.sets`,
  })
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm">Exercise {exIdx + 1}</CardTitle>
        <Button type="button" variant="ghost" size="icon" onClick={onRemove}><Trash2 className="h-4 w-4" /></Button>
      </CardHeader>
      <CardContent className="space-y-2">
        {setFields.map((set, setIdx) => (
          <div key={set.id} className="flex items-center gap-2">
            <span className="text-sm text-muted-foreground w-6">{setIdx + 1}</span>
            <Input type="number" placeholder="kg" className="w-20" {...register(`exercises.${exIdx}.sets.${setIdx}.weight_kg`)} />
            <Input type="number" placeholder="reps" className="w-20" {...register(`exercises.${exIdx}.sets.${setIdx}.reps`)} />
            <Button type="button" variant="ghost" size="icon" onClick={() => removeSet(setIdx)}><Trash2 className="h-3 w-3" /></Button>
          </div>
        ))}
        <Button type="button" variant="ghost" size="sm" onClick={() => addSet({ weight_kg: setFields[setFields.length - 1]?.weight_kg ?? 0, reps: 5, is_warmup: false })}>
          <Plus className="mr-1 h-3 w-3" /> Add Set
        </Button>
      </CardContent>
    </Card>
  )
}
```

## Common mistakes

- **Storing all set data in a single JSON column instead of normalized tables** — Querying inside JSON arrays in Postgres is possible but slow and complex. Finding the max weight ever lifted for a specific exercise requires jsonb path queries across all workout rows. Fix: Use the three-table normalized structure: workouts → workout_exercises → workout_sets. The PR query becomes a simple SELECT MAX(weight_kg) JOIN — fast with proper indexes.
- **Fetching all workout history for chart rendering** — A dedicated user with 500 workouts and 20 exercises each generates thousands of rows. Fetching all of them for a progress chart causes slow initial load and unnecessary data transfer. Fix: Query only the data needed for each chart: for the progress chart, fetch only (started_at, MAX(weight_kg), SUM(weight_kg*reps)) per session for the selected exercise in the selected date range. Group the aggregation in the Supabase query, not in JavaScript.
- **Not validating the nested form before submission** — The dynamic exercises/sets form can have empty exercise selectors or zero-weight sets if the user adds rows without filling them in. Submitting inserts invalid data. Fix: Use zod schema validation with react-hook-form. The workoutSchema requires exercise_id to be a valid UUID, weight_kg to be a positive number, and reps to be at least 1. Display per-field errors inline rather than a generic form-level error.
- **Displaying weight in kg without a unit preference setting** — Fitness app users are split between kg and lbs. Displaying the wrong unit is a significant UX problem and can lead to logging errors. Fix: Store all weights internally in kg (the SI unit). Add a unit_preference column to the user's profile (default 'kg'). Convert for display: lbs = kg * 2.20462. All inputs accept the user's preferred unit and convert before saving.

## Best practices

- Store all weights in kilograms in the database regardless of user preference. Apply unit conversion only at the display and input layer.
- Index workout_sets on (workout_exercise_id) and workout_exercises on (workout_id) to keep the nested data loading fast.
- Use useFieldArray from react-hook-form for dynamic form arrays — do not try to manage nested form state manually with useState.
- The get_personal_records function should be called once per page load and cached in component state, not re-called after every set save. Call it on the progress page load and after completing a full workout.
- Set minimum reps to 1 and minimum weight to 0 (bodyweight exercises) in your zod schema. Negative values are invalid data that will corrupt PR calculations.
- Show the total volume (sum of weight_kg times reps for all sets) for each workout in the history list. Volume is the primary driver of hypertrophy and users appreciate seeing it alongside max weight.
- Implement optimistic updates for set logging: add the set to local state immediately when the user clicks 'Add Set', then sync to Supabase in the background. This makes the form feel instant even on slow connections.

## Frequently asked questions

### How does the app detect a new personal record?

After saving a workout, call the get_personal_records Postgres function via supabase.rpc('get_personal_records', { p_user_id: userId }). Compare the returned max weights with the weights just logged. If any new set weight exceeds the stored PR for that exercise, display the PR notification. The function uses MAX(weight_kg) grouped by exercise across all historical sets.

### Can I track cardio sessions like running alongside lifting?

Add a duration_seconds and distance_km column to workout_sets (nullable). For cardio exercises (identified by muscle_group='cardio'), the form shows duration and distance inputs instead of weight and reps. The exercise schema handles both. Progress charts for cardio show pace (distance/time) instead of max weight.

### How do I handle exercises where bodyweight is the load?

Set weight_kg = 0 for pure bodyweight exercises (pull-ups, push-ups). Add a bodyweight_addition_kg field that stores added weight (e.g. +10kg with a weight vest). The effective weight for PR calculation is user's bodyweight + bodyweight_addition_kg. Store the user's bodyweight from body_metrics for this calculation.

### What is RPE and should I make it required?

RPE (Rate of Perceived Exertion) is a 1-10 scale where 10 is maximum effort. It is optional — many users prefer to track only weight and reps. Make it optional in the form with a tooltip explaining what it means. Advanced users who practice RPE-based programming will appreciate having it. Never make it required as it adds friction for beginners.

### How many workouts can Supabase store before performance degrades?

With proper indexing, Supabase handles millions of rows comfortably. A dedicated user logging 5 workouts per week with 20 sets each generates roughly 5,200 workout_sets rows per year. Even at 10 years of data, that is 52,000 rows — trivial for PostgreSQL with indexes on user_id and workout_id.

### Can multiple users share the same exercises database?

Yes. Seed a set of global exercises (is_custom = false, created_by = null) that all users can see. These are exercises like Bench Press, Squat, and Deadlift. Users can create their own custom exercises (is_custom = true, created_by = auth.uid()) visible only to them. The exercises Combobox shows global exercises first, then the user's custom exercises.

### How do I export a user's full workout history?

Create an export function that queries all workouts with their nested exercises and sets, flattens the data to a CSV format (one row per set: date, workout name, exercise name, set number, weight, reps), and triggers a browser download. For large histories, paginate the query in 500-row batches and combine them client-side before creating the CSV blob.

### Is there help building a fitness app with social features or trainer management?

RapidDev builds Lovable apps with social feeds, coach-client relationships, and subscription billing. Reach out if your fitness app needs features beyond personal tracking.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/fitness-tracking
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/fitness-tracking
