A phone-first workout tracker. Tap an exercise, punch the rep count into a big keypad, and it adds to your running total for the day. A second screen charts reps per day so you can see whether you're actually keeping it up.
The shell is fixed to the viewport — the nav bar stays pinned to the bottom of the screen and each screen scrolls inside it. Light and dark are both selected designs; the app follows your device by default and Settings can override it.
Plain HTML, CSS and vanilla JavaScript. No framework, no build step, no npm, no backend.
Service workers and ES modules need a real origin — opening index.html from
the file system will not work. Serve the folder:
python3 -m http.server 8080Then open http://localhost:8080.
Drag and drop — go to https://app.netlify.com/drop and drag this folder onto the page. That's the whole deploy. You get an HTTPS URL immediately, which is required for the app to install to a home screen.
From Git — connect the repo. netlify.toml already sets the publish
directory to the project root with no build command.
On each redeploy, bump CACHE_VERSION in sw.js so returning
visitors drop their old cache.
- iPhone — open the URL in Safari, tap Share, then Add to Home Screen.
- Android — Chrome offers Install app, or use the menu.
Once installed it launches full screen and works with no connection.
In this browser, on this device. Nowhere else. There is no account and no server, which is why it's free to run and instant to use — and also why:
- Your phone and your laptop keep separate, unrelated histories.
- Clearing site data, resetting the device, or "Clear History and Website Data" in Safari erases everything permanently.
- iOS can evict storage for sites you haven't opened in a while.
Export a backup from Settings now and then. It downloads a JSON file, and importing it merges entries back in — importing never deletes anything, so it's safe to import onto a device that already has data. That file is the only way to move history between devices or recover from a wipe.
| File | Role |
|---|---|
index.html |
Markup for the three screens, plus the inline pre-paint theme script |
styles.css |
Dark-first styling; light mode is a separate selected set of steps, keyed off [data-theme] |
app.js |
State, storage, theming, rendering, the SVG chart |
sw.js |
Offline cache |
manifest.webmanifest |
Install metadata |
tools/make_icons.py |
Regenerates icons/ — stdlib only, not deployed |
Two localStorage keys. The workout data lives in workout-tracker-v1:
{
"version": 1,
"exercises": [{ "id": "e1", "name": "Push-ups", "goal": 100 }],
"entries": [
{ "id": "k3f9a", "exerciseId": "e1", "reps": 25, "ts": 1753228800000, "day": "2026-07-23" }
]
}The appearance preference lives separately in workout-tracker-theme, holding
one of auto, light or dark. It is deliberately not part of state
and therefore not part of a backup export: it describes this device, and
restoring a backup from another phone should not repaint this one.
Exercise ids never change, so renaming an exercise in Settings keeps its
entire history attached. Anything read out of storage or an imported file is run
through normalize(), so malformed data degrades to defaults instead of a blank
screen.
They answer different questions and neither can be recomputed from the other.
ts is a UTC instant — it orders entries and prints the clock time beside each
set. day is the local calendar day, frozen at the moment of logging.
Deriving the day from ts later would evaluate it in whatever timezone the
device is in at that moment. Log a set at 00:30 in Berlin, open the app in New
York, and the recomputed day lands on the previous date: history silently
rewrites itself and a streak you actually earned breaks. Storing day pins the
entry to the calendar day the user was living in. It is also the grouping key
for every total, streak and chart bucket, so keeping it is a plain Map lookup
instead of constructing a Date per entry on every render.
Day keys are always built from local calendar fields, never toISOString() —
that is UTC and would misfile evening workouts for anyone behind it.
A single entry is capped at 99 reps — two digits, which is what makes the auto-save-on-second-digit keypad work. Log a bigger set as two entries; the daily total is unbounded.
Deleting a set offers Undo in the toast. Adding one does not — the chip that appears in the keypad log removes it in a single tap, so a second affordance only ate the toast's dwell time.
The chart carries no title or caption; the exercise tabs above it already name what you're looking at. A legend sits above the plot, and each key mirrors the mark it stands for — a filled rect for the bars, a dashed stroke for your goal line, a solid orange stroke for the average.
Tapping a bar opens a tooltip anchored to it with the date and rep count. It clamps inside the plot and flips below the bar when a tall one leaves no headroom overhead. Tap the same bar again, or anywhere else, to dismiss. The tooltip only ever enhances — every number in it is also in the data table under the chart, so nothing is reachable by pointer alone.
Past 30 days avr — shown as a tile and drawn as the orange reference line — covers the 30 days before today, and counts only days that have records.
Both exclusions are deliberate. Today is a partial total while it is still in progress; averaging it in would drag the figure down every morning and creep it back up over the day. Rest days are excluded so the number reads as "what a session looks like" rather than being diluted by days you didn't train.
Settings has a three-way Auto / Light / Dark control. Auto is the default on a first visit and resolves from the device.
The stored value is the preference; what lands on <html data-theme> is the
resolved theme. That split is what keeps the stylesheet to a single light
rule and lets an explicit choice beat the device in both directions — a
prefers-color-scheme media query cannot express "Light, even though this
phone is set to dark".
Three things follow from that design:
- Resolution happens twice, on purpose. An inline synchronous script in
<head>applies the theme before first paint, becauseapp.jsis a deferred module and would repaint after the dark default was already on screen. Both copies carry a comment pointing at the other — change the rule in one and you must change it in the other. - Auto stays live. A
changelistener on the media query repaints when you flip your OS appearance mid-session, but only while the preference is Auto. - Switching re-renders the chart. Bar colours are read from the stylesheet
at render time and baked into the SVG
fill, so a live chart would otherwise keep its old steps.
theme-color is a single meta tag set from the computed --plane, rather than
the usual pair of media-scoped tags, for the same override reason. The static
theme_color in manifest.webmanifest is read by the OS at install time and
cannot follow the setting, so an installed PWA set to Light still shows a dark
splash screen.
The three exercise colours are the validated categorical slots violet / orange / aqua, with separate steps chosen for the dark and light surfaces. They clear colour-blind separation and contrast checks against this app's own surfaces in both modes. (The light-mode aqua sits just under 3:1 contrast; the data table under the chart is what relieves that, so don't remove it.) If you change them, keep identity carried by more than hue alone — the chart's value labels, the data table, and the exercise names all back up the colour.
The average line reuses the orange slot as an annotation colour, so on the Crunches tab the line and the bars share a hue. Form separates them there — vertical columns against one horizontal rule, with matching shapes in the legend — but hue alone does not. Worth knowing before adding a fourth exercise.
python3 tools/make_icons.pyWrites icons/icon-192.png, icon-512.png, icon-maskable-512.png,
apple-touch-icon-180.png and icon.svg. Pure standard library — no Pillow,
no ImageMagick.
© 2026 Stan Kosyakov. All rights reserved.
The source code, design, and content of this application are the intellectual property of the copyright holder. You may view the code and run your own personal copy.
Commercial use requires prior written permission. Anyone intending to replicate, redistribute, resell, or build on this app — in whole or in part — for any commercial purpose must request and obtain written permission first. Contact video_wavy.7k@icloud.com before doing so.