Building

Last updated •

A free tool every Monday, for five weeks

Iron & Code promised one free training tool every Monday. It shipped five and then missed five. How the codebase makes a one-week tool possible, what the tools actually compute, how the numbers are checked, and what the record shows. · 11 min read

Iron & Code is a site of small free tools for people who lift. The promise on the home page is one new tool every Monday. I kept that promise for five weeks and then stopped. On 11 October 2026 the site's own record reads 5 of 48 weeks shipped, five weeks missed, 50% kept.

This post covers how the code lets a tool ship inside a week without touching the old ones, what three of the tools compute and how I check the numbers, and what the weekly limit cut.

5

tools shipped, weeks 1 to 5 (2 to 30 August 2026)

5

weeks missed since, weeks 6 to 10, as of 11 October

114

unit tests in 7 files, all on logic modules, none on components

The record, from the git history

The repository has 49 commits, all between 2 and 30 August 2026. The series counts weeks from Monday 27 July. These are the days the tool commits landed:

  • **Week 1, Strength Percentile:** Sunday 2 August, the last day of the week, together with the site itself.
  • **Week 2, Workouts Left:** Friday 7 August.
  • **Week 3, Muscle Index:** Sunday 16 August. A different tool, Discipline Score, was committed on Saturday 15 August and deleted the next day.
  • **Week 4, Arc Builder:** Sunday 16 August, one day before its week opened.
  • **Week 5, Depth:** Saturday 29 August, followed by ten more commits over that weekend, most of them fixes.
  • **Weeks 6 to 10:** nothing. The last commit is 30 August.

Every tool landed inside or before its own week, but by commit date none landed on a Monday. For the first four tools, the date stored with the tool is the Monday that opens its week, not the day the code was written. After week 5 the series stopped, and the project is on hold for now. Nothing in the code forced that: the tests pass and the backlog lists eight more ideas.

One row per tool

I did not count those missed weeks by hand. The site computes them. Every tool is one entry in an array called TOOLS, with its week number. The week in the header, the 48-cell shipping record on the home page, the sitemap and the grid printed on every share card all derive from that array and one start date.

lib/tools.ts
export const SERIES_START = "2026-07-27";const WEEK_MS = 7 * 24 * 60 * 60 * 1000;/** The week number the series is currently in, 1-indexed and never below 1. */export function currentWeek(now: Date = new Date()): number {  const elapsed = now.getTime() - new Date(`${SERIES_START}T00:00:00Z`).getTime();  return Math.max(1, Math.floor(elapsed / WEEK_MS) + 1);}export type WeekState = "shipped" | "missed" | "pending" | "future";export function weekStates(  weeks: number,  current: number,  shipped: Set<number>): WeekState[] {  return Array.from({ length: weeks }, (_, i) => {    const week = i + 1;    if (shipped.has(week)) return "shipped";    if (week > current) return "future";    return week === current ? "pending" : "missed";  });}
Trimmed from lib/tools.ts: comments and two helper functions removed. The week in progress is pending, not missed. A week only counts against the record once it is over.

Shipping a tool is a pure calculation module with tests, a page under /tools, and one row in TOOLS. Week 4's launch commit edited nothing else. Week 5's did: a card that carries a picture needed changes to the shared share-card code. The first version kept the week number by hand in four places. I replaced that the day after the first commit.

Pages are prerendered, so the date would normally freeze at the last deploy. The root layout revalidates hourly, so the week turns over without me. That is why the live site shows five missed weeks although the last commit is from August.

One mistake: the first tests for the record read the live TOOLS array, so they broke the moment a second tool existed. A suite that fails every time I ship is the wrong suite for this project. The logic now takes the shipped weeks as an argument and the tests pass in fixed data.

Strength Percentile: five anchors and a table

The first tool ranks a lift against other lifters at the same bodyweight. The data is small: for each lift and sex, five ratios of lift to bodyweight, placed at the 5th, 20th, 50th, 80th and 95th percentiles. For a man's bench press those are 0.5, 0.75, 1.05, 1.45 and 1.85. A result is a straight-line interpolation between the two nearest anchors.

lib/strength.ts
/** Interpolate a percentile from the anchor ladder. */function percentileFromRatio(ratio: number, anchors: number[]): number {  if (ratio <= anchors[0]) {    // Below the first anchor, fade toward zero rather than clamping flat.    return Math.max(0.5, (ratio / anchors[0]) * ANCHOR_PERCENTILES[0]);  }  for (let i = 0; i < anchors.length - 1; i++) {    const lo = anchors[i];    const hi = anchors[i + 1];    if (ratio <= hi) {      const t = (ratio - lo) / (hi - lo);      const p =        ANCHOR_PERCENTILES[i] +        t * (ANCHOR_PERCENTILES[i + 1] - ANCHOR_PERCENTILES[i]);      return p;    }  }  // Past elite the curve flattens hard; 99.9 is the ceiling we will show.  const last = anchors[anchors.length - 1];  const over = (ratio - last) / last;  return Math.min(99.9, 95 + over * 60);}
A 100 kg bench at 80 kg bodyweight is a ratio of 1.25, halfway between the 50th and 80th anchors, so it reads as the 65th percentile.

Before interpolation the ratio is multiplied by an age coefficient. Ages 14 to 23 use the Foster coefficients, 24 to 39 are left at 1.0, and 40 to 83 use the McCulloch coefficients. Competitive powerlifting uses these tables to compare lifters of different ages. The published table runs to 90, but the code's copy stops at 83. Past 83 the code continues the curve at about 2.2% a year and the page calls that an extrapolation. Under 14 the tool gives no number, because the standards are adult standards.

The first version had the age adjustment backwards. It multiplied an older lifter's ratio by as little as 0.8, so the same bar ranked lower at eighty than at twenty-five. The test that covered it was titled correctly and asserted the wrong direction, so it passed. I fixed it two days after the first commit, replaced my hand-shaped curve with the published tables, and pinned individual values in tests: 1.23 at 14, 1.055 at 45, 1.645 at 70, 2.05 at 80.

What those tests do not prove

A pinned value catches the table drifting after an edit. It does not catch a value copied wrongly from the source, because the test and the table were typed in the same commit. And the five anchor ratios have no citation in the code. They are the weakest data on the site.

Muscle Index: a formula whose test is its own inverse

The third tool computes FFMI, the fat-free mass index: lean mass in kilograms divided by height in metres squared, then normalised to a 1.8 m frame by adding 6.3 × (1.8 − height). An 80 kg lifter at 15% body fat and 1.80 m carries 68 kg of lean mass and scores 20.99. The reference point comes from Kouri et al. (1995), whose 74 non-steroid-using athletes had normalised values extending up to 25.0.

The headline is the lean kilos between the user and 25.0: 13.0 kg for that lifter. It inverts the normalisation, so it is the number most likely to be quietly wrong. The test does the round trip at four heights: add exactly the reported kilos as lean mass, recompute body fat, run the formula again, expect 25.0.

The paper's abstract calls 25.0 a "well-defined limit" for its non-users and calls the findings preliminary. The tool treats it as the top of one sample, not a limit on any person. The top band is worded as an observation about a 1995 sample, and a test fails if that wording stops saying it proves nothing about the person. The sample was male, so the tool offers no bands for women.

Depth: a ratio, not a measurement

The fifth tool takes a video of a squat and decides whether the hip finished below the knee. It runs pose estimation (a model that finds joint positions in an image) in the browser with MediaPipe. The model and its WebAssembly runtime, about 18 MB, are served from the site's own origin. The video is never uploaded.

There is no known length in the frame, so nothing is reported in centimetres. Every figure is an angle or a fraction of the lifter's own thigh. The verdict has three values, because the method cannot separate results that finish close to the line.

lib/depth.ts
export const RULE_OFFSET = 0.1;export const TOO_CLOSE_BAND = 0.05;/** * Landmark coordinates are not in a square space: x is a fraction of the * frame's width and y a fraction of its height. On a 9:16 clip the same 0.1 * is 108 px across and 192 px down, so measuring a length with * `hypot(dx, dy)` mixes two different units. * ... * Everything below works in units of frame height: x is multiplied by the * frame's aspect ratio, y is left alone. */function distance(a: Landmark, b: Landmark, aspect: number): number {  return Math.hypot((a.x - b.x) * aspect, a.y - b.y);}// ... inside analyse(), at the deepest frame:  const thigh = distance(hip, knee, aspect);  if (thigh === 0) return null;  // y grows downward, so a hip below the knee is a larger y.  const margin = (hip.y - knee.y) / thigh;  const pastRule = margin + RULE_OFFSET;  // ...    verdict:      pastRule > TOO_CLOSE_BAND        ? "depth"        : pastRule < -TOO_CLOSE_BAND          ? "above"          : "too-close",
Trimmed from lib/depth.ts. Anything within a twentieth of a thigh of the line comes back as too close to call.

The aspect argument and the offset both exist because of bugs I shipped. The launch version measured the thigh with a plain hypotenuse, so on a 9:16 phone clip a horizontal thigh came out about 1.8 times too long and every squat read shallow. It also put the line where the two joint centres are level. The competition rule names the top of the knee, which sits above the knee joint, so I was asking for about a tenth of a thigh more depth than a referee would. I fixed both the day after launch.

Depth has 22 tests, all on synthetic frames with known geometry. One runs the same pose through a square and a portrait frame and expects the same verdict. I have not validated it against real squats judged by a referee. The tests show the geometry is consistent. They do not show the tool is accurate.

When it fails

Depth worked in Chrome on a laptop and hung three times on an iPhone, where almost all visits come from. A dynamic import inside a Function body never resolved in WebKit and never threw. iOS does not decode a frame until playback has been attempted, so waiting for the first decoded frame waited forever. A video element styled display: none is one iOS never loads. Each was a silent hang, not an error.

components/depth-tool.tsx
const SEEK_TIMEOUT_MS = 3000;const seek = (video: HTMLVideoElement, timeSec: number) =>  new Promise<boolean>((resolve) => {    const ready = () =>      Math.abs(video.currentTime - timeSec) < 0.01 &&      video.readyState >= 2; /* HAVE_CURRENT_DATA */    if (ready()) {      resolve(true);      return;    }    let timer = 0;    const done = (ok: boolean) => {      window.clearTimeout(timer);      video.removeEventListener("seeked", onSeeked);      resolve(ok);    };    const onSeeked = () => done(true);    video.addEventListener("seeked", onSeeked);    timer = window.setTimeout(() => done(false), SEEK_TIMEOUT_MS);    video.currentTime = timeSec;  });
Assigning currentTime the value it already holds fires no seeked event. The first version waited for one and sat at 0%.

Two rules came out of that weekend and are written in the repository. Never await a browser event without a deadline. Never let one status message cover several operations: 'Loading the model' stood for four steps, so a report of where it stopped told me nothing. The model load now times out with a message, and runs are numbered so a second video cannot write over the first one's result.

The run counter must never be the reason a tool fails. The browser sends only the tool's slug and does not wait. The API route checks the slug against TOOLS, applies a best-effort limit of 60 requests an hour per IP address, and returns 204 whether or not the write succeeded. The database stores one row per tool per day and nothing about the visitor.

  • **Five runtime dependencies:** Next.js, React, React DOM, Vercel Analytics and a QR code generator. The database is called with fetch, not its client library.
  • **Results are computed on the device.** The share card is a 1080 × 1920 PNG drawn on a canvas, so there is no image server.
  • **Heavy assets are lazy.** The pose model is requested only when a video is chosen, built once per page and reused.
  • **Search:** each tool is a prerendered page with its own title, description and a plain-text section on the method and its limits. The sitemap is generated from TOOLS.

There is no structured data and no canonical tag, and I have no search numbers. The pages are indexable. I cannot claim they rank.

What the week forced

The limit decided what got reused. I extracted the share block in week 2, because it holds the one rule every tool must meet (end in an image worth posting) and the phone-specific code I least wanted to copy.

It decided what got cut. Discipline Score was a 679-line commit, built on a Saturday and deleted on the Sunday. It scored numbers the user had just typed, so it told them nothing new. The rule that replaced it: a tool has to know something the user cannot work out alone. Accounts and email capture were left out. Arc Builder saves a plan against a ten-character code and has no login.

One cut was not a decision. Arc Builder tells the user their plan becomes a daily checklist on 1 October. The database functions for check-ins exist. No screen calls them, and the date has passed. A weekly deadline makes it easy to ship the half of a tool that fits in the week and promise the rest.

What this does and does not show

It shows a structure where the fourth tool cost one array entry outside its own files, calculation code written as tested pure functions, and limits stated in the product. It shows five weeks of shipping. It does not show a habit that survived. The record on the home page says so, and I have left it unedited.

Read more

Have something that needs to be built?

Tell me the workflow and what a good outcome looks like. I'll reply with a clear next step.

  • 1,200+ clients handled
  • Published at Mercari
  • 30K followers