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" ✅
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
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;
}
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
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
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
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:
- Parse input into a single base unit (epoch seconds, or total minutes for clock durations).
- Do all math there.
- Format back to human representation only at the very end.
Get that pipeline right and time math stops surprising you.
Top comments (0)