Dates & Time
Dates & Time

Date-only values & daylight saving time

Full · ~45 min
After this unit you can
  • Choose a date-only "YYYY-MM-DD" value, not an instant, for birthdays, due dates and holidays, and bring in a zone only to answer "what is today?"
  • Predict what + 86_400_000, a local setDate(d + 1) and UTC-day arithmetic give across a daylight saving time change (New York's 23-hour March 8 and 25-hour November 1, 2026)
  • Spot the prefix trap when a date-only string is compared with an ISO instant string: "2026-03-10" < "2026-03-10T…" is always true
  • Validate a date-only string as a real calendar date: the regex accepts 2026-02-30, Date.UTC rolls it to March 2, so round-trip the parts
  • Write addDays, daysBetween and isOverdue helpers that give the same answers in every time zone

Read

Simple
1 / 2

A birthday is not a moment in time

Flag

Some values are instants: an order was placed, a message was sent. Others are calendar dates: a birthday, a due date, a public holiday, a hotel check-in day. A birthday on July 4 is July 4 in Tokyo and in New York. It has no time and no zone.

Turn a calendar date into an instant and it starts moving:

const birthday = new Date("1990-07-04"); // an instant: midnight UTC
birthday.toLocaleDateString("en-US");    // in New York: "7/3/1990"

Midnight UTC is the evening of July 3 anywhere west of UTC, so half the world sees the wrong birthday. Nothing in that code looks wrong, which is why this bug ships.

The fix is to keep calendar dates as date-only values, the string "YYYY-MM-DD", end to end:

  • in JSON: { "birthday": "1990-07-04" }, not "1990-07-04T00:00:00.000Z";
  • in the database: a date type without a time;
  • in code: as the string, or as UTC midnight used only with UTC methods (next example).

To display one, either print the parts yourself, or format UTC midnight in UTC, so no zone can shift it:

new Date("1990-07-04").toLocaleDateString("en-US", { timeZone: "UTC", dateStyle: "long" });
// "July 4, 1990" on every machine

There is exactly one place where a calendar date needs a zone: "what is today?" Today is March 10 in New York while it's already March 11 in Tokyo. So "is this task overdue?" compares the due date with today in the user's zone, never with the server's clock.

Medium

Date-only arithmetic in UTC, line by line

Flag

A small set of helpers that treat "YYYY-MM-DD" as UTC midnight of that day. Every function takes and returns date-only strings, so callers never hold a Date they could read with a local getter.

const DAY_MS = 86_400_000;

/** "2026-03-10" → epoch ms of 2026-03-10T00:00:00Z. */
function toUtcMidnight(day: string): number {
  const [year, month, dayOfMonth] = day.split("-").map(Number);
  return Date.UTC(year, month - 1, dayOfMonth); // month is 0-indexed here too
}

/** epoch ms of a UTC midnight → "YYYY-MM-DD". toISOString is always UTC. */
const fromUtcMidnight = (ms: number): string => new Date(ms).toISOString().slice(0, 10);

export const addDays = (day: string, days: number): string =>
  fromUtcMidnight(toUtcMidnight(day) + days * DAY_MS);

export const daysBetween = (from: string, to: string): number =>
  (toUtcMidnight(to) - toUtcMidnight(from)) / DAY_MS;

/** 0 = Sunday … 6 = Saturday, the same on every machine. */
export const weekday = (day: string): number => new Date(toUtcMidnight(day)).getUTCDay();

Why each piece is the way it is:

  • Date.UTC, not new Date(y, m, d). The constructor builds local midnight; Date.UTC builds UTC midnight and returns a number.
  • days * DAY_MS is safe here, and only here. It's wrong for local times because a local day can be 23 or 25 hours. In UTC it's always 24.
  • daysBetween needs no rounding. Both values are UTC midnights, so the difference is an exact multiple of DAY_MS. With New York local midnights, March 7 to March 9 spans the 23-hour March 8: 47 hours, and Math.floor(47 / 24) is 1.
  • toISOString().slice(0, 10) to read back. getDate() and getMonth() would read the machine's zone and could hand back the previous day.
  • getUTCDay() for the weekday. Same reason as getUTCDate().

Overflow still works for you: Date.UTC(2026, 11, 32) is January 1, 2027, so month and year ends need no special cases.

What these helpers don't do yet: reject "2026-02-30" (Date.UTC rolls it into March), or answer "is it overdue?", which needs today's date in the user's zone. Both are in the next example.

Complex

The prefix trap, Feb 30, and times that never happen

Flag

A date-only string compared with an ISO instant

Strings compare character by character, and a string that is a prefix of another sorts first:

"2026-03-10" < "2026-03-10T00:00:00.000Z";  // true
"2026-03-10" >= "2026-03-10T05:00:00.000Z"; // false: ">=" drops its own day

So tasks.filter((t) => t.due >= new Date().toISOString()), meant as "due today or later", silently drops everything due today, and due < now.toISOString() marks every task overdue on its own due date. The same bug happens in a database query that compares a text date column with an ISO timestamp. Compare like with like: two date-only strings (which do sort chronologically), or two instants. For "overdue", that means today's date in the user's zone:

const overdue = dateInZone(new Date(), user.timeZone) > task.due; // both "YYYY-MM-DD"

dateInZone is the formatToParts helper from the parsing unit. Slicing toISOString() instead gives today in UTC, which for a user in Los Angeles is already tomorrow from 5 p.m. (4 p.m. in winter).

A regex isn't a calendar

/^\d{4}-\d{2}-\d{2}$/ checks the shape. It accepts "2026-02-30", and so does every step after it: Date.UTC(2026, 1, 30) rolls over to March 2, and V8's new Date("2026-02-30") does the same. Build the date, then check that what you asked for is what you got:

const [y, m, d] = value.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
const real =
  date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;

Reject at the boundary with a RangeError, so "2026-02-30" can't become a due date of March 2 that nobody entered.

Local times that never happen, or happen twice

On a spring-forward day, local times in the gap don't exist; on a fall-back day, an hour happens twice. Date doesn't fail on either. The spec reads a skipped time with the offset from before the change, and picks the earlier of two repeated times:

// machine in America/New_York
new Date(2026, 2, 8, 2, 30);  // 03:30 EDT: 2:30 a.m. didn't happen on March 8
new Date(2026, 10, 1, 1, 30); // 01:30 EDT, the first of the two 1:30s (05:30 UTC)

A reminder scheduled for "2:30 a.m. local" quietly fires at 3:30 once a year. Store the wall time and zone, and decide explicitly what a skipped or repeated time should mean.

Quiz

A user's birthday is July 4, 1990. What should the API send?

Flag

Which way of adding one day to the date-only value "2026-11-01" gives the same answer on every machine?

Flag

tasks.filter((t) => t.due >= new Date().toISOString()) should list tasks due today or later. On March 10, what happens to a task with due: "2026-03-10"?

Flag

Which check rejects "2026-02-30"?

Flag

A server in UTC marks tasks overdue with task.due < new Date().toISOString().slice(0, 10). A user in Los Angeles (UTC−7 in March) has a task due March 10. At 6 p.m. on March 10 in Los Angeles, what do they see?

Flag

On a machine in New York, what is new Date(2026, 2, 8, 2, 30)? (Clocks jump from 2:00 to 3:00 a.m. that day.)

Flag

Fill in the blank

Turn a date-only value into UTC midnight of that day, in epoch ms.

Flag
const [y, m, d] = "2026-03-10".split("-").map(Number);
const ms = Date.(y, m - , d);

Add a day and read the result back as a date-only string, without touching the machine's zone.

Flag
const next = new Date(ms + 86_400_000).().slice(0, ); // "2026-03-11"

Finish the calendar check: the date must come back out exactly as it went in.

Flag
const date = new Date(Date.UTC(y, m - 1, d));
const real = date.() === y && date.getUTCMonth() === m - 1 && date.() === d;

The weekday of a date-only value, the same on every machine (0 = Sunday).

Flag
const weekday = new Date(Date.UTC(2026, 2, 10)).(); // 2: Tuesday

Predict the output

// The machine is in America/New_York. Clocks go back at 2 a.m. on November 1, 2026.
const a = new Date(2026, 10, 1); // local midnight, November 1
const b = new Date(2026, 10, 1);
a.setTime(a.getTime() + 86_400_000);
b.setDate(b.getDate() + 1);
console.log(a.getDate(), a.getHours(), b.getDate(), b.getHours());
Flag
// The machine is in America/New_York. Clocks go forward on March 8, 2026.
const ms = new Date("2026-03-09T00:00") - new Date("2026-03-07T00:00");
console.log(ms / 3_600_000, Math.floor(ms / 86_400_000));
Flag
const due = "2026-03-10";
console.log(
  due < "2026-03-10T00:00:00.000Z",
  due >= "2026-03-10T23:59:59.999Z",
  due === "2026-03-10T00:00:00.000Z".slice(0, 10),
);
Flag
const value = "2026-02-30";
const [y, m, d] = value.split("-").map(Number);
console.log(
  /^\d{4}-\d{2}-\d{2}$/.test(value),
  new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10),
);
Flag

Challenge

Date-only helpers that work in every time zone

Flag

A task tracker stores due dates as calendar dates, "YYYY-MM-DD": "due 2026-03-10" means March 10 for the user, wherever they are. due-dates.ts has the four helpers the app uses. They pass a quick look, and the bugs only show up on certain dates, in certain time zones, or for input nobody tried:

  • isValidDateOnly checks the shape with a regex, so "2026-02-30" is "valid".
  • addDays / daysBetween build local midnight (new Date("2026-03-10T00:00")) and add or divide by 24 hours. On a daylight saving time change a local day is 23 or 25 hours long, so in New York addDays("2026-11-01", 1) returns "2026-11-01" and daysBetween("2026-03-07", "2026-03-09") returns 1. They also take "2026-02-30" without complaint.
  • isOverdue compares the due date with now.toISOString() as strings. "2026-03-10" is a prefix of "2026-03-10T13:00:00.000Z", so it always sorts first: every task is overdue on its own due date. It also ignores timeZone, but whether March 10 is over depends on whose calendar you ask.

How the grader tests time zones. A browser tab can't change its time zone, and in UTC, where CI usually runs, local-time code gives the same answers as UTC code. So the DST tests only fail if your machine's zone changes its clocks on one of the tested dates. To catch local-time arithmetic everywhere, two tests run addDays and daysBetween inside withoutLocalTime (local-time-trap.ts), which makes every local-time Date API throw for the duration of the call, including in any helper they call (such as your isValidDateOnly): getDate(), getMonth(), setDate(), new Date(y, m, d), new Date("2026-03-10T00:00") and the like. UTC APIs keep working. That's deliberately stricter than "right on this machine": a local-time version can only be checked one zone at a time, while UTC arithmetic is the same computation on every machine. isOverdue takes the zone as an argument, so its tests are the same everywhere.

Your task

Edit due-dates.ts so the tests pass:

  • isValidDateOnly accepts only real calendar dates: "2028-02-29" passes; "2026-02-30", "2026-02-29" and "2026-13-01" fail
  • addDays and daysBetween throw a RangeError for a date that doesn't exist, instead of rolling it over
  • addDays crosses month ends, year ends, leap days and daylight saving time changes: addDays("2026-11-01", 1) is "2026-11-02"
  • daysBetween counts calendar days, across daylight saving time changes too: daysBetween("2026-03-07", "2026-03-09") is 2
  • addDays and daysBetween never read the machine's time zone (the grader runs them with local-time Date APIs disabled)
  • isOverdue is false during the due date itself and true from the next day
  • isOverdue decides what day it is in the user's timeZone: at 02:00 UTC on March 11, a March 10 task is overdue in Tokyo but not in New York

Loading the in-browser editor…

Further reading