Build a Workout Tracker with AI Coach

Build a React workout tracker with charts and an AI coaching feature.

Introduction

30 Second Summary

You know that moment between sets where you try to remember what weight you used last week. A dedicated tracker solves that, but the best trackers also show you whether you are actually getting stronger over time.

In this project, you will build a mobile-first workout tracking app with interactive progress charts and an AI coach that analyzes your training history and gives you personalized suggestions.

What You'll Build

Picture yourself at the gym, finishing a set, pulling up your phone, and instantly seeing a line chart proving your bench press has climbed 10kg over the last month.

By the end of this project, you'll have:

  • A working workout logging form where you record exercises with sets, reps, and weight by category, then pull up your history on your phone to know exactly what to beat next session.
  • Interactive progress charts that reveal at a glance whether you are progressing, plateauing, or declining on any exercise over time.
  • An AI coach powered by Google Gemini that reviews your logged workouts and gives you specific, actionable training suggestions.
  • Secret Mission: Add automatic personal records tracking that detects when you hit a new max weight on any exercise and celebrates with a banner.

Are there any prerequisites?

You will need Node.js installed on your machine, a code editor (VS Code or Cursor), a GitHub account, and a Google account for your free Gemini API key.

Step 1 walks you through everything you need to set up, so you can jump right in.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're creating a real tool for your own training. Every feature you add should serve that purpose.

Scaffold the React App with Tailwind CSS

To build a workout tracker you can actually use at the gym, you need a React app that updates the browser instantly every time you save a file.

In this step, you will scaffold a React project with Vite, add Tailwind CSS for fast utility-based styling, and start a dev server so every future change shows up live in the browser.

In this step, get ready to:
  • Scaffold a React project with Vite.
  • Install and configure Tailwind CSS.
  • Start the dev server and confirm Tailwind is working.
Scaffold a new React project with Vite
  • Open a terminal in your code editor. In VS Code, press Ctrl+` (macOS and Windows) to open the integrated terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your new React project by running this command:
npm create vite@latest workout-tracker -- --template react

What will you see when you run this?

npm may prompt you to install the create-vite package. Type y and press Enter to proceed.

Because you specified both the project name and template in the command, Vite scaffolds the project directly with no further interactive prompts.

  • Move into the new project folder and install its dependencies by running these commands:
cd workout-tracker
npm install

You should see output listing the installed packages, ending with a line like "added X packages".

Seeing permission errors or command not found?

  • Make sure you are in the workout-tracker folder before running npm install. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
  • If npm is not recognized, Node.js may not be on your PATH. Close and reopen your terminal, then try again.
  • Help me troubleshoot my Vite scaffold command.
  • Open the workout-tracker folder in your code editor. In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the workout-tracker folder, and click Open.
Install and configure Tailwind CSS

Tailwind CSS lets you style elements directly in your markup using utility classes instead of writing custom CSS files.

  • Install Tailwind CSS and its Vite plugin by running this command in your terminal:
npm install tailwindcss @tailwindcss/vite

You should see output confirming the packages were added.

  • In the VS Code sidebar, click on vite.config.js to open it. Replace the entire file contents with this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(),
    react(),
  ],
})
  • Now open src/index.css and replace everything inside with this single line:
@import "tailwindcss";

What do these two changes do?

  • The tailwindcss() plugin in vite.config.js tells Vite to process Tailwind utility classes at build time.
  • The @import "tailwindcss" line in index.css injects all of Tailwind's base, component, and utility styles into your app. This replaces the old three-directive approach from Tailwind v3.
  • Save both vite.config.js and src/index.css.
Clean the boilerplate and start the dev server

Vite generates demo content you do not need. You will replace it with a minimal component that proves Tailwind is working.

  • Open src/App.jsx and replace its entire contents with this code:
function App() {
  return (
    <div>
      <h1 className="text-3xl font-bold">Workout Tracker</h1>
    </div>
  )
}

export default App

What does this code do?

  • This is a minimal React component that renders a single heading.
  • The text-3xl and font-bold classes are Tailwind utilities. If they render as large bold text, Tailwind is wired up correctly.
  • In the VS Code sidebar, right-click src/App.css and select Delete. You do not need this file because Tailwind handles all styling through utility classes.
  • Save src/App.jsx.

Before you run the dev server, what do you expect to see in the browser?

  • Start the development server by running this command:
npm run dev

Your terminal displays a local URL. Open http://localhost:5173 in your browser.

You should see a large, bold "Workout Tracker" heading on the page. That bold styling comes from Tailwind's font-bold class, which confirms everything is wired up.

Don't see the styled heading?

  • Check that src/index.css contains only @import "tailwindcss"; with no leftover default styles.
  • Make sure vite.config.js has tailwindcss() listed before react() in the plugins array.
  • Stop the server with Ctrl+C, then restart with npm run dev. Config changes require a server restart.
  • Help me debug why Tailwind is not working.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is each file as it should look at the end of this step.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    tailwindcss(),
    react(),
  ],
})
@import "tailwindcss";
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
function App() {
  return (
    <div>
      <h1 className="text-3xl font-bold">Workout Tracker</h1>
    </div>
  )
}

export default App

Your dev environment is ready and Tailwind is applying styles. Next up, you will build the workout logging form so you can start capturing real exercise data.

Build the Workout Logging Form

Your dev server is running and Tailwind is confirmed working. Now it is time to build the core feature of the app: capturing workout data.

Without a way to record exercises, sets, reps, and weight, there is nothing for charts or AI to analyze later. In this step, you will build a workout logging form that saves data to localStorage so it persists between browser sessions.

In this step, get ready to:
  • Create a WorkoutForm component with inputs for date, category, exercise name, sets, reps, and weight.
  • Wire up state management and localStorage persistence in App.jsx.
  • Verify that saved workouts survive a full browser refresh.
Set up the components folder and app shell

React apps organize reusable UI into a components folder. You will create this folder and set up the app shell with tab navigation before building the form itself.

  • Create the components directory inside src by running this command in your terminal:
mkdir src/components

The app will eventually have four tabs: Log, History, Progress, and AI Coach. Each tab renders its own component. You will build the WorkoutForm (Log tab) now and create placeholder files for the other three so the app does not crash on missing imports.

  • Create a placeholder WorkoutHistory.jsx file in src/components with this temporary content:
function WorkoutHistory({ workouts, onDelete }) {
  return (
    <p className="text-center text-gray-500">No workouts logged yet.</p>
  )
}

export default WorkoutHistory
  • Create a placeholder ProgressChart.jsx file in src/components:
function ProgressChart({ workouts }) {
  return (
    <p className="text-center text-gray-500">
      Log some workouts to see progress charts.
    </p>
  )
}

export default ProgressChart
  • Create a placeholder AICoach.jsx file in src/components:
function AICoach({ workouts }) {
  return (
    <div className="space-y-4"></div>
  )
}

export default AICoach

Why create placeholder files?

App.jsx will import all four components for its tab navigation. If those files do not exist, Vite throws an import error and your dev server crashes. You will replace these placeholders with full implementations in later steps.

  • Now replace the entire contents of src/App.jsx with the following code that sets up imports, state management, and localStorage persistence:
import { useState, useEffect } from 'react'
import WorkoutForm from './components/WorkoutForm'
import WorkoutHistory from './components/WorkoutHistory'
import ProgressChart from './components/ProgressChart'
import AICoach from './components/AICoach'

function App() {
  const [workouts, setWorkouts] = useState([])
  const [activeTab, setActiveTab] = useState('log')

  useEffect(() => {
    const saved = localStorage.getItem('workouts')
    if (saved) {
      setWorkouts(JSON.parse(saved))
    }
  }, [])

  function saveWorkouts(updated) {
    setWorkouts(updated)
    localStorage.setItem('workouts', JSON.stringify(updated))
  }

  function addWorkout(workout) {
    const updated = [...workouts, workout]
    saveWorkouts(updated)
  }

  function deleteWorkout(index) {
    const updated = workouts.filter((_, i) => i !== index)
    saveWorkouts(updated)
  }

What does this code do?

The useEffect hook runs once when the app loads and checks localStorage for any previously saved workouts. If it finds data, it parses the JSON string back into an array.

The saveWorkouts function does two things at once: it updates the React state AND writes the data to localStorage. This keeps them in sync so data survives a page refresh.

  • Directly below the code you just pasted (still inside the App function), add the tabs array and the JSX return:
  const tabs = [
    { id: 'log', label: 'Log' },
    { id: 'history', label: 'History' },
    { id: 'progress', label: 'Progress' },
    { id: 'coach', label: 'AI Coach' },
  ]

  return (
    <div className="min-h-screen bg-gray-900 text-white">
      <header className="bg-gray-800 p-4 text-center">
        <h1 className="text-2xl font-bold">Workout Tracker</h1>
      </header>

      <nav className="flex border-b border-gray-700 bg-gray-800">
        {tabs.map((tab) => (
          <button
            key={tab.id}
            onClick={() => setActiveTab(tab.id)}
            className={`flex-1 p-3 text-sm font-medium ${
              activeTab === tab.id
                ? 'border-b-2 border-blue-500 text-blue-400'
                : 'text-gray-400'
            }`}
          >
            {tab.label}
          </button>
        ))}
      </nav>
  • Finally, add the main content area and close the component. Paste this directly below the </nav> tag:
      <main className="mx-auto max-w-2xl p-4">
        {activeTab === 'log' && <WorkoutForm onSave={addWorkout} />}
        {activeTab === 'history' && (
          <WorkoutHistory workouts={workouts} onDelete={deleteWorkout} />
        )}
        {activeTab === 'progress' && <ProgressChart workouts={workouts} />}
        {activeTab === 'coach' && <AICoach workouts={workouts} />}
      </main>
    </div>
  )
}

export default App

What does the tab navigation do?

The activeTab state controls which component renders. Clicking a tab button updates activeTab, and the conditional rendering in <main> shows only the matching component. The Log tab is active by default so WorkoutForm renders first.

  • Save src/App.jsx.

Your browser will show an error because WorkoutForm does not exist yet. That is expected. You will create it next.

Build the WorkoutForm component

This component handles all the form inputs and exercise management. It receives an onSave callback from App.jsx and calls it with the completed workout data.

  • Create a new file src/components/WorkoutForm.jsx and paste the following code for the component logic:
import { useState } from 'react'

function WorkoutForm({ onSave }) {
  const [date, setDate] = useState(new Date().toISOString().split('T')[0])
  const [category, setCategory] = useState('Upper')
  const [exercises, setExercises] = useState([])
  const [exerciseName, setExerciseName] = useState('')
  const [sets, setSets] = useState('')
  const [reps, setReps] = useState('')
  const [weight, setWeight] = useState('')

  function addExercise() {
    if (!exerciseName || !sets || !reps || !weight) return
    setExercises([
      ...exercises,
      { name: exerciseName, sets: Number(sets), reps: Number(reps), weight: Number(weight) },
    ])
    setExerciseName('')
    setSets('')
    setReps('')
    setWeight('')
  }

  function saveWorkout() {
    if (exercises.length === 0) return
    onSave({ date, category, exercises })
    setExercises([])
  }

What does this code do?

Each form field gets its own useState variable. The addExercise function validates that all fields are filled, appends a new exercise object to the exercises array, and clears the inputs for the next entry.

The saveWorkout function passes the complete workout (date, category, and all exercises) up to App.jsx via the onSave prop, then resets the exercises list for a fresh session.

  • Directly below the saveWorkout function, add the return statement with the date and category inputs:
  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-3">
        <div>
          <label className="block text-sm text-gray-400">Date</label>
          <input
            type="date"
            value={date}
            onChange={(e) => setDate(e.target.value)}
            className="w-full rounded bg-gray-800 p-2 text-white"
          />
        </div>
        <div>
          <label className="block text-sm text-gray-400">Category</label>
          <select
            value={category}
            onChange={(e) => setCategory(e.target.value)}
            className="w-full rounded bg-gray-800 p-2 text-white"
          >
            <option>Upper</option>
            <option>Lower</option>
            <option>Push</option>
            <option>Pull</option>
            <option>Cardio</option>
          </select>
        </div>
      </div>
  • Below the closing </div> of the grid, add the exercise input section:
      <div className="space-y-3 rounded-lg bg-gray-800 p-4">
        <input
          type="text"
          placeholder="Exercise name"
          value={exerciseName}
          onChange={(e) => setExerciseName(e.target.value)}
          className="w-full rounded bg-gray-700 p-2 text-white placeholder-gray-500"
        />
        <div className="grid grid-cols-3 gap-2">
          <input
            type="number"
            placeholder="Sets"
            value={sets}
            onChange={(e) => setSets(e.target.value)}
            className="rounded bg-gray-700 p-2 text-white placeholder-gray-500"
          />
          <input
            type="number"
            placeholder="Reps"
            value={reps}
            onChange={(e) => setReps(e.target.value)}
            className="rounded bg-gray-700 p-2 text-white placeholder-gray-500"
          />
          <input
            type="number"
            placeholder="Weight"
            value={weight}
            onChange={(e) => setWeight(e.target.value)}
            className="rounded bg-gray-700 p-2 text-white placeholder-gray-500"
          />
        </div>
        <button
          onClick={addExercise}
          className="w-full rounded bg-blue-600 p-2 font-medium hover:bg-blue-700"
        >
          Add Exercise
        </button>
      </div>
  • Save src/components/WorkoutForm.jsx and check your browser.
  • You should see the dark app shell with four tab buttons at the top, and below them a date picker, category dropdown, exercise name input, and three number fields for sets, reps, and weight.

Don't see the form?

Make sure your file is saved as src/components/WorkoutForm.jsx (not src/WorkoutForm.jsx). The import path in App.jsx points to ./components/WorkoutForm.

If you see a blank page, open DevTools (F12) and check the Console tab for error messages. A typo in the filename or a missing closing bracket is the most common cause.

help me fix my WorkoutForm import error

  • Now add the final section of WorkoutForm that shows added exercises and the save button. Paste this below the exercise input </div>, still inside the outer <div className="space-y-4">:
      {exercises.length > 0 && (
        <div className="space-y-2">
          <h3 className="text-sm font-medium text-gray-400">
            Current Workout ({exercises.length} exercises)
          </h3>
          {exercises.map((ex, i) => (
            <div key={i} className="rounded bg-gray-800 p-2 text-sm">
              {ex.name} - {ex.sets}x{ex.reps} @ {ex.weight}kg
            </div>
          ))}
          <button
            onClick={saveWorkout}
            className="w-full rounded bg-green-600 p-3 font-bold hover:bg-green-700"
          >
            Save Workout
          </button>
        </div>
      )}
    </div>
  )
}

export default WorkoutForm

What does this section do?

This conditional block only renders when there is at least one exercise added. It lists each exercise showing its name, sets, reps, and weight in a compact format. The green Save Workout button only appears after you have added exercises, preventing accidental empty saves.

  • Save src/components/WorkoutForm.jsx.
  • In your browser, type Bench Press in the exercise name field, enter 3 for sets, 10 for reps, and 60 for weight.
  • Click the Add Exercise button.

You should see "Current Workout (1 exercises)" appear below the form with Bench Press - 3x10 @ 60kg listed, plus the green Save Workout button.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both src/App.jsx and src/components/WorkoutForm.jsx.

ⓧ I'd like to double check the full code

Your src/components/WorkoutForm.jsx should contain these sections in order. Scroll up and confirm each is present:

  • The import { useState } from 'react' line and function declaration with all seven useState variables.
  • The addExercise and saveWorkout functions.
  • The return statement with the date/category grid, exercise inputs, Add Exercise button, exercises list, and Save Workout button.
  • The export default WorkoutForm at the bottom.

Your src/App.jsx should contain these sections in order:

  • Imports for useState, useEffect, and all four components.
  • The workouts and activeTab state declarations plus the useEffect that loads from localStorage.
  • The saveWorkouts, addWorkout, and deleteWorkout functions.
  • The tabs array and the full JSX return with header, nav, and main sections.
  • The export default App at the bottom.
Verify localStorage persistence

The real test of your form is whether data survives a page refresh. The useEffect in App.jsx loads saved workouts from localStorage on mount, so a refresh should bring your data back.

  • Make sure you still have Bench Press listed in "Current Workout". If not, add it again (Bench Press, 3 sets, 10 reps, 60 weight).
  • Click the green Save Workout button.

The exercise list disappears (the form resets for a new session). Your workout has been saved to localStorage.

  • Before you refresh, think: will your saved workout still be there after a page reload? What mechanism brings it back?
  • Refresh your browser with Cmd+R (macOS) or Ctrl+R (Windows).
  • Click the History tab at the top.

You should see the placeholder text "No workouts logged yet." from your stub file. To confirm your data actually persisted, open DevTools (F12), go to the Application tab, expand Local Storage, and click your localhost:5173 entry. You should see a workouts key containing your saved workout as a JSON array.

Why use DevTools here?

The History tab is just a placeholder right now. You will build the full WorkoutHistory component in the next step. For now, the DevTools Application panel is your proof that localStorage is working. You can see the exact JSON structure with date, category, and exercises array.

Don't see the workouts key in localStorage?

Make sure you clicked Save Workout (the green button), not just Add Exercise (the blue button). Adding an exercise only puts it in the temporary list. Saving writes the full workout to localStorage.

Check that your saveWorkouts function in App.jsx calls localStorage.setItem('workouts', JSON.stringify(updated)). A missing JSON.stringify call will store [object Object] instead of valid JSON.

help me debug why localStorage is not saving my workout data

Your form captures workout data, saves it to localStorage, and loads it back on refresh. Next up, you will build the full workout history display so you can see all your saved sessions at a glance.

Display Workout History

Your workout form is logging data to localStorage and you can confirm it persists across refreshes.

A flat list of workouts is not useful at the gym. You need to see your history organized by date with full exercise details so you know what you lifted last session and can aim to beat it. In this step, you will build a dedicated History view with category filtering and per-workout deletion.

In this step, get ready to:
  • Create a WorkoutHistory component that renders workout cards with date, category, and exercise details.
  • Add category filter pills and a delete button per workout.
  • Verify tab navigation between the Log and History views works end-to-end.
Create the WorkoutHistory component with filter logic

The History view needs to do three things: filter workouts by category, sort them newest-first, and render each one as a card.

  • Create a new file at src/components/WorkoutHistory.jsx.
  • Add the component logic by pasting this code:
import { useState } from 'react'

function WorkoutHistory({ workouts, onDelete }) {
  // Track which category filter is active
  const [filter, setFilter] = useState('All')

  // Available filter categories
  const categories = ['All', 'Upper', 'Lower', 'Push', 'Pull', 'Cardio']

  // Apply the category filter to workouts
  const filtered =
    filter === 'All'
      ? workouts
      : workouts.filter((w) => w.category === filter)

  // Sort by date, newest first
  const sorted = [...filtered].sort(
    (a, b) => new Date(b.date) - new Date(a.date)
  )

What does this code do?

The filter state tracks which category button is active. It defaults to 'All' so the learner sees everything on first load.

The filtered variable either passes all workouts through or keeps only those matching the active category. Then sorted rearranges them so the most recent session appears at the top.

Add the filter pills and workout cards

The logic is ready. Now you need the JSX that renders category filter buttons and each workout as a styled card with a delete option.

  • Directly below the code you just pasted in WorkoutHistory.jsx, add the return statement with the filter pill buttons:
  return (
    <div className="space-y-4">
      <div className="flex gap-2 overflow-x-auto">
        {categories.map((cat) => (
          <button
            key={cat}
            onClick={() => setFilter(cat)}
            className={`shrink-0 rounded-full px-3 py-1 text-sm ${
              filter === cat
                ? 'bg-blue-600 text-white'
                : 'bg-gray-800 text-gray-400'
            }`}
          >
            {cat}
          </button>
        ))}
      </div>

What are the filter pills?

Each category gets a small pill-shaped button. Tapping one sets the filter state, which immediately re-filters the list. The active pill turns blue so you always know which filter is selected.

  • Continue directly below in the same file. Add the workout cards section and close the component:
      {sorted.length === 0 ? (
        <p className="text-center text-gray-500">No workouts logged yet.</p>
      ) : (
        sorted.map((workout, index) => {
          // Find original index for correct deletion
          const originalIndex = workouts.indexOf(workout)
          return (
            <div key={index} className="rounded-lg bg-gray-800 p-4">
              <div className="mb-2 flex items-center justify-between">
                <div>
                  <span className="font-medium">{workout.date}</span>
                  <span className="ml-2 rounded bg-blue-900 px-2 py-0.5 text-xs text-blue-300">
                    {workout.category}
                  </span>
                </div>
                <button
                  onClick={() => onDelete(originalIndex)}
                  className="text-sm text-red-400 hover:text-red-300"
                >
                  Delete
                </button>
              </div>
              <div className="space-y-1">
                {workout.exercises.map((ex, i) => (
                  <div key={i} className="text-sm text-gray-300">
                    {ex.name} - {ex.sets}x{ex.reps} @ {ex.weight}kg
                  </div>
                ))}
              </div>
            </div>
          )
        })
      )}
    </div>
  )
}

export default WorkoutHistory

What does this code do?

  • If no workouts match the filter, a "No workouts logged yet." message appears instead of an empty screen.
  • Each card shows the date, a blue category badge, and all exercises formatted as name, sets, reps, and weight.
  • The originalIndex lookup ensures the Delete button removes the correct workout from the original array, even when the displayed list is filtered or sorted differently.
  • Save src/components/WorkoutHistory.jsx.
  • Switch to your browser at localhost:5173 and click the History tab.

You should see your previously saved workouts displayed as dark cards, each showing the date, a blue category badge, exercise names with sets/reps/weight, and a red Delete link.

Don't see your workouts?

  • Make sure you saved at least one workout using the Log tab first. The History view reads from localStorage, so you need existing data.
  • Check your browser console for errors. A typo in the file (especially in the JSX curly braces) will stop the component from rendering.
  • Confirm the file is saved at src/components/WorkoutHistory.jsx (not in the wrong folder).

Why isn't my WorkoutHistory component rendering any workout cards?

Test the category filter and delete

The filter and delete features are built into the component. Time to confirm they work correctly.

  • Click the Log tab and save two workouts with different categories (for example, one "Upper" and one "Lower").
  • Switch back to the History tab.
  • Tap the Upper filter pill at the top.

Only your Upper workouts appear. The pill turns blue to indicate the active filter. Tap All to see everything again.

  • Click Delete on one of your test workouts.

The card disappears immediately. Refresh the page and confirm it stays gone. The deletion updates both React state and localStorage at the same time.

Deleted workout reappears after refresh?

This means the deletion updated the display but not localStorage. Confirm your App.jsx has the deleteWorkout function calling saveWorkouts (which writes to localStorage). The function is passed to WorkoutHistory as the onDelete prop.

Why does my deleted workout come back when I refresh the page?

Why use originalIndex for deletion?

The displayed list is filtered and sorted differently from the raw workouts array. If you deleted by the displayed index, you would remove the wrong workout. workouts.indexOf(workout) finds the correct position in the source array so the right entry gets removed.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/components/WorkoutHistory.jsx.

ⓧ I'd like to double check the full code

Scroll back up through this substep and confirm your file contains these sections in order:

  • The import and component function with filter state, categories array, filtered logic, and sorted logic.
  • The return statement starting with the filter pill buttons (a flex gap-2 overflow-x-auto div mapping over categories).
  • The conditional rendering: empty state message when sorted.length === 0, otherwise workout cards with date, category badge, delete button, and exercise list.
  • The export default WorkoutHistory at the bottom.

Your workout history now shows organized, filterable cards that tell you exactly what you lifted and when. Next up, you will turn that raw data into interactive progress charts so you can see your strength gains over time.

Add Progress Charts

Your workout history is organized and filterable. You can see exactly what you did last session.

But raw numbers in a table do not tell you whether you are getting stronger. A line chart showing your bench press weight over the last month instantly reveals if you are progressing, plateauing, or declining. In this step, you will add interactive charts that turn your logged data into visual progress trends.

In this step, get ready to:
  • Install Recharts and its peer dependency.
  • Create a ProgressChart component that transforms workout data into chart-ready format.
  • Render an interactive line chart with an exercise selector dropdown.
Install Recharts

Recharts is a charting library built specifically for React. It provides composable chart components like LineChart, XAxis, and Tooltip that you combine declaratively in JSX.

  • Install Recharts and its required peer dependency by running this command in your terminal:
npm install recharts react-is
  • Confirm the install succeeded. You should see recharts and react-is listed under dependencies in your package.json.

Why react-is?

Recharts 3.x requires react-is as a peer dependency to correctly identify React element types internally. Without it, the charts will fail to render.

Install failed?

Make sure you are running the command from inside your workout-tracker folder (the one containing package.json). If your dev server is occupying the terminal, open a second terminal tab before running the install.

Help me debug my npm install error for recharts.

Build the data transformation logic

Before rendering a chart, you need to extract the right data from your workouts array. The component will derive a list of unique exercise names, let the user pick one, then filter the workout history down to just that exercise's weight entries over time.

  • Create a new file src/components/ProgressChart.jsx and paste the following imports and data hooks:
import { useState, useMemo } from 'react'
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  Tooltip,
  ResponsiveContainer,
} from 'recharts'

function ProgressChart({ workouts }) {
  // Extract unique exercise names from all workouts
  const exerciseNames = useMemo(() => {
    const names = new Set()
    workouts.forEach((w) => {
      w.exercises.forEach((ex) => names.add(ex.name))
    })
    return Array.from(names).sort()
  }, [workouts])

  const [selectedExercise, setSelectedExercise] = useState(
    exerciseNames[0] || ''
  )

What does this code do?

  • The imports pull in chart components from Recharts that you will use in the render step.
  • useMemo(() => ...) collects every exercise name you have ever logged into a sorted, deduplicated list. It recalculates only when workouts changes.
  • selectedExercise tracks which exercise the user wants to see charted. It defaults to the first name in the list.
  • Directly below the code you just pasted, add the chart data computation and an empty-state return:
  // Filter workouts for the selected exercise and build {date, weight} pairs
  const chartData = useMemo(() => {
    if (!selectedExercise) return []
    const data = []
    workouts.forEach((w) => {
      w.exercises.forEach((ex) => {
        if (ex.name === selectedExercise) {
          data.push({ date: w.date, weight: ex.weight })
        }
      })
    })
    return data.sort((a, b) => new Date(a.date) - new Date(b.date))
  }, [workouts, selectedExercise])

  if (exerciseNames.length === 0) {
    return (
      <p className="text-center text-gray-500">
        Log some workouts to see progress charts.
      </p>
    )
  }

What does this code do?

  • chartData loops through every workout, finds entries matching the selected exercise, and builds an array of { date, weight } objects sorted chronologically. This is exactly what Recharts needs for its X and Y axes.
  • The early return handles the case where the user has no workouts at all, showing a helpful message instead of a broken chart.
Add the exercise selector and chart rendering

Now you need a return statement that renders the dropdown selector and the chart. The dropdown populates itself from the unique exercise names your useMemo already computed.

  • Below the empty-state if block, add the main return with the exercise dropdown:
  return (
    <div className="space-y-4">
      <select
        value={selectedExercise}
        onChange={(e) => setSelectedExercise(e.target.value)}
        className="w-full rounded bg-gray-800 p-2 text-white"
      >
        {exerciseNames.map((name) => (
          <option key={name} value={name}>
            {name}
          </option>
        ))}
      </select>

      {chartData.length < 2 ? (
        <p className="text-center text-gray-500">
          Log this exercise at least twice to see a chart.
        </p>
      ) : (

What does this code do?

  • The <select> dropdown maps over exerciseNames to let the user switch between exercises they have logged.
  • The ternary checks if there are at least 2 data points. You need at least two points to draw a line.
  • Immediately after the ) : ( line, paste the chart rendering and the closing brackets:
        <div className="h-64 w-full">
          <ResponsiveContainer width="100%" height="100%">
            <LineChart data={chartData}>
              <XAxis dataKey="date" stroke="#9ca3af" fontSize={12} />
              <YAxis stroke="#9ca3af" fontSize={12} />
              <Tooltip
                contentStyle={{
                  backgroundColor: '#1f2937',
                  border: 'none',
                  borderRadius: '8px',
                }}
                labelStyle={{ color: '#9ca3af' }}
              />
              <Line
                type="monotone"
                dataKey="weight"
                stroke="#3b82f6"
                strokeWidth={2}
                dot={{ fill: '#3b82f6' }}
              />
            </LineChart>
          </ResponsiveContainer>
        </div>
      )}
    </div>
  )
}

export default ProgressChart

What does this code do?

  • ResponsiveContainer makes the chart resize to fit its parent container, so it looks right on both phone and desktop screens.
  • XAxis displays dates along the bottom. YAxis displays weight values on the left.
  • Tooltip shows the exact weight and date when you hover over (or tap) a data point. The dark styling matches the app theme.
  • Line draws the actual data line in blue connecting each weight entry chronologically.
  • Save src/components/ProgressChart.jsx.

Seeing a blank screen or errors?

Check that your closing brackets match. The return statement has a ternary expression (? ... : ...) wrapped in curly braces. Make sure you have the closing )} after the chart </div> and export default ProgressChart at the very bottom of the file.

Help me fix my ProgressChart component.

✔️ Awesome, I've got everything!

Great. Double check you have saved ProgressChart.jsx and your dev server is still running.

ⓧ I'd like to double check the full code

Your src/components/ProgressChart.jsx should contain these sections in order. Scroll up and confirm each is present:

  • Imports from react and recharts (chunk 1).
  • exerciseNames useMemo + selectedExercise state (chunk 1).
  • chartData useMemo + empty state if return (chunk 2).
  • Main return with <select> dropdown + ternary (chunk 3).
  • ResponsiveContainer with LineChart + export default ProgressChart (chunk 4).
Verify the progress chart works

Your App.jsx already renders <ProgressChart workouts={workouts} /> when the Progress tab is active, so no wiring changes are needed. The component is ready to test.

To see a chart, you need at least two workouts that include the same exercise name. Before you test, what do you think you will see if you only have one entry for an exercise?

  • Switch to the browser at localhost:5173.
  • If you do not have at least two workouts with the same exercise name logged, go to the Log tab and create two workouts that both include an exercise called Bench Press (use different weights and different dates).
  • Click the Progress tab in the navigation.
  • Select the exercise you logged twice from the dropdown.

You should see a blue line chart with dates along the bottom and weight on the left axis. Hovering over a dot shows a dark tooltip with the exact date and weight value.

Chart not appearing?

  • Make sure you logged the same exercise name exactly (case-sensitive) in at least two separate workouts.
  • Check the browser console for errors. A common issue is a missing import or a typo in the Recharts component names.
  • Confirm your file is saved and the dev server is still running.

My Recharts line chart is not rendering. Help me debug.

Your progress charts are live. You can now see at a glance whether you are getting stronger on any exercise. Next up, you will connect an AI coach that reads this data and gives you personalized training advice.

Integrate the AI Coach with Gemini

Your progress charts show the trend, but they don't tell you what to do about it.

In this step, you will wire up an AI coach powered by Google Gemini that analyzes your full workout history and returns specific, actionable feedback. You will build a serverless function that handles the API call securely, keeping your key on the server side.

In this step, get ready to:
  • Get your free Gemini API key and install the Google GenAI SDK.
  • Create the AI Coach frontend component with loading and error states.
  • Build a Vercel serverless function that sends your workout data to Gemini and returns coaching advice.
Get your Gemini API key and install the SDK

The AI coach needs a key to authenticate with Google Gemini. You will generate a free key from Google AI Studio.

  • Open your browser and go to aistudio.google.com/app/apikey.
  • Sign in with your Google account.
  • Click Create API key.
  • Copy the generated key and paste it somewhere safe (a text file or password manager). You will need it shortly.

Why a serverless function instead of calling from the browser?

If you called the Gemini API directly from your React code, your API key would be visible in the browser's network tab. A serverless function runs on the server, so the key never leaves the backend. Your frontend sends workout data to /api/analyze, and the function handles the Gemini call in private.

  • Switch back to your terminal. Stop the running dev server with Ctrl+C.
  • Install the Google GenAI SDK by running:
npm install @google/genai

Install failing?

Make sure you are inside your workout-tracker folder (the one containing package.json). Run ls to confirm you see package.json in the output.

Help me fix my npm install error for @google/genai

Create the AI Coach component

Your App.jsx already imports an AICoach component that does not exist yet. That means the dev server is currently showing an error. Let's fix that by creating the component.

  • In VS Code, create a new file at src/components/AICoach.jsx.
  • Paste the following code to set up the component state and the validation logic at the start of the analyze function:
import { useState } from 'react'

function AICoach({ workouts }) {
  const [analysis, setAnalysis] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')

  async function analyzeProgress() {
    if (workouts.length < 2) {
      setError('Log at least 2 workouts before requesting analysis.')
      return
    }
    setLoading(true)
    setError('')
    setAnalysis('')
  • Directly below the code you just pasted, add the fetch logic that calls your serverless function:
    try {
      const response = await fetch('/api/analyze', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ workouts }),
      })

      if (!response.ok) {
        throw new Error('Failed to get analysis')
      }

      const data = await response.json()
      setAnalysis(data.analysis)
    } catch (err) {
      setError('Something went wrong. Please try again.')
    } finally {
      setLoading(false)
    }
  }

What does this code do?

The analyzeProgress function first checks the learner has logged at least 2 workouts. Then it POSTs the full workouts array to /api/analyze (the serverless function you will create next). The response contains an analysis string from Gemini, which gets stored in state.

The finally block resets the loading spinner regardless of success or failure.

  • Now add the component's UI below the function. This renders the button, any error message, and the AI analysis card:
  return (
    <div className="space-y-4">
      <div className="rounded-lg bg-gray-800 p-4 text-center">
        <p className="mb-3 text-gray-400">
          Your AI coach will analyze your workout history and provide
          personalized feedback on your progress.
        </p>
        <button
          onClick={analyzeProgress}
          disabled={loading}
          className="rounded bg-purple-600 px-6 py-3 font-bold hover:bg-purple-700 disabled:opacity-50"
        >
          {loading ? 'Analyzing...' : 'Analyze My Progress'}
        </button>
      </div>

      {error && (
        <div className="rounded-lg bg-red-900/50 p-4 text-red-300">
          {error}
        </div>
      )}
  • Finally, add the analysis display section and close out the component:
      {analysis && (
        <div className="rounded-lg bg-gray-800 p-4">
          <h3 className="mb-2 font-bold text-purple-400">AI Coach Analysis</h3>
          <div className="whitespace-pre-wrap text-sm text-gray-300">
            {analysis}
          </div>
        </div>
      )}
    </div>
  )
}

export default AICoach

What does the UI render?

The button triggers analyzeProgress and shows "Analyzing..." while loading. If the fetch fails, a red error card appears. On success, the AI response renders inside a purple-accented card using whitespace-pre-wrap so Gemini's line breaks display correctly.

  • Save src/components/AICoach.jsx.
  • Start the dev server again by running:
npm run dev
  • Open localhost:5173 in your browser and click the AI Coach tab.

You should see the purple "Analyze My Progress" button. The app no longer errors because the missing component now exists.

Still seeing an error in the browser?

Check that your file is saved at exactly src/components/AICoach.jsx (capital A, capital C). The import in App.jsx is case-sensitive.

Help me fix my AICoach import error

Build the serverless function and test end-to-end

The button is in place but it has nowhere to send data yet. Now you will create the Vercel serverless function that receives your workout history, sends it to Gemini, and returns the coaching analysis.

  • Stop the dev server with Ctrl+C.
  • Create the api folder in your project root by running:
mkdir api
  • Create a new file at api/analyze.js in VS Code.
  • Paste the following code to set up the handler, validate the request, and initialize the Gemini client:
import { GoogleGenAI } from '@google/genai'

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const { workouts } = req.body

  if (!workouts || workouts.length < 2) {
    return res.status(400).json({ error: 'Need at least 2 workouts to analyze' })
  }

  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY })
  • Directly below, add the workout formatting, prompt construction, and the API call:
  const workoutSummary = workouts
    .map(
      (w) =>
        `${w.date} (${w.category}): ${w.exercises
          .map((e) => `${e.name} ${e.sets}x${e.reps} @ ${e.weight}kg`)
          .join(', ')}`
    )
    .join('\n')

  const prompt = `You are an experienced strength and conditioning coach. Analyze this workout history and provide:
1. Overall progress assessment (are they improving, plateauing, or regressing?)
2. Specific observations about individual exercises
3. 2-3 actionable suggestions for improvement

Keep your response concise (under 200 words) and encouraging but honest.

Workout History:
${workoutSummary}`

  try {
    const response = await ai.models.generateContent({
      model: 'gemini-2.5-flash',
      contents: prompt,
    })

    return res.status(200).json({ analysis: response.text })
  } catch (err) {
    return res.status(500).json({ error: 'AI analysis failed' })
  }
}

What does this function do?

It transforms each workout into a readable one-line summary (date, category, exercises with sets/reps/weight). That summary is injected into a prompt that asks Gemini to act as a strength coach. The response text is returned as JSON to the frontend.

The process.env.GEMINI_API_KEY reads the key from an environment variable so it never appears in your code.

  • Save api/analyze.js.
  • Confirm the file exists by running:
ls api

You should see analyze.js in the output.

  • Now create a .env.local file in your project root. This is where your API key lives locally:
GEMINI_API_KEY=your-api-key-here
  • Replace your-api-key-here with the actual API key you copied from Google AI Studio.
  • Open .gitignore in your project root and add .env.local on a new line so your key is never committed to version control.
  • Save both files.

Why .env.local?

Vercel automatically reads .env.local when running vercel dev locally. In production, you will set the same variable in the Vercel dashboard. This keeps your secret out of your code and out of Git.

Your regular Vite dev server does not know how to run serverless functions. To test the full flow locally, you need vercel dev, which emulates the Vercel deployment environment on your machine.

  • Start the Vercel local dev server by running:
npx vercel dev

What will vercel dev ask me?

On first run, vercel dev will ask you to log in to Vercel and link the project. Follow the prompts and accept the defaults. This creates a Vercel project locally but does not make your app public yet.

Once the server is running, open the URL shown in your terminal (typically localhost:3000).

  • Make sure you have at least 2 workouts saved. If not, switch to the Log tab and log 2 quick workouts with different dates.
  • Click the AI Coach tab.

Before you click the button, what do you think will happen? Will you get an analysis, or will you see an error?

  • Click Analyze My Progress.

After a few seconds, you should see a personalized analysis appear in a purple-accented card. Gemini will comment on your exercises and offer suggestions. The AI Coach is working end-to-end.

Seeing "Something went wrong"?

Check that your .env.local file is in the project root (not inside src/) and contains your real API key with no quotes around the value.

Make sure the server running is vercel dev, not npm run dev. The Vite dev server cannot run serverless functions.

Help me debug my AI Coach serverless function

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Your project now has two new files. Scroll up and confirm you have all the chunks in order:

  • src/components/AICoach.jsx - component state and validation, try/catch fetch logic, return JSX with button/error/analysis, and the export.
  • api/analyze.js - import and handler setup with validation, then the workout summary, prompt, and Gemini API call.
  • .env.local - a single line: GEMINI_API_KEY= followed by your actual key.

Your app now has brains. Charts tell you what happened. The AI Coach tells you what to do next. In the next step, you will deploy this to a live Vercel URL so you can use it at the gym from your phone.

Deploy to Vercel

Your app works locally. You can log workouts, view history, see charts, and get AI coaching feedback. But a workout tracker you can only use at your desk is not very useful.

To use this app between sets at the gym, you need it live on the internet with HTTPS. In this step, you will push your code to GitHub and deploy it to Vercel so you can access it from your phone at a public URL.

In this step, get ready to:
  • Push your project code to a GitHub repository.
  • Deploy the app to Vercel with your API key configured.
  • Verify the live app works from any device.
Push your code to GitHub

Vercel deploys from a Git repository. You need to push your project to GitHub first so Vercel can pull and build it automatically on every push.

  • Stop the dev server running in your terminal by pressing Ctrl+C.
  • Initialize a Git repository and create your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"

What do these commands do?

git init turns your project folder into a Git repository. git add . stages all your files. git commit saves a snapshot of your code that you can push to GitHub.

  • Open your browser and go to github.com/new to create a new repository.
  • Name it workout-tracker.
  • Leave all the checkboxes unchecked (no README, no .gitignore, no license). You already have code to push.
  • Click Create repository.
  • GitHub shows you setup commands. Copy the HTTPS URL for your repository (it looks like https://github.com/your-username/workout-tracker.git).
  • Back in your terminal, connect your local repo to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/workout-tracker.git"]]
git branch -M main
git push -u origin main
  • Refresh the GitHub page in your browser. You should see all your project files listed in the repository.

Push failed or permission denied?

Make sure you are signed into the correct GitHub account in your terminal. If prompted for authentication, use a personal access token or GitHub CLI (gh auth login) instead of your password.

help me fix a git push permission error

Deploy to Vercel

With your code on GitHub, you can now connect it to Vercel. Vercel will build your React app and host the api/analyze.js file as a serverless function automatically.

  • Go to vercel.com and click Sign Up.
  • Choose Continue with GitHub to create your free Hobby account using the same GitHub account you just pushed to.
  • Authorize Vercel to access your GitHub repositories when prompted.
  • Once signed in, click Add New... and select Project.
  • Find your workout-tracker repository in the list and click Import.

Vercel shows a configuration page before deploying. This is where you add your API key so the serverless function can access it in production.

  • Expand the Environment Variables section on the configuration page.
  • In the Name field, enter GEMINI_API_KEY.
  • In the Value field, paste the same API key from your .env.local file.
  • Click Deploy.

Why add the key here?

Your .env.local file is in .gitignore, so it never reaches GitHub or Vercel. Adding GEMINI_API_KEY as an environment variable in Vercel makes it available to your serverless function in production via process.env.GEMINI_API_KEY without ever exposing it in your code.

Vercel streams the build output live. Wait for it to complete. This typically takes 30 to 60 seconds.

Build failed?

Check the build logs Vercel shows. The most common cause is a missing dependency. Make sure your package.json includes recharts, react-is, and @google/genai. If one is missing, run npm install locally with the missing package, commit, and push again.

help me fix a Vercel build failure

Verify the live app from your phone

Once the build succeeds, Vercel shows a success page with your production URL. Before you try it, take a moment to predict what will happen.

  • Do you think the AI Coach will work on the live site? What would prevent it from working?
  • Click the production URL Vercel shows (it looks like https://workout-tracker-xxx.vercel.app).
  • Your workout tracker loads in the browser with all four tabs working: Log, History, Progress, and AI Coach.
  • Log two quick test workouts from the Log tab.
  • Switch to the AI Coach tab and click Analyze My Progress.
  • The AI coach returns a personalized analysis. This confirms your serverless function and Gemini API key are both working in production.

Try it on your phone

Open your phone's browser and type in your Vercel URL. The app is mobile-first, so it looks great on a small screen. Bookmark it or add it to your home screen for quick access at the gym.

Your workout tracker is live on the internet. You can pull it up on your phone between sets and start tracking your real workouts. From here, every push to your main branch automatically triggers a new deployment.

Secret mission

Add Personal Records Tracking

Every lifter wants to know their PRs. In this bonus challenge, you will add automatic personal records detection that scans your workout history for all-time max weights per exercise. When you beat a PR, a celebratory banner appears.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Vercel Hobby plan (free) and the Gemini API free tier, so there are no ongoing costs.

Resources you used:

  • Local workout-tracker project folder on your Desktop.
  • Vercel project deployment (Hobby plan, free).
  • Gemini API key (free tier, no billing).
  • GitHub repository (free).

Keep everything running

No action needed. Choose this if you plan to keep using your workout tracker at the gym.

  • Your Vercel deployment stays live at no cost on the Hobby plan.
  • Your Gemini API key remains on the free tier with no charges.
  • Your localStorage data persists in your browser between sessions.
  • Your GitHub repository stays in your account for free.

Pause - I'll come back to this later

No action needed. The app sits dormant on Vercel at no cost, and your Gemini API key incurs no charges while unused.

  • Your Vercel deployment remains live but idle. There is nothing to shut down.
  • Your local dev server only runs when you start it manually. If it is still running, press Ctrl+C in the terminal to stop it.
  • Everything will be exactly as you left it when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the Vercel project: open your project on the Vercel dashboard, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Enter the project name and type delete my project in the confirmation dialog, then click Continue.
  • Delete the Gemini API key: open the Google Cloud API Credentials page, find your API key, click the Actions icon, select Delete API key, and confirm by clicking Delete in the modal.
  • Delete the GitHub repository: open your repository on GitHub, click Settings, scroll to the bottom of the page, and click Delete this repository in the Danger Zone. Confirm by typing the repository name.
  • Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf workout-tracker
  • On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force workout-tracker

Well Done!

Well Done!

Nice work. You just built and deployed a full-stack workout tracking app that you can actually use at the gym between sets.

Here's what you learned:

  • Built a complete React application from scratch with Vite and Tailwind CSS v4, including CRUD operations with localStorage persistence for workout data.
  • Created interactive data visualizations with Recharts line charts to track your weight progression per exercise over time.
  • Integrated Google Gemini AI via a secure serverless function on Vercel to provide personalized training analysis with environment variables for production use.
  • Secret Mission: Added automatic personal records detection and a celebration banner UI that fires when you hit a new max weight.

Ready to quiz yourself?