traininglogs
Workout notes written however you like, turned into structured training data.
What it is
traininglogs turns workout notes written however you like into structured training data.
You paste in a session the way you'd jot it down in Apple Notes: half sentences, 100 x 3 atg, "left knee felt off on set 2". The app reads it and lays out a confirmation card showing what it understood from your note, like exercises and sets, with weights, reps and effort. You fix whatever it got wrong by tapping it, confirm, and the session lands in Postgres DB.
The goal is an app that can log any kind of training. Someone who runs, cycles, lifts and practises skills like reflex drills shouldn't need a different app, or a different format, for each. So the input stays open: no form, no template. The app's job is to make sense of what you wrote and store it reliably enough that progress can be tracked from it later.
The insights part of this application is being built: a user dashboard that turns the stored sessions into a picture of your progress.
Right now it's a personal app with a single user, running at traininglogs-875429444117.us-east1.run.app (it asks for an API key). Accounts for other people come later.
The web app
A React app in frontend/, built into static files and served by the API at /. It has five tabs: Train, Programs, Progress, History and Settings. Settings keeps your API key in the browser.
Train shows the program you follow and its next workout. Start opens a session: each exercise from the plan, with last time's weights and reps in grey. You type over them, tick each set when it's done, and add or remove sets and exercises as the day goes. Every change is saved in the browser (IndexedDB). Finish sends the ticked sets to POST /sessions once; with no connection, the session waits on the phone and sends itself when the connection returns. An exercise in a workout can list alternatives ("Shoulder Press or Bench press"); the session starts with the first, and a swap icon switches to another.
Programs holds the programs: workouts in order, back to 1 after the last. Train shows a one-line deload reminder when the followed program reaches its deload days, 28 by default.
Log from notes is the original path: paste a note, the model reads it, and Review shows the card. You type over values on the card, open a set's drawer for RPE, type and notes, or describe a change in words, which calls the model. Confirm writes the session.
Progress
The Progress tab shows your key lifts first: by default Squat, Bench Press, Shoulder Press, Deadlift, Barbell Clean and Pull-up, or the ones you choose in Settings (up to 12, in your order, saved in users.key_lifts). Each shows its latest and best estimated max, the number of sessions and an arrow for the 4-week trend. Under "Other lifts" is every other exercise trained in 3 or more sessions. Tapping a lift opens a chart with one point per session, record sessions in red and the program's goal weight as a dashed line, and below it each session's sets.
A set's estimated max is weight × (1 + (reps + reps left) / 30), where reps left is 10 minus RPE, so 1 rep at RPE 9 counts the same as 2 at RPE 10. A set without an RPE gives no estimate, since how many reps were left is unknown. Only working sets with a weight above 0, 1 to 12 full reps and an RPE of 6 or more count; a unilateral set counts its weaker side. The session's point is its best set. A session with no such set is plotted at its heaviest weight as a hollow dot, and doesn't count toward the latest, best or trend. Published RPE charts disagree with each other, which is why this uses a formula instead of a chart.
Pull-ups are mostly bodyweight, and the app stores no bodyweight, so the chart shows best reps at bodyweight with added weight on a separate axis. Which logged names count as each key lift is a fixed list in analytics/key_lifts.py (Squat and Squats, for example); stored names are never changed.
How a session flows
Every step saves before it returns, so a crash, a failed model call or a card you walk away from never loses what you wrote.
Nothing overwrites the model's reading. The app records each change, from a tap or a typed correction, as a list of {path, value} edits, appends it to extractions.corrections and marks it manual or ai. So what the model read, what you changed and what the database holds stay separate for good. scripts/correction_stats.py reads that log to show which fields you correct most, and how many sets the model missed.
The page doesn't keep any state on the server between steps. Each edit or correction sends the current reading and gets the new one back, and Confirm sends the final reading with the list of changes.
A session's id is its date plus a short hash of the text you pasted. Pasting the same text twice collides on that id, and confirm refuses the second copy instead of storing a duplicate.
Repeating a session
A repeat starts from a session you've already logged, with no model call at all. The app has no screen for it since programs replaced it; the endpoint stays.
Notes are shown, never copied: a note belongs to the day it was written, and copying it would save last week's note as today's. The page matches the "Last time" lines to exercises by name, so adding or removing sets doesn't move them.
Repeating is the start of saved programs: a week of sessions you save once and run each workout, the way Hevy does, with the card filled in and only today's numbers to change.
The AI step
The model is Claude Haiku 4.5, called through Anthropic's API. Reading one session takes several calls rather than one, so each call's answer stays short and can't hit the output limit on a long session:
| Call | Reads | Returns |
|---|---|---|
split_exercises | the whole text | the exercises in order, each with a line that marks where it starts |
extract_session_shell | the whole text | date, focus, program, phase, week, session notes, session warmup and cooldown |
extract_exercise, once per exercise | only that exercise's part of the text | the exercise with its sets and warmup sets |
edit_extraction, per typed correction | the current reading and your correction | a list of edits, never a rewritten session |
Each call asks for a tool call whose schema comes from the Pydantic models, so there's no second schema to keep in sync. An answer that fails validation is sent back with the error, up to two more times. If an exercise's start line can't be found in the text, that call gets the whole text instead, with a warning on the card.
Then the checks, all plain Python:
- Every set has to quote the line it came from, and that line has to exist in what the model was shown. The card flags any set whose source line isn't in the text.
- Sets have to run 1, 2, 3 without gaps or repeats. A gap is what a dropped set looks like.
- An exercise whose call fails becomes a placeholder on the card saying so, never a crash and never a silent gap.
- The model marks fields it was unsure of. A session with no date in the text gets today's date, marked unsure, because "logged today" isn't the same as "trained today".
A typed correction comes back as edits that Python applies, so a field the correction didn't name can't change.
The app logs every call in llm_calls with its tokens and cost, failed calls included. Haiku 4.5 costs $1 per million input tokens and $5 per million output tokens. A session makes two calls plus one per exercise; across the first nine sessions logged in the app (September to October 2026), reading one cost between 1.5 and 4 cents.
The data
Postgres, on Supabase, is the source of truth. Thirteen tables: three record how a session was read, six hold the session itself, and four hold programs and pinned notes.
| Table | One row per |
|---|---|
raw_inputs | what a session was made from, kept untouched: text you wrote, which the model reads, or manual, entered in the app or copied from a past session with no model call |
llm_calls | model call: step, tokens, cost, time, and the last raw answer even if it failed |
extractions | reading of a raw input: the model's reading, uncertain fields, warnings, status (pending, confirmed, rejected) and the list of corrections |
sessions | session: date, program, phase, week, focus, duration, notes, the extraction it came from, and the planned workout it came from |
warmups, cooldowns | session-level warmup or cooldown movement |
exercises | exercise in a session, with its goal, notes and cues |
working_sets | set that counts: weight, reps (full, partial, or left and right), RPE, quality, rest, duration, distance, heart rate, failure technique |
warmup_sets | an exercise's own ramp-up set |
programs | program: name, deload reminder in days, whether you follow it (one at a time) and since when |
program_workouts | workout in a program: its number and optional name ("Push") |
program_workout_exercises | exercise in a workout's plan: warmup sets, working sets, target reps or as many as you can, and other exercises that can take its place. No weights; a session takes them from the last time. |
exercise_pins | note pinned to an exercise name, shown in every later session of it |
The database stores weights in kilograms; the model converts pounds as it reads. Deleting a session deletes its exercises and sets with it, and deleting a raw input deletes its extractions and call log.
The Pydantic models
The same shape as the tables, as the code sees it. A session has an optional session-level warmup, the exercises, and an optional session-level cooldown. Session warmup and cooldown movements are their own small models, not exercises. An exercise's warmup sets are a different thing again: ramp-up sets before that lift's working weight.
TrainingSession
├── session_id, user_id, user_name, data_model_version, data_model_type
│ set by the app, never by the model
├── date YYYY-MM-DD
├── program, program_author, program_length_weeks, phase, week, is_deload_week
├── focus, session_duration_minutes
├── weight_unit "kg"
├── warmup = None [SessionWarmup]
├── exercises [Exercise]
├── cooldown = None [SessionCooldown]
└── notes = None remarks that don't belong to one exercise or set
SessionWarmup / SessionCooldown
├── number, name
└── reps, duration_seconds, notes (all optional)
Exercise
├── number, name
├── tags, modality, movement_pattern, target_muscle_groups, rep_tempo (optional)
├── current_goal = None Goal: weight, sets, rep range, rest, distance, duration
├── notes, warmup_notes, form_cues
├── warmup_sets = None [WarmupSet]
└── sets = None [WorkingSet]
WarmupSet
├── number, weight_kg
└── rep_count, notes (optional)
WorkingSet one model for every kind of set; the fields present say what it was
├── number
├── weight_kg, rpe rpe 1 to 10, whole or half steps
├── rep_count {full, partial}
├── unilateral_rep_count {left: {full, partial}, right: {full, partial}}
├── rep_quality_assessment good, bad, perfect or learning
├── rest {minutes} or {seconds}, never both
├── duration_seconds, distance_meters, heart_rate_bpm
├── notes
└── failure_technique only allowed at RPE 10
├── MyoReps mini-sets, each {number, rep_count}
├── LLP partial_rep_count
├── StaticHold hold_duration_seconds
└── DropSet drops, each {number, weight_kg, rep_count}
Every column
The API
FastAPI. Every endpoint except the page itself needs the X-Api-Key header, and the app won't start without API_KEY set.
| Endpoint | Does |
|---|---|
GET / | The web page, never cached. Its script and style files under /assets/ carry a hash in their names and are cached until a release renames them. Responses over 1 KB are gzip-compressed. |
POST /inputs | Saves the text, then runs the model. Returns {raw_input_id, extraction_id}; if the model fails, the app keeps the text anyway and the response says why. |
GET /extractions/{id} | The card for a reading, as JSON |
POST /extractions/{id}/edit | Values changed on the card, or one line added or removed. No model call. 400 names the field when a value is invalid. |
POST /extractions/{id}/correct | A correction in words. One model call. |
POST /extractions/{id}/confirm | Writes the session, optionally as a planned workout (program_workout_id) so the program moves on. 409 if that text was already confirmed; 422 for an unknown workout. |
POST /sessions/{id}/repeat | A new reading built from a past session. No model call. Same response as POST /inputs. |
POST /sessions | Saves a session entered in the app, no model call: a raw input of kind manual, an extraction, the session. The phone's client_id makes a repeated send return the saved session (200) instead of a second one. |
GET /sessions | Sessions, newest first, each with its exercise names. Filters: phase, week, date range, limit. |
GET /sessions/{id} | One session in full |
GET /exercises/last?name=… | For each name, the latest session that had it (any program): date, note, warmup and working sets. Names match ignoring case. |
GET /exercises/{name}/history | Every working set of one exercise, across all sessions |
GET /progress/lifts | Key lifts, then other lifts with 3 or more sessions: latest and best value, sessions, 4-week trend |
GET /me/export | Everything the person has logged, as a file: ?format=csv (one line per set) or ?format=json (sessions, exercises, sets, programs) |
POST /feedback | Saves a message from Settings: kind (feature, bug or other) and message (up to 2000 characters), with the sender and the app's version |
PUT /me/key-lifts | Saves which lifts Progress shows first, in order (up to 12 names); answers with Progress's lifts |
GET /progress/lifts/{name} | One lift's sessions: the value, the set behind it, records and goal weight. 404 for an unknown lift. |
GET /programs, POST /programs | Programs not archived, the followed one first, each with its workouts, their plans, when each was last done, the next workout, and the deload count (days since the program was followed, its last deload, or a break of 7 days or more; whether one is due or under way). Create one by name. |
GET, PATCH, DELETE /programs/{id} | One program; change its name or deload days; archive it |
POST /programs/{id}/follow, /unfollow | Follow this program (any other stops being followed), or stop |
GET /templates | Ready-made programs to copy from, each with its workouts and their plans. Kept in the server's code (program_templates.py) while there are a few. |
POST /templates/{id}/copy | Add a copy of the template to your programs, in one transaction. Not followed; copying twice gives two programs. |
POST /programs/{id}/workouts | Add a workout after the last one |
PUT /programs/{id}/workout-order | New order of all the program's workouts. 422 unless each is listed once. |
PATCH, DELETE /workouts/{id} | Rename a workout (empty clears the name); archive it and renumber the rest |
PUT /workouts/{id}/exercises | Replace a workout's plan |
GET /pins, PUT, DELETE /pins/{exercise} | Pinned notes; pin or replace one; unpin. Exercise names match ignoring case. |
Where it runs
One container, the API and the web page together, on Google Cloud Run in us-east1. It scales to zero when idle and to at most two instances under load, so it stays inside Cloud Run's free tier and traffic can't run up a bill. The database stays on Supabase, in Singapore.
| Piece | What it is |
|---|---|
Cloud Run service traininglogs | 512 MiB, 0 to 2 instances, CPU only while handling a request. Runs as app-runtime. |
Artifact Registry images | The app's images, tagged by commit. Only the newest two are kept. |
| Secret Manager | database-url, api-key, anthropic-api-key, supabase-publishable-key, readable by app-runtime only. Terraform creates them empty and you add the values with gcloud, so no secret is in Terraform's state. An instance that starts after you add a new version uses the new value. |
| Service accounts | terraform applies the app layer, app-deploy ships images and works only from main, app-runtime is the running app. |
| GitHub sign-in | Workload identity federation. Workflows in this repository get short-lived tokens; no service account key file exists. |
Infrastructure
All of it is Terraform, in infra/. Each environment is its own Google Cloud project, in two layers split by who applies them:
Terraform's state is in one bucket per environment, prod-traininglogs-510513-terraform-state, versioned. That bucket is the only thing created by hand. infra/README.md has the commands and the naming rules.
CI and CD
Terraform owns how the service is set up; CD owns which image runs, so Terraform ignores the image. A release is merging dev into main and approving the run.
Limits and costs
- Hosting is free inside Google's limits: Cloud Run's monthly free tier, 0.5 GB of image storage, 6 secrets. Supabase is on its free plan.
- The model costs money: 1.5 to 4 cents to read a session, and about one call's worth per typed correction. Editing on the card and repeating cost nothing.
- One user. Everyone with the API key reads and writes as that user.
- The app runs in the US and the database in Singapore, so every query crosses the Pacific. I haven't measured how much that slows a page down yet. An instance that has scaled to zero also takes a few seconds to start.
- Only the retired command-line flow rebuilds the old static dashboard (
docs/index.html), so sessions logged in the app don't appear in it. The user dashboard planned next replaces it.
What's next
- More of the insights dashboard, after the Progress tab: hard sets per muscle per week, strength per muscle, next-session suggestions that show their working, then load and recovery once wearable data is connected.
- Installable on the phone: an app icon and manifest, so the page opens from the home screen.
- A UI with screens (Home, Review, History, Settings), rebuilt with htmx and server-rendered templates so the logic stays in Python.
- Saved programs, growing out of repeat.
- Accounts for other people, through Supabase Auth, with every write scoped to the person who made it.