MMA LiveSim
Free UFC DFS optimizer and MMA fight simulator for DraftKings. Live win probabilities for every lineup, plus post-contest EV, ownership, and optimal lineups.
💪 My personal gym workout tracking app, crafted my a human, using native browser APIs and simple TypeScript, CSS, and HTML. No frameworks, no deps.
Sports & fitnessTypeScript · HTMLGitHub @nop33OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Personal workout tracking PWA for following your own programs, logging every set, and visualizing progressive overload over time.
After more than a decade in web dev, I am suffering from framework fatigue. This project is an experiment and challenge for myself to build an app using only what the browser, HTML, and CSS can provide me (with the exception of TypeScript. I still want TypeScript. Oh, and Tailwind because I haven't tried that out yet).
The data lives in the browser's IndexedDB. Anyone can use the app without an account. An optional cloud backup, signed up for with a passkey, syncs it to a small Cloudflare Worker and brings it back on a new phone.
@tailwindcss/vitevp), one CLI over Vite, Rolldown, Vitest, Oxlint and Oxfmtworker/| Route | Purpose |
|---|---|
/ | This week at a glance, highlights, and the next workout to start |
/workouts/ | Weekly workout calendar: history, new workouts, workout mode |
/gymtime/ | The active workout: log sets, break timer, completion |
/exercises/ | Exercise library: create, edit, delete |
/programs/ | Programs built from exercises |
/stats/ | Totals across completed workouts |
/settings/ | Break time, passkey backup, JSON export and import, database reset |
The app stores four kinds of rows, and the worker syncs the same rows.
| Row | Fields |
|---|---|
| Exercise | id, name, kind, preset, muscle, targetSets, defaults, isDeleted, updatedAt |
| Program | id, name, exercises as an ordered list of exercise IDs, isDeleted, updatedAt |
| Session header | id (a UUID), date, programId, location, status, notes, exercises, isDeleted, updatedAt |
| Set | id, sessionId, exerciseId, position, set, isDeleted, updatedAt |
exercises holds a snapshot of each exercise in the workout: name, kind, preset, muscle, target sets and defaults. Renaming or deleting an exercise later leaves old workouts alone.id is sessionId:exerciseId:position. The app builds a WorkoutSession from a header and its live set rows.set is a union discriminated by preset: lifting (reps, weight), rehabReps (reps, optional weight), rehabHold (durationSec, optional weight), cardioTreadmill (durationSec, speed, incline).lifting, rehab and cardio. Each kind owns one or more log presets, and rehab is the only kind that offers a choice. src/db/exerciseLogging.ts is the registry that maps a preset to its slots, labels, break-timer behaviour and prescription text.quads, chest, …) with display labels (Quads, Chest, …). Cardio exercises have no muscle group.completed once every exercise has its target number of sets, and incomplete until then. pending and skipped exist only in the workouts calendar, for workouts nobody started.isDeleted: true, so the deletion syncs like any other change./)/workouts/, which offers the seed or the restore./workouts/)/gymtime/)The page you keep open during a workout.
?programId=<id> opens a program's workout before it starts and ?id=<session-id> opens a saved one. An unknown program or session shows an error with a link back to the workouts page.location. A new workout takes the gym of the last saved one, and the field suggests the gyms already used, the most recent first. Phones show the suggestions above the keyboard, and the ✕ in the field clears it so they all show. Once the workout has started, a change saves when the field is left.?id= in the URL./exercises/)/programs/)/stats/)Totals over completed workouts: the first workout's date and how long ago it was, days active, workouts with a count of incomplete ones, exercises, sets, reps, volume as weight × reps, and average workouts per week.
/settings/)AUTH_TOKENS, for local runs, tests and the MCP server. "Sync now" shows once any credentials are saved.jimbro-export-YYYY-MM-DD.json.Sync is optional. Without credentials the app sends nothing to the worker. With them, the worker keeps a backup of the device's data and restores it onto a new install.
src/sync/passkeys.ts), and the worker checks it. The worker hands back a user ID and a bearer token, which the app saves as its credentials, marked as set up by a passkey. A user ID and token typed into Settings are marked as a token, as are credentials saved before passkeys existed.restartFirstSync in src/sync/queue.ts) and then saves the credentials, so the next sync is a first sync with the new account. That sync pulls the account's rows and pushes every row only the device has, so a device that synced with another account backs everything up to the new one. Like any first sync it keeps the account's copy of a row both sides have, even one the phone edited: the sample exercises have fixed IDs and every row of a phone without a backup waits in the outbox, so the phone winning would let a freshly seeded phone overwrite the account's exercises. So signing in on a phone with data asks first, and sign-out pushes first and warns about what could not go.otherDevices), and once the user agrees, the app claims the account (POST /api/claim), which forgets every other token a passkey handed out. The old device's next push answers 401, and the app takes a 401 on passkey credentials as the move: it forgets the credentials, marks itself signed out and records the moved sync problem. So a lost or replaced phone migrates by signing in on the new one, and signing in on a laptop says first that the phone will stop backing up. AUTH_TOKENS tokens are never claimed, so two devices with one typed token still share an account: neither sees the other's changes, and each push overwrites the server's copy of the rows it changed. Nor are two tabs supported: a page hears only its own notices, so another tab's changes show only after a reload.worker/ is a Cloudflare Worker with a D1 database. worker/migrations/0001_rows.sql has one table per row type keyed by user and row ID, and 0002_passkeys.sql the accounts:
| Table | Holds |
|---|---|
users | One row per account a passkey signed up: id, created_at |
passkeys | id (credential ID), user_id, rp_id, public_key (SPKI), alg (-7 ES256 or -257 RS256), sign_count, created_at |
auth_tokens | token_hash (SHA-256 of a bearer token, hex), user_id, created_at. The token itself is never stored |
auth_challenges | challenge, purpose (register, signin or add), user_id, expires_at. Each is used once, within 5 minutes |
The routes under /api/ and /mcp need an Authorization: Bearer <token> header. A token is one from the worker's AUTH_TOKENS variable, a JSON object that maps each token to a user ID (for tests, local runs and MCP), or one a passkey sign-up or sign-in handed out. Every query is scoped to the token's user.
The passkey routes take the RP ID from the request's Origin, which must be an allowed origin: jimbro.nop33.com in production, localhost locally. Registrations ask for a discoverable credential with user verification and attestation none, and the app sends the public key from the browser's getPublicKey(). The worker checks the client data's type, single-use challenge and origin, that no other origin embedded the ceremony (crossOrigin), the authenticator data's RP ID hash and its user present and user verified flags, and, on sign-in, that the passkey was made for this RP ID, the ES256 or RS256 signature and a sign count that has started counting. Each account's passkey is named after the day the account was made, "Jimbro · 12 Oct 2026", so two accounts read apart in a password manager. A credential registered already answers 409 credential_exists.
| Public route | What it does |
|---|---|
POST /auth/register/options | Returns the options for navigator.credentials.create() for a new account, with every buffer as base64url |
POST /auth/register/verify | Takes the registration, stores the account and its passkey, and returns { userId, token } |
POST /auth/signin/options | Returns the options for navigator.credentials.get(), with no allowCredentials |
POST /auth/signin/verify | Takes the assertion, checks it against the stored passkey, and returns { userId, token, otherDevices } with a new token and the count of the account's other passkey tokens |
The public routes allow each client address 20 calls a minute, through the AUTH_LIMITER rate limiting binding in wrangler.jsonc, and answer 429 past that. The limit counts per Cloudflare location and is approximate, as Cloudflare's rate limiting is. The Playwright run's local worker sets AUTH_RATE_LIMIT=off, since all its browsers share one address.
Since anyone can sign up, each user also has limits: PUSH_LIMITER allows 60 pushes a minute (429 rate_limited, which the outbox retries later), and an account stores at most 50,000 rows (ROW_CAP in worker/src/rows.ts, which the ROW_CAP variable can override). A push that would pass the cap stores nothing and answers 413 { error: 'row_limit', cap }; the check runs inside the push's one batch. Any error the worker does not expect answers 500 with the CORS headers.
| Route | What it does |
|---|---|
GET /api/ping | Returns { ok: true, userId } |
POST /api/push | Takes { rows: [{ table, row }] } with up to 1000 rows, validates and stores them, and returns { revision } |
GET /api/pull?cursor=<rev>&limit=<n> | Returns { rows, cursor, more } with the rows above cursor, up to 1000 per page |
GET /api/export | Downloads every row as a version 4 export file |
POST /api/passkeys/options | Returns the options to add another passkey to the signed-in account, excluding its passkeys |
POST /api/passkeys/verify | Takes the registration and stores the passkey on the account |
POST /api/signout | Deletes the token, when a passkey handed it out, and returns { ok: true } |
POST /api/claim | Moves the account to this device: deletes the account's other passkey tokens and returns { signedOut } |
POST /mcp | A read-only MCP server over JSON-RPC, with the tools recent_sessions, get_session, exercise_history and catalog |
CORS allows https://jimbro.nop33.com and http://localhost:5173, plus DEV_ORIGIN when a local run sets it.
Deploy the worker and its D1 migrations before an app version that needs them. For passkeys, that is vp exec wrangler d1 migrations apply jimbro --remote, then vp run deploy, both in worker/, then the app.
| Feature | Details |
|---|---|
| Web manifest | app.webmanifest with name, icons (192, 384, 512, 1024), standalone display and WebP screenshots |
| Install prompt | Keeps the beforeinstallprompt event so the install button can open the prompt, and removes the button after appinstalled |
| iOS install | Without that event, the install button shows an alert with "Add to Home Screen" instructions |
| Screen wake lock | Requests a screen wake lock when gymtime opens, and again when the tab becomes visible if the first request succeeded |
| Haptic feedback | A short vibration (navigator.vibrate(2)) on buttons, links, <summary> and .light-haptic elements, in browsers that have the Vibration API, which Safari lacks |
| Persistent storage | Asks for persistent storage with navigator.storage.persist(). The browser decides, and the app only logs the answer |
| Service worker | src/serviceWorker.ts, built to /sw.js, caches every page and asset of the build on install and answers from that cache, so the app opens offline. Each deploy installs a new one, which drops the old cache |
jim-dark, jim-primary, jim-accent, …) in src/style.css.<dialog> for modals and <details> for collapsible sections, which animate unless prefers-reduced-motion is set.window.history.back().index.html that works on its own when opened by URL. Where the browser has the Navigation API (Chromium, Safari 26.2+), the router in src/router.ts shows the next page in the same document: it loads the page's HTML from the service worker cache, swaps the body under a view transition and mounts the page's module. The break timer, toasts and confetti stay on screen across it. Elsewhere every link is a full page load.The export format, version 4:
{
"version": 4,
"exportDate": "ISO string",
"stores": {
"exercises": [...],
"programs": [...],
"workoutSessions": [...]
}
}
isDeleted: true.src/db/schemaUpgrade.ts upgrades older records on the way in.GET /api/export returns the same format, built from D1.The database is gymbro-database, version 9.
| Store | Key path | Indexes | Holds |
|---|---|---|---|
exercises | id | Exercise rows | |
programs | id | Program rows | |
workoutSessions | id | date, programId | Session headers |
sets | id | sessionId, exerciseId | Set rows |
setGroups | sessionId | Each session's sets in one record, for reading all sessions at once | |
outbox | key | One entry per row waiting to be pushed, keyed table:id | |
meta | name | Sync state: cursor, seq and bootstrapped |
cursor is the highest server revision the first sync reached, by its pull or its push. Only the first sync moves it.seq numbers the outbox entries, so a push can tell whether a row changed again while the push was running.bootstrapped marks a first sync that finished with cursor still at 0, because neither side had any rows.src/db/migrations.ts creates these stores on a fresh install. A database from before version 9 is rebuilt empty, and the next sync pulls everything back from the server.
src/
db/ Persistence (IndexedDB)
storage.ts The shared IndexedDB connection, the generic reads, and writeRows
types.ts Every data shape: rows, sessions, export files. Shared with the worker
catalogStore.ts CatalogStore, the store exercises and programs share
stores/ Exercises, programs and workout sessions, and the seed data
migrations.ts DB_VERSION and the version 9 stores
exerciseLogging.ts Exercise kinds, log presets and set slots
schemaUpgrade.ts Upgrades records from older exports
export.ts JSON export, version 4
import.ts JSON import, versions 1 to 4
cloudBackup.ts Credentials and authenticated requests to the worker
reactiveStore.ts ReactiveStore<T>, the observable value behind the stores' state
sync/ Cloud sync
syncClient.ts The app's sync(), wired to the worker and the open page
passkeys.ts Passkey sign-up, sign-in, another passkey and sign-out, with the browser's WebAuthn API
syncEngine.ts createSync: the first sync (restore), the push and reruns, behind a server port
server.ts The server port, and httpServer, its adapter for the worker's API
queue.ts Outbox entries and meta, queueRows, which writeRows calls in its transaction, and restartFirstSync
outbox.ts The push: take a chunk, send it, ack it under a Web Lock
pull.ts The first sync's commit of what it pulled
bootstrap.ts The first sync's plan for each row
rows.ts Row comparison and export conversion, shared with the worker
pageChannel.ts Page notices, as jimbro: window events
freshness.ts Whether the database changed since this page rendered it
status.ts Last sync time
pages/ One folder per route
features/ Toasts, confetti, haptic feedback
navigation.ts Back button and sync triggers
settings.ts Device settings in localStorage
eventEmitter.ts Typed EventTarget subclass
worker/
src/ Routes and auth, row storage, MCP tools
migrations/ D1 schema
test/ Worker tests
tests/ Playwright specs and unit tests
Install Vite+, which provides the vp command and manages Node and pnpm. Then install both packages:
vp install
cd worker && vp install
| Task | Command |
|---|---|
| Dev server on http://localhost:5173 | vp dev |
| Format and lint | vp check, or vp check --fix to fix |
| Typecheck | vp run typecheck and cd worker && vp run typecheck |
| Unit tests | vp test run |
| Worker tests | cd worker && vp run test |
| End-to-end tests | vp run test |
Production build in dist/ | vp run build |
| Serve the build on http://localhost:4173 | vp preview |
vp check doesn't typecheck, and the build typechecks src/ only, so run both typecheck commands too. CI runs the checks and all three test suites.
The app syncs with the production worker at https://api.jimbro.nop33.com unless VITE_API_BASE points it elsewhere.
Run these in worker/. Create the local D1 database:
vp exec wrangler d1 migrations apply jimbro --local
Give yourself a token in worker/.dev.vars:
AUTH_TOKENS='{"dev-token":"dev"}'
Start the worker, which listens on http://localhost:8787:
vp run dev
In another terminal at the repo root, start the app against it:
VITE_API_BASE=http://localhost:8787 vp dev
Then open http://localhost:5173 (not 127.0.0.1: WebAuthn takes no IP address as the RP ID) and tap "Create passkey" under Cloud Backup in Settings, or save user ID dev and token dev-token under "Use a token" and tap "Sync now".
If something else already listens on port 8787, start the worker with vp run dev --port 8791 and put that port in VITE_API_BASE.
tests/*.unit.test.ts, run by Vitest.worker/test/ run inside the Workers runtime with a local D1 database.tests/*.spec.ts, run by Playwright on Chromium, WebKit and Mobile Safari (iPhone 12). Playwright starts the dev server on port 5173. For each run it also starts a fresh local worker on WORKER_PORT, 8790 by default, which tests/sync.spec.ts and tests/passkeys.spec.ts talk to. The passkey specs use Chromium's virtual authenticator, so they run on Chromium only. If that port is busy, those tests fail with a message instead of using whatever listens there.vp dev before a run.vp run test tests/gymtime.spec.ts --project=chromium.