ONE-TIME SETUP
Your workouts.
Your Google Sheet.
Connect a private Google Sheet so every completed set has a history. Use the app connection form locally, or Cloudflare environment values for your hosted website.
Create a private Sheet
Open a new Google Sheet ↗ and name it Workout Log. Keep Share → General access set to Restricted. Leave it empty; the script creates the columns. The connector sets the Sheet’s timezone to Asia/Kolkata (IST) automatically.
Paste the connector
In your Sheet, choose Extensions → Apps Script. Name the project Workout Logger. Open Code.gs, replace its default code with the supplied script below, and save.
Run setup once
Choose setupWorkoutLog from the dropdown beside Run, then click Run. Review and approve your project’s spreadsheet permissions. If Google shows an unverified-app screen for this project you created, choose Advanced → Go to Workout Logger, review the request and allow it. Check that your Sheet now has a Workout Log tab with a header row.
Copy the connection token
Open the Apps Script Project Settings gear. Under Script properties, copy the entire WORKOUT_TOKEN value. You may need Edit script properties to select it. Leave WORKOUT_SHEET_ID unchanged.
Deploy only the Google connector
Choose Deploy → New deployment → Select type gear → Web app. Set Execute as to Me and Who has access to Anyone. Deploy and copy the Web app URL ending in /exec. The Sheet stays Restricted; the connector requires your token.
Connect and test
For the local app: open Workout log → Connect or update Google Sheets. Paste the deployment URL and WORKOUT_TOKEN, then click Connect & test. For Cloudflare hosting, use the Cloudflare setup below instead. A successful connection shows Google Sheets connected. Testing does not add a workout row.
Sync changes to saved sets
Reopening an exercise loads today’s saved sets. Saving updates those entries. For an existing Google connector, copy the script below into Code.gs, save, then choose Deploy → Manage deployments → Edit (pencil) → Version: New version → Deploy. Keep the existing URL and WORKOUT_TOKEN. This version updates matching rows, removes deleted sets without duplicates, and saves morning practice in Duration (min) and Practice Block columns on the same Workout Log tab.
Log a completed exercise
Choose Your week, select your week option and gym session, then click Log exercise beside an exercise. Fill in the actual weight and reps for every planned set. Add or remove sets if needed. Each set has optional Side, RIR and Failure controls; Side and RIR can stay blank. Checking Failure records 0 RIR. Then enter a Feel rating from 0–10 and add optional notes once. Use 0 kg for unweighted work. Click Save exercise once; date and time are recorded automatically in IST. Check Workout log shows all sets together and says Synced, and your Sheet has one matching row per set. Pending logs send automatically when connected, on reopening, and when your internet returns. Retry sync now is an optional immediate retry.
Connect your Cloudflare website
- Open Cloudflare → Workers & Pages → your Pages project → Settings → Variables and Secrets → Add. Use the Production environment.
- Add WORKOUT_SHEETS_URL as a text variable. Paste the Google Web app URL ending in /exec.
- Add WORKOUT_SHEETS_TOKEN. Paste the Apps Script WORKOUT_TOKEN value, choose Encrypt / Secret, and save.
- Create a new production deployment using the updated site files. Include _worker.js and _routes.json alongside index.html; these add the server connection. Upload the supplied Cloudflare ZIP, or the contents of the dist folder.
- Open your Cloudflare website → Workout log → Unlock Google Sheets on this browser. Enter WORKOUT_TOKEN once and click Unlock & test. You do not enter the URL in the app. The browser stays unlocked for 30 days.
- Check Google Sheets connected, save an exercise, then check Synced and the matching rows in your Sheet.
Credentials stay in Cloudflare's server environment. The browser unlock protects the Sheet from writes by public visitors. Keep using the same browser and website address for pending logs. The Google connector must still be deployed as Me with access Anyone.
Cloudflare documentation: Variables and Secrets · Direct Upload
Your connector script
Copy everything into Code.gs before running step 3.
// Paste this entire file into the Code.gs file of a spreadsheet-bound Apps Script.
const WORKOUT_HEADERS = ['Entry ID', 'Logged At', 'Workout Date', 'Week', 'Session', 'Day', 'Exercise', 'Set', 'Weight (kg)', 'Reps', 'Feel', 'Side', 'Notes', 'RIR', 'Failure', 'Duration (min)', 'Practice Block'];
function setupWorkoutLog() {
const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
if (!spreadsheet) throw new Error('Open Apps Script through Extensions in your workout spreadsheet.');
spreadsheet.setSpreadsheetTimeZone('Asia/Kolkata');
const props = PropertiesService.getScriptProperties();
props.setProperty('WORKOUT_SHEET_ID', spreadsheet.getId());
if (!props.getProperty('WORKOUT_TOKEN')) {
props.setProperty('WORKOUT_TOKEN', Utilities.getUuid().replace(/-/g, '') + Utilities.getUuid().replace(/-/g, ''));
}
const sheet = spreadsheet.getSheetByName('Workout Log') || spreadsheet.insertSheet('Workout Log');
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, WORKOUT_HEADERS.length).setValues([WORKOUT_HEADERS]);
sheet.setFrozenRows(1);
sheet.getRange(1, 1, 1, WORKOUT_HEADERS.length).setFontWeight('bold');
sheet.autoResizeColumns(1, WORKOUT_HEADERS.length);
}
workoutHeaders(sheet);
Logger.log('Setup complete. Open Project Settings → Script properties to copy WORKOUT_TOKEN.');
}
function doPost(e) {
let lock;
try {
const payload = JSON.parse(e.postData.contents);
const props = PropertiesService.getScriptProperties();
const token = props.getProperty('WORKOUT_TOKEN');
if (!token || typeof payload.token !== 'string' || payload.token !== token) {
return workoutReply({ok: false, error: 'Connection token does not match. Copy WORKOUT_TOKEN again.'});
}
const id = props.getProperty('WORKOUT_SHEET_ID');
if (!id) throw new Error('Run setupWorkoutLog first.');
const spreadsheet = SpreadsheetApp.openById(id);
const sheet = spreadsheet.getSheetByName('Workout Log');
if (!sheet) throw new Error('Workout Log tab is missing. Run setupWorkoutLog again.');
if (payload.action === 'ping') {
return workoutReply({ok: true, sheetUrl: spreadsheet.getUrl().split('#')[0].split('?')[0] + '?gid=' + sheet.getSheetId() + '#gid=' + sheet.getSheetId(), sheetName: sheet.getName(), supportsEdits: true, supportsMorning: true});
}
if (!['append', 'save'].includes(payload.action) || !Array.isArray(payload.records) || payload.records.length < 1 || payload.records.length > 200) {
throw new Error('Send between 1 and 200 workout logs.');
}
const records = payload.records.map(record => {
if (record.deleted === true) {
if (payload.action !== 'save' || !/^[A-Za-z0-9-]{8,120}$/.test(record.id || '')) throw new Error('Removed entry ID is invalid.');
return [record.id];
}
return workoutRow(record);
});
if (new Set(records.map(row => row[0])).size !== records.length) throw new Error('Entry IDs must be unique.');
lock = LockService.getScriptLock();
lock.waitLock(30000);
const lastRow = sheet.getLastRow();
workoutHeaders(sheet);
const knownIds = new Map(lastRow > 1 ? sheet.getRange(2, 1, lastRow - 1, 1).getValues().map((row, index) => [String(row[0]), index + 2]) : []);
const newRows = [];
const removedRows = [];
let updated = 0;
records.forEach(row => {
const existing = knownIds.get(row[0]);
if (row.length === 1) {
if (existing) removedRows.push(existing);
} else if (!existing) {
newRows.push(row);
} else if (payload.action === 'save') {
sheet.getRange(existing, 1, 1, WORKOUT_HEADERS.length).setValues([row]);
sheet.getRange(existing, 2, 1, 1).setNumberFormat('yyyy-mm-dd hh:mm:ss "IST"');
updated++;
}
});
if (newRows.length) {
spreadsheet.setSpreadsheetTimeZone('Asia/Kolkata');
sheet.getRange(lastRow + 1, 1, newRows.length, WORKOUT_HEADERS.length).setValues(newRows);
sheet.getRange(lastRow + 1, 2, newRows.length, 1).setNumberFormat('yyyy-mm-dd hh:mm:ss "IST"');
}
removedRows.sort((a, b) => b - a).forEach(row => sheet.deleteRow(row));
SpreadsheetApp.flush();
return workoutReply({ok: true, acceptedIds: records.map(row => row[0]), added: newRows.length, updated, removed: removedRows.length});
} catch (error) {
return workoutReply({ok: false, error: error.message || 'Unable to save workout sets.'});
} finally {
if (lock && lock.hasLock()) lock.releaseLock();
}
}
function workoutRow(record) {
if (!record || !/^[A-Za-z0-9-]{8,120}$/.test(record.id || '')) throw new Error('Workout entry ID is invalid.');
if (!/^\d{4}-\d{2}-\d{2}$/.test(record.date || '')) throw new Error('Workout date is invalid.');
if (!Number.isInteger(record.set) || record.set < 1 || record.set > 100) throw new Error('Set number is invalid.');
if (record.activity === 'morning') {
if (record.session !== 'AM' || record.week !== 'Daily practice' || record.set > 4) throw new Error('Morning practice session is invalid.');
if (typeof record.durationMinutes !== 'number' || !Number.isFinite(record.durationMinutes) || record.durationMinutes < 0 || record.durationMinutes > 180 || !Number.isInteger(record.durationMinutes * 2)) throw new Error('Practice minutes must be 0–180, in half-minute steps.');
if (record.weight !== 0 || record.reps !== 0 || (record.side != null && record.side !== '') || (record.rir != null && record.rir !== '') || (record.failure != null && record.failure !== false)) throw new Error('Morning practice uses minutes, not gym set fields.');
if (typeof record.block !== 'string' || !record.block.trim()) throw new Error('Practice block is missing.');
workoutFeel(record.feel, false);
const timestamp = new Date(record.loggedAt);
if (isNaN(timestamp.getTime())) throw new Error('Logged-at time is invalid.');
return [record.id, timestamp, record.date, record.week, record.session, workoutText(record.day, 20), workoutText(record.exercise, 160), '', '', '', record.feel, '', workoutText(record.notes || '', 2000), '', '', record.durationMinutes, workoutText(record.block, 180)];
}
if (record.activity != null && record.activity !== '' && record.activity !== 'gym') throw new Error('Activity is invalid.');
if (!Number.isInteger(record.reps) || record.reps < 1 || record.reps > 1000) throw new Error('Reps must be between 1 and 1000.');
if (typeof record.weight !== 'number' || !Number.isFinite(record.weight) || record.weight < 0 || record.weight > 2000) throw new Error('Weight is invalid.');
workoutFeel(record.feel, true);
const side = record.side == null ? '' : record.side;
if (!['', 'Both', 'Left', 'Right'].includes(side)) throw new Error('Side is invalid.');
const rir = record.rir == null || record.rir === '' ? '' : record.rir;
if (rir !== '' && (!Number.isInteger(rir) || rir < 0 || rir > 100)) throw new Error('RIR must be a whole number between 0 and 100, or left blank.');
if (record.failure != null && typeof record.failure !== 'boolean') throw new Error('Failure must be checked or unchecked.');
if (record.failure === true && rir !== '' && rir !== 0) throw new Error('A set taken to failure must have 0 RIR.');
if (!/^F[1-4]$/.test(record.session || '') || !/^Week [1-3]$/.test(record.week || '')) throw new Error('Session or week is invalid.');
const timestamp = new Date(record.loggedAt);
if (isNaN(timestamp.getTime())) throw new Error('Logged-at time is invalid.');
return [record.id, timestamp, record.date, workoutText(record.week, 20), record.session,
workoutText(record.day, 20), workoutText(record.exercise, 160), record.set, record.weight,
record.reps, record.feel, side, workoutText(record.notes || '', 2000), record.failure === true ? 0 : rir, record.failure == null ? '' : record.failure ? 'Yes' : 'No', '', ''];
}
function workoutFeel(feel, allowLegacy) {
if (allowLegacy && ['Easy', 'Good', 'Hard'].includes(feel)) return;
if (!Number.isInteger(feel) || feel < 0 || feel > 10) throw new Error('Feel must be a whole number from 0 to 10.');
}
// Preserve all old rows and add missing effort/practice columns at the end.
function workoutHeaders(sheet) {
if (sheet.getLastRow() < 1) throw new Error('Workout Log headers are missing. Run setupWorkoutLog again.');
const headers = sheet.getRange(1, 1, 1, WORKOUT_HEADERS.length).getValues()[0];
if (headers.slice(0, 13).join('|') !== WORKOUT_HEADERS.slice(0, 13).join('|')) {
throw new Error('The Workout Log headers were changed. Restore the original header row.');
}
if (headers.slice(13).some((value, index) => value !== '' && value !== WORKOUT_HEADERS[index + 13])) {
throw new Error('Keep effort and practice columns in their original positions.');
}
if (headers.slice(13).some(value => value === '')) {
sheet.getRange(1, 14, 1, WORKOUT_HEADERS.length - 13).setValues([WORKOUT_HEADERS.slice(13)]).setFontWeight('bold');
sheet.autoResizeColumns(14, WORKOUT_HEADERS.length - 13);
}
}
function workoutText(value, maxLength) {
if (typeof value !== 'string' || value.length > maxLength) throw new Error('An entry contains invalid text.');
return /^\s*[=+\-@]/.test(value) ? "'" + value : value;
}
function workoutReply(value) {
return ContentService.createTextOutput(JSON.stringify(value)).setMimeType(ContentService.MimeType.JSON);
}
Already using the connector? Replace Code.gs with this script, then choose Deploy → Manage deployments → Edit → New version → Deploy. RIR and Failure columns are added after Notes automatically on setup or the next save, preserving earlier entries. This version accepts numeric Feel ratings from 0–10.
Run the local app with python3 server.py. The older basic file server cannot connect Sheets. Phone access requires the same Wi-Fi and a running Mac. Saved entries remain on the browser/address you used; open that same app address to sync pending logs.
Google references: Deployment settings · Script properties