A week to week CrossFit programming tool. It captures workouts as structured data as they're written, then checks them against what the coach actually intends to program that cycle, so imbalances and missed priorities are visible at planning time instead of discovered weeks later.
This is a solo product and engineering case study: one person acting as PM, designer, and engineer, building against a real workflow (my own affiliate's programming) with a written PRD, a documented data model, and a dated decision record for every real tradeoff. It is also a working v1, not a mockup, and is in real use for my gym's weekly programming.
CrossFit affiliate programmers plan in tools that store documents, not training data. Workouts live in Google Docs tables or day by day entries in gym management software (Wodify, SugarWOD, BTWB). None of these can answer the questions that define good programming: how many times have I hit each movement pattern this cycle. Is volume progressing or drifting, and at what intensity. Did everything I'm prioritizing this cycle actually show up this week.
The result is that imbalance and missed priorities get caught late or not at all. The programmer either rereads weeks of documents to audit by hand, or trusts memory. Both fail quietly. Gym management platforms don't fill this gap because they're class delivery tools: workout entry exists so athletes can see the whiteboard and log scores, and their data model is a document per day. Cycle level analysis needs two things none of them have: structured decomposition of every workout, and a declared statement of programming intent to measure against. Their buyer is the gym owner, not the programmer, so nobody is asking for this, and their text blob architecture makes it a rebuild, not a feature.
Freetext-first entry, not a form. The core requirement, and the reason this tool can win against both Google Docs and the incumbents. A coach programming five to six days a week will not tolerate field by field data entry mid session. The entry surface has to feel like typing in Docs today, at Docs speed, with structure extracted underneath rather than demanded up front. This was the biggest open risk in the plan, so it was timeboxed and spiked before anything else was built: could a raw string like "Build to weight, then 3 sets of 5 reps, 5-10lbs heavier than last time or 62.5% of your 1-rm" actually be parsed into movement, sets, reps, load, and intensity without asking the user to fill out a form. The spike hit full field accuracy against a real programming week before the rest of the app was built on top of it (ADR-001). Structured forms remained the documented fallback if that bar wasn't met. It was met, so the fallback was never triggered. This is the tradeoff worth naming: a rule based parser will always have edges it gets wrong, and this project accepted that risk deliberately rather than forcing correctness through rigid input, because the alternative kills adoption before the product ever proves its value. The parser's known edges are tracked openly (see Current state).
Structured data over documents, as a one-way door. Everything downstream, the dashboard, the priority checklist, and anything built later, depends on programming being captured as data rather than text. This is named explicitly as the one decision in the whole system that isn't reversible the way UI or parsing quality is. Raw text stays the source of truth for what's displayed, but the parser's structured output sits alongside it, never replacing it, so a parse failure degrades to unparsed text visible and editable, never to lost or mangled programming.
One single source of truth for movement classification. Movements resolve to a broad training pattern (squat, hinge, push, pull, core, mono structural) through one lookup table, and every part of the app that needs a pattern is required to call the same resolver rather than build its own. This sounds obvious until it isn't: an audit during the frequency dashboard build found that the dashboard's own lookup only indexed movements by canonical name and silently missed anything referenced by alias, undercounting push and core work in real data (ADR-008). The fix was to delete the second lookup and make the table itself the only place resolution logic lives, with a permanent coverage check across every movement and every alias so the gap can't quietly reopen. The decision worth surfacing isn't the bug, it's the response to it: found via a self imposed audit against the project's own stated data model contract, not via a user complaint.
AMRAP volume estimation moved from parse time to compute time. The data model promises that rep volume includes AMRAP work, visibly marked as estimated, since rounds completed in an AMRAP aren't known until the athlete finishes. The parser was initially built to leave sets: null for AMRAP instances and stop there, which meant volume math would have silently excluded the single most common component type in real programming once a volume dashboard existed. The fix wasn't to guess a number earlier. It was to keep the parser honest, storing what's actually knowable at parse time, and push the estimate to display time, where it can be computed from a configurable assumption and rendered clearly as an estimate rather than fact (ADR-002). The principle: never let a downstream convenience quietly corrupt what the system knows to be true versus what it's guessing.
Single pattern per movement, kept deliberately simple. Toes to bar was reclassified from pull to core because its actual training stimulus is midline work, not grip or pull strength (ADR-008). The more interesting decision is what didn't happen: a movement legitimately training more than one pattern is a real edge case, and multi pattern classification was explicitly considered and set aside rather than built, because the six broad patterns match how the programmer actually thinks about balance, and one pattern per movement is a deliberate simplification, not an oversight. It's flagged as revisit-if-this-starts-costing-real-accuracy, not silently declared final.
The priority panel, descoped from four categories to one flat checklist. The original design, pulled directly from the coach's real Google Doc sidebar (discovered by reviewing the actual artifact, not by asking about it, see ADR-000), had four fixed categories plus a Not Prioritizing list plus a persistent cross-cycle change log. It got built that way first. Then it got cut down to a single per-week list that resets every week and auto-marks an item done the moment its movement shows up anywhere in that week's programming, with no manual checking off. The full version isn't gone, it's resequenced: a simpler surface that verifies itself against real programming data was judged to reach the coverage payoff faster than finishing the fuller sidebar, and that tradeoff is written down and flagged against the original PRD rather than quietly substituted for it (ADR-010). This is the same instinct as the AMRAP decision in a different shape: prefer a smaller thing that's honestly correct over a bigger thing that isn't finished yet.
Discovery via artifact, not just interview. The entire programming-intent concept, arguably the sharpest differentiator against incumbent tools, was missed by the original PRD and only surfaced by reading the coach's actual Google Doc and noticing a priority sidebar that had never come up in description (ADR-000). It became the primary signal in the whole system, ahead of generic pattern balance rules. Worth naming as a decision because it changed what the product's sharpest edge even was.
Entry is freetext, one input per workout component, formatted the way a coach actually writes (line breaks, bullets, numbered lists) using a rich text editor capped at exactly four controls: bullets, numbered lists, indent, outdent, nothing else, enforced at the document schema level so there's no formatting to accidentally add later (ADR-005). What's typed is flattened to plain text through one narrow bridge function and handed to the parser as a black box; the parser never sees or needs to know about formatting (ADR-004). The parser extracts component type, time domain, and per movement: canonical movement, role (base, Rx+, or alternative), sets, reps, load text, and intensity band. That structured output sits next to the original rich text, never replacing it as what's displayed.
From there, two views read the same structured data: a rolling four-week movement pattern frequency dashboard (base and variant appearances counted and shown separately, warm-ups excluded from the math), and a per-week priority checklist that auto-marks each item done as soon as its movement appears anywhere in that week's programming, with no manual tracking required.
The parser is graded against a permanent test harness (npm test) built from thirteen components taken verbatim from one real programming week, checked field by field against hand-authored ground truth: component type, time domain, and every movement instance's movement, role, sets, reps, load text, and intensity. It currently passes all thirteen at full field accuracy on those cases. This is real-week data, not synthetic; the known edges the parser does not yet handle are tracked separately under Current state rather than hidden by a headline number.
The frequency dashboard was validated separately against a synthetic dataset built with known expected counts for each pattern, which is how the alias-resolution undercount described above was caught: the dashboard's output disagreed with hand-computed truth, and the gap traced to the resolution bug rather than the counting logic. Movement to pattern resolution now has its own permanent check (npm run test:patterns) covering every movement in the table, every alias, normalization edge cases, and a deliberately unresolvable string, each asserted against the correct pattern.
Every real tradeoff in this project has a dated ADR in docs/decisions/, eleven so far (ADR-000 through ADR-010), including entries that record the project correcting itself: a movement count that was undercounted in an earlier ADR and fixed once noticed, and the pattern-resolution bug described above (ADR-008). Feature level correctness (the dashboard, the priority panel, persistence and import/export) has been verified through scripted headless browser passes against real and synthetic data with known expected outcomes for each release, documented in the relevant ADR. That verification is rigorous but currently manual per change, not yet a committed automated regression suite the way the parser and pattern resolution are. That gap is named here rather than glossed over.
Working today: freetext entry with rich formatting, a full week view with drag and drop between days, multi-week navigation, local persistence (IndexedDB as the working copy, an exported JSON file as the durable source of truth, full import/export with fail-safe validation), the rolling pattern frequency dashboard, and the per-week auto-marking priority checklist. The tool is in real use for my own affiliate's weekly programming.
Deliberately deferred, not missing by accident:
- Rep volume math (sets times reps by intensity band) is not built yet. It's blocked on a known parsing gap, not forgotten: strength components using a "build to weight, then N sets of M reps" scheme currently parse the primary movement correctly but fail to attach the sets and reps to it, and emit junk unmatched fragments from the scheme text itself. Building volume on top of that today would ship numbers known in advance to be wrong, which is worse than not shipping volume yet, so it's explicitly blocked until the parser fix lands first (ADR-007).
- Flags (pattern hit too many days in a row, pattern absent too long, week over week volume spikes, priority item still absent) are not built. The priority checklist already computes the core signal a flag would surface; there's no alerting UI on top of it yet.
- The fuller programming-intent model (categories, an explicit Not Prioritizing list with leak detection, a persistent cross-cycle change log) was built once and then intentionally descoped to the simpler per-week checklist described above. It remains the stated target in the PRD, not abandoned.
- Two smaller parser gaps are logged and scheduled alongside the strength fix: "Squat Snatch" currently collapses to the same entry as "Snatch," losing the squat versus power distinction, and a foot-mark distance shorthand like
24'is currently read as a rep count instead of a distance.
TypeScript throughout. React 19 with Vite for the UI. TipTap (ProseMirror) for rich text entry, extension list capped deliberately short. dnd-kit for drag and drop. IndexedDB via the idb wrapper for local persistence, no backend, no accounts, no network calls. A hand-written rule-based parser with no external NLP dependency. Tests run via tsx against a plain script harness, no test framework beyond that.