My workout logger automation in this website

I built Rep Log for a very specific itch: my AI fitness coach had no idea what I actually did in the gym. Fitbit sees heart rate and step counts, but “chest felt strong, bench moved up 2.5kg, right shoulder slightly tight” - the context a coach actually needs - lived only in my head. So I made a small PWA workout logger that prefills my last session, times my rests, and generates an LLM-friendly summary I paste straight into the coach:

Workout Log — Push Day — 6 Aug 2026
Clock-in: 06:32 IST · Clock-out: 07:48 IST · Time in gym: 1h 16m

Warm-up: Treadmill — 10 min
1. Bench Press: 40kg×12, 45kg×10, 45kg×8
2. Dips: BW×15
Stretch: 10 min
Notes: Right shoulder slightly tight

That solved the coach problem. Then I added a now page to this site with a gym consistency grid — and immediately created a new problem: the grid needed data, and I was not going to log workouts twice. Once in the gym app, once for the website? The rule I’ve built this whole site around applies here too: if it needs manual updating, it will stop being updated.

So now the two are wired together. I tap “Finish & Save” on my phone, and the grid on this site updates itself. This post is about that wire - which turned out to be more interesting than it sounds, because Rep Log has no backend at all.

The constraint: the phone is the database#

Rep Log is deliberately server-less - not “serverless,” server-less. Every session lives in the browser’s localStorage on my phone. No accounts, no API, nothing to query. That’s a feature: the app works in a basement gym with zero signal and costs nothing to run.

But it means the website can’t pull anything. There is no endpoint to ask “how many gym days this week?” The data is inside a phone that’s usually in a locker. The only possible direction is push: the phone must tell the website what happened, when it can.

Rep Log (phone) Worker · /gym /now page localStorage only offline queue of dates flush on save / reopen token check dedupe by date KV · ~120 days GET → weekly counts only, never dates fetches aggregates draws the dot grid static fallback POST {date, token} GET /gym

Offline queue#

When I save a session, Rep Log doesn’t fire a request and hope. It appends the workout’s date to a queue in the same localStorage store, then tries to flush:

function persistRecord(record) {
  // ...save the session locally, as always...
  queueGymSync(record.date);
  saveData(data);
  flushGymSync(); // fire-and-forget; offline saves stay queued
}

The flush walks the queue and POSTs each date to api.lokeshnanda.com/gym with a token; entries only leave the queue on a 200. If the basement has no signal, nothing is lost - the queue flushes the next time the app opens, or when the browser fires its online event. The sync can never break the logger: every failure path ends in “try again later,” not an error in my face mid-workout.

The token lives in Rep Log’s settings (pasted once, and it travels inside the app’s backup file), so sync is strictly opt-in. Anyone else using Rep Log has it off by default, and the Worker answers anything without my token with a 401.

Receiving side#

The same Cloudflare Worker that runs this site’s chatbot gained a /gym route. The POST handler does three things: check the token, validate the date, and merge it into a deduped, sorted list in KV:

const cutoff = new Date(Date.now() - 120 * 86400000).toISOString().slice(0, 10);
const dates = JSON.parse((await env.RATE.get('gym:dates')) ?? '[]');
const next = [...new Set([...dates, date])].filter((d) => d >= cutoff).sort();
await env.RATE.put('gym:dates', JSON.stringify(next));

The Set is the idempotency approach. Push day and a re-saved session on the same date? One gym day. The offline queue retried a date it already sent? One gym day. I never have to think about double-counting, because the storage model makes it impossible.

The GET side is where the privacy decision lives. Exact gym dates reveal a routine - when I’m predictably away from home. So the public endpoint never returns dates. It buckets them into Monday-anchored weeks and returns counts:

{ "updated": "2026-08-22", "weeks": [{ "start": "2026-08-17", "days": 1 }] }

The /now page fetches that and draws the dot grid client-side, with the server-rendered version as a fallback if the API is ever unreachable. Weekly counts are exactly enough for accountability and exactly too little for surveillance - that’s the line, and it’s enforced at the API, not in the UI.

Why publish gym days at all?#

Because the grid works on me. This site already shames me into writing - the weekly learnings exist because an empty week is publicly visible. The gym grid applies the same trick to training: a column with one faint dot on a page with my name on it is a very specific kind of annoying.

The habit-science framing is “make it satisfying, make it visible.” The engineering framing is better: reduce the habit to a single action and automate everything downstream of it. I log the workout once - something I already do for my fitbit coach - and the summary goes to the AI, the history feeds next session’s prefills, and the website updates itself. The habit only has to survive at one point in the chain, and it’s the point I was already doing.

Takeaways#

  • Local-first apps can still feed dashboards - invert to push. No backend doesn’t mean no integration; it means the device initiates, with a queue for the offline reality.
  • Make idempotency structural. A Set of dates cannot double-count. That’s cheaper than any amount of careful retry logic.
  • Publish aggregates, keep raw data. The Worker knows dates; the world gets weekly counts. Decide the privacy boundary at the API and the UI can’t leak what it never receives.
  • Automation is a habit tool. Every manual step between “did the thing” and “the record shows it” is a place the habit can quietly die.

Rep Log is open on GitHub, and so is this site — the Worker route and the grid are small enough to read in one sitting.