Fitness test calculators look like simple math problems, but most of them are not formulas at all. Military fitness tests, school fitness programs, and many sports assessments publish score tables: a grid of raw performances (repetitions, seconds, pounds) mapped to points, split by age band and sex. If you try to replace the grid with a curve fit, you will be wrong in a handful of cells, and "a handful of cells" is exactly where a user's promotion points or pass/fail result lives.

This post is about how I approach building one of these calculators: model the data as tables, keep the scoring function pure, and test against the source documents. The numbers in the code below are made up for illustration and are not real standards.

Start from the published table, not a formula

A typical scoring table for a repetition event looks like this: for an age band and sex, there are rows where "at least N reps earns P points." Between those rows, the score does not change. It's a step function. Real tables also have quirks:

  • Some point values are skipped in the middle of the scale (gaps).
  • Different age bands have different row counts.
  • Sex-specific scales may exist for some events and not others.
  • Timed events work in the opposite direction: lower is better.
  • A newer edition of a table can change only a few cells.

Interpolating between rows invents scores that the publisher never issued. A lookup keeps every answer traceable to a cell in the original document.

A small, pure scoring function

Here is a minimal version for a "more is better" event. Notice that the function takes the table as an argument, so it has no hidden state and is trivial to test.

// ILLUSTRATIVE numbers only - not real military standards. const PUSHUP_TABLE = { // ageBand -> sex -> array of [minReps, points], sorted high to low "17-21": { M: [[60, 100], [50, 90], [40, 80], [30, 70], [20, 60]], F: [[50, 100], [40, 90], [30, 80], [20, 70], [10, 60]], }, }; function ageBand(age) { if (age < 17) throw new RangeError(`age ${age} is below the table range`); if (age <= 21) return "17-21"; throw new RangeError(`no band for age ${age} in this demo`); } function scoreReps(table, age, sex, reps) { if (!Number.isInteger(reps) || reps < 0) { throw new TypeError("reps must be a whole number >= 0"); } const rows = table[ageBand(age)]?.[sex]; if (!rows) throw new Error(`no scale for ${sex}`); for (const [minReps, points] of rows) { if (reps >= minReps) return points; } return 0; // below the lowest published row } // tests const assert = require("node:assert"); assert.equal(scoreReps(PUSHUP_TABLE, 19, "M", 60), 100); assert.equal(scoreReps(PUSHUP_TABLE, 19, "M", 59), 90); assert.equal(scoreReps(PUSHUP_TABLE, 19, "M", 19), 0); assert.throws(() => scoreReps(PUSHUP_TABLE, 19, "M", 12.5), TypeError); console.log("all demo tests passed"); 

A few design choices worth copying:

  1. Rows sorted from highest to lowest threshold. The first row you meet where reps >= minReps wins.
  2. Explicit errors for out-of-range input. A calculator that quietly returns zero for a bad age is worse than one that throws.
  3. Whole-number validation. Repetition events have integer inputs. Timed events need their own parsing, for example converting "15:30" to seconds before comparing.
  4. Data separate from logic. The table lives in data, not in if statements, so a new edition of the standard is a data change.

Timed events flip the comparison

For a run or a plank hold, you write the same function with the comparison reversed for runs (seconds <= maxSeconds) and the same direction for planks (seconds >= minSeconds). Do not try to share one function with a boolean flag unless the tests make the direction obvious. Two tiny functions are easier to review than one clever one.

Rounding and truncation are part of the standard

Raw measurements are not always whole numbers. A body measurement, a time recorded to a tenth of a second, or a distance may be rounded or truncated before the lookup, and the rule is usually written in the governing document. Put the rule in one function, name it after what it does (roundDownToHalfInch, for example), and write tests that show both sides of the boundary.

Make the same code run everywhere

If you generate pages statically and also calculate in the browser, use the same pure functions in both places. Then the number on the pre-rendered example and the number after the user presses a button cannot drift apart. On the site I work on, the AFTPTCalc methodology page describes this approach: every enabled score is a table lookup rather than an interpolated estimate, the original source files are archived with SHA-256 hashes, and the same pure calculation functions run during static generation and in the browser.

Test against the document, not against your own code

Tests that re-derive the answer with the same logic only prove your code agrees with itself. Better:

  • Transcribe a sample of cells by hand from the source document into test cases.
  • Test every boundary: the exact threshold, one below it, and one above it.
  • Test the top and bottom of every age band, where off-by-one errors in band edges show up.
  • Keep a record of what is not covered. A statement like "sample coverage, not every cell" is honest and helps the next person decide where to look.

A calculator that is right for the tested cells and says so is more trustworthy than one that claims to be perfect.

Handling a data import

When tables arrive as PDFs, the import step is where mistakes sneak in. A few practical habits:

  • Keep the original file and note its effective date.
  • Write an import script instead of copying by hand, so a second run gives the same output.
  • Fail the import loudly when a row is missing or a value is out of order.
  • Spot-check imported rows against the PDF visually, especially near age band boundaries.

What I would do differently next time

A useful property test to add: for any table, points should never decrease as reps increase. That one invariant catches many transcription errors where two digits were swapped.

If you have built something similar, I'd like to hear how you handle editions of a standard that change over time.

Disclosure: I used an AI writing assistant to help draft and edit this post, and ran the code sample before publishing. The table values are invented for illustration.