DEV Community

MAHER IKRAMAH
MAHER IKRAMAH

Posted on

Building a Work-Hours Calculator? Time Arithmetic Is the Trap

If you have ever built a timesheet tool, a shift planner, or a payroll helper, you know the
feature looks trivial until you write the first test. Start time minus end time, minus a
break, times an hourly rate. What could go wrong? Plenty — and almost all of it comes from
treating time like ordinary decimal numbers.

This article walks through the four traps that bite every work-hours calculator, with the
validation cases I use to check them. The examples are in JavaScript, but the lessons apply
in any language.

1. Use minutes since midnight, not decimals

The first mistake is storing times as decimals: 9:30 as 9.5, 5:45 PM as 17.75. It works
until someone types 9:60, or until floating-point arithmetic turns 7.5 hours into
7.4999999 and your pay figure comes out a cent short.

Convert every time to minutes since midnight as soon as it enters your code:

function toMinutes(time) {
  const [hours, minutes] = time.split(":").map(Number);
  return hours * 60 + minutes;
}
Enter fullscreen mode Exit fullscreen mode

Now 09:00 is 540, 17:00 is 1020, and the shift length is 1020 - 540 = 480
minutes. All of your arithmetic is integer arithmetic until the very last step, when you
convert back to decimal hours for display (480 / 60 = 8.00). Integers don't have
rounding surprises; decimals do.

The same rule applies to breaks. Store a break as minutes, not as 0.5 hours. A 30-minute
lunch is 30, and it stays 30 through every calculation.

2. Subtract breaks before rounding, never after

This is the bug that costs real money. Suppose a shift runs 9:00 AM to 5:00 PM with a
30-minute break. Gross time is 8 hours; net time is 7.5 hours. At $20/hour, net pay is
$150.00.

Now suppose a developer rounds each piece first — shift length to the nearest hour, break
to the nearest hour — and then subtracts. The 30-minute break rounds to 1 hour or 0 hours
depending on the rule, and the result is off by half an hour. Multiply that by every shift
in a pay period and you have a payroll correction waiting to happen.

The rule: do all subtraction in minutes, and round only once, at the very end, for display.

function netMinutes(start, end, breakMinutes) {
  return toMinutes(end) - toMinutes(start) - breakMinutes;
}

// 9:00–17:00, 30-minute break
netMinutes("09:00", "17:00", 30); // 450 minutes = 7.50 hours
Enter fullscreen mode Exit fullscreen mode

Display rounding is a presentation choice. Calculation rounding is a correctness choice.
Keep them separate, and keep the calculation in whole minutes until the final division.

3. Overnight shifts need modular arithmetic

A shift from 10:00 PM to 6:00 AM breaks naive subtraction: 360 - 1320 = -960 minutes.
The fix is modular arithmetic — wrap the difference around a 24-hour day:

function shiftMinutes(start, end) {
  const diff = toMinutes(end) - toMinutes(start);
  return ((diff % 1440) + 1440) % 1440; // 1440 = minutes in a day
}

shiftMinutes("22:00", "06:00"); // 480 minutes = 8.00 hours
Enter fullscreen mode Exit fullscreen mode

Note the edge case this creates: a start and end time that are equal now means a zero-length
shift, not a 24-hour one. That is the safer default — a 24-hour shift should be entered
explicitly, not inferred from identical times. And there is a second edge case worth
deciding deliberately: which day does an overnight shift belong to? The day it starts, or
the day it ends? Pick one, document it, and apply it consistently, because payroll
periods and overtime thresholds depend on the answer.

4. Validate with concrete cases, not vibes

A calculator with no test cases is a guess. These are the cases I run before trusting any
work-hours tool, and they cover every trap above:

Case Expected result
9:00 AM–5:00 PM, 30-minute break 7.50 hours
9:00 AM–5:00 PM, 60-minute break 7.00 hours
9:00 AM–5:00 PM, 30-minute break, $20/hour $150.00
Bi-weekly: 10 weekdays × 7.5 hours 75.00 hours / $1,500 at $20/hour
10:00 PM–6:00 AM overnight shift 8.00 hours, not a negative number

The bi-weekly case matters because it catches a different class of bug: totals computed
from rounded daily values instead of exact minutes. If each day is rounded before summing,
ten days of 7.5 hours can drift. Sum the minutes first, convert once at the end, and 75.00
comes out exactly.

If you want a live example of this kind of tool to poke at, the free browser-based
calculator at workhours-calculator.com runs the same arithmetic with no signup — useful as
a reference for what the correct outputs look like.

5. Test the on-load state — it is a trust issue, not a cosmetic one

Here is the trap most developers never think to test: what does the calculator show before
the user touches anything?

A calculator that loads with a headline result of 7.50 hours but a breakdown section full
of zeroes has a bug, and every user who sees it will conclude the tool is broken — even if
the math is perfect once they press "calculate." The same goes for the reverse: a
breakdown that shows values while the headline shows nothing.

The on-load state is a first impression, and for a tool that handles people's pay, first
impressions are trust. Test it explicitly: load the page, change nothing, and check that
every displayed number is consistent with every other one. Then change one input and check
again. If your calculator computes on input change, the initial render must go through the
exact same code path as a recalculation — not a separate, hand-written default.

The checklist

  1. Convert times to minutes since midnight on input; do all math in integers.
  2. Subtract breaks in minutes, before any rounding; round once, for display only.
  3. Use modular arithmetic for overnight shifts, and decide which day they belong to.
  4. Validate against concrete cases: 7.50, 7.00, 75.00, and an overnight shift that is not negative.
  5. Test the on-load state as rigorously as the calculations themselves.

Get those five right and your calculator will be boring in the best possible way: it will
just give the right answer, every time.

[Author bio — to be confirmed]

Top comments (0)