Technical Design · App v3.0.0 · 2026-08-09 · github.com/apoorvasharma007/traininglogs

traininglogs

An app that turns workout notes into a queryable, chartable training history.

Why this exists

I lift weights. I write down what I do, and later I want to answer questions: am I getting stronger? am I hammering the same muscle group three days in a row? what was my squat doing six months ago? did the deload actually do anything?

Spreadsheets work until they don't. Off-the-shelf apps assume a workout shape that isn't mine — they don't model myo-reps, partial reps, or the difference between a warmup set and a working set the way I think about them. The shape I want is: write the session the way I'd write it in a notebook, and have a system parse it into something strict enough to query and chart.

That's traininglogs.

Vision

traininglogs today is a post-workout pipeline: write a markdown file, parse it, store it. The direction it is heading is a live, in-workout agent that captures sets as they happen, on mobile, by voice or tap, with the same data fidelity.

The shape of the system

input text │ ▼ capture() → raw_inputs saved │ ▼ extract() → extractions saved, pending │ ▼ confirm loop card shown, user confirms or corrects │ ▼ confirm() → Pydantic models → PostgreSQL │ ┌─────────┴─────────┐ ▼ ▼ dashboard (HTML) FastAPI (REST)

Four steps. capture() saves the raw text. extract() runs the LLM and saves the result as pending. The card is shown; the user confirms or corrects it. confirm() writes the normalized session to Postgres. Each step saves before returning, so a crash or a rejected extraction never loses what was captured.

These three functions live in ingest/. cli/log.py calls them directly, and the API will too.

Postgres is the source of truth. The dashboard and the API both read from it; neither writes back. A rule-based parser is also available via --parser rules.

Pydantic Data Model

A session is one workout on one day. A session has three ordered groups of movements: an optional session-level warmup, the main work (exercises), and an optional session-level cooldown. Session warmup/cooldown movements are lightweight (name, reps or duration, notes) and use their own models — they are not exercises. An exercise has sets and, separately, its own optional warmup sets (e.g. ramp-up sets before a lift's working weight) — a different thing from the session-level warmup despite the shared name. A working set may carry a failure technique (myo-reps, lengthened-partials, static hold, drop set) when it's taken to RPE 10.

TrainingSession
├── data_model_version, data_model_type, session_id, user_id, user_name
│                            system fields — injected by the processor, never user-supplied
├── date                     YYYY-MM-DD
├── program = None           real program name; stays unset for ad-hoc sessions (inputs/sessions/)
├── program_author = None
├── program_length_weeks = None
│                            set for real programs — used to validate `week` below
├── phase = None
├── week = None              must be 1..program_length_weeks when both are set
├── is_deload_week = None
├── focus = None
├── session_duration_minutes = None
├── weight_unit               "kg" (default) or "lbs" — lbs converted to kg at parse time
├── warmup = None            [SessionWarmup] — session-level warmup movements
├── exercises                [Exercise] — the main work, always at least one
├── cooldown = None          [SessionCooldown] — session-level cooldown movements
└── notes = None             remarks that don't belong to any specific exercise or set

SessionWarmup / SessionCooldown   (identical shape, two separate models — not Exercise)
├── number, name
├── reps = None
├── duration_seconds = None
└── notes = None

Exercise
├── number, name
├── tags = None              free-text classification: ["strength"], ["cardio"],
│                            ["mobility"], ["skill", "jiu-jitsu"], etc.
├── modality = None          free-text equipment/style: "barbell", "bodyweight", "gi", "pool", etc.
├── movement_pattern = None  list, supports compound patterns: squat, hip_hinge, push,
│                            pull, lunge, carry, rotation
├── target_muscle_groups = None
├── rep_tempo = None
├── current_goal = None      Goal — target weight/sets/rep range/rest for this exercise
├── notes = None
├── warmup_notes = None
├── form_cues = None
├── warmup_sets = None       [WarmupSet] — this exercise's own ramp-up sets, lower-resolution
└── sets = None              [WorkingSet] — the sets that count

WarmupSet
├── number, weight_kg
├── rep_count = None
└── notes = None

WorkingSet                    one flat model — every measurement field optional; the fields
│                            present on a given set describe what kind of set it was
├── number
├── rpe = None                1–10, whole or half steps
├── rest = None                {minutes, seconds} — never both set at once
├── notes = None
├── weight_kg = None
├── rep_count = None          {full, partial}
├── unilateral_rep_count = None   {left: {full, partial}, right: {full, partial}}
├── rep_quality_assessment = None  good | bad | perfect | learning
├── duration_seconds = None
├── distance_meters = None
├── heart_rate_bpm = None
└── failure_technique = None   only valid when rpe == 10; discriminated union on technique_type
    ├── MyoReps    → mini_sets[]        {number, rep_count}
    ├── LLP        → partial_rep_count
    ├── Static     → hold_duration_seconds
    └── DropSet    → drop_sets[]        {number, weight_kg, rep_count}

How it works

PostgreSQL is the source of truth: nine tables. Here is what happens to one submission, table by table.

What happens when a session is submitted

input text capture() raw_inputs (table 1) extract() llm_calls (table 2) extractions (table 3) confirm loop llm_calls (table 2) extractions.corrections (table 3) confirm() sessions (table 4) warmups (5) · cooldowns (6) exercises (table 7) working_sets (8) · warmup_sets (9)

The person writes a session, as markdown or free text. capture() saves it before anything else happens — raw_inputs (table 1) gets one row: content (the raw text, untouched), source_kind (markdown, photo, or speech), source_file, checksum, captured_at.

extract() reads that row and calls the model. The work is split into several calls — segmenting the exercises, reading the session's shell (date, focus, program), then one call per exercise. Each call writes one row to llm_calls (table 2): step (which call this was), model, attempts, input_tokens, output_tokens, cost_usd, ms, failed, raw_payload (the last thing the model actually returned, kept even if it failed validation). Once every call returns, the assembled reading is saved as one row in extractions (table 3): raw_input_id, model, prompt_version, the whole reading as extract (JSONB), uncertain_fields, warnings, status set to pending.

The confirmation card is built from that extraction and shown to the person. Typing y moves on. Describing a correction makes another model call — another llm_calls (table 2) row — and the edit it produces is appended to extractions.corrections (table 3), a JSONB list. extractions.extract is never rewritten; only corrections grows. This repeats until the person confirms.

confirm() then writes the final reading — the original extract plus every correction — as a normalized session. sessions (table 4) gets one row (date, program, phase, week, focus, weight_unit, source_file, extraction_id linking back to the reading that produced it). Session-level warmup/cooldown movements go to warmups (table 5)/cooldowns (table 6). Each exercise goes to exercises (table 7); each exercise's sets go to working_sets (table 8) and warmup_sets (table 9). extractions.status flips to confirmed, confirmed_at is set.

After the DB insert succeeds, the same session is written to output_training_logs_json/ as JSON — a cheap, diffable copy for checking the parser without touching the database.

The rule-based parser skips all of this: markdown goes straight to sessions (table 4) with no raw_inputs (table 1), extractions (table 3), or llm_calls (table 2) rows at all.

Table reference

raw_inputs (table 1) id TEXT PK content TEXT source_kind TEXT markdown | photo | speech source_file TEXT checksum TEXT captured_at TIMESTAMPTZ extractions (table 3) id TEXT PK raw_input_id TEXT → raw_inputs.id model TEXT prompt_version TEXT extract JSONB uncertain_fields TEXT[] warnings TEXT[] status TEXT pending | confirmed | rejected corrections JSONB created_at TIMESTAMPTZ confirmed_at TIMESTAMPTZ llm_calls (table 2) id SERIAL PK raw_input_id TEXT → raw_inputs.id step TEXT 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 sessions (table 4) 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 created_at TIMESTAMPTZ warmups (table 5) / cooldowns (table 6) (identical shape, two separate tables) id SERIAL PK session_id TEXT → sessions.session_id number INT name TEXT reps INT duration_seconds INT notes TEXT exercises (table 7) 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 (table 8) 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 (table 9) id SERIAL PK exercise_id INT → exercises.id number INT weight_kg NUMERIC rep_count INT notes TEXT

Inside the AI agent

The default parsing path. The user writes freely — during or after the workout — and the pipeline maps the draft to a TrainingSession. Nothing is written to the database until the person confirms the reading.

The flow

input text │ ▼ capture() saved to raw_inputs │ ▼ extract() segment the exercises, read the session shell, │ then one call per exercise — each call logged │ to llm_calls ▼ extractions (pending) the assembled reading, saved │ ▼ confirmation card built and rendered │ ▼ user: "y" or a correction │ ├── "y" → confirm() → sessions │ └── correction → model returns edits, not a rewrite edits applied, correction appended card rebuilt, shown again

TrainingSession.model_validate() always runs on the final result. The model produces input to Pydantic, never a bypass around it.

ConfirmationCard architecture

The card is a tree of dataclasses defined in agent/validation_card_data.py, built from a TrainingLogLLMExtract by agent/validation_card_builder.py. It is the stable contract between the LLM output and any renderer — terminal today, JSON/frontend later.

UserValidationCard
  SessionHeader              date · focus · duration · program / phase / week
  NotePreview = None         session-level note, shown right after the header
  SessionMovementSection[]   warmup / cooldown, each a MovementRow[]
  ExerciseCard[]
    ExerciseHeader           number · name · goal (plain English)
    WarmupRow[]               weight · reps
    WorkingSetRow[]           weight · reps · RPE · quality · failure (plain English)
    NotePreview = None        first ~40 chars of note + "…" if truncated
    NotePreview = None        same, for the exercise's own warmup notes

Fields the LLM was uncertain about are flagged in uncertain_fields: list[str] (dot-path format, e.g. "exercises[1].sets[0].weight_kg"). The renderer marks those positions with ?. The user's eyes go directly to uncertain fields; everything else can be scanned in seconds.

Form cues are not shown on the card — they are stored but low-stakes if wrong, and omitting them keeps the card skimmable. Notes (session-level and exercise-level) are shown as a truncated preview. Goals and warmup sets are shown in full because they are stored data the user should verify.

Correction loop

After the card is printed, the person sees one prompt: confirm, or say what's wrong. Anything other than y/yes is a correction. The model is sent the current extract and the correction, and returns a list of edits — {path, value} pairs — not a rewritten extract. The edits are applied in Python; a field the correction doesn't name cannot change, because Python only touches the paths it's given. Measured on a real ten-exercise session: the old whole-document shape needed roughly 5,410 output tokens against a 4,096 ceiling, so it could not have returned a correction for a session that size at all.

Each correction is appended to extractions.corrections. extractions.extract itself is never rewritten. The card is rebuilt from the edited extract and shown again. This repeats until the person confirms.

LLM call design

CallToolInputOutput
Segmentsplit_exercisesraw session textexercise positions and anchors
Session shellextract_session_shellraw session textdate, focus, program, phase, week
Exercise workerextract_exerciseone exercise's isolated text, or the full text as a fallbackone exercise, with its sets
Correctionedit_extractioncurrent extract + the correctiona list of edits

All four use claude-haiku-4-5, structured tool-call output, and a schema derived from the Pydantic models — no separate schema to keep in sync. A worker gets an isolated chunk when the segmenter's anchor for that exercise can be found verbatim in the text; when it can't, the worker falls back to the full text with a warning noting lower reliability, not a failure. A worker that raises becomes a flagged placeholder exercise plus a warning — never a crash.

CLI interface

# AI parser (default) — free-text input, confirmation card
traininglogs log draft.md

# Rule-based parser — structured markdown, no confirmation card
traininglogs log session.md --parser rules

All log flags (--no-commit, --pr) apply to both parser modes. The --parser flag only controls the parse stage; everything downstream is identical. Use traininglogs validate to test parsing with no DB or git side effects.

The API

FastAPI. Two halves: read endpoints answer questions about existing sessions; ingest endpoints are capture → extract → confirm/correct, the same functions cli/log.py already uses, reachable over HTTP.

Read

EndpointAnswers
GET /sessionsWhat did I do, and when? (filterable by phase, week, date range)
GET /sessions/{id}Full detail of one session, exercises and sets included.
GET /exercises/{name}/historyHow has this lift moved over time? Every working set, every session.

Ingest

EndpointDoes
POST /inputscapture() then extract(){raw_input_id, extraction_id}. A failed extraction still returns raw_input_id; the text isn't lost.
GET /extractions/{id}The confirmation card, as JSON — the same ValidationCardBuilder the CLI renders to a terminal.
POST /extractions/{id}/confirmingest.confirm() — writes the normalized session. 409 on a session_id collision.
POST /extractions/{id}/correctOne correction. Fully stateless — the client round-trips extract between calls; the server holds nothing.

Every request requires an X-Api-Key header. The app fails at startup if API_KEY is unset. Single key, no users table, no OAuth — it's mine and the dashboard's.

The dashboard

The dashboard is a single static HTML file rebuilt from the database by scripts/build_dashboard.py and written to docs/index.html. Clean white background, Inter for body text, JetBrains Mono for labels and numbers. Six sections:

SectionPurpose
OverviewTotal sessions · current plan · current phase · last session date
Session TimelineSessions grouped by ISO week; color-coded by focus; links to source files on GitHub
Strength ProgressionTop weight per day per exercise (actual kg, not e1RM); goal weight as a faded dashed red line; set-level note shown in side panel
Weekly LoadTotal kg moved per phase/week; deload weeks in red
Personal BestsHeaviest set per highlight exercise — weight, reps, date
ProgramAuto-discovered from inputs/programs/*/program.md; alias read from YAML frontmatter

The data flow at build time:

PostgreSQL → analytics/queries.py → scripts/build_dashboard.py → docs/index.html (6 SQL queries) (renders HTML + inline JSON)

The HTML embeds all data as inline JSON and renders charts with Chart.js. No server is required to view it. It is committed to docs/index.html as part of the normal traininglogs log workflow, which publishes it two ways: GitHub Pages serves this repo's own docs/ folder directly at apoorvasharma007.github.io/traininglogs; separately, the website's deploy workflow checks out this repo at build time and copies docs/index.html into the site at apoorvasharma7.com/dashboard. Neither requires an explicit push step from this repo beyond the normal commit — the website redeploy (automatic on its own pushes, or manual via workflow_dispatch) is what picks up the latest committed dashboard.

Versioning

Four things are versioned, each a different way.

  • App version. pyproject.toml's version field (currently 3.0.0), semver, bumped by hand when cutting a release. CI syncs it into this document's eyebrow on merge to main — that span is never hand-edited.
  • Data model version. data_model_version on every TrainingSession, written into the JSON snapshot only, not a DB column. A hand-set constant in processor.py, bumped when the Pydantic model's shape changes in a way worth marking.
  • Prompt version. extractions.prompt_version, a hash of the three live extraction prompts, recomputed automatically on import. Never hand-bumped — a hand-maintained constant is only correct while someone remembers to update it, and a hash can't go stale.
  • Schema version. No version number. db/schema.sql is the authoritative current shape; every change so far has been additive (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS) and recorded in CHANGELOG.md. db/db.py:apply_schema() applies it explicitly, run by migration/import scripts — never automatically on app startup. A breaking migration, when one lands, will be documented here.

What's next

  • Confirm UI. A minimal web surface — textarea, rendered card, confirm button — so a session can be logged from a browser, not just a terminal.
  • Deploy. The API hosted on Fly, against the Supabase database already live.
  • Mobile capture. Offline draft capture on a phone, synced on reconnect, feeding the same pipeline. Further out — photo and speech input follow the same pattern once this lands.

This document is meant to be the one place that explains the system as it is today, not as it was or as it might be.

Last updated 2026-08-09