Technical Design · App v5.0.0 · 2026-10-11 · github.com/apoorvasharma007/traininglogs

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.

you paste a session, press Extract │ ▼ POST /inputs ├── capture() the text, untouched → raw_inputs └── extract() the model reads it (several calls) → llm_calls the assembled reading, status pending → extractions │ ▼ GET /extractions/{id} the card │ ▼ you change things (repeat as often as needed) ├── POST /extractions/{id}/edit tap to edit, add, remove no model call └── POST /extractions/{id}/correct a correction in words one model call → llm_calls │ ▼ POST /extractions/{id}/confirm └── confirm() the final reading, as one session → sessions, exercises, working_sets, warmup_sets, warmups, cooldowns extraction marked confirmed, every change kept in extractions.corrections

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.

you pick a past session │ ▼ POST /sessions/{id}/repeat ├── reads the stored session ├── copies the plan: exercises, goals, cues, warmup sets, │ every set's weight and reps, program, phase, week ├── leaves out how that day went: RPE, quality, failure technique, │ rest, heart rate, duration, and every note ├── date = today, flagged for you to check → raw_inputs (kind: repeat) └── a pending extraction, model "none" → extractions │ ▼ the same card, the same edits, the same confirm as above │ └── alongside it: the source session's notes, shown read-only as "Last time" under each exercise (GET /sessions/{id})

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:

CallReadsReturns
split_exercisesthe whole textthe exercises in order, each with a line that marks where it starts
extract_session_shellthe whole textdate, focus, program, phase, week, session notes, session warmup and cooldown
extract_exercise, once per exerciseonly that exercise's part of the textthe exercise with its sets and warmup sets
edit_extraction, per typed correctionthe current reading and your correctiona 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.

TableOne row per
raw_inputswhat 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_callsmodel call: step, tokens, cost, time, and the last raw answer even if it failed
extractionsreading of a raw input: the model's reading, uncertain fields, warnings, status (pending, confirmed, rejected) and the list of corrections
sessionssession: date, program, phase, week, focus, duration, notes, the extraction it came from, and the planned workout it came from
warmups, cooldownssession-level warmup or cooldown movement
exercisesexercise in a session, with its goal, notes and cues
working_setsset that counts: weight, reps (full, partial, or left and right), RPE, quality, rest, duration, distance, heart rate, failure technique
warmup_setsan exercise's own ramp-up set
programsprogram: name, deload reminder in days, whether you follow it (one at a time) and since when
program_workoutsworkout in a program: its number and optional name ("Push")
program_workout_exercisesexercise 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_pinsnote 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

raw_inputs id TEXT PK content TEXT source_kind TEXT text (read by the model) | manual (no model call) source_file TEXT for a repeat: the session it came from checksum TEXT captured_at TIMESTAMPTZ llm_calls id SERIAL PK raw_input_id TEXT → raw_inputs.id step TEXT split_exercises | extract_session_shell | extract_exercise | edit_extraction model TEXT attempts INT input_tokens INT output_tokens INT cost_usd NUMERIC ms INT cached BOOLEAN failed TEXT raw_payload JSONB created_at TIMESTAMPTZ extractions id TEXT PK raw_input_id TEXT → raw_inputs.id model TEXT "none" for a repeat prompt_version TEXT a hash of the prompts; "repeat" for a repeat extract JSONB uncertain_fields TEXT[] warnings TEXT[] status TEXT pending | confirmed | rejected corrections JSONB [{at, source, edits, instruction | op + path}] created_at TIMESTAMPTZ confirmed_at TIMESTAMPTZ sessions session_id TEXT PK date DATE program TEXT program_author TEXT program_length_weeks INT phase INT week INT is_deload_week BOOLEAN focus TEXT duration_minutes INT weight_unit TEXT user_id TEXT user_name TEXT source_file TEXT notes TEXT extraction_id TEXT → extractions.id program_workout_id TEXT → program_workouts.id; empty for blank workouts created_at TIMESTAMPTZ warmups / cooldowns id SERIAL PK session_id TEXT → sessions.session_id number INT name TEXT reps INT duration_seconds INT notes TEXT exercises id SERIAL PK session_id TEXT → sessions.session_id number INT name TEXT tags TEXT[] modality TEXT movement_pattern TEXT[] notes TEXT warmup_notes TEXT form_cues TEXT[] goal_weight_kg NUMERIC goal_sets INT goal_rep_min INT goal_rep_max INT goal_rest_min INT goal_rest_seconds INT goal_distance_meters NUMERIC goal_target_duration_sec INT target_muscle_groups TEXT[] rep_tempo TEXT working_sets id SERIAL PK exercise_id INT → exercises.id number INT weight_kg NUMERIC reps_full INT reps_partial INT left_reps_full INT left_reps_partial INT right_reps_full INT right_reps_partial INT rpe NUMERIC rep_quality TEXT rest_minutes NUMERIC rest_seconds INT duration_seconds INT distance_meters NUMERIC heart_rate_bpm INT notes TEXT failure_technique JSONB warmup_sets id SERIAL PK exercise_id INT → exercises.id number INT weight_kg NUMERIC rep_count INT notes TEXT programs id TEXT PK name TEXT deload_after_days INT default 28 following BOOLEAN at most one row true following_since DATE the deload count starts here or at the last deload archived_at TIMESTAMPTZ set instead of deleting created_at TIMESTAMPTZ program_workouts id TEXT PK program_id TEXT → programs.id position INT 1..n among workouts not archived name TEXT optional archived_at TIMESTAMPTZ created_at TIMESTAMPTZ program_workout_exercises id TEXT PK workout_id TEXT → program_workouts.id position INT name TEXT warmup_sets INT working_sets INT target_reps INT empty means no target amrap BOOLEAN as many reps as you can alternatives TEXT[] other exercises that can take this one's place exercise_pins name_key TEXT PK the exercise name, lower case, trimmed note TEXT pinned_at TIMESTAMPTZ

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.

EndpointDoes
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 /inputsSaves 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}/editValues 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}/correctA correction in words. One model call.
POST /extractions/{id}/confirmWrites 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}/repeatA new reading built from a past session. No model call. Same response as POST /inputs.
POST /sessionsSaves 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 /sessionsSessions, 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}/historyEvery working set of one exercise, across all sessions
GET /progress/liftsKey lifts, then other lifts with 3 or more sessions: latest and best value, sessions, 4-week trend
GET /me/exportEverything the person has logged, as a file: ?format=csv (one line per set) or ?format=json (sessions, exercises, sets, programs)
POST /feedbackSaves 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-liftsSaves 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 /programsPrograms 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, /unfollowFollow this program (any other stops being followed), or stop
GET /templatesReady-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}/copyAdd a copy of the template to your programs, in one transaction. Not followed; copying twice gives two programs.
POST /programs/{id}/workoutsAdd a workout after the last one
PUT /programs/{id}/workout-orderNew 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}/exercisesReplace 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.

PieceWhat it is
Cloud Run service traininglogs512 MiB, 0 to 2 instances, CPU only while handling a request. Runs as app-runtime.
Artifact Registry imagesThe app's images, tagged by commit. Only the newest two are kept.
Secret Managerdatabase-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 accountsterraform applies the app layer, app-deploy ships images and works only from main, app-runtime is the running app.
GitHub sign-inWorkload 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:

infra/ modules/project/ APIs, GitHub sign-in, terraform and app-deploy accounts modules/app/ Cloud Run, Artifact Registry, secrets, app-runtime environments/prod/project/ applied by hand, because it creates the pipeline's own account environments/prod/app/ applied by the pipeline

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

pull request, or push to main │ ▼ CI tests · terraform format and validate · plan of prod/app, posted on the pull request │ checks only, changes nothing ▼ main, all green CD waits for approval in the GitHub environment "prod" ├── infra terraform apply of prod/app (nothing to do when infra/ didn't change) └── app build the image, push it, move Cloud Run onto it

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.
Last updated 2026-10-11