DEV Community

Time and date calculator
Time and date calculator

Posted on

I Built a Date Calculator. Daylight Saving Time Almost Broke It.

Flat illustration of a code editor window next to a clock with hands positioned to suggest a skipped hour, representing a daylight saving time bug in date calculation code<br>

A few months back I built what I thought was a simple weekend project: a calculator that finds the difference between two dates, or adds/subtracts time from a date. Enter some inputs, subtract, done.

It took a lot longer than a weekend, because almost every "obvious" way to do date math in JavaScript has a specific, well-documented way of quietly producing a wrong answer. This post is the actual engineering behind getting it right, not the marketing version, the real algorithms, with the real code.

If you want to see the finished thing before reading how it works: timeanddatecalculator.com

The core trick: stop thinking in calendar units

The first mistake I made, and I'd guess most people make, is trying to do date arithmetic the way you'd explain it to a person: "subtract the months, then the days, borrowing from the previous month if needed." That's how humans think about it, and it's a bad way to write code, because months aren't a fixed unit. February borrows differently than April does.

The fix is to never actually think in calendar units for the underlying math. Convert everything to a single number first (in JavaScript, that's milliseconds since the epoch, which Date objects already store internally), do the arithmetic on that number, and only convert back to years/months/days at the very end, for display.

Here's the actual breakdown function from the calculator:

function calendarBreakdown(p1, p2) {
  var years = p2.y - p1.y, months = p2.m - p1.m, days = p2.d - p1.d;
  var hours = p2.h - p1.h, minutes = p2.mi - p1.mi, seconds = p2.s - p1.s;

  if (seconds < 0) { seconds += 60; minutes--; }
  if (minutes < 0) { minutes += 60; hours--; }
  if (hours < 0) { hours += 24; days--; }
  if (days < 0) {
    var prevMonthLastDay = daysInMonth(p1.y, p1.m);
    days += prevMonthLastDay;
    months--;
  }
  if (months < 0) { months += 12; years--; }

  return { years: years, months: months, days: days, hours: hours, minutes: minutes, seconds: seconds };
}
Enter fullscreen mode Exit fullscreen mode

This is a cascading borrow, same concept as subtracting 47 - 19 by hand when the ones digit doesn't have enough to give. The part that actually matters is daysInMonth, since that's what makes the borrow correct regardless of which month you're borrowing from:

function daysInMonth(y, m) {
  return new Date(Date.UTC(y, m, 0)).getUTCDate();
}
Enter fullscreen mode Exit fullscreen mode

That's a genuinely useful JS trick worth knowing on its own: passing day 0 to Date.UTC for month m gives you the last day of the previous month, m - 1. So daysInMonth(2024, 2) (asking for February, month index 2 in 1-indexed terms here) returns 29, correctly, because 2024 is a leap year, without you writing a single explicit leap-year check. The Date object's own internal calendar logic does it for you.

Adding a month isn't the same as adding 30 days

The reverse operation, add or subtract a duration from a date, has its own trap. Add "1 month" to January 31st and there's no February 31st to land on. Something has to give.

function addMonthsClamped(y, m, d, deltaMonths) {
  var totalMonths = (m - 1) + deltaMonths;
  var newY = y + Math.floor(totalMonths / 12);
  var newM = ((totalMonths % 12) + 12) % 12 + 1;
  var newD = Math.min(d, daysInMonth(newY, newM));
  return { y: newY, m: newM, d: newD };
}
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out here. First, the ((totalMonths % 12) + 12) % 12 pattern is the standard way to handle a modulo that might be negative, JavaScript's % operator returns negative results for negative inputs (unlike Python), so a plain totalMonths % 12 breaks when you're subtracting months across a year boundary. Second, the actual clamping is just Math.min(d, daysInMonth(newY, newM)), once you have the correct target month's length, the fix is a one-liner.

I tested this against a handful of cases that are easy to get subtly wrong:

addMonthsClamped(2026, 1, 31, 1)   // -> Feb 28, 2026 (not a leap year)
addMonthsClamped(2024, 1, 31, 1)   // -> Feb 29, 2024 (leap year)
addMonthsClamped(2024, 2, 29, 12)  // -> Feb 28, 2025 (target year isn't leap)
addMonthsClamped(2026, 12, 31, 1)  // -> Jan 31, 2027 (crosses a year boundary)
Enter fullscreen mode Exit fullscreen mode

All four pass. The leap year handling isn't a special case anywhere in the function, it falls out naturally from daysInMonth doing the right thing.

The part that actually took the most time: daylight saving time

Everything above is solvable with careful arithmetic. DST is different, because it's not a math problem, it's a problem with the mapping between wall-clock time and an actual instant.

Two specific failure modes:

The gap. On the day clocks spring forward, a window of wall-clock time (2:00–2:59 AM in most of the US) never happens. If a user enters 2026-03-08 02:30 in America/New_York, that literal time doesn't exist. A naive implementation will still produce some instant for it, silently, and that instant won't correspond to what the user meant.

The fold. On the day clocks fall back, a window of wall-clock time happens twice. 2026-11-01 01:30 in America/New_York is ambiguous, there are two real UTC instants that both format back to that same local time.

Most date libraries I looked at either ignore this entirely or handle it inconsistently. Here's the resolver I ended up with:

function resolveZonedTime(y, m, d, h, mi, timeZone) {
  var tz = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
  var naiveUTC = Date.UTC(y, m - 1, d, h, mi, 0);
  var DAY = 86400000;

  // Bracket with offsets a day and a half before/after to reliably
  // catch a transition near this date, rather than relying on
  // iterative convergence, which can miss one side of a fold.
  var offsetBefore = getZoneOffsetMinutes(naiveUTC - 1.5 * DAY, tz);
  var offsetAfter = getZoneOffsetMinutes(naiveUTC + 1.5 * DAY, tz);

  if (offsetBefore === offsetAfter) {
    return { instant: new Date(naiveUTC - offsetBefore * 60000), status: "ok" };
  }

  var candBefore = naiveUTC - offsetBefore * 60000;
  var candAfter = naiveUTC - offsetAfter * 60000;
  var okBefore = roundTripsLocalTime(candBefore, tz, y, m, d, h, mi);
  var okAfter = roundTripsLocalTime(candAfter, tz, y, m, d, h, mi);

  if (okBefore && okAfter && candBefore !== candAfter) {
    return { instant: new Date(Math.min(candBefore, candAfter)), status: "ambiguous" };
  }
  if (okBefore) return { instant: new Date(candBefore), status: "ok" };
  if (okAfter) return { instant: new Date(candAfter), status: "ok" };

  return { instant: new Date(Math.max(candBefore, candAfter)), status: "gap" };
}
Enter fullscreen mode Exit fullscreen mode

The key idea: instead of a single fixed-point iteration (guess an offset, apply it, check if it's stable, repeat), which can converge to only one side of a fold and never discover the other, I bracket the date with two offset checks a day and a half on either side. If those two offsets differ, there's a transition somewhere nearby, and I explicitly test both candidate offsets against the original input, rather than trusting whichever one the iteration happened to land on.

roundTripsLocalTime is the actual test: take a candidate UTC instant, format it back into the target time zone, and check whether you get back the exact wall-clock time the user typed.

function roundTripsLocalTime(instantMs, timeZone, y, m, d, h, mi) {
  var parts = zonedParts(new Date(instantMs), timeZone);
  return parts.y === y && parts.m === m && parts.d === d && parts.h === h && parts.mi === mi;
}
Enter fullscreen mode Exit fullscreen mode

If neither candidate round-trips, the input was a gap. If both round-trip to two different real instants, it's a fold, and I pick the earlier one (the first occurrence) as the default, while still flagging it so the UI can tell the user what happened rather than pretending nothing unusual occurred:

"The start time occurs twice because of a daylight saving change; the earlier occurrence was used."
Enter fullscreen mode Exit fullscreen mode

That message only appears when the ambiguity is real. Silent wrong answers are worse than a visible caveat.

Testing this properly

Casual testing won't catch any of this, you have to specifically pick dates that land on a transition. I ended up building a small standalone verification pass using Node's own Intl.DateTimeFormat, which has full IANA time zone data, and checked the resolver against known transition dates in both hemispheres (US/EU spring-forward is in March, Australia's is in October, since DST timing flips by hemisphere):

resolveZonedTime(2026, 3, 8, 2, 30, "America/New_York").status   // "gap"
resolveZonedTime(2026, 11, 1, 1, 30, "America/New_York").status  // "ambiguous"
resolveZonedTime(2026, 10, 4, 2, 30, "Australia/Sydney").status  // "gap"
Enter fullscreen mode Exit fullscreen mode

All three come back correctly classified. That's the actual bar for "this works," not "it looked right when I tried one example."

Where this ended up

Here's a two-minute walkthrough of the finished tool, showing all three modes:

If you're building anything that touches dates and time zones, my actual takeaway isn't "use my calculator," it's this: convert to a single numeric scale before doing any arithmetic, and treat DST as a distinct problem from calendar math, not a variant of it. The two failure modes (gap and fold) are well-defined and testable once you know to look for them specifically, they just don't show up unless you go looking.

The live tool, if you want to poke at it or find a case it still gets wrong: timeanddatecalculator.com

Top comments (0)