DEV Community

SEN LLC
SEN LLC

Posted on

A Countdown Tracker That Handles DST, Timezones, and Calendar-Day Math Correctly

A Countdown Tracker That Handles DST, Timezones, and Calendar-Day Math Correctly

"Days between two dates" sounds trivial, but (to - from) / 86400000 gives wrong answers around DST transitions and can even be off by a day depending on local timezone vs UTC. The correct approach: convert both dates to UTC midnight, subtract, divide. That way a "day" is always 24 hours and the answer is in calendar days regardless of when the user's clocks shift.

Countdown timers are everywhere — wedding sites, exam apps, event pages. Most of them get date math subtly wrong. Twice a year, "10 days until the event" briefly shows as "9 days 23 hours" because DST moved the clock forward. The fix requires knowing that JavaScript Dates are wall-clock local time but arithmetic should be done in UTC.

🔗 Live demo: https://sen.ltd/portfolio/countdown-days/
📦 GitHub: https://github.com/sen-ltd/countdown-days

Screenshot

Features:

  • Multiple events tracked simultaneously
  • Counts down to future, counts up from past
  • Live updates with h/m/s
  • 10 presets (Japanese holidays, birthday, exam, wedding, etc.)
  • URL sharing
  • localStorage persistence
  • Japanese / English UI
  • Zero dependencies, 42 tests

The DST-proof calendar day subtraction

export function getDaysBetween(from, to) {
  const fromUTC = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
  const toUTC = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
  return Math.round((toUTC - fromUTC) / (1000 * 60 * 60 * 24));
}
Enter fullscreen mode Exit fullscreen mode

The trick: Date.UTC(year, month, day) returns the timestamp for midnight UTC on that date, ignoring the local timezone. Subtracting two UTC midnights gives you an integer multiple of 86400000 — exactly N calendar days, regardless of DST transitions.

Without this, if your local timezone is going from summer (UTC+9) to winter (UTC+8) on March 27, new Date('2026-03-28') - new Date('2026-03-26') gives 172800000 + 3600000 = 176400000 ms. Divided by 86400000 = 2.04 days. Math.round fixes it in this case, but edge cases with times other than midnight fail.

The Math.round at the end handles any residual float noise. UTC-based arithmetic should give exactly integer days, but the round provides safety.

Live updating with requestAnimationFrame

The countdown shows hours, minutes, seconds. Updating once a second with setInterval looks jerky because each update arrives at a slightly different offset:

function tick() {
  updateAllCards();
  // Align to the next whole second
  const msUntilNext = 1000 - (Date.now() % 1000);
  setTimeout(tick, msUntilNext);
}
Enter fullscreen mode Exit fullscreen mode

The msUntilNext calculation ensures each update happens at a clean second boundary. The displayed seconds tick evenly instead of drifting.

isPast detection

export function getTimeUntil(date, now = new Date()) {
  const diff = date.getTime() - now.getTime();
  const isPast = diff < 0;
  const absDiff = Math.abs(diff);
  return {
    days: Math.floor(absDiff / (1000 * 60 * 60 * 24)),
    hours: Math.floor((absDiff / (1000 * 60 * 60)) % 24),
    minutes: Math.floor((absDiff / (1000 * 60)) % 60),
    seconds: Math.floor((absDiff / 1000) % 60),
    total: absDiff,
    isPast,
  };
}
Enter fullscreen mode Exit fullscreen mode

Note < 0 not <= 0. Exactly 0 is "now" — not past, not future. The UI treats past events with "days since" prefix and future events with "days until". At the transition moment it briefly shows 0 and flips.

Sharing via URL

Each event can be shared as a URL with query params:

?name=Birthday&date=2026-06-15&color=%23ff6b6b&icon=🎂
Enter fullscreen mode Exit fullscreen mode

encodeURIComponent on each value so special characters (including the # in colors and emoji) survive the URL:

function buildShareURL(event) {
  const params = new URLSearchParams({
    name: event.name,
    date: event.date,
    color: event.color || '',
    icon: event.icon || '',
  });
  return `${location.origin}${location.pathname}?${params}`;
}
Enter fullscreen mode Exit fullscreen mode

When the page loads with these params, it auto-adds the event. Easy sharing without any backend.

Presets

Common countdown targets are baked in:

  • New Year (dynamic next Jan 1)
  • Golden Week (Apr 29)
  • O-bon (Aug 15)
  • Christmas (Dec 25)
  • Valentine's Day, White Day
  • Summer solstice, winter solstice
  • "My birthday" template
  • Exam date template

Click a preset and the form populates. Users can still edit before saving.

Series

This is entry #93 in my 100+ public portfolio series.

Top comments (0)