Date-only values & daylight saving time
- 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 localsetDate(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.UTCrolls it to March 2, so round-trip the parts - Write
addDays,daysBetweenandisOverduehelpers that give the same answers in every time zone
Read
A birthday is not a moment in time
FlagSome 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.
Date-only arithmetic in UTC, line by line
FlagA 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, notnew Date(y, m, d). The constructor builds local midnight;Date.UTCbuilds UTC midnight and returns a number.days * DAY_MSis 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.daysBetweenneeds no rounding. Both values are UTC midnights, so the difference is an exact multiple ofDAY_MS. With New York local midnights, March 7 to March 9 spans the 23-hour March 8: 47 hours, andMath.floor(47 / 24)is 1.toISOString().slice(0, 10)to read back.getDate()andgetMonth()would read the machine's zone and could hand back the previous day.getUTCDay()for the weekday. Same reason asgetUTCDate().
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.
The prefix trap, Feb 30, and times that never happen
FlagA 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?
FlagWhich way of adding one day to the date-only value "2026-11-01" gives the same answer on every machine?
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"?
Which check rejects "2026-02-30"?
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?
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.)
Fill in the blank
Turn a date-only value into UTC midnight of that day, in epoch ms.
Flagconst [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.
Flagconst 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.
Flagconst 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).
Flagconst 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());
// 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));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), );
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),
);Challenge
Date-only helpers that work in every time zone
FlagA 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:
isValidDateOnlychecks the shape with a regex, so"2026-02-30"is "valid".addDays/daysBetweenbuild 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 YorkaddDays("2026-11-01", 1)returns"2026-11-01"anddaysBetween("2026-03-07", "2026-03-09")returns1. They also take"2026-02-30"without complaint.isOverduecompares the due date withnow.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 ignorestimeZone, 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:
isValidDateOnlyaccepts only real calendar dates:"2028-02-29"passes;"2026-02-30","2026-02-29"and"2026-13-01"failaddDaysanddaysBetweenthrow aRangeErrorfor a date that doesn't exist, instead of rolling it overaddDayscrosses month ends, year ends, leap days and daylight saving time changes:addDays("2026-11-01", 1)is"2026-11-02"daysBetweencounts calendar days, across daylight saving time changes too:daysBetween("2026-03-07", "2026-03-09")is2addDaysanddaysBetweennever read the machine's time zone (the grader runs them with local-timeDateAPIs disabled)isOverdueisfalseduring the due date itself andtruefrom the next dayisOverduedecides what day it is in the user'stimeZone: at 02:00 UTC on March 11, a March 10 task is overdue in Tokyo but not in New York