DEV Community

Cover image for Time Math Is Harder Than It Looks: 6 Duration Bugs and How to Avoid Them
Anh Quân Nguyễn
Anh Quân Nguyễn

Posted on

Time Math Is Harder Than It Looks: 6 Duration Bugs and How to Avoid Them

Adding two durations sounds like first-grade math. 2:45 plus 1:30 — easy, right? Then a ticket comes in: a user logged 11:45 PM → 7:15 AM and your timesheet says they worked negative 16 hours. Welcome to time arithmetic, where the obvious answer is usually wrong.

Here are six duration bugs I keep seeing in code reviews, and the mental model that makes them go away.

1. Minutes are base-60, not base-100

The number-one duration bug is treating 1:30 as the decimal 1.30. It isn't — it's 1.5 hours. If you store time as HH:MM strings and do arithmetic on the pieces without normalizing, you get garbage.

// WRONG: treats minutes like a decimal fraction
const hours = 2.45 + 1.30; // 3.75 ❌ (you meant 2h45m + 1h30m)

// RIGHT: normalize to a single base unit first
const toMinutes = (h, m) => h * 60 + m;
const total = toMinutes(2, 45) + toMinutes(1, 30); // 255 minutes
const fmt = `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; // "4:15" ✅
Enter fullscreen mode Exit fullscreen mode

Rule: convert everything to one base unit (seconds or minutes), do the math, convert back at the end. When I just need to confirm a result by hand before trusting my code, I'll punch the two values into a time calculator and check the output matches — faster than adding a console.log and re-running.

2. Crossing midnight makes durations go negative

The 11:45 PM → 7:15 AM case. If end < start, the interval wrapped past midnight. Naive subtraction gives a negative number.

let diff = endMin - startMin;
if (diff < 0) diff += 24 * 60; // add a full day to unwrap
Enter fullscreen mode Exit fullscreen mode

This bites overnight shifts, sleep trackers, and anything spanning a day boundary. The fix is one line, but only if you remember the case exists.

3. 12-hour vs 24-hour parsing

12:00 PM is noon. 12:00 AM is midnight. Almost every hand-rolled AM/PM parser gets the 12 case backwards because the conversion isn't +12 for PM — it's special-cased at 12.

function to24(h, period) {
  if (period === "AM") return h === 12 ? 0 : h;
  return h === 12 ? 12 : h + 12;
}
Enter fullscreen mode Exit fullscreen mode

If your product serves both US (12h) and most of Europe (24h), normalize on input and store 24h internally. Display formatting is a presentation concern — keep it out of your math layer.

4. DST means a "day" isn't always 24 hours

Twice a year, a calendar day is 23 or 25 hours long. If you compute durations by subtracting wall-clock times across a DST boundary, you'll be off by an hour. This is why you never do duration math on local timestamps — convert to UTC (or epoch seconds) first, subtract, then format back to local for display.

const start = new Date("2026-03-08T01:30:00-05:00"); // before US spring-forward
const end   = new Date("2026-03-08T03:30:00-04:00"); // after
const hours = (end - start) / 3.6e6; // 1 hour, not 2 — DST ate an hour
Enter fullscreen mode Exit fullscreen mode

For calendar-level differences (business days, age, date spans) the same UTC-first principle applies — our date calculator handles that side, working in whole days rather than clock time.

5. Epoch math is your friend — until you mix units

Date.now() returns milliseconds. Unix time() in most backends returns seconds. Postgres EXTRACT(EPOCH ...) returns seconds (as a float). Mix them and you're off by 1000×.

const ms = Date.now();            // 1780736400000
const sec = Math.floor(ms / 1000); // 1780736400
Enter fullscreen mode Exit fullscreen mode

When I'm debugging a raw epoch value and need to see it as a human time, I keep a unix timestamp converter open rather than mentally dividing by 1000 and squinting.

6. Decimal hours for payroll rounding

Payroll systems want decimal hours (8.25), not 8:15. The conversion is minutes / 60, but rounding policy matters: some jurisdictions round to the nearest quarter-hour, some truncate. Decide the policy explicitly — don't let toFixed(2) make it for you.

const decimal = totalMinutes / 60;        // 8.25
const quarterRounded = Math.round(decimal * 4) / 4; // nearest 0.25
Enter fullscreen mode Exit fullscreen mode

This is exactly the conversion a time calculator does when it shows both HH:MM and decimal output — handy when you're reconciling a timesheet against what your code produced.


The one mental model

Every one of these bugs comes from doing arithmetic in a representation that isn't additive. Wall-clock HH:MM strings aren't additive (base-60, midnight wrap, DST). Local timestamps aren't additive (DST, timezones). Seconds-since-epoch are additive. So:

  1. Parse input into a single base unit (epoch seconds, or total minutes for clock durations).
  2. Do all math there.
  3. Format back to human representation only at the very end.

Get that pipeline right and time math stops surprising you.

Top comments (0)