Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Parsing Run Times in a Calculator: mm:ss, Seconds, and the Ties That Cost Points

Any calculator that scores a timed event has the same quiet problem: people type times in five different ways. One person types 14:30. Another types 870. A third types 14.30 and means fourteen minutes and thirty seconds.

Any calculator that scores a timed event has the same quiet problem: people type times in five different ways. One person types 14:30. Another types 870. A third types 14.30 and means fourteen minutes and thirty seconds. If your parser guesses wrong, the score is wrong, and the user usually cannot tell.

This post walks through a small, boring approach that has held up well for me when building fitness-test calculators: accept a few clearly defined formats, reject everything else with a helpful message, and compare times as whole seconds.

Decide what you accept, and write it down

Before writing code, list the formats you will accept. Mine are:

  • mm:ss such as 14:30 or 9:05
  • m:ss with a single-digit minute
  • h:mm:ss for very long events, such as 1:02:15
  • plain seconds such as 870, only when the field is labelled as seconds

Everything else is an error. That includes 14.30, 14,30, and 14m30s. It is tempting to be generous, but a decimal point in a time field is genuinely ambiguous, because it can mean 14.3 minutes or 14 minutes 30 seconds. When a wrong guess changes someone's score, asking again is the kinder choice.

A parser that returns seconds or an error

// Returns total seconds, or null if the text is not a valid time.
export function parseTime(text) {
  const t = String(text).trim();

  // Plain seconds: digits only
  if (/^\d+$/.test(t)) return Number(t);

  // mm:ss or h:mm:ss
  const parts = t.split(":");
  if (parts.length < 2 || parts.length > 3) return null;
  if (!parts.every(p => /^\d+$/.test(p))) return null;

  const nums = parts.map(Number);
  const seconds = nums[nums.length - 1];
  const minutes = nums[nums.length - 2];
  const hours = nums.length === 3 ? nums[0] : 0;

  if (seconds > 59) return null;
  if (nums.length === 3 && minutes > 59) return null;

  return hours * 3600 + minutes * 60 + seconds;
}

Three details matter here.

Seconds above 59 are rejected. 14:75 is not "fifteen fifteen". It is a typo, and silently accepting it moves the score by a full minute.

Minutes are only capped when hours are present. 75:00 is a valid way to type a very slow walk, but 1:75:00 is not valid.

The function returns null, not zero. A zero would flow through the scoring code as a fantastic time. Returning null forces the caller to handle the failure.

Compare in integers, display in strings

Once everything is whole seconds, scoring becomes simple integer comparison against a table. This avoids a class of bugs where "9:05" < "10:00" is evaluated as a string comparison and returns the wrong answer, because "9" sorts after "1".

// Rows are sorted from fastest to slowest.
// maxSeconds is the slowest time that still earns the points.
export function scoreRun(seconds, table) {
  for (const row of table) {
    if (seconds <= row.maxSeconds) return row.points;
  }
  return 0;
}

Only convert back to mm:ss when you show a value to the user:

export function formatTime(totalSeconds) {
  const m = Math.floor(totalSeconds / 60);
  const s = totalSeconds % 60;
  return `${m}:${String(s).padStart(2, "0")}`;
}

Ties and boundaries

Scoring tables usually say something like "14:00 or faster". That means the boundary belongs to the better row. If the table's slowest time for 70 points is 14:00, then exactly 840 seconds should earn 70 points, and 841 seconds should earn the next row down.

I write boundary tests for every row, three of them each:

  1. One second faster than the boundary
  2. Exactly the boundary
  3. One second slower than the boundary
import { test } from "node:test";
import assert from "node:assert";

test("boundary is inclusive", () => {
  const table = [
    { maxSeconds: 840, points: 70 },
    { maxSeconds: 900, points: 60 },
  ];
  assert.equal(scoreRun(839, table), 70);
  assert.equal(scoreRun(840, table), 70);
  assert.equal(scoreRun(841, table), 60);
});

The numbers above are illustrative. When you build a real calculator, copy the thresholds from the governing document for the test you are scoring, and cite that document on the page.

Be kind in the error messages

A good error message names the field, shows an example, and does not blame the user:

  • "Enter your run time as minutes and seconds, like 14:30."
  • "Seconds must be between 00 and 59."

I also keep the raw text in the field when validation fails. Clearing the input makes people retype everything, and they notice.

Input masks: use sparingly

Auto-inserting a colon as people type looks polished, but it breaks on mobile keyboards and with paste. If you add a mask, make sure pasting 14:30 and typing 1430 both work, and that backspace behaves. In my experience a plain text input with clear validation beats a clever mask.

Accessibility notes

  • Use a visible label, not placeholder text alone.
  • Set inputmode="numeric" only if you also accept the colon on that keyboard. Otherwise a numeric keypad can make mm:ss impossible to type on some phones.
  • Announce validation errors with aria-live="polite" so screen readers pick them up.

What I would test first

If you only have time for a few tests, write these:

  • parseTime("14:30") equals 870
  • parseTime("870") equals 870
  • parseTime("14:75") is null
  • parseTime("14.30") is null
  • parseTime("") is null
  • Every boundary in the table, three ways

I applied this approach in AFTPTCalc, a free set of military fitness test calculators I maintain: https://aftptcalc.com/ . The scoring tables there are kept as data and checked row by row against the published source documents.

Disclosure: I used an AI writing assistant to help draft and edit this post. The code and examples are my own and the numbers are illustrative.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.