2026-08-23
This function shows how many days until a task is due. A user in Los Angeles (PDT, UTC-7) types tomorrow's date into the form.
function daysUntil(dueDateStr) {
const due = new Date(dueDateStr);
const today = new Date();
const MS_PER_DAY = 86_400_000;
return Math.ceil((due - today) / MS_PER_DAY);
}
// It's the afternoon of 2026-08-23 in LA. User picks tomorrow:
console.log(daysUntil('2026-08-24')); // prints 0 — "due today"?!
// Bizarrely, the "wrong" format works:
console.log(daysUntil('2026/08/24')); // prints 1 — correct
// And the unit test that passed at 10:00 UTC fails at 20:00 UTC.
// CI is green in London and red in San Francisco.
The ECMAScript spec pins two contradictory rules onto new Date(string):
YYYY-MM-DD) is parsed as UTC midnight.So for a Los Angeles user, new Date('2026-08-24') resolves to 2026-08-23T17:00:00 local time. At 3pm on Aug 23, "tomorrow" is only two hours away — (due - today) / MS_PER_DAY is roughly 0.08, and Math.ceil rounds it to 1… except when today is already past 5pm local, at which point the difference goes negative and Math.ceil returns 0. The bug is time-of-day sensitive and timezone sensitive, which is why it slips through every test the developer runs on their laptop and lights up only in customer bug reports from the West Coast.
The '2026/08/24' version "works" because slashes force local-time parsing, so both endpoints share the same zone. It's the same bug in a lucky disguise.
Never let the Date constructor guess. Parse the components yourself and use the multi-argument form, which is always local time. Normalize both endpoints to local midnight so DST hour shifts don't leave you with a 23h or 25h diff:
function daysUntil(dueDateStr) {
const [y, m, d] = dueDateStr.split('-').map(Number);
const due = new Date(y, m - 1, d); // local midnight
const today = new Date();
today.setHours(0, 0, 0, 0); // local midnight today
const MS_PER_DAY = 86_400_000;
return Math.round((due - today) / MS_PER_DAY);
}
Three things to notice: m - 1 because JavaScript months are zero-indexed (another trap for another day); setHours(0,0,0,0) to strip the wall-clock time from "now"; and Math.round instead of ceil or floor, because on DST transition days the elapsed milliseconds between "local midnight today" and "local midnight tomorrow" is not 86,400,000 — it's 82,800,000 or 90,000,000. Rounding absorbs that hour; ceiling and floor turn it into an off-by-one.
If you're serving a global audience and the due date is meant to be a specific instant (not "midnight in the user's zone"), do the opposite: force both endpoints to UTC, and require the user's client to convert on display. The rule is not "local is right" or "UTC is right" — it is "the two sides of the subtraction must agree."
new Date('YYYY-MM-DD') is UTC; new Date(y, m-1, d) is local — mixing the two silently shifts every date by your timezone offset.
