A Countdown Tracker That Handles DST, Timezones, and Calendar-Day Math Correctly
"Days between two dates" sounds trivial, but
(to - from) / 86400000gives 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
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));
}
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);
}
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,
};
}
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=🎂
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}`;
}
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.
- 📦 Repo: https://github.com/sen-ltd/countdown-days
- 🌐 Live: https://sen.ltd/portfolio/countdown-days/
- 🏢 Company: https://sen.ltd/

Top comments (0)