Build a Free Workout Tracker App

A Google Apps Script workout app that uses Google Sheets and syncs exercise data with Claude for insights.

Introduction

30 Second Summary

Logging your workouts shouldn't mean relying on clunky, third-party apps that lock away your data. When you own your workout data, you can feed it into tools like Claude or Codex to analyze your progressions over time.

In this project, you will build and deploy a mobile-friendly workout tracker web app that connects directly to a Google Sheet you completely own and control. Using Google Apps Script, you'll write the logic to load workout templates, log your sessions, and automatically display your previous weights so you always know what to beat.

What You'll Build

You open the app on your phone, tap a template like "Push Day", and instantly see what you benched last session. You log today's sets, hit submit, and the data flows straight into a Google Sheet you own and control.

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

  • A live workout tracker you open on your phone or computer, select a template, and instantly see your previous weights and reps for every exercise.
  • A Google Sheet database where you design your own workout templates by simply typing in cells, and view all your logged sessions as structured rows.
  • A full-screen rest timer that auto-launches between sets with an editable default, real-time +15/-15 adjustment, and an audio beep when time is up.
  • A body weight log that records your weight over time in a dedicated tab.
  • Secret Mission: Connect Claude Desktop to your Google Sheet so Claude can read your workout history live and generate interactive progression visualizations.

Are there any prerequisites?

You need a Google account to create the Sheet and deploy the app. The Secret Mission requires the Claude Desktop app with a Pro plan or higher for artifact creation.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Create Your Workout Database

Your workout tracker needs somewhere to store all its data. In this project, your Google Sheets spreadsheet IS the database.

How you structure the Sheet's tabs and columns determines what your app can do. You'll create three tabs that each hold a different type of workout data.

In this step, get ready to:
  • Create a Google Sheet named "Workout Tracker" with three tabs.
  • Add header rows that define the data structure for each tab.
  • Open the Apps Script editor to prepare for coding.
Create your Google Sheet and add tabs

Each tab in your Sheet will store a different kind of data: one for your workout templates, one for your workout log entries, and one for body weight tracking.

  • Open sheets.google.com in your browser.
  • Click the Blank spreadsheet option (the large "+" card) to create a new spreadsheet.
  • Click the title at the top left (it says "Untitled spreadsheet") and rename it to Workout Tracker.
  • At the bottom of the screen, double-click the tab labeled Sheet1.
  • Type Templates and press Enter to rename it.
  • Click the + icon at the bottom left (next to your tabs) to add a new tab.
  • Double-click the new tab and rename it to Workout Log.
  • Click + again to add one more tab and rename it to Body Weight.

You should now see three tabs at the bottom of your spreadsheet: Templates, Workout Log, and Body Weight.

Don't see three tabs?

Make sure you pressed Enter after typing each tab name. If a tab still shows the old name, double-click it again and retype.

Check for extra spaces before or after the name. The tab names must be exactly Templates, Workout Log, and Body Weight for the app to work later.

help me rename my Google Sheet tabs

Set up the header rows

Headers in row 1 of each tab tell your app what data belongs in each column. These exact column names matter because your code will reference them later.

  • Click the Templates tab at the bottom.
  • Click cell A1 and type Template Name. Press Tab to move to B1 and type Exercise. Press Tab again and type Sets in C1.
  • Click the Workout Log tab at the bottom.
  • In row 1, type these headers across cells A1 through G1, pressing Tab between each: Timestamp, Date, Template, Exercise, Set, Weight, Reps.
  • Click the Body Weight tab at the bottom.
  • In cell A1 type Date, press Tab, and type Weight in B1.

Click through each tab to confirm your headers are in place. Each tab should have its full header row filled in across row 1.

Why these specific columns?

The Workout Log stores one row per set. That means 4 sets of Bench Press creates 4 rows. This makes it easy to query individual set progression over time.

The Templates tab defines your workout structure. Your app reads it to know which exercises belong to which template and how many sets each exercise has.

🙋‍♀️ Typed a header in the wrong cell?

Click the cell with the mistake, delete its contents, then click the correct cell and retype. Make sure your headers start in cell A1 and go across row 1 with no gaps.

help me fix my Google Sheet header rows

Open the Apps Script editor

Your Sheet is ready to store data. Now you need somewhere to write the code that turns it into a live web app. Google Apps Script is a code editor built directly into Google Sheets.

  • In your Google Sheet, click Extensions in the top menu bar.
  • Click Apps Script.

What just happened?

A new browser tab opened with the Apps Script code editor. This editor is connected to your Workout Tracker spreadsheet. Any code you write here can read from and write to your Sheet.

You should see a file called Code.gs in the left sidebar with a default myFunction inside it.

  • Click inside the code editor area (where you see the myFunction code).
  • Select all the text by pressing Cmd+A (macOS) or Ctrl+A (Windows).
  • Press Delete or Backspace to clear everything.

Your Code.gs file should now be completely empty. You will paste your app's backend code here in the next step.

Don't see Extensions in the menu?

Make sure you are looking at your Google Sheet tab (not the Apps Script tab). The Extensions menu only appears in the spreadsheet interface, not inside the Apps Script editor itself.

help me find the Apps Script editor in Google Sheets

Take a moment to confirm your setup. Do you have three tabs at the bottom of your Sheet (Templates, Workout Log, Body Weight), each with its header row? Is your Code.gs file empty in the Apps Script editor?

Your database is structured and your code editor is ready. Next up, you'll paste in the backend and frontend code to bring this Sheet to life as a real web app.

Build and Deploy Your Workout App

Your Google Sheet is set up with the right structure to hold workout data. Now it is time to turn that empty database into a live web app you can open in any browser.

You will paste the backend logic into Code.gs, create a frontend file called Index.html, and deploy the whole thing as a web app with its own URL. The app will be live, but because your Templates tab is still empty, it will load with nothing to show yet.

In this step, get ready to:
  • Add the backend code that reads and writes to your Sheet.
  • Create the frontend interface for logging workouts.
  • Deploy the app and open it in your browser.
Paste the backend code into Code.gs

The backend handles everything that touches your Google Sheet. It reads template names, fetches previous weights, and saves new workout entries.

  • Switch back to the Apps Script editor tab in your browser. Make sure the file Code.gs is selected in the left sidebar.
  • Paste the following code, which serves the web app and reads your templates:
// Serve the HTML page when someone visits the deployed URL
function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('Workout Tracker')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// Return unique template names from column A of the Templates tab
function getTemplates() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Templates');
  var data = sheet.getDataRange().getValues();
  var templates = [];
  for (var i = 1; i < data.length; i++) {
    if (data[i][0] && templates.indexOf(data[i][0]) === -1) {
      templates.push(data[i][0]);
    }
  }
  return templates;
}

// Return exercises and set counts for a given template
function getExercises(templateName) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Templates');
  var data = sheet.getDataRange().getValues();
  var exercises = [];
  for (var i = 1; i < data.length; i++) {
    if (data[i][0] === templateName) {
      exercises.push({ name: data[i][1], sets: data[i][2] });
    }
  }
  return exercises;
}

What does this code do?

  • doGet() is the entry point for the web app. When someone visits your URL, this function serves the Index.html file as a web page.
  • .addMetaTag('viewport', ...) tells the browser to use the device's actual screen width instead of a zoomed-out desktop view. Apps Script ignores viewport meta tags placed directly in HTML files, so this line is the only way to make your app render correctly on phones.
  • getTemplates() reads the Templates tab and returns a list of unique template names (Push Day, Pull Day, etc.).
  • getExercises() returns all exercises and their set counts for a given template.
  • Directly below the code you just pasted, paste the function that looks up your previous weights:
// Find the most recent session for a template and return its weights/reps
function getPreviousWeights(templateName) {
  var logSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Workout Log');
  var data = logSheet.getDataRange().getValues();
  var previous = {};

  // Scan from bottom to find the date of the last session for this template
  var lastDate = null;
  for (var i = data.length - 1; i >= 1; i--) {
    if (data[i][2] === templateName) {
      lastDate = data[i][1];
      break;
    }
  }

  if (!lastDate) return previous;

  // Collect all sets from that date, keyed by exercise name
  for (var i = 1; i < data.length; i++) {
    if (data[i][2] === templateName && data[i][1].toString() === lastDate.toString()) {
      var exercise = data[i][3];
      var set = data[i][4];
      var weight = data[i][5];
      var reps = data[i][6];
      if (!previous[exercise]) previous[exercise] = [];
      previous[exercise].push({ set: set, weight: weight, reps: reps });
    }
  }

  return previous;
}

What does getPreviousWeights do?

This function scans your Workout Log from bottom to top to find the most recent session for a given template. It then collects the weight and reps for every set of every exercise from that session. This is what powers the "Previous" labels in your app.

  • Directly below, paste the two data-saving functions:
// Save each set as a separate row in the Workout Log tab
function logWorkout(entries) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Workout Log');
  var now = new Date();
  var date = now.toISOString().split('T')[0];

  for (var i = 0; i < entries.length; i++) {
    var entry = entries[i];
    sheet.appendRow([now, date, entry.template, entry.exercise, entry.set, entry.weight, entry.reps]);
  }

  return { success: true };
}

// Save today's body weight as a single row in the Body Weight tab
function logBodyWeight(weight) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Body Weight');
  var now = new Date();
  var date = now.toISOString().split('T')[0];
  sheet.appendRow([date, weight]);
  return { success: true };
}

What do these functions do?

  • logWorkout() takes an array of set entries and appends one row per set to the Workout Log tab. Each row includes the timestamp, date, template name, exercise, set number, weight, and reps.
  • logBodyWeight() appends a single row to the Body Weight tab with today's date and the weight value.
  • Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check the editor for red underlines or error icons. If none appear, your backend is ready.

✔️ Awesome, I've got everything!

Great. Confirm you see 6 functions in Code.gs: doGet, getTemplates, getExercises, getPreviousWeights, logWorkout, and logBodyWeight.

ⓧ I'd like to double check the full code

Scroll through your Code.gs file and confirm these sections appear in order:

  • doGet() returning HtmlService.createHtmlOutputFromFile('Index').
  • getTemplates() reading from the Templates sheet and returning unique names.
  • getExercises(templateName) filtering rows by template name.
  • getPreviousWeights(templateName) scanning Workout Log for the last session.
  • logWorkout(entries) appending rows to Workout Log.
  • logBodyWeight(weight) appending a row to Body Weight.

Seeing red error highlights in the editor?

  • Make sure you deleted the default empty myFunction() that was in Code.gs before pasting.
  • Check that you pasted all three code sections one after another with no duplicate function names.
  • If you see a red underline on a specific line, look for a missing closing brace } or bracket ] from a partial paste.

Help me fix syntax errors in my Code.gs file

Create the frontend file

The frontend is what you actually see and interact with. It is an HTML file with embedded CSS for styling and JavaScript that communicates with your backend functions.

  • In the Apps Script editor, click the + icon next to Files in the left sidebar.
  • Select HTML.
  • Type Index as the file name and press Enter. Do not type Index.html. Apps Script adds the .html extension automatically.
  • Delete all the default content in the new file.
  • Paste the opening HTML structure and first set of CSS styles:
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
      /* Reset and base layout */
      * { box-sizing: border-box; margin: 0; padding: 0; }
      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        background: #1a1a2e;
        color: #eee;
        padding: 16px;
        max-width: 500px;
        margin: 0 auto;
      }
      h1 { text-align: center; margin: 16px 0 20px; color: #fff; font-size: 1.5rem; }
      h2 { font-size: 1.1rem; margin: 20px 0 10px; color: #ccc; }
      /* Form inputs shared styling */
      select, input, button {
        width: 100%;
        padding: 12px;
        border: 1px solid #333;
        border-radius: 8px;
        font-size: 1rem;
        margin-bottom: 10px;
        background: #16213e;
        color: #eee;
      }
      select { appearance: none; cursor: pointer; }
  • Directly below, paste the button and card styles:
      /* Button states */
      button {
        background: #0f3460;
        color: #fff;
        border: none;
        cursor: pointer;
        font-weight: bold;
      }
      button:hover { background: #1a5276; }
      button:disabled { background: #333; cursor: not-allowed; }
      /* Exercise card container */
      .exercise-card {
        background: #16213e;
        border-radius: 12px;
        padding: 16px;
        margin-bottom: 12px;
      }
      .exercise-name { font-weight: bold; font-size: 1.1rem; margin-bottom: 8px; }
      .previous { font-size: 0.85rem; color: #7ec8e3; margin-bottom: 10px; }
      /* Each set row: label + weight input + reps input in a row */
      .set-row {
        display: flex;
        gap: 8px;
        align-items: center;
        margin-bottom: 8px;
      }
      .set-row label { width: 50px; font-size: 0.9rem; color: #aaa; }
      .set-row input { flex: 1; margin-bottom: 0; }
  • Directly below, paste the message styles and close the head section:
      /* Success and error feedback messages */
      .success-msg {
        background: #1b4332;
        color: #95d5b2;
        padding: 12px;
        border-radius: 8px;
        text-align: center;
        margin: 10px 0;
      }
      .error-msg {
        background: #4a1c1c;
        color: #f4a4a4;
        padding: 12px;
        border-radius: 8px;
        text-align: center;
        margin: 10px 0;
      }
      .loading { text-align: center; color: #7ec8e3; padding: 20px; }
      .section-divider { border-top: 1px solid #333; margin: 24px 0; }
      /* Body weight section at the bottom */
      .body-weight-section { margin-top: 16px; }
      .bw-row { display: flex; gap: 8px; }
      .bw-row input { flex: 1; margin-bottom: 0; }
      .bw-row button { width: auto; margin-bottom: 0; padding: 12px 20px; }
    </style>
  </head>
  • Save Index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Check that the editor does not show any red marks or errors. Apps Script does not validate HTML/CSS syntax deeply, but a mismatched brace or unclosed tag sometimes triggers a warning.
  • Directly below, paste the HTML body structure that defines the app interface:
  <body>
    <h1>Workout Tracker</h1>

    <!-- Template dropdown: populated by init() from the Sheet -->
    <select id="templateSelect" onchange="loadTemplate()">
      <option value="">Select Template</option>
    </select>

    <!-- Exercise cards render here dynamically -->
    <div id="exercises"></div>

    <!-- Submit button: hidden until exercises load -->
    <button id="logBtn" onclick="submitWorkout()" style="display:none;">Log Workout</button>
    <div id="workoutMsg"></div>

    <div class="section-divider"></div>

    <!-- Body weight quick-log section -->
    <div class="body-weight-section">
      <h2>Body Weight</h2>
      <div class="bw-row">
        <input type="number" id="bodyWeight" placeholder="Enter weight" step="0.1">
        <button onclick="submitBodyWeight()">Log</button>
      </div>
      <div id="bwMsg"></div>
    </div>

    <script>

What is this HTML structure?

  • A <select> dropdown for choosing your workout template.
  • An empty div#exercises container where exercise cards will be dynamically rendered.
  • A hidden Log Workout button that appears once exercises load.
  • A body weight section at the bottom for quick daily weight logging.
  • Directly below, paste the JavaScript that initializes the app by fetching template names from your Sheet:
      // State: holds the current template's exercises and last session's data
      var currentExercises = [];
      var previousWeights = {};

      // Load template names from the Sheet and populate the dropdown
      function init() {
        google.script.run
          .withSuccessHandler(function(templates) {
            var select = document.getElementById('templateSelect');
            for (var i = 0; i < templates.length; i++) {
              var option = document.createElement('option');
              option.value = templates[i];
              option.textContent = templates[i];
              select.appendChild(option);
            }
          })
          .withFailureHandler(function(err) {
            document.getElementById('exercises').innerHTML =
              '<div class="error-msg">Error loading templates: ' + err.message + '</div>';
          })
          .getTemplates();
      }

What is google.script.run?

google.script.run is the bridge between your frontend and backend. It calls your Code.gs functions asynchronously. The .withSuccessHandler() chain runs when the call succeeds. The .withFailureHandler() chain runs if something goes wrong.

Here, init() calls getTemplates() on the server and populates the dropdown with whatever template names it finds in your Sheet.

  • Directly below, paste the first part of the template-loading function, which handles the early return and sets up the loading state:
      // Fetch exercises and previous weights when a template is selected
      function loadTemplate() {
        var templateName = document.getElementById('templateSelect').value;
        var container = document.getElementById('exercises');
        var logBtn = document.getElementById('logBtn');

        // Reset the view if no template is selected
        if (!templateName) {
          container.innerHTML = '';
          logBtn.style.display = 'none';
          return;
        }

        container.innerHTML = '<div class="loading">Loading exercises...</div>';
        logBtn.style.display = 'none';
  • Directly below, paste the rest of loadTemplate which fetches exercises and previous weights from the server:

        // Chain two server calls: get exercises, then get previous weights
        google.script.run
          .withSuccessHandler(function(exercises) {
            currentExercises = exercises;
            google.script.run
              .withSuccessHandler(function(prev) {
                previousWeights = prev;
                renderExercises();
              })
              .withFailureHandler(function() {
                previousWeights = {};
                renderExercises();
              })
              .getPreviousWeights(templateName);
          })
          .withFailureHandler(function(err) {
            container.innerHTML = '<div class="error-msg">Error: ' + err.message + '</div>';
          })
          .getExercises(templateName);
      }
  • Directly below, paste the first part of the function that renders exercise cards with previous weight labels:
      // Build exercise cards with previous weight labels
      function renderExercises() {
        var container = document.getElementById('exercises');
        var html = '';

        for (var i = 0; i < currentExercises.length; i++) {
          var ex = currentExercises[i];
          var prevSets = previousWeights[ex.name] || [];
          html += '<div class="exercise-card">';
          html += '<div class="exercise-name">' + ex.name + '</div>';

          // Show last session's weight x reps summary if available
          if (prevSets.length > 0) {
            html += '<div class="previous">Previous: ';
            var parts = [];
            for (var p = 0; p < prevSets.length; p++) {
              parts.push(prevSets[p].weight + 'x' + prevSets[p].reps);
            }
            html += parts.join(', ');
            html += '</div>';
          }
  • Directly below, paste the rest of renderExercises which generates the weight and rep input fields for each set:
          // Generate weight and rep inputs for each set, pre-filled with previous values
          for (var s = 1; s <= ex.sets; s++) {
            var prevWeight = '';
            var prevReps = '';
            if (prevSets[s - 1]) {
              prevWeight = prevSets[s - 1].weight;
              prevReps = prevSets[s - 1].reps;
            }
            html += '<div class="set-row">';
            html += '<label>Set ' + s + '</label>';
            html += '<input type="number" placeholder="Weight" step="0.5" data-exercise="' + ex.name + '" data-set="' + s + '" data-field="weight" value="' + prevWeight + '">';
            html += '<input type="number" placeholder="Reps" data-exercise="' + ex.name + '" data-set="' + s + '" data-field="reps" value="' + prevReps + '">';
            html += '</div>';
          }

          html += '</div>';
        }

        container.innerHTML = html;
        document.getElementById('logBtn').style.display = 'block';
      }
  • Directly below, paste the confetti animation function that fires when a workout is logged:
      function launchConfetti() {
        // Add confetti keyframes if not already present
        if (!document.getElementById('confetti-style')) {
          var style = document.createElement('style');
          style.id = 'confetti-style';
          style.textContent = '@keyframes confettiFall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 1; } 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } }';
          document.head.appendChild(style);
        }

        // Create 300 colorful confetti pieces falling from the top
        var colors = ['#ff0', '#f0f', '#0ff', '#f00', '#0f0', '#00f', '#ff6600', '#ff69b4'];
        for (var i = 0; i < 300; i++) {
          var piece = document.createElement('div');
          var size = Math.random() * 10 + 5;
          piece.style.position = 'fixed';
          piece.style.top = '-10px';
          piece.style.left = Math.random() * 100 + '%';
          piece.style.width = size + 'px';
          piece.style.height = size + 'px';
          piece.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
          piece.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
          piece.style.animation = 'confettiFall ' + (Math.random() * 2 + 1.5) + 's ease-out ' + (Math.random() * 0.5) + 's forwards';
          piece.style.zIndex = '9999';
          piece.style.pointerEvents = 'none';
          document.body.appendChild(piece);

          // Remove each piece after animation finishes
          (function(el) {
            setTimeout(function() { el.remove(); }, 4000);
          })(piece);
        }
      }

What does launchConfetti do?

This function creates 300 small colored squares and circles, positions them randomly across the top of the screen, and animates them falling down with rotation. Each piece has a random size, color, delay, and speed so the effect looks natural. The pieces remove themselves from the page after 4 seconds so they do not pile up in memory.

  • Directly below, paste the celebration sound function that plays alongside the confetti:
      // Play a short ascending two-note celebration sound
      function playSuccessSound(ctx) {
        if (!ctx) return;
        var now = ctx.currentTime;

        // First note: C5 (523Hz) fades out over 0.15s
        var osc1 = ctx.createOscillator();
        var gain1 = ctx.createGain();
        osc1.type = 'sine';
        osc1.frequency.value = 523;
        gain1.gain.setValueAtTime(0.3, now);
        gain1.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
        osc1.connect(gain1);
        gain1.connect(ctx.destination);
        osc1.start(now);
        osc1.stop(now + 0.15);

        // Second note: E5 (659Hz) slightly delayed for ascending feel
        var osc2 = ctx.createOscillator();
        var gain2 = ctx.createGain();
        osc2.type = 'sine';
        osc2.frequency.value = 659;
        gain2.gain.setValueAtTime(0.3, now + 0.1);
        gain2.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
        osc2.connect(gain2);
        gain2.connect(ctx.destination);
        osc2.start(now + 0.1);
        osc2.stop(now + 0.3);
      }

Why does this function take a context parameter?

Mobile browsers block audio unless the AudioContext is created during a user gesture (like a button tap). By creating the context in submitWorkout() (which runs on the button tap) and passing it to playSuccessSound() later, the sound is guaranteed to play on both phones and desktops.

  • Directly below, paste the first part of the workout submission function that gathers data from the input fields:
      // Collect weight and rep values from all inputs and prepare to send
      function submitWorkout() {
        var templateName = document.getElementById('templateSelect').value;
        var entries = [];
        // Unlock audio now (on tap) so the success sound can play after the server responds
        var successAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
        var inputs = document.querySelectorAll('[data-exercise]');

        // Group inputs by exercise + set number
        var setData = {};
        for (var i = 0; i < inputs.length; i++) {
          var input = inputs[i];
          var exercise = input.getAttribute('data-exercise');
          var set = input.getAttribute('data-set');
          var field = input.getAttribute('data-field');
          var key = exercise + '_' + set;

          if (!setData[key]) {
            setData[key] = { template: templateName, exercise: exercise, set: parseInt(set) };
          }
          setData[key][field] = parseFloat(input.value) || 0;
        }

        // Convert the grouped data into an array of entries
        var keys = Object.keys(setData);
        for (var i = 0; i < keys.length; i++) {
          entries.push(setData[keys[i]]);
        }

        document.getElementById('logBtn').disabled = true;
        document.getElementById('workoutMsg').innerHTML = '<div class="loading">Saving...</div>';
  • Directly below, paste the rest of submitWorkout which sends the data to the server:

        // Send entries to the backend and show success/error
        google.script.run
          .withSuccessHandler(function() {
            document.getElementById('workoutMsg').innerHTML =
              '<div class="success-msg">Workout logged!</div>';
            document.getElementById('logBtn').disabled = false;
            launchConfetti();
            playSuccessSound(successAudioCtx);
          })
          .withFailureHandler(function(err) {
            document.getElementById('workoutMsg').innerHTML =
              '<div class="error-msg">Error: ' + err.message + '</div>';
            document.getElementById('logBtn').disabled = false;
          })
          .logWorkout(entries);
      }
  • Directly below, paste the body weight function and the closing tags to finish the file:
      // Validate and send body weight to the backend
      function submitBodyWeight() {
        var weight = parseFloat(document.getElementById('bodyWeight').value);
        if (!weight) {
          document.getElementById('bwMsg').innerHTML =
            '<div class="error-msg">Please enter a weight</div>';
          return;
        }

        google.script.run
          .withSuccessHandler(function() {
            document.getElementById('bwMsg').innerHTML =
              '<div class="success-msg">Body weight logged!</div>';
            document.getElementById('bodyWeight').value = '';
          })
          .withFailureHandler(function(err) {
            document.getElementById('bwMsg').innerHTML =
              '<div class="error-msg">Error: ' + err.message + '</div>';
          })
          .logBodyWeight(weight);
      }

      init();
    </script>
  </body>
</html>

How does the frontend talk to the backend?

Every time you select a template, click Log Workout, or log your body weight, the frontend uses google.script.run to call the corresponding function in Code.gs. Data flows from the frontend to the Sheet and back without a page reload.

  • Save Index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the left sidebar now shows two files: Code.gs and Index.html.

✔️ Awesome, I've got everything!

Nice. Both files are saved and the sidebar shows Code.gs and Index.html.

ⓧ I'd like to double check the full code

Scroll through your Index.html file and confirm these sections appear in order:

  • DOCTYPE, head, viewport meta tag, and opening <style> tag.
  • CSS styles for body, inputs, buttons, exercise cards, set rows, success/error messages, and the body weight section.
  • Closing </style> and </head> tags.
  • Body with templateSelect dropdown, exercises div, logBtn button, and body weight section.
  • Script with functions: init(), loadTemplate(), renderExercises(), submitWorkout(), submitBodyWeight().
  • init() call at the bottom, followed by closing </script>, </body>, </html> tags.

File not appearing in the sidebar?

  • If you accidentally named the file Index.html (with the extension typed out), it will appear as Index.html.html. Delete the file by clicking the three dots next to it and selecting Remove, then create a new one named just Index.
  • Make sure you selected HTML (not Script) when creating the new file.

Help me fix my Index.html file

Deploy as a web app

Your code is saved but nobody can access it yet. Deploying turns your script into a live web app with its own URL that works on any device.

  • Click the blue Deploy button in the top-right corner of the Apps Script editor.
  • Select New deployment.
  • In the dialog that appears, click the gear icon next to Select type.
  • Choose Web app.
  • Set Execute as to Me.
  • Set Who has access to Anyone.
  • Click Deploy.

Why these deployment settings?

Execute as Me means the app always uses your Google account to read and write to the Sheet. Anyone means anyone with the URL can use the app without signing in. Since this is your personal tracker, you are the only one who will have the URL.

  • Google will ask you to authorize the app. Click Authorize access.
  • Choose your Google account when prompted.

Seeing a warning screen? Do not panic

Google may show a screen that says the app is not verified. This is normal for personal Google Apps Script projects.

  • Click Advanced (small text at the bottom left of the warning).
  • Click the link that says Go to [your project name] (unsafe).
  • Click Allow to grant your own app permission to access your Sheet.
  • After authorization completes, you will see a deployment confirmation with a Web app URL. Copy this URL.
  • Open the URL in a new browser tab.

Before you look at the page, what do you think you will see? The Templates tab in your Sheet is still empty.

You should see the Workout Tracker heading, a Select Template dropdown, and a body weight input at the bottom. The dropdown is empty because there are no templates in your Sheet yet. This proves the app is live and reading from your Templates tab.

Seeing a blank white page or an error?

  • If you see a blank page, check that your HTML file is named exactly Index (not index or Index.html). The name is case-sensitive and must match what doGet() references in createHtmlOutputFromFile('Index').
  • If you see a script error, go back to the Apps Script editor and click the Run button with doGet selected in the function dropdown. The execution log at the bottom shows what line failed.
  • If you skipped the authorization step, redeploy by clicking Deploy then Manage deployments. Click the pencil icon, set the version to New version, and click Deploy.

Help me debug my deployed web app

Your app is live and connected to your Sheet. Next up, you will add a full-screen rest timer so you can track your rest between sets like a real gym app.

Add a Rest Timer to Your App

Your workout tracker is live and connected to your Sheet. But anyone who has trained in a gym knows that rest between sets matters just as much as the sets themselves.

In this step, you will add a full-screen rest timer that auto-launches every time you finish a set. It will have an editable default (90 seconds) and +15 and -15 buttons for real-time adjustment.

In this step, get ready to:
  • Add a full-screen timer overlay with adjustable countdown.
  • Wire the timer to launch automatically after each set.
  • Redeploy and test the timer between sets.
Add the timer styles

The timer is a full-screen overlay that covers the app while you rest. It needs its own set of CSS styles to control the layout, sizing, and appearance.

  • In the Apps Script editor, open Index.html.
  • Find the closing </style> tag (around line 83, just before </head>).
  • Paste the following timer styles directly above </style>:
      /* Full-screen timer overlay: hidden by default, shown via .active */
      .timer-overlay {
        display: none;
        position: fixed;
        top: 0; left: 0;
        width: 100%; height: 100%;
        background: rgba(0, 0, 0, 0.95);
        z-index: 1000;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
      .timer-overlay.active { display: flex; }
      /* Large countdown readable from arm's length */
      .timer-display { font-size: 5rem; color: #fff; font-weight: bold; margin-bottom: 30px; }
      .timer-default { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; color: #aaa; }
      .timer-default input { width: 60px; text-align: center; padding: 8px; margin-bottom: 0; }
      .timer-controls { display: flex; gap: 16px; margin-bottom: 20px; }
      .timer-controls button { width: auto; padding: 12px 24px; font-size: 1.1rem; }
      .timer-skip { background: transparent; border: 1px solid #555; color: #aaa; padding: 12px 32px; }
      /* Small checkmark button at the end of each set row */
      .set-done-btn { width: 40px; padding: 8px; font-size: 1rem; margin-bottom: 0; }

What do these styles do?

  • .timer-overlay creates a fixed full-screen layer that sits on top of everything. When .active is added, it switches from hidden to visible using flexbox centering.
  • .timer-display makes the countdown large and readable from a distance (5rem is roughly the size of your fist on a phone screen).
  • .set-done-btn is the small checkmark button that will appear at the end of each set row to trigger the timer.
  • Save Index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Add the timer overlay and JavaScript

The overlay is the HTML that you actually see when the timer is running. The JavaScript handles the countdown and the +15/-15 adjustments.

  • Find the line that says <div id="workoutMsg"></div> in the body section of your HTML.
  • Paste the timer overlay directly below that line:
    <!-- Timer overlay: covers the screen during rest periods -->
    <div id="timerOverlay" class="timer-overlay">
      <div class="timer-default">
        <label>Rest:</label>
        <input type="number" id="restTimeInput" value="90" min="10" step="5">
        <span>sec</span>
      </div>
      <div class="timer-display" id="timerDisplay">1:30</div>
      <div class="timer-controls">
        <button onclick="adjustTimer(-15)">-15</button>
        <button onclick="adjustTimer(15)">+15</button>
      </div>
      <button class="timer-skip" onclick="skipTimer()">Skip</button>
    </div>

What is this overlay?

The overlay is hidden by default (display: none in the CSS). When the timer starts, JavaScript adds the active class which flips it to display: flex, covering the screen. The editable input at the top lets you change the default rest time before your next set.

  • Now scroll down to the <script> section. Find the line that says init(); near the bottom of the file.
  • Paste the first set of timer functions directly above init();:
      // Rest timer state
      var timerInterval = null;
      var timerSeconds = 90;

      // Show the timer overlay and start the 1-second countdown
      function startTimer() {
        var input = document.getElementById('restTimeInput');
        timerSeconds = parseInt(input.value) || 90;
        updateTimerDisplay();
        document.getElementById('timerOverlay').classList.add('active');
        timerInterval = setInterval(function() {
          timerSeconds--;
          updateTimerDisplay();
          if (timerSeconds <= 0) {
            clearInterval(timerInterval);
            setTimeout(skipTimer, 1500);
          }
        }, 1000);
      }

      // Format seconds as M:SS and update the on-screen display
      function updateTimerDisplay() {
        var mins = Math.floor(timerSeconds / 60);
        var secs = timerSeconds % 60;
        document.getElementById('timerDisplay').textContent =
          mins + ':' + (secs < 10 ? '0' : '') + secs;
      }
  • Directly below, paste the adjustment and skip functions:
      // Add or subtract seconds from the running countdown (min 0)
      function adjustTimer(amount) {
        timerSeconds = Math.max(0, timerSeconds + amount);
        updateTimerDisplay();
      }

      // Close the timer overlay immediately
      function skipTimer() {
        clearInterval(timerInterval);
        document.getElementById('timerOverlay').classList.remove('active');
      }

What does this code do?

  • startTimer() reads the rest-time input, starts the overlay, and kicks off a 1-second interval that decrements the countdown. When it hits zero, the overlay auto-closes after 1.5 seconds.
  • adjustTimer() adds or subtracts seconds from the running countdown (clamped at zero so it never goes negative.
  • skipTimer() stops the interval and hides the overlay immediately, in case you want to cut your rest short.
Wire the timer to each set

The timer functions exist but nothing triggers them yet. You need to add a small checkmark button to each set row so that when you finish a set, you tap it and the timer starts.

  • In the <script> section, scroll up to the renderExercises() function.
  • Inside that function, find the for (var s = 1; s <= ex.sets; s++) loop. This is the loop that builds each set row.
  • Inside that loop, find the html += '</div>'; line that comes directly after the Reps input line (html += '<input type="number" placeholder="Reps"...'). It looks like this:
            html += '<input type="number" placeholder="Reps" id="reps-' + ex.name + '-' + s + '">';
            html += '</div>';
  • Replace that html += '</div>'; line with these two lines, so the checkmark button sits between the Reps input and the closing div:
            html += '<button class="set-done-btn" onclick="startTimer()">\u2713</button>';
            html += '</div>';

Make sure you replace the old line

Delete the original html += '</div>'; line and paste the two-line version above. You should end up with the button line followed by the closing div line, not two closing divs.

  • Save Index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Click the blue Deploy button in the top-right corner.
  • Select Manage deployments.
  • Click the pencil icon on your existing deployment.
  • In the Version dropdown, select New version.
  • Click Deploy.
  • Refresh your web app tab in the browser.

After the page refreshes, select a template and look at any exercise's set row. You should see a small checkmark button (✓) at the end of each set row. Tap it and the full-screen timer overlay appears with a 1:30 countdown, -15 and +15 buttons, and a Skip button at the bottom.

Timer not appearing when you tap the checkmark?

  • Make sure you replaced the old html += '</div>'; line with the two-line version (button line + closing div). If both lines exist, you have a duplicate closing div.
  • Confirm you deployed a New version (not just saved the file). Apps Script caches the old version until you explicitly publish a new one.
  • Check that your timer styles are placed above </style> and not inside another CSS rule block.

Help me debug why the rest timer isn't showing up in my workout tracker app

Your app now has a proper gym timer built in. Next up, you will design your workout templates so the dropdown has exercises to display.

Design Your Workout Templates

Your app has a working timer that counts down between sets. But if you open the template dropdown it is still empty. That is because the Templates tab in your Google Sheets has no data rows yet.

Your Google Sheet is the control panel for this app. Whatever you type in the Templates tab is exactly what appears in the app's interface. In this step, you'll design 4 workout templates directly in the Sheet and watch them come to life in your app.

In this step, get ready to:
  • Design 4 workout templates with exercises and set counts.
  • Verify the templates load in your web app dropdown.
Fill in your first workout template

Each exercise in your template gets its own row in the Sheet. The app reads column A for the template name, column B for the exercise name, and column C for how many sets.

  • Switch back to your Workout Tracker Google Sheet.
  • Click the Templates tab at the bottom of the screen.
  • In row 2, type Push Day in column A, Bench Press in column B, and 4 in column C.
  • In row 3, type Push Day in column A, Shoulder Press in column B, and 3 in column C.
  • In row 4, type Push Day in column A, Push Ups in column B, and 3 in column C.

You should now see three rows of data under your headers. Each row represents one exercise within the "Push Day" template.

Why repeat the template name on every row?

The app's getExercises() function filters column A to find all exercises that belong to the selected template. Repeating "Push Day" on each row is what groups these exercises together.

The getTemplates() function reads column A and returns only the unique names. So even though "Push Day" appears three times, it only shows once in the dropdown.

Add exercises for all 4 templates

Now fill in the remaining 3 templates. Use whatever workout split works for you. Push/Pull/Legs/Full Body is a common starting point.

  • Starting from the next empty row, add exercises for your second template (for example, Pull Day with exercises like Barbell Row, Pull Ups, and Bicep Curls).
  • Add exercises for your third template (for example, Leg Day with exercises like Squats, Romanian Deadlift, and Leg Press).
  • Add exercises for your fourth template (for example, Full Body with exercises like Deadlift, Overhead Press, and Pull Ups).
  • Make sure every row has a value in all three columns: Template Name, Exercise, and Sets (a number between 3 and 5).

Edit templates anytime

You can add, remove, or rename exercises in this tab whenever you want. The app reads from the Sheet every time you load a template, so your changes appear immediately with no code updates needed.

Verify your templates in the web app

Your Sheet now has 4 templates with multiple exercises each. Before you refresh, think about what you expect to see in the app.

  • What do you think will appear in the dropdown when you refresh? Will you see every row, or just the unique template names?
  • Switch to your web app tab and refresh the page.
  • Click the Select Template dropdown.

You should see your 4 template names listed in the dropdown. The app's getTemplates() function pulled the unique names from column A.

  • Select one of your templates from the dropdown.

The exercises for that template appear as cards, each with the correct number of weight and reps input rows matching the Sets value you entered in column C.

Dropdown still empty after refreshing?

Check that your Template Name values in column A start in row 2 (not row 1, which has the headers). Also make sure there are no extra spaces before or after the text in your cells.

If you renamed the tab, confirm it is named exactly Templates (capital T). The getTemplates() function uses getSheetByName('Templates') and the match is case-sensitive.

Still stuck? Why isn't my web app reading data from the Templates tab?

Your templates are loaded and ready. Next up, you'll log your first workout and watch data flow from the app back into your Sheet.

Log Your First Workout and Track Body Weight

Your web app now loads templates and displays exercises with the correct number of set input rows. Next up: proving that data can flow the other direction, from the app back into your Sheet.

Right now your "Workout Log" and "Body Weight" tabs are empty headers with no data. Logging your first session proves the complete cycle works.

In this step, get ready to:
  • Log a full workout with weights and reps for each set.
  • Record a body weight entry.
  • Verify the data appears as structured rows in your Google Sheet.
Log your first workout

Your app has been reading data from the Sheet. Now it is time to write data back. The logWorkout function in your backend saves one row per set to the "Workout Log" tab.

  • Open your web app in a browser tab (use the deployed URL from Step 2).
  • Select one of your templates from the Select Template dropdown.
  • For each exercise, enter a weight and rep count into every set row. Use your actual gym numbers or make some up for testing.
  • Click Log Workout at the bottom of the exercise list.

You should see a burst of colorful confetti rain down from the top of the screen, followed by a green "Workout logged!" success message below the button.

Seeing an error message instead?

If you see a red error message, make sure your "Workout Log" tab is named exactly Workout Log (with a space, matching what the code expects). Also confirm the tab has 7 header columns in row 1.

If the button stays greyed out and nothing happens, try refreshing the page and selecting your template again.

Why is my workout not saving?

Log your body weight

The body weight section at the bottom of the app writes to a separate tab. This keeps weight-tracking data cleanly separated from workout sets.

  • Scroll down below the exercise cards to the Body Weight section.
  • Enter your weight in the input field.
  • Click the Log button next to the input field.

A green "Body weight logged!" message confirms the entry was saved.

Verify data in your Google Sheet

You saw success messages in the app, but the real proof is seeing the data land in your Sheet. Before you check, think about what you expect to see.

What do you expect to see?

If you logged 4 sets of Bench Press and 3 sets of Shoulder Press, how many new rows do you think you will find in the Workout Log tab?

  • Switch to your Google Sheet browser tab.
  • Click the Workout Log tab at the bottom of the Sheet.

You should see data rows below your headers. Each row represents one set of one exercise. If you did 4 sets of Bench Press, you see 4 separate rows for Bench Press, each with its own set number, weight, and reps.

Why one row per set?

Storing each set as its own row makes it simple to query individual exercise progression later. When the app looks up your previous weights, it can find exactly what you lifted for set 3 of Bench Press last session.

  • Click the Body Weight tab at the bottom of the Sheet.

You should see one new row with today's date and the weight you entered. The full data cycle is now confirmed: your app reads from the Sheet and writes back to it.

Don't see any data rows?

Confirm you saw the green success messages in the app. If you did not, go back to the app and try submitting again.

Check that your tab names match exactly: Workout Log and Body Weight (with spaces, capital letters). The backend code uses these exact names to find the right tabs.

My data is not appearing in the Google Sheet after logging a workout.

Your app now reads AND writes. Data flows from the Sheet to the app and back again. Next, you will see the real payoff of all this: when you come back for your next session, the app automatically shows what you lifted last time.

See Progressive Overload in Action

Your first workout is logged and your data sits in the Workout Log tab. But one session alone is just a record.

The real value shows up when you come back for session two. The app automatically displays what you lifted last time for every exercise and set, so you always know what to beat.

In this step, get ready to:
  • See your previous weights auto-populate when you select a template.
  • Log a second workout with increased weights or reps.
  • Confirm two sessions of data appear in your Sheet.
See your previous weights load automatically

The getPreviousWeights function in your backend finds the most recent session for any template and returns the weight and reps for every set. The frontend displays these as "Previous:" labels above each exercise's input fields.

Before you load your template, what do you think you'll see differently compared to the first time you selected it in the previous step?

  • Switch back to your web app tab in the browser.
  • Refresh the page to start fresh.
  • Select the same template you used when you logged your first workout.

This time, each exercise card shows a "Previous:" line with the weight and reps from your last session. For example, if you logged 60x8 for Bench Press Set 1, you now see "Previous: 60x8" above that set's inputs.

What is progressive overload?

Progressive overload means gradually increasing the demands on your muscles over time. This could be more weight, more reps, or more sets.

The "Previous:" labels solve the hardest part of progressive overload. You never have to remember what you did last time. The app tells you your target, and your only job is to match or beat it.

🙋‍♀️ Not seeing Previous labels?

  • Make sure you selected the exact same template name you used when logging your first workout. The name must match exactly.
  • Check your Workout Log tab in the Sheet. Confirm the Template column (column C) contains the same template name that appears in your dropdown.
  • Try a hard refresh of your web app page. On macOS press Cmd+Shift+R. On Windows press Ctrl+Shift+R.

Why aren't my previous weights showing up in the workout tracker app?

Log a second workout with progressive overload

Now put progressive overload into practice. Your previous numbers are right there on screen. Try to beat them.

  • For each exercise, enter weights or reps that are slightly higher than the Previous values. Even adding 1 rep or 0.5kg counts as progression.
  • Fill in all sets for every exercise in the template.
  • Click Log Workout to submit your second session.

You should see the green "Workout logged!" success message appear below the button.

Confirm your progression data in the Sheet

Your Google Sheet now holds two full workout sessions. Each time you come back to the app, it pulls the most recent session and shows you your targets.

  • Switch back to your Workout Tracker spreadsheet.
  • Open the Workout Log tab.

You now see rows from both sessions. Each set from session one appears alongside each set from session two, all with timestamps, dates, template names, exercises, set numbers, weights, and reps.

  • Return to your web app and refresh the page.
  • Select the same template one more time.

The Previous labels now show the higher weights and reps from session two. Every time you log a workout, the app updates to reflect your latest numbers. You can see at a glance what you need to match or exceed.

Edit templates anytime

Want to add a new exercise or change your set count? Edit the Templates tab directly in your Sheet. No code changes needed. The app reads from the Sheet each time you select a template.

Secret mission

Connect Claude to Your Workout Data

Your workout data lives in a Google Sheet, but right now you can only look at raw rows and columns. What if Claude could read that data directly and turn it into interactive progression charts? In this secret mission, you will connect Claude Desktop to your Google Sheet via an MCP server so Claude can analyze your workout history live.

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 runs entirely locally and on free Google services, so there are no ongoing costs.

Resources you used:

  • Google Sheet "Workout Tracker" (with Templates, Workout Log, and Body Weight tabs).
  • Apps Script web app deployment (the live URL serving your workout tracker).
  • Google Cloud project (with Sheets API enabled and service account).
  • Local google-sheets-mcp/ folder (cloned MCP server repository).
  • Service account credentials at ~/.config/google-sheets-mcp/credentials.json.
  • Claude Desktop MCP config entry in claude_desktop_config.json.

Keep everything running

No action needed. Choose this if you are still actively using your workout tracker and want Claude to keep reading your data.

  • Your web app stays live at its URL. Bookmark it on your phone for easy access.
  • Your Google Sheet continues storing workout data at no cost.
  • The MCP server remains configured in Claude Desktop for future conversations.
  • Node.js remains installed on your machine (useful for other projects).

Pause - I'll come back to this later

Shut down the MCP connection to free up resources, but keep your data and deployment intact for when you return.

  • Open ~/Library/Application Support/Claude/claude_desktop_config.json and remove the google-sheets entry from the mcpServers object. Save the file.
  • Quit Claude Desktop fully with Cmd+Q and reopen it. The MCP server will no longer load on startup.
  • Your web app URL, Google Sheet data, and Google Cloud project all remain intact. When you want to resume, re-add the MCP config entry and restart Claude Desktop.

Delete - I don't want to use this again

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

  • Archive the web app deployment. Open your Apps Script project, click Deploy, then click Manage deployments. Select the deployment and click Archive deployment. This deactivates the web app URL.
  • Delete the Google Sheet. Open your "Workout Tracker" sheet, click File, then click Move to trash.
  • Delete the Google Cloud project. Go to the Manage resources page in the Google Cloud console. Select your project and click Delete. Enter the project ID and click Shut down.
  • Remove the MCP server entry from ~/Library/Application Support/Claude/claude_desktop_config.json. Save the file, then quit and reopen Claude Desktop.
  • Delete the service account credentials by running this command in Terminal:
rm ~/.config/google-sheets-mcp/credentials.json
rmdir ~/.config/google-sheets-mcp
  • Delete the cloned MCP server folder by running:
rm -rf ~/Desktop/google-sheets-mcp

Not sure where you cloned it?

If you cloned google-sheets-mcp to a different location than ~/Desktop, replace the path above with wherever you ran git clone.

Nice Work!

Nice Work!

You just built a fully functional workout tracker web app that costs $0 to run, shows you exactly what to beat every session, and stores all your data in a Google Sheet you own.

You've learned how to:

  • Build and deploy a Google Apps Script web app that uses a Google Sheet as its database, with a mobile-friendly interface accessible from a bookmarkable URL.
  • Design editable workout templates directly in a Sheet, log workouts with weight and rep tracking for 3-5 sets per exercise, and track body weight over time.
  • Implement progressive overload tracking by auto-populating your previous session's weights so you always know your target to match or exceed.
  • Secret Mission: Connected Claude Desktop to your Google Sheet via an MCP server, letting Claude read your workout data live and generate interactive progression visualizations.

Ready to quiz yourself?