JavaScript: Date, 1–40
Reading, formatting and comparing dates without a library.
Exercises 1 to 40 of 57
1 Build a date from year, month and day
easyMake a Date for 15 January 2026 from its parts, then read the parts back out. Watch the month.
Stuck? Three hints, smallest first
- 1.
new Date(year, month, day)takes numbers, and the month is not the one you would write down. - 2. January is
0and December is11, while the day of the month still starts at1. - 3. If a 1-to-12 month is what your callers have, wrap the constructor rather than making them subtract.
// The month is ZERO-BASED. January is 0, December is 11.
const jan15 = new Date(2026, 0, 15); // 15 January 2026, local midnight
const dec25 = new Date(2026, 11, 25); // 25 December 2026
// A wrapper that takes the month the way a human writes it, 1 to 12.
function makeDate(year, humanMonth, day) {
return new Date(year, humanMonth - 1, day);
}
console.log('parts of 15/01/2026 ->', jan15.getFullYear(), jan15.getMonth(), jan15.getDate());
console.log('as a string ->', jan15.toDateString());
// The live clock, shown and never asserted: it is different every time you run.
console.log('right now ->', new Date().toDateString());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the year reads back as written', jan15.getFullYear(), 2026);
test('January is month 0, not 1', jan15.getMonth(), 0);
test('but the day of the month starts at 1', jan15.getDate(), 15);
test('December is month 11', dec25.getMonth(), 11);
test('an omitted time means local midnight', [jan15.getHours(), jan15.getMinutes(), jan15.getSeconds()], [0, 0, 0]);
test('the 1-to-12 wrapper agrees', makeDate(2026, 1, 15).getTime(), jan15.getTime());
test('and reads 12 as December', makeDate(2026, 12, 25).getTime(), dec25.getTime());
test('so passing 1 straight in really means February', new Date(2026, 1, 15).getMonth(), 1);
The zero-based month is the single most common date bug in JavaScript, and it is asymmetric: the month counts from 0 while the day counts from 1 and the year is the real year. Nothing warns you, because new Date(2026, 1, 15) is a perfectly valid 15 February.
Two habits remove the problem. Convert at the boundary, as makeDate does, so the off-by-one lives in one line instead of scattered through the code. And build from parts rather than from a string when you mean a local date — new Date(2026, 0, 15) is local midnight in whatever zone the machine is in, which is usually what a calendar date means.
Answers JS Date 1 in the original set.
2 Read every field off a date
easyGiven a date, collect its year, month, day, weekday, hours, minutes, seconds and milliseconds into one object.
Stuck? Three hints, smallest first
- 1. There is one getter per field, and their names are not quite consistent:
getFullYear,getMonth,getDate,getDay. - 2.
getDateis the day of the month;getDayis the day of the week. - 3.
getDayreturns 0 for Sunday, so it is zero-based like the month.
// 9 March 2026 at 14:30:45.250, local.
const stamp = new Date(2026, 2, 9, 14, 30, 45, 250);
function fields(date) {
return {
year: date.getFullYear(),
month: date.getMonth(), // 0 to 11
day: date.getDate(), // 1 to 31, the day of the MONTH
weekday: date.getDay(), // 0 to 6, the day of the WEEK, 0 = Sunday
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
ms: date.getMilliseconds(),
};
}
console.log(fields(stamp));
console.log('the live clock, never asserted:', fields(new Date()).year);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('every field, in one object', fields(stamp), { year: 2026, month: 2, day: 9, weekday: 1, hours: 14, minutes: 30, seconds: 45, ms: 250 });
test('March is month 2', fields(stamp).month, 2);
test('9 March 2026 is a Monday, so getDay is 1', fields(stamp).weekday, 1);
test('getDate is the day of the month', stamp.getDate(), 9);
test('the two are easy to confuse and rarely equal', stamp.getDate() === stamp.getDay(), false);
test('milliseconds survive', stamp.getMilliseconds(), 250);
test('an omitted time reads as zero', fields(new Date(2026, 2, 9)).hours, 0);
test('eight fields in all', Object.keys(fields(stamp)).length, 8);
getDate and getDay are one letter apart and mean different things — day of the month against day of the week. Mixing them up produces code that works for a few days a month and then silently stops, which is worse than failing outright.
There is no getWeek, no getQuarter and no getDayOfYear. Everything beyond this list you compute yourself, which is what most of this section is about.
Answers JS Date 2 in the original set.
3 The name of the day
easyTurn a date into its weekday name, such as Thursday, and into a three-letter short form.
Stuck? Three hints, smallest first
- 1.
getDay()gives 0 to 6, so an array of names indexed by it is all you need. - 2. The array has to start at Sunday, because that is what 0 means.
- 3. A short form is the first three letters of the long one — do not keep a second array in step by hand.
// getDay() returns 0 for Sunday, so index 0 must be Sunday.
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayName = (date) => DAY_NAMES[date.getDay()];
const shortDayName = (date) => dayName(date).slice(0, 3); // derived, so it cannot drift
const thursday = new Date(2026, 0, 15); // 15 January 2026
const sunday = new Date(2026, 0, 18); // 18 January 2026
console.log('15/01/2026 ->', dayName(thursday), '/', shortDayName(thursday));
console.log('18/01/2026 ->', dayName(sunday), '/', shortDayName(sunday));
// The locale-aware version. Shown only: its output depends on the machine's
// language settings, so it is not something a test can pin down.
console.log('toLocaleDateString ->', thursday.toLocaleDateString(undefined, { weekday: 'long' }));
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('15 January 2026 is a Thursday', dayName(thursday), 'Thursday');
test('and its short form', shortDayName(thursday), 'Thu');
test('18 January 2026 is a Sunday', dayName(sunday), 'Sunday');
test('Sunday is index 0, which is why the array starts there', sunday.getDay(), 0);
test('Saturday is index 6', dayName(new Date(2026, 0, 17)), 'Saturday');
test('there are seven names', DAY_NAMES.length, 7);
test('the week wraps: seven days on is the same name', dayName(new Date(2026, 0, 22)), 'Thursday');
An array indexed by getDay() is the whole trick, and the only thing to get right is that index 0 is Sunday. If your product treats Monday as the first day of the week, do not reorder this array — you would break the indexing. Convert instead, which exercise 34 does.
toLocaleDateString(undefined, { weekday: 'long' }) gives a translated name and is the right answer in a real interface. It is shown here rather than asserted because its output depends on the reader's locale and on which languages their runtime shipped with.
Answers JS Date 3 in the original set.
4 The name of the month
easyTurn a date into its month name, long and short. Then go the other way: month name from a 1-to-12 number.
Stuck? Three hints, smallest first
- 1.
getMonth()is zero-based, so the array index and the month number differ by one. - 2. Twelve names, index 0 being January.
- 3. For a 1-to-12 number, subtract one before indexing — and reject anything outside that range.
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const monthName = (date) => MONTH_NAMES[date.getMonth()];
const shortMonthName = (date) => monthName(date).slice(0, 3);
// From a human 1-to-12 number. Out-of-range returns null rather than undefined,
// so a caller can tell "no such month" from "you forgot an argument".
function nameOfMonthNumber(humanMonth) {
if (!Number.isInteger(humanMonth) || humanMonth < 1 || humanMonth > 12) return null;
return MONTH_NAMES[humanMonth - 1];
}
console.log('15/01/2026 ->', monthName(new Date(2026, 0, 15)));
console.log('25/12/2026 ->', monthName(new Date(2026, 11, 25)));
console.log('month 1 by number ->', nameOfMonthNumber(1));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('month 0 is January', monthName(new Date(2026, 0, 15)), 'January');
test('month 11 is December', monthName(new Date(2026, 11, 25)), 'December');
test('the short form', shortMonthName(new Date(2026, 8, 2)), 'Sep');
test('twelve names', MONTH_NAMES.length, 12);
test('the last index is 11, not 12', MONTH_NAMES[11], 'December');
test('there is no index 12', MONTH_NAMES[12], undefined);
test('month number 1 is January', nameOfMonthNumber(1), 'January');
test('month number 12 is December', nameOfMonthNumber(12), 'December');
test('month number 13 is refused', nameOfMonthNumber(13), null);
MONTH_NAMES[12] is undefined, not an error, so an off-by-one here leaks a literal undefined into whatever you were building rather than failing where the mistake is. That is the case worth an assertion.
nameOfMonthNumber returns null for a bad month instead of undefined, so the caller can distinguish a rejected value from a missing one.
Answers JS Date 4 in the original set.
5 Format a date as YYYY-MM-DD by hand
easyProduce ISO calendar form, 2026-01-05, from a local date. Two digits for month and day, always.
Stuck? Three hints, smallest first
- 1.
String(n).padStart(2, '0')is the padding. - 2. Add one to
getMonth()before padding it. - 3.
toISOString().slice(0, 10)looks like a shortcut, but it converts to UTC first.
const pad2 = (n) => String(n).padStart(2, '0');
// Local getters, so the answer is the date the reader would call today.
function toIsoDate(date) {
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
}
const fifth = new Date(2026, 0, 5); // 5 January 2026
console.log('5 January 2026 ->', toIsoDate(fifth));
console.log('25 December ->', toIsoDate(new Date(2026, 11, 25)));
// The shortcut, and why it is not the same thing. toISOString() converts to UTC
// first, so east of Greenwich a late evening comes back as the NEXT day and
// west of it an early morning comes back as the previous one.
const evening = new Date(2026, 0, 5, 23, 30);
console.log('local :', toIsoDate(evening));
console.log('via UTC:', evening.toISOString().slice(0, 10), '<- may differ, depending on the zone');
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a single-digit month and day are padded', toIsoDate(fifth), '2026-01-05');
test('December needs no padding', toIsoDate(new Date(2026, 11, 25)), '2026-12-25');
test('always ten characters', toIsoDate(new Date(2026, 8, 2)).length, 10);
test('the month is shifted up by one', toIsoDate(new Date(2026, 1, 1)), '2026-02-01');
test('ISO text sorts chronologically, which is the point of it', toIsoDate(fifth) < toIsoDate(new Date(2026, 11, 25)), true);
test('and it survives a round trip through the parts', toIsoDate(new Date(2026, 0, 15)), '2026-01-15');
test('a UTC-built date agrees with toISOString', new Date(Date.UTC(2026, 0, 5)).toISOString().slice(0, 10), '2026-01-05');
Pad by hand and you get the local calendar date. Reach for toISOString().slice(0, 10) and you get the UTC calendar date, which is a different day for part of every 24 hours. This is the most common way a date drifts by one in a real application: someone in Lagos saves a task at 23:30 and the list shows it on tomorrow.
The sorting assertion is the reason ISO ordering is worth insisting on. '2026-01-05' < '2026-12-25' is true as plain text, so ISO dates sort correctly with no parsing at all. No other common format does.
Answers JS Date 5 in the original set.
6 Format a date as DD/MM/YYYY
easyProduce 05/01/2026 for 5 January 2026 — day first, the British and most-of-the-world order. Then produce the American order from the same parts and compare them.
Stuck? Three hints, smallest first
- 1. Build the three padded parts once and then arrange them.
- 2. Only the order changes between the two formats, not the values.
- 3. Pick a date where the day and month are both under 13 to see why the ambiguity matters.
const pad2 = (n) => String(n).padStart(2, '0');
function parts(date) {
return {
dd: pad2(date.getDate()),
mm: pad2(date.getMonth() + 1),
yyyy: String(date.getFullYear()),
};
}
const dayFirst = (date) => { const p = parts(date); return p.dd + '/' + p.mm + '/' + p.yyyy; };
const monthFirst = (date) => { const p = parts(date); return p.mm + '/' + p.dd + '/' + p.yyyy; };
const fifthOfJanuary = new Date(2026, 0, 5);
console.log('5 January 2026, day first :', dayFirst(fifthOfJanuary));
console.log('5 January 2026, month first:', monthFirst(fifthOfJanuary));
console.log('the same eight characters mean two different days');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('day first, as written in Britain', dayFirst(fifthOfJanuary), '05/01/2026');
test('month first, as written in the States', monthFirst(fifthOfJanuary), '01/05/2026');
test('both are eight digits and two slashes', dayFirst(fifthOfJanuary).length, 10);
test('25 December is unambiguous, because 25 cannot be a month', dayFirst(new Date(2026, 11, 25)), '25/12/2026');
test('and reversing it is still readable', monthFirst(new Date(2026, 11, 25)), '12/25/2026');
test('but 5 January and 1 May swap into each other', monthFirst(fifthOfJanuary), dayFirst(new Date(2026, 4, 1)));
test('which is a real collision, not a contrived one', dayFirst(new Date(2026, 4, 1)), '01/05/2026');
test('the parts themselves never change, only their order', parts(fifthOfJanuary), { dd: '05', mm: '01', yyyy: '2026' });
The sixth assertion is the whole exercise: 01/05/2026 is 5 January in London and 1 May in New York, and nothing in the string says which. For any date where both numbers are 12 or less — that is 144 days a year — the format is genuinely ambiguous.
So: store and transmit ISO 2026-01-05, and format day-first or month-first only at the point of display, where you know who is reading. Everything in this section that has to be pinned down uses ISO or DD/MM, and says so.
Answers JS Date 6 in the original set.
7 A 12-hour clock with AM and PM
easyShow the time as 1:45 PM. Midnight and noon are the two cases that catch people.
Stuck? Three hints, smallest first
- 1.
hours % 12maps 13 to 1, which is what you want — until you try it on 0 and 12. - 2. Both midnight and noon should read 12, so the remainder 0 needs a fallback.
- 3. Noon is PM. The boundary is
hours < 12, nothours <= 12.
const pad2 = (n) => String(n).padStart(2, '0');
function clock12(date) {
const h24 = date.getHours();
const suffix = h24 < 12 ? 'AM' : 'PM'; // 12:00 is PM, so < and not <=
const h12 = h24 % 12 === 0 ? 12 : h24 % 12; // 0 and 12 both display as 12
return h12 + ':' + pad2(date.getMinutes()) + ' ' + suffix;
}
// Six times on one day, 15 January 2026.
const at = (h, m) => new Date(2026, 0, 15, h, m);
for (const [h, m] of [[0, 5], [9, 7], [12, 0], [12, 30], [13, 45], [23, 59]]) {
console.log(pad2(h) + ':' + pad2(m), '->', clock12(at(h, m)));
}
console.log('right now:', clock12(new Date())); // shown, not asserted
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('just after midnight is 12 AM, not 0 AM', clock12(at(0, 5)), '12:05 AM');
test('noon is 12 PM, not 12 AM', clock12(at(12, 0)), '12:00 PM');
test('and half past noon is still PM', clock12(at(12, 30)), '12:30 PM');
test('the afternoon loses twelve hours', clock12(at(13, 45)), '1:45 PM');
test('the last minute of the day', clock12(at(23, 59)), '11:59 PM');
test('the hour is not padded, the minute is', clock12(at(9, 7)), '9:07 AM');
test('no hour ever displays as 0', clock12(at(0, 0)).startsWith('12'), true);
test('11 AM is the last AM hour', clock12(at(11, 59)), '11:59 AM');
hours % 12 is right for eleven hours out of twelve and wrong for the two that matter. It gives 0 at midnight and at noon, so both need the fallback to 12 — and they need different suffixes, which is why the AM/PM test is on h24 and not on the converted hour.
The other half-mistake is h24 <= 12 ? 'AM' : 'PM', which labels noon as AM. The 12:00 and 12:30 assertions exist to catch exactly that.
Answers JS Date 7 in the original set.
8 How many days are in a month?
easyGiven a year and a zero-based month, return the number of days in it — and get February right in a leap year without writing the leap rule.
Stuck? Three hints, smallest first
- 1. Day
0of a month is the last day of the month before it. - 2. So
new Date(year, month + 1, 0)lands on the last day ofmonth. - 3.
getDate()on that is the answer, and it already knows about leap years.
// Day 0 of the NEXT month is the last day of THIS month. The constructor does
// the leap-year arithmetic, so there is no rule to get wrong here.
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
// The lengths for a whole year, January first.
const yearLengths = (year) => Array.from({ length: 12 }, (unused, m) => daysInMonth(year, m));
console.log('February 2026:', daysInMonth(2026, 1));
console.log('February 2024:', daysInMonth(2024, 1), '<- leap year');
console.log('2026:', yearLengths(2026).join(' '));
console.log('day 0 of March 2026 is', new Date(2026, 2, 0).toDateString());
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('January has 31', daysInMonth(2026, 0), 31);
test('February 2026 has 28', daysInMonth(2026, 1), 28);
test('February 2024 has 29, with no leap rule written', daysInMonth(2024, 1), 29);
test('April has 30', daysInMonth(2026, 3), 30);
test('December has 31', daysInMonth(2026, 11), 31);
test('the whole year, in order', yearLengths(2026), [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
test('a common year adds up to 365', yearLengths(2026).reduce((sum, n) => sum + n, 0), 365);
test('a leap year adds up to 366', yearLengths(2024).reduce((sum, n) => sum + n, 0), 366);
test('day 0 of March is the last day of February', new Date(2026, 2, 0).getDate(), 28);
Day 0 is the trick worth keeping. The Date constructor accepts out-of-range parts and rolls them, so day 0 of March means "one day before 1 March", which is the last day of February whatever length it happens to be. February 2024 comes back as 29 without a leap rule anywhere in the code, and code you did not write cannot be wrong.
The same rolling behaviour is a hazard elsewhere — exercise 19 is entirely about that — but here it does exactly the work you want.
Answers JS Date 8 in the original set.
9 Is this a leap year?
easyImplement the leap-year rule: every 4 years, except every 100, except every 400. Check it against 1900, 2000 and 2100.
Stuck? Three hints, smallest first
- 1. Three conditions, not one. Divisible by 4 alone is not enough.
- 2. Divisible by 100 cancels the rule; divisible by 400 brings it back.
- 3. Cross-check your answer against what the
Dateconstructor thinks 29 February is.
const isLeapYear = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
// An independent check: if 29 February exists, the date does not roll into March.
const hasFeb29 = (year) => new Date(year, 1, 29).getDate() === 29;
for (const y of [1900, 2000, 2023, 2024, 2100, 2400]) {
console.log(y, isLeapYear(y) ? 'leap' : 'common', '| 29 Feb exists:', hasFeb29(y));
}
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('2024 is a leap year', isLeapYear(2024), true);
test('2023 is not', isLeapYear(2023), false);
test('1900 is not, because it is divisible by 100', isLeapYear(1900), false);
test('2000 is, because it is divisible by 400', isLeapYear(2000), true);
test('2100 will not be', isLeapYear(2100), false);
test('2400 will be', isLeapYear(2400), true);
test('the constructor agrees about 2024', hasFeb29(2024), true);
test('and about 1900', hasFeb29(1900), false);
test('the two rules never disagree, 1600 to 2500', Array.from({ length: 901 }, (unused, i) => 1600 + i).every((y) => isLeapYear(y) === hasFeb29(y)), true);
The 100 and 400 clauses are not trivia. A calendar that only checks year % 4 === 0 was correct for every year between 1901 and 2099, which is why so much software shipped with the bug and why 2100 will be interesting.
The last assertion is worth the loop: it checks the hand-written rule against the engine's own calendar for 901 consecutive years. Two independent implementations agreeing across nine centuries is a much stronger claim than four spot checks.
Answers JS Date 9 in the original set.
10 The length of February, and what 29 February does in a common year
easyReturn February's length for a given year. Then show what new Date(2023, 1, 29) actually produces.
Stuck? Three hints, smallest first
- 1. February is month 1, so its length is
daysInMonth(year, 1). - 2. The constructor never refuses an impossible day: it rolls forward.
- 3. So a 29 February that does not exist becomes 1 March, quietly.
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
const februaryDays = (year) => daysInMonth(year, 1);
// 29 February 2023 does not exist. The constructor does not complain.
const rolled = new Date(2023, 1, 29);
const real = new Date(2024, 1, 29);
console.log('February 2023:', februaryDays(2023), '| February 2024:', februaryDays(2024));
console.log('new Date(2023, 1, 29) ->', rolled.toDateString(), '<- March!');
console.log('new Date(2024, 1, 29) ->', real.toDateString());
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('February 2024 has 29 days', februaryDays(2024), 29);
test('February 2023 has 28', februaryDays(2023), 28);
test('February 1900 had 28', februaryDays(1900), 28);
test('February 2000 had 29', februaryDays(2000), 29);
test('29 February 2023 silently becomes 1 March', rolled.getDate(), 1);
test('in month 2, which is March', rolled.getMonth(), 2);
test('while in 2024 it stays in February', real.getMonth(), 1);
test('on the 29th', real.getDate(), 29);
test('so the day you asked for is not always the day you get', rolled.getDate() === 29, false);
February's length is easy. The lesson is underneath it: new Date(2023, 1, 29) does not throw, does not return Invalid Date, and does not warn. It returns 1 March. A form that accepts a day and a month and builds a date this way will happily accept 29 February 2023 and store the wrong day.
Validation therefore cannot be "did the constructor succeed" — it always succeeds. It has to be "did I get back the day I asked for", which is exercise 56.
Answers JS Date 10 in the original set.
11 How many days are in a year?
easyReturn 365 or 366 for a given year, then measure the same thing from two timestamps and check the two agree.
Stuck? Three hints, smallest first
- 1. The leap rule gives you the answer directly.
- 2. To measure it instead, subtract 1 January of one year from 1 January of the next.
- 3. Do that subtraction in UTC. A local year containing a clock change is not a whole number of 24-hour days.
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const isLeapYear = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
const daysInYear = (year) => (isLeapYear(year) ? 366 : 365);
// Measured instead of looked up. Date.UTC because in UTC every day is exactly
// 24 hours; a local year with a daylight-saving change is not, so the same
// division on local dates can come out as 364.958.
const measured = (year) => (Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / MS_PER_DAY;
console.log('2026:', daysInYear(2026), '| measured:', measured(2026));
console.log('2024:', daysInYear(2024), '| measured:', measured(2024));
console.log('1900:', daysInYear(1900), '| 2000:', daysInYear(2000));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('2026 has 365 days', daysInYear(2026), 365);
test('2024 has 366', daysInYear(2024), 366);
test('1900 had 365, despite being divisible by 4', daysInYear(1900), 365);
test('2000 had 366, because 400 divides it', daysInYear(2000), 366);
test('measuring in UTC gives a whole number', measured(2026), 365);
test('and agrees on the leap year', measured(2024), 366);
test('the two never disagree, 1900 to 2100', Array.from({ length: 201 }, (unused, i) => 1900 + i).every((y) => daysInYear(y) === measured(y)), true);
test('four consecutive years hold 1461 days', [2024, 2025, 2026, 2027].reduce((sum, y) => sum + daysInYear(y), 0), 1461);
Both halves are worth having: the rule, because it needs no Date at all, and the measurement, because it checks the rule against the engine's calendar.
The reason the measurement uses Date.UTC is the point to carry forward. UTC has no daylight saving, so every UTC day is exactly 86,400,000 milliseconds and division is exact. Local time does not have that property: in a zone that shifts its clocks, two of the days in the year are 23 and 25 hours long. Every "how many days between" calculation in this section normalises to UTC first for that reason.
Answers JS Date 11 in the original set.
12 Is this date a weekend?
easyReturn true for a Saturday or Sunday. Then make the weekend configurable, because it is not Saturday and Sunday everywhere.
Stuck? Three hints, smallest first
- 1.
getDay()gives 0 for Sunday and 6 for Saturday, so the weekend is the two ends of the range. - 2. Take the weekend days as an argument, defaulting to
[0, 6]. - 3.
includeson a small array of day numbers is clearer than a chain of||.
const isWeekend = (date) => date.getDay() === 0 || date.getDay() === 6;
// Not universal: much of the Middle East treats Friday and Saturday as the
// weekend, so the days belong to the caller, not to the function.
const isWeekendWhere = (date, weekendDays) => weekendDays.includes(date.getDay());
function weekendDaysIn(year, month) {
const last = new Date(year, month + 1, 0).getDate();
let count = 0;
for (let day = 1; day <= last; day++) {
if (isWeekend(new Date(year, month, day))) count++;
}
return count;
}
// 16 to 19 January 2026: Friday, Saturday, Sunday, Monday.
const friday = new Date(2026, 0, 16);
const saturday = new Date(2026, 0, 17);
const sunday = new Date(2026, 0, 18);
const monday = new Date(2026, 0, 19);
for (const d of [friday, saturday, sunday, monday]) {
console.log(d.toDateString(), '-> weekend:', isWeekend(d));
}
console.log('weekend days in January 2026:', weekendDaysIn(2026, 0));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('Saturday is a weekend', isWeekend(saturday), true);
test('so is Sunday', isWeekend(sunday), true);
test('Friday is not', isWeekend(friday), false);
test('nor is Monday', isWeekend(monday), false);
test('January 2026 has nine weekend days', weekendDaysIn(2026, 0), 9);
test('and 22 weekdays', 31 - weekendDaysIn(2026, 0), 22);
test('with a Friday-Saturday weekend, Friday counts', isWeekendWhere(friday, [5, 6]), true);
test('and Sunday does not', isWeekendWhere(sunday, [5, 6]), false);
test('the default convention is Sunday and Saturday', [sunday.getDay(), saturday.getDay()], [0, 6]);
The mechanics are two comparisons. The design point is the second function: "weekend" is a business rule, not a property of a date, and hard-coding Saturday and Sunday is a bug in any product with users in Egypt, the UAE or Israel. Passing the days in costs nothing and makes the assumption visible.
Note that Sunday and Saturday are 0 and 6 — the two ends of the range, not two adjacent numbers. That is why day >= 5 is wrong: it catches Friday and Saturday, not Saturday and Sunday.
Answers JS Date 12 in the original set.
13 Detecting an Invalid Date
fairWrite a check for whether a value is a usable date. An unparseable string still gives you a Date object, so instanceof alone proves nothing.
Stuck? Three hints, smallest first
- 1. An invalid date is a real
Datewhose internal timestamp isNaN. - 2.
Number.isNaN(d.getTime())is the test.d === ‘Invalid Date’compares an object with a string and is always false. - 3. Remember that
NaN === NaNis false, so comparing the timestamp toNaNdoes not work either.
const isValidDate = (value) =>
value instanceof Date && !Number.isNaN(value.getTime());
const bad = new Date('not a date');
const good = new Date(2026, 0, 15);
console.log('bad ->', String(bad), '| valid:', isValidDate(bad));
console.log('good ->', good.toDateString(), '| valid:', isValidDate(good));
console.log('typeof bad:', typeof bad, '| instanceof Date:', bad instanceof Date);
console.log('new Date(undefined) valid?', isValidDate(new Date(undefined)));
// Out-of-range days in a string are NOT reliable ground: engines disagree about
// whether '2026-02-30' is invalid or is rolled into 2 March. Shown, not asserted.
console.log("new Date('2026-02-30') ->", String(new Date('2026-02-30')));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an unparseable string gives an invalid date', isValidDate(bad), false);
test('a real date passes', isValidDate(good), true);
test('the invalid one is still a Date object', bad instanceof Date, true);
test('its timestamp is NaN', Number.isNaN(bad.getTime()), true);
test('comparing the object to the text never works', bad === 'Invalid Date', false);
test('even though that is exactly what it prints', String(bad), 'Invalid Date');
test('and comparing the timestamp to NaN fails too, since NaN !== NaN', bad.getTime() === NaN, false);
test('new Date(null) is the epoch, not invalid', new Date(null).getTime(), 0);
test('and a string is rejected before getTime is ever reached', isValidDate('2026-01-15'), false);
Three wrong answers, each of which looks reasonable. d instanceof Date is true for an invalid date. d === 'Invalid Date' compares an object with a string, so it is false for every date, valid or not — a check that never fires is worse than no check. And d.getTime() === NaN is false by definition, because NaN is not equal to itself.
Number.isNaN(d.getTime()) is the whole answer, guarded by an instanceof so that a string or null does not throw on .getTime(). Note new Date(null): null converts to the number 0, so you get 1 January 1970 rather than an error, which is a quiet way for a missing value to become a real-looking date.
Answers JS Date 13 in the original set.
14 Why getYear is not the year
fairgetYear() still exists in browsers. Show what it returns, and show the matching trap in the constructor: what year does new Date(99, 0, 1) mean?
Stuck? Three hints, smallest first
- 1.
getYearreturns the year minus 1900. - 2. The constructor has the same 1900 offset for one- and two-digit years.
- 3.
setFullYearis the way to reach a year in the first century.
const sample = new Date(2026, 0, 15);
// getYear is Annex B: kept alive only so old pages keep working. Guarded so
// this runs anywhere.
const legacyYear = typeof sample.getYear === 'function' ? sample.getYear() : 'not implemented';
// The same 1900 offset lives in the constructor: a year of 0 to 99 means 1900+y.
const ninetyNine = new Date(99, 0, 1);
const twentySix = new Date(26, 0, 1);
// To mean the actual year 26, set it explicitly.
const yearTwentySix = new Date(2026, 0, 1);
yearTwentySix.setFullYear(26);
console.log('getFullYear:', sample.getFullYear(), '| getYear:', legacyYear);
console.log('new Date(99, 0, 1) ->', ninetyNine.getFullYear());
console.log('new Date(26, 0, 1) ->', twentySix.getFullYear());
console.log('setFullYear(26) ->', yearTwentySix.getFullYear());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('getFullYear gives the year', sample.getFullYear(), 2026);
test('getYear gives the year minus 1900', legacyYear, 126);
test('so for 1999 it returned 99', new Date(1999, 0, 1).getYear(), 99);
test('the constructor reads 99 as 1999', ninetyNine.getFullYear(), 1999);
test('and 26 as 1926, not 2026', twentySix.getFullYear(), 1926);
test('only setFullYear reaches the year 26', yearTwentySix.getFullYear(), 26);
test('four-digit years are taken literally', new Date(2026, 0, 1).getFullYear(), 2026);
test('and 100 is not two-digit, so it means the year 100', new Date(100, 0, 1).getFullYear(), 100);
getYear is not gone — it is in Annex B of the specification, the appendix of things kept working for the web's sake — and it returns 126 for 2026. Never use it. getFullYear is the one that means what it says.
The interesting half is that the same 1900 offset is still in the constructor, and that one you cannot avoid by choosing a better method: new Date(26, 0, 1) is 1926. So a two-digit year arriving from a form or a CSV must be widened before it reaches the constructor, and a year genuinely below 100 has to be set with setFullYear.
Answers JS Date 14 in the original set.
15 A date from a timestamp
easyBuild dates from millisecond timestamps: the epoch, one day after it, and one day before. Then round-trip a date back to its number.
Stuck? Three hints, smallest first
- 1. One argument that is a number is a millisecond count from 1 January 1970 UTC.
- 2. A day is 86,400,000 milliseconds.
- 3. Negative numbers are allowed, and mean before 1970.
const MS_PER_DAY = 86400000;
const epoch = new Date(0);
const dayAfter = new Date(MS_PER_DAY);
const dayBefore = new Date(-MS_PER_DAY);
// A timestamp is an INSTANT, not a calendar date. toISOString shows it in UTC,
// which is the only reading that is the same everywhere.
console.log('0 ->', epoch.toISOString());
console.log('86400000 ->', dayAfter.toISOString());
console.log('-86400000 ->', dayBefore.toISOString());
console.log('local reading of 0:', epoch.getFullYear(), '<- 1969 west of Greenwich');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('zero is the epoch', epoch.toISOString(), '1970-01-01T00:00:00.000Z');
test('and its timestamp is zero', epoch.getTime(), 0);
test('a day later', dayAfter.toISOString(), '1970-01-02T00:00:00.000Z');
test('a day earlier, using a negative number', dayBefore.toISOString(), '1969-12-31T00:00:00.000Z');
test('a timestamp round-trips exactly', new Date(1768435200000).getTime(), 1768435200000);
test('and that one is 15 January 2026 in UTC', new Date(1768435200000).toISOString(), '2026-01-15T00:00:00.000Z');
test('Date.UTC computes the same number', Date.UTC(2026, 0, 15), 1768435200000);
test('the local calendar year of the epoch is 1969 or 1970, depending on the zone', [1969, 1970].includes(epoch.getFullYear()), true);
One number, one instant. The last assertion is the honest one: the epoch's local year is 1970 in Lagos and 1969 in New York, because local midnight UTC is still the previous evening there. That is why every assertion about a timestamp in this section is written against toISOString or getTime, never against a local getter.
new Date(0) is also a handy sentinel while learning, but do not use it as a "no date" value in real code: it is a perfectly valid instant, so nothing downstream can tell it from a real one.
Answers JS Date 15 in the original set.
16 Date.UTC and the UTC getters
fairBuild a date that means 09:30 UTC on 15 January 2026, and read it back with the UTC getters. Then show how the local getters relate to them.
Stuck? Three hints, smallest first
- 1.
Date.UTC(...)takes the same arguments as the constructor but returns a number. - 2. Every local getter has a UTC twin:
getUTCDate,getUTCHours,getUTCDay. - 3.
getTimezoneOffset()is minutes of UTC minus local, so local minutes are UTC minutes minus the offset.
// Date.UTC returns a timestamp, not a Date, so it has to be wrapped.
const stamp = Date.UTC(2026, 0, 15, 9, 30); // month still zero-based
const utc = new Date(stamp);
// The offset is minutes of UTC minus local: -60 for a zone an hour ahead.
const offset = utc.getTimezoneOffset();
const utcMinutes = utc.getUTCHours() * 60 + utc.getUTCMinutes();
const localMinutes = utc.getHours() * 60 + utc.getMinutes();
console.log('ISO :', utc.toISOString());
console.log('UTC parts:', utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate(), utc.getUTCHours() + ':' + utc.getUTCMinutes());
console.log('this machine is', -offset, 'minutes from UTC, so locally it reads', utc.getHours() + ':' + utc.getMinutes());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('Date.UTC hands back a number', typeof stamp, 'number');
test('the UTC year and month', [utc.getUTCFullYear(), utc.getUTCMonth()], [2026, 0]);
test('the UTC day of the month', utc.getUTCDate(), 15);
test('the UTC time', [utc.getUTCHours(), utc.getUTCMinutes()], [9, 30]);
test('15 January 2026 is a Thursday in UTC too', utc.getUTCDay(), 4);
test('the ISO form is exactly what was asked for', utc.toISOString(), '2026-01-15T09:30:00.000Z');
test('local time is UTC minus the offset, whatever zone this runs in', (utcMinutes - offset + 2880) % 1440, localMinutes);
test('and the offset is a whole number of minutes', offset % 1, 0);
Date.UTC is the counterpart of the multi-argument constructor: same arguments, same zero-based month, but it interprets them as UTC and returns a timestamp rather than an object. Use it whenever the parts you hold are already UTC, and use it inside date arithmetic to sidestep daylight saving.
The offset assertion is the one worth reading twice. getTimezoneOffset() returns UTC minus local, so a zone one hour ahead of UTC reports -60. The sign surprises everyone once. Writing the relationship as an identity is also how you assert something about time zones without asserting anything about the reader's machine.
Answers JS Date 16 in the original set.
17 The trap: a bare YYYY-MM-DD string is UTC midnight
fairnew Date('2026-01-15') does not mean midnight where you are. Show what it does mean, and show what changes when you add a time.
Stuck? Three hints, smallest first
- 1. A date-only ISO string is defined to be UTC. A date-time string with no offset is defined to be local.
- 2. So compare the timestamp against
Date.UTC(2026, 0, 15)rather than against a local date. - 3. West of Greenwich, the local day of that date is the 14th. That is the bug this exercise is about.
const parsed = new Date('2026-01-15'); // date only -> UTC midnight
const withTime = new Date('2026-01-15T00:00:00'); // has a time -> LOCAL midnight
console.log('bare string :', parsed.toISOString());
console.log(' UTC date :', parsed.getUTCDate(), '<- always 15');
console.log(' local date :', parsed.getDate(), '<- 15 here, 14 west of Greenwich');
console.log('with a time :', withTime.getDate(), '<- always 15, in any zone');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the bare string is UTC midnight', parsed.getTime(), Date.UTC(2026, 0, 15));
test('which is what its ISO form says', parsed.toISOString(), '2026-01-15T00:00:00.000Z');
test('the UTC day is the 15th', parsed.getUTCDate(), 15);
test('and the UTC hour is 0', parsed.getUTCHours(), 0);
test('but the LOCAL day is the 14th or the 15th, depending on the zone', [14, 15].includes(parsed.getDate()), true);
test('adding a time makes it local, so the day is always the 15th', withTime.getDate(), 15);
test('and its local hour is midnight', withTime.getHours(), 0);
test('Date.parse agrees with the constructor', Date.parse('2026-01-15'), Date.UTC(2026, 0, 15));
This is the bug behind most "the date is one day out" reports. '2026-01-15' is parsed as UTC midnight; '2026-01-15T00:00:00' is parsed as local midnight. One character of difference, up to a day of difference in the answer.
The fifth assertion is written as a set membership rather than a fixed value on purpose. The local day genuinely differs by zone — 15 in Kiritimati, 14 in Niue — so pinning it to one number would only be asserting where this build happened to run.
The habit that avoids the whole problem: to get a local calendar date out of a YYYY-MM-DD string, split it and use new Date(y, m - 1, d). To keep an instant, keep the full ISO string with its Z.
Answers JS Date 17 in the original set.
18 Dashes, slashes and the Z suffix
fairCompare how '2026-01-15', '2026/01/15', '2026-01-15T00:00:00' and '2026-01-15T00:00:00Z' are parsed.
Stuck? Three hints, smallest first
- 1. Only the ISO forms are described by the specification. Slashes go to the engine’s legacy parser.
- 2. The legacy parser treats what it reads as local time.
- 3. An explicit
Zor+01:00removes all doubt, which is why an API should always send one.
const bare = new Date('2026-01-15'); // ISO, date only -> UTC
const slashes = new Date('2026/01/15'); // legacy parser -> local
const localIso = new Date('2026-01-15T00:00:00'); // ISO, no offset -> local
const zulu = new Date('2026-01-15T00:00:00Z'); // ISO with Z -> UTC
const plusOne = new Date('2026-01-15T00:00:00+01:00');
// The gap between a local reading and a UTC reading IS the zone offset.
const offset = slashes.getTimezoneOffset();
console.log('bare :', bare.toISOString());
console.log('slashes :', slashes.toISOString(), '<- moves with the machine zone');
console.log('localIso :', localIso.toISOString(), '<- same as slashes');
console.log('zulu :', zulu.toISOString());
console.log('+01:00 :', plusOne.toISOString());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('slashes are parsed as local, exactly like the ISO form with a time', slashes.getTime(), localIso.getTime());
test('so their local day is the 15th', slashes.getDate(), 15);
test('and their local month is January', slashes.getMonth(), 0);
test('the Z suffix means UTC', zulu.getTime(), Date.UTC(2026, 0, 15));
test('so the bare date-only string matches it', bare.getTime(), zulu.getTime());
test('the local and UTC readings differ by exactly the zone offset', slashes.getTime() - bare.getTime(), offset * 60000);
test('an explicit +01:00 is an hour before UTC midnight', plusOne.getTime(), Date.UTC(2026, 0, 14, 23, 0));
test('none of the four is invalid', [bare, slashes, localIso, zulu].every((d) => !Number.isNaN(d.getTime())), true);
Two of these forms are in the specification and two are not. YYYY-MM-DD and YYYY-MM-DDTHH:mm:ss are defined, the first as UTC and the second as local. '2026/01/15' and 'January 15, 2026' fall through to each engine's own parser, which historically differed — that is why new Date('a string I found') is not a portable operation.
The sixth assertion is the useful one to hold on to: the difference between the local reading and the UTC reading is precisely getTimezoneOffset(), which is why it comes out as zero on a server running in UTC and as an hour in Lagos. Send ISO with an explicit offset and none of this arises.
Answers JS Date 18 in the original set.
19 Overflow: month 12 and day 32
fairFind out what new Date(2026, 12, 1), new Date(2026, -1, 1), new Date(2026, 0, 32) and new Date(2026, 0, 0) give you. None of them is an error.
Stuck? Three hints, smallest first
- 1. Out-of-range parts are not rejected, they are carried into the next or previous unit.
- 2. Month 12 is the thirteenth month, so it is January of the following year.
- 3. Day 0 is the day before the first, so it is the last day of the previous month.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const monthTwelve = new Date(2026, 12, 1); // the 13th month of 2026
const monthMinusOne = new Date(2026, -1, 1); // the month before January
const dayThirtyTwo = new Date(2026, 0, 32); // 32 January
const dayZero = new Date(2026, 0, 0); // the day before 1 January
console.log('new Date(2026, 12, 1) ->', iso(monthTwelve));
console.log('new Date(2026, -1, 1) ->', iso(monthMinusOne));
console.log('new Date(2026, 0, 32) ->', iso(dayThirtyTwo));
console.log('new Date(2026, 0, 0) ->', iso(dayZero));
console.log('new Date(2026, 0, 15, 25) ->', iso(new Date(2026, 0, 15, 25)), 'at hour', new Date(2026, 0, 15, 25).getHours());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('month 12 rolls into the next year', iso(monthTwelve), '2027-01-01');
test('month -1 rolls back into the previous one', iso(monthMinusOne), '2025-12-01');
test('32 January is 1 February', iso(dayThirtyTwo), '2026-02-01');
test('day 0 is the last day of December', iso(dayZero), '2025-12-31');
test('and 24 months on is exactly two years on', new Date(2026, 24, 1).getFullYear(), 2028);
test('hour 25 spills into the next day', new Date(2026, 0, 15, 25).getDate(), 16);
test('the useful side: month + 3 needs no wrapping logic', iso(new Date(2026, 10 + 3, 1)), '2027-02-01');
test('nothing here is an Invalid Date', [monthTwelve, monthMinusOne, dayThirtyTwo, dayZero].every((d) => !Number.isNaN(d.getTime())), true);
Overflow is deliberate and it is documented: the constructor and every setter normalise their arguments rather than rejecting them. That cuts both ways. It is why new Date(y, m + 3, 1) can add a quarter without any December-to-January special case, and it is why a form that accepts 32 in the day field stores 1 February without a word of complaint.
So overflow is a tool for arithmetic and a hazard for input. Use it for the first and validate against it for the second.
Answers JS Date 19 in the original set.
20 setDate mutates, and returns a number
fairMove a date on by one day with setDate. Show what the call returns, what happened to the original object, and what happens if you try to chain.
Stuck? Three hints, smallest first
- 1. Every
set*method onDatechanges the object in place. - 2. What they return is the new timestamp, not the date, so chaining fails.
- 3.
setDate(0)is the previous month’s last day, which is occasionally useful.
const d = new Date(2026, 0, 31); // 31 January 2026
const returned = d.setDate(d.getDate() + 1); // asked for the 32nd
// Chaining reads well and does not work: the setter handed back a number.
let chainError = '';
try {
const other = new Date(2026, 0, 31);
other.setDate(1).getFullYear();
} catch (err) {
chainError = err.constructor.name;
}
// setDate(0) steps back to the last day of the previous month.
const backwards = new Date(2026, 2, 15); // 15 March 2026
backwards.setDate(0);
console.log('returned:', returned, typeof returned);
console.log('the object itself moved to', d.toDateString());
console.log('chaining threw a', chainError);
console.log('setDate(0) from 15 March ->', backwards.toDateString());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('setDate returns a timestamp, not the date', typeof returned, 'number');
test('and that number is the mutated date', returned, d.getTime());
test('the original object moved on', d.getDate(), 1);
test('into February', d.getMonth(), 1);
test('so chaining onto the return value throws', chainError, 'TypeError');
test('setMonth behaves the same way', typeof new Date(2026, 0, 15).setMonth(5), 'number');
test('setDate(0) lands on the last day of February', backwards.getDate(), 28);
test('in month 1', backwards.getMonth(), 1);
d.setDate(d.getDate() + 1) is the standard way to move a date, and it has two properties people trip over. It mutates, so every reference to that object now points at a different day. And it returns a number, so d.setDate(1).getFullYear() is a TypeError — numbers have no getFullYear.
The fix for both is the same and it is the next exercise: copy first, mutate the copy, return the copy.
Answers JS Date 20 in the original set.
21 Copy a date before you change it
fairShow that assigning a date to another variable does not copy it, and show two ways that do.
Stuck? Three hints, smallest first
- 1.
const b = agives two names for one object. - 2.
new Date(a)andnew Date(a.getTime())both build a new one. - 3. Two dates for the same instant are equal by timestamp and not by
===.
// A real copy. Mutating it leaves the source alone.
const start = new Date(2026, 0, 15);
const copy = new Date(start);
copy.setDate(20);
// Not a copy: one object with two names.
const shared = new Date(2026, 0, 15);
const alias = shared;
alias.setDate(20); // this moves `shared` as well
const viaTimestamp = new Date(start.getTime());
const twin = new Date(start); // built and left alone
console.log('start :', start.toDateString(), '<- untouched');
console.log('copy :', copy.toDateString());
console.log('shared:', shared.toDateString(), '<- moved by the alias');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the copy moved', copy.getDate(), 20);
test('the source did not', start.getDate(), 15);
test('the alias moved the object it shares', shared.getDate(), 20);
test('because the alias IS the same object', alias === shared, true);
test('while the copy is a different object', copy === start, false);
test('an untouched copy holds the same instant', twin.getTime(), start.getTime());
test('but it is still not === to it', twin === start, false);
test('a timestamp round-trip copies just as well', viaTimestamp.getTime(), start.getTime());
test('JSON comparison sees them as equal, which is why same() says true', same(twin, start), true);
Date is mutable, so passing one into a function is passing a handle to something the function can change under you. Every helper in the rest of this section therefore starts with const copy = new Date(date) and returns the copy. It is one line and it removes a whole category of bug.
The last two assertions are worth holding together: twin === start is false while same(twin, start) is true. The helper compares JSON.stringify output, and JSON.stringify puts a date through toISOString, so two distinct objects for the same instant serialise identically. Equality for dates is a question about instants, not about objects.
Answers JS Date 21 in the original set.
22 Add and subtract days
fairWrite addDays(date, n) that returns a new date n days later, leaving the input alone. Negative n goes backwards.
Stuck? Three hints, smallest first
- 1. Copy, then
setDate(getDate() + n)on the copy. - 2. You do not need any month-end logic: the setter normalises out-of-range days for you.
- 3. Do not add
n * 86400000milliseconds. That is a different operation, and exercise 53 shows where the two part company.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days); // out-of-range days roll into the next month
return copy;
}
const endOfJanuary = new Date(2026, 0, 31);
console.log('31/01/2026 + 1 ->', iso(addDays(endOfJanuary, 1)));
console.log('31/12/2026 + 1 ->', iso(addDays(new Date(2026, 11, 31), 1)));
console.log('01/01/2026 - 1 ->', iso(addDays(new Date(2026, 0, 1), -1)));
console.log('the input is still', iso(endOfJanuary));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('one day past the end of January', iso(addDays(endOfJanuary, 1)), '2026-02-01');
test('one day past the end of the year', iso(addDays(new Date(2026, 11, 31), 1)), '2027-01-01');
test('a negative count goes backwards', iso(addDays(new Date(2026, 0, 1), -1)), '2025-12-31');
test('into 29 February in a leap year', iso(addDays(new Date(2024, 1, 28), 1)), '2024-02-29');
test('but straight to 1 March in a common one', iso(addDays(new Date(2026, 1, 28), 1)), '2026-03-01');
test('zero days is the same date', iso(addDays(endOfJanuary, 0)), '2026-01-31');
test('the input was not modified', iso(endOfJanuary), '2026-01-31');
test('365 days on from 15 January 2026', iso(addDays(new Date(2026, 0, 15), 365)), '2027-01-15');
test('and 366 days on from a date in a leap year', iso(addDays(new Date(2024, 0, 15), 366)), '2025-01-15');
Two lines, and all the month-end and year-end work is done for you by the same normalising behaviour that made exercise 19 dangerous. setDate(32) in January is 1 February, so addDays needs no idea how long any month is.
The last two assertions are a reminder that a year is not a fixed number of days: 365 days from a date in 2026 lands on the same date, but you have to use 366 when a 29 February falls inside the span. If you mean "a year later", say addYears — exercise 25 — rather than counting days.
Answers JS Date 22 in the original set.
23 Add weeks
easyAdd and subtract whole weeks. Show that the weekday never changes, and that 52 weeks is not a year.
Stuck? Three hints, smallest first
- 1. A week is seven days, so this is
addDayswith the count multiplied. - 2. Adding a multiple of seven days cannot change
getDay(). - 3. 52 weeks is 364 days, one or two short of a year.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
const addWeeks = (date, weeks) => addDays(date, weeks * 7);
const thursday = new Date(2026, 0, 15); // 15 January 2026
console.log('+1 week ->', iso(addWeeks(thursday, 1)));
console.log('-2 weeks ->', iso(addWeeks(thursday, -2)));
console.log('+52 weeks->', iso(addWeeks(new Date(2026, 0, 1), 52)), '<- still 2026');
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a week on', iso(addWeeks(thursday, 1)), '2026-01-22');
test('two weeks back', iso(addWeeks(thursday, -2)), '2026-01-01');
test('the weekday is unchanged, whatever the count', addWeeks(thursday, 37).getDay(), thursday.getDay());
test('a week on from the end of the year', iso(addWeeks(new Date(2026, 11, 31), 1)), '2027-01-07');
test('52 weeks from 1 January 2026 is 31 December 2026', iso(addWeeks(new Date(2026, 0, 1), 52)), '2026-12-31');
test('so 52 weeks is 364 days, not a year', iso(addWeeks(new Date(2026, 0, 1), 52)) === '2027-01-01', false);
test('and 53 weeks overshoots into the next year', iso(addWeeks(new Date(2026, 0, 1), 53)), '2027-01-07');
Weeks are the one unit of calendar arithmetic with no awkward cases: seven days is seven days, so the weekday is preserved and no month-end clamping is needed.
The 52-week assertion matters when someone asks for "a year of weekly reports". 52 weeks is 364 days, so an annual schedule built by adding weeks drifts back by a day or two each year and eventually loses a whole week. If the schedule is annual, count in years; if it is weekly, accept that some years contain 53 of them.
Answers JS Date 23 in the original set.
24 Add months, and clamp the overflow
fairAdd one month to 31 January 2026. The obvious code gives you 3 March. Fix it so it gives 28 February, and show both.
Stuck? Three hints, smallest first
- 1.
setMonthkeeps the day of the month, so 31 February normalises into March. - 2. Park the copy on day 1 before changing the month, so nothing can overflow while you do it.
- 3. Then put the day back, clamped to the length of the month you have landed in.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
// The obvious version. 31 January + 1 month asks for 31 February.
function naiveAddMonths(date, months) {
const copy = new Date(date);
copy.setMonth(copy.getMonth() + months);
return copy;
}
function addMonths(date, months) {
const day = date.getDate();
const copy = new Date(date);
copy.setDate(1); // a day every month has
copy.setMonth(copy.getMonth() + months);
copy.setDate(Math.min(day, daysInMonth(copy.getFullYear(), copy.getMonth())));
return copy;
}
const endOfJanuary = new Date(2026, 0, 31);
console.log('naive 31/01/2026 + 1 month ->', iso(naiveAddMonths(endOfJanuary, 1)), '<- March');
console.log('clamped 31/01/2026 + 1 month ->', iso(addMonths(endOfJanuary, 1)));
console.log('clamped 31/01/2024 + 1 month ->', iso(addMonths(new Date(2024, 0, 31), 1)), '<- leap year');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the naive version skips February entirely', iso(naiveAddMonths(endOfJanuary, 1)), '2026-03-03');
test('the clamped version stops at the end of February', iso(addMonths(endOfJanuary, 1)), '2026-02-28');
test('and gains a day in a leap year', iso(addMonths(new Date(2024, 0, 31), 1)), '2024-02-29');
test('a day that exists in both months is untouched', iso(addMonths(new Date(2026, 0, 15), 1)), '2026-02-15');
test('thirteen months on crosses the year', iso(addMonths(endOfJanuary, 13)), '2027-02-28');
test('going backwards clamps too', iso(addMonths(new Date(2026, 2, 31), -1)), '2026-02-28');
test('twelve months on is the same date a year later', iso(addMonths(endOfJanuary, 12)), '2027-01-31');
test('and the input is untouched', iso(endOfJanuary), '2026-01-31');
setMonth changes the month and leaves the day where it was, so any day past the 28th can overflow. 31 January plus one month asks for 31 February, which normalises to 3 March — a date in the wrong month, with no error.
Clamping is the usual fix and it is what almost every library does, but be clear that it is a choice: it makes the operation lossy, so addMonths(addMonths(jan31, 1), -1) is 28 January rather than 31 January. If a subscription has to bill on the 31st where the month allows it, keep the original day of the month somewhere and re-derive from that each cycle instead of stepping from the last result. Exercise 54 shows the difference.
Answers JS Date 24 in the original set.
25 Add years, and what happens to 29 February
fairAdd a year to 29 February 2024. Show what setFullYear does on its own, and clamp it to 28 February 2025 instead.
Stuck? Three hints, smallest first
- 1.
setFullYearkeeps the month and day, so 29 February in a common year overflows. - 2. The same trick as adding months: park on day 1, change the year, then put the day back clamped.
- 3. Four years on it fits again, because leap years come in fours — except at a century.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();
function naiveAddYears(date, years) {
const copy = new Date(date);
copy.setFullYear(copy.getFullYear() + years);
return copy;
}
function addYears(date, years) {
const day = date.getDate();
const copy = new Date(date);
copy.setDate(1);
copy.setFullYear(date.getFullYear() + years);
copy.setDate(Math.min(day, daysInMonth(copy.getFullYear(), copy.getMonth())));
return copy;
}
const leapDay = new Date(2024, 1, 29); // 29 February 2024
console.log('naive 29/02/2024 + 1 year ->', iso(naiveAddYears(leapDay, 1)), '<- March');
console.log('clamped 29/02/2024 + 1 year ->', iso(addYears(leapDay, 1)));
console.log('clamped 29/02/2024 + 4 years->', iso(addYears(leapDay, 4)), '<- fits again');
Output
7 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the naive version lands in March', iso(naiveAddYears(leapDay, 1)), '2025-03-01');
test('the clamped version stays in February', iso(addYears(leapDay, 1)), '2025-02-28');
test('four years on, 29 February exists again', iso(addYears(leapDay, 4)), '2028-02-29');
test('76 years on it does not, because 2100 is not a leap year', iso(addYears(leapDay, 76)), '2100-02-28');
test('an ordinary date is unaffected', iso(addYears(new Date(2026, 0, 15), 1)), '2027-01-15');
test('and going backwards works', iso(addYears(new Date(2026, 0, 15), -1)), '2025-01-15');
test('the input is untouched', iso(leapDay), '2024-02-29');
Same shape of problem as adding months, same fix. The case that makes it real is a birthday: someone born on 29 February 2024 has no 29 February in 2025, so your code has to decide between 28 February and 1 March. Clamping down to 28 February is the common choice and the one most legal definitions of "anniversary" use.
The 2100 assertion is there because 76 years is a multiple of four, so the naive expectation is that 29 February exists — and it does not, because 2100 is divisible by 100 and not by 400. The leap rule from exercise 9 is doing real work here.
Answers JS Date 25 in the original set.
26 How many days between two dates?
fairCount the days from one date to another, ignoring the time of day. Get it right across a month end, across a leap February, and in both directions.
Stuck? Three hints, smallest first
- 1. Strip the time first, or 23:59 to 00:01 the next morning will count as zero days.
- 2. Convert both to
Date.UTC(year, month, day), where every day is exactly 24 hours long. - 3. That makes the division exact, so no rounding is needed at all.
const MS_PER_DAY = 86400000;
// Take the local calendar date and re-express it as UTC midnight. UTC has no
// daylight saving, so the subtraction below is an exact number of days.
const utcDay = (date) => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
const daysBetween = (from, to) => (utcDay(to) - utcDay(from)) / MS_PER_DAY;
// What happens if you subtract the raw instants instead.
const lateEvening = new Date(2026, 0, 1, 23, 59);
const nextMorning = new Date(2026, 0, 2, 0, 1);
const rawFloor = Math.floor((nextMorning - lateEvening) / MS_PER_DAY);
console.log('01/01/2026 to 31/01/2026 :', daysBetween(new Date(2026, 0, 1), new Date(2026, 0, 31)));
console.log('31/01/2026 to 01/03/2026 :', daysBetween(new Date(2026, 0, 31), new Date(2026, 2, 1)));
console.log('two minutes apart, over midnight: dates', daysBetween(lateEvening, nextMorning), 'vs raw floor', rawFloor);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the first to the last of January', daysBetween(new Date(2026, 0, 1), new Date(2026, 0, 31)), 30);
test('across a 28-day February', daysBetween(new Date(2026, 0, 31), new Date(2026, 2, 1)), 29);
test('across a 29-day one', daysBetween(new Date(2024, 0, 31), new Date(2024, 2, 1)), 30);
test('the same day is zero', daysBetween(new Date(2026, 0, 15), new Date(2026, 0, 15)), 0);
test('the time of day is ignored', daysBetween(lateEvening, nextMorning), 1);
test('whereas subtracting the instants and flooring says zero', rawFloor, 0);
test('reversing the arguments negates the answer', daysBetween(new Date(2026, 0, 31), new Date(2026, 0, 1)), -30);
test('a common year is 365 days', daysBetween(new Date(2026, 0, 1), new Date(2027, 0, 1)), 365);
test('a leap year is 366', daysBetween(new Date(2024, 0, 1), new Date(2025, 0, 1)), 366);
Two mistakes are being avoided at once. Subtracting the raw instants counts elapsed time, not calendar days, so two minutes either side of midnight floors to zero — that assertion is the bug most "days until" counters ship with. And dividing local milliseconds by 86,400,000 goes wrong in any zone with daylight saving, where two days a year are 23 or 25 hours long.
Normalising both ends to Date.UTC(y, m, d) fixes both. The time is discarded deliberately, and the division is exact rather than rounded, which is a stronger position than reaching for Math.round and hoping.
Answers JS Date 26 in the original set.
27 Age in whole years
fairWork out someone's age on a given day from their date of birth. Subtracting the years is not enough — the birthday may not have happened yet.
Stuck? Three hints, smallest first
- 1. Start with the difference in years, then take one off if the birthday is still to come.
- 2. "Still to come" means an earlier month, or the same month and an earlier day.
- 3. Take the "on" date as an argument. A function that reads the clock itself cannot be tested.
function ageOn(birth, on) {
let age = on.getFullYear() - birth.getFullYear();
const birthdayStillToCome =
on.getMonth() < birth.getMonth() ||
(on.getMonth() === birth.getMonth() && on.getDate() < birth.getDate());
if (birthdayStillToCome) age--;
return age;
}
const birth = new Date(1990, 5, 15); // 15 June 1990
console.log('on 15/01/2026:', ageOn(birth, new Date(2026, 0, 15)));
console.log('on 14/06/2026:', ageOn(birth, new Date(2026, 5, 14)), '<- day before the birthday');
console.log('on 15/06/2026:', ageOn(birth, new Date(2026, 5, 15)), '<- on the day');
console.log('today :', ageOn(birth, new Date()), '<- shown, never asserted');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('before the birthday in that year', ageOn(birth, new Date(2026, 0, 15)), 35);
test('the day before it', ageOn(birth, new Date(2026, 5, 14)), 35);
test('on the day itself, the age goes up', ageOn(birth, new Date(2026, 5, 15)), 36);
test('and stays up the day after', ageOn(birth, new Date(2026, 5, 16)), 36);
test('an earlier month counts as still to come', ageOn(birth, new Date(2026, 4, 30)), 35);
test('on the day of birth, the age is zero', ageOn(birth, birth), 0);
test('someone born on 29 February is 21 on 28 February 2026', ageOn(new Date(2004, 1, 29), new Date(2026, 1, 28)), 21);
test('and 22 on 1 March', ageOn(new Date(2004, 1, 29), new Date(2026, 2, 1)), 22);
on.getFullYear() - birth.getFullYear() is right for roughly half the year and a year too high for the other half. The correction is the whole exercise, and note that it compares month and day only — never the timestamps, which would drag the time of birth and the time zone into an answer that should not depend on either.
The two 29 February assertions show the edge case without pretending there is one right answer: this implementation treats the birthday as having passed on 1 March in a common year, which is the usual reading and matches English law. Some jurisdictions say 28 February. Whichever you pick, pick it deliberately.
Answers JS Date 27 in the original set.
28 Whole months between two dates
fairCount complete months from one date to another: 15 January to 14 March is one month, 15 January to 15 March is two.
Stuck? Three hints, smallest first
- 1. Years times twelve, plus the difference in months, is the starting figure.
- 2. Then take one off if the day of the month has not been reached yet.
- 3. Handle the reversed case by recursing with the arguments swapped, so the answer is symmetric.
function monthsBetween(from, to) {
if (to < from) return -monthsBetween(to, from); // symmetric, so the sign is honest
let months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth());
if (to.getDate() < from.getDate()) months--; // the day of the month has not come round
return months;
}
const jan15 = new Date(2026, 0, 15);
console.log('15/01 to 15/03:', monthsBetween(jan15, new Date(2026, 2, 15)));
console.log('15/01 to 14/03:', monthsBetween(jan15, new Date(2026, 2, 14)), '<- a day short');
console.log('31/01 to 28/02:', monthsBetween(new Date(2026, 0, 31), new Date(2026, 1, 28)), '<- debatable');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('two full months', monthsBetween(jan15, new Date(2026, 2, 15)), 2);
test('one day short of two', monthsBetween(jan15, new Date(2026, 2, 14)), 1);
test('a year is twelve', monthsBetween(jan15, new Date(2027, 0, 15)), 12);
test('the same day is zero', monthsBetween(jan15, jan15), 0);
test('reversing the arguments negates it', monthsBetween(new Date(2026, 2, 15), jan15), -2);
test('31 January to 28 February counts as none', monthsBetween(new Date(2026, 0, 31), new Date(2026, 1, 28)), 0);
test('but 31 January to 3 March counts as one', monthsBetween(new Date(2026, 0, 31), new Date(2026, 2, 3)), 1);
test('30 November to 31 December is one', monthsBetween(new Date(2026, 10, 30), new Date(2026, 11, 31)), 1);
The month difference is easy; the correction for "the day has not come round yet" is the part that gets forgotten, and without it 15 January to 1 February reports a whole month.
The sixth assertion is genuinely arguable, and worth being explicit about rather than hiding. 31 January to 28 February is a whole calendar month by most people's reading, but this rule says zero, because 28 is less than 31. Libraries differ. If you need the other reading, add a clause: count it as a whole month when to is the last day of its month. Pick one, write it down, and test it — the disagreement only becomes a bug when it is undocumented.
Answers JS Date 28 in the original set.
29 Comparing dates: why === never works
fairTwo Date objects for the same instant. Show which comparison operators say they are equal and which do not, and explain the split.
Stuck? Three hints, smallest first
- 1.
===on two objects asks whether they are the same object, not whether they hold the same value. - 2. The relational operators convert to numbers first, which is why
<and>do work. - 3. So
a <= banda >= bcan both be true whilea === bis false.
const a = new Date(2026, 0, 15);
const b = new Date(2026, 0, 15); // same instant, different object
const later = new Date(2026, 0, 16);
console.log('a === b :', a === b, '<- object identity');
console.log('a <= b :', a <= b, ' a >= b:', a >= b, '<- numeric comparison');
console.log('a < later:', a < later);
console.log('b - a :', b - a, 'ms');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('=== compares objects, so it is false', a === b, false);
test('and so is ==, for the same reason', a == b, false);
test('the timestamps are what to compare', a.getTime() === b.getTime(), true);
test('< and > convert to numbers, so they work', a < later, true);
test('and the other way round', later > a, true);
test('which leaves this: not less, not greater, and not ===', [a <= b, a >= b, a === b], [true, true, false]);
test('unary + is the shortest way to compare for equality', Number(a) === Number(b), true);
test('and subtraction gives zero for the same instant', b - a, 0);
test('the deep-equality helper agrees, because JSON goes through toISOString', same(a, b), true);
The rule is the difference between the two families of operator. === and == compare object references, so two dates are equal only if they are literally the same object. <, >, <= and >= convert both sides to numbers first, so they compare instants and behave sensibly.
That gives the situation in the sixth assertion, which is the one to remember: a <= b and a >= b are both true while a === b is false. For equality, compare getTime() — or +a === +b, which is the same thing more briefly. Sorting and range checks can use the operators directly.
Answers JS Date 29 in the original set.
30 Sort an array of dates
fairSort dates oldest first. Then run sort() with no comparator on the same array and explain the order you get.
Stuck? Three hints, smallest first
- 1.
sort((a, b) => a - b)works, because subtracting two dates gives a number. - 2.
sort()with no comparator converts every element to a string first. - 3. A date’s string form starts with the weekday name, so the default sort is alphabetical by weekday.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const dates = [new Date(2026, 2, 1), new Date(2026, 0, 15), new Date(2026, 1, 20)];
// slice() first: sort mutates the array it is given.
const oldestFirst = dates.slice().sort((x, y) => x - y);
const newestFirst = dates.slice().sort((x, y) => y - x);
const noComparator = dates.slice().sort();
console.log('as given :', dates.map(iso).join(' '));
console.log('oldest first:', oldestFirst.map(iso).join(' '));
console.log('no comparator:', noComparator.map(iso).join(' '), '<- alphabetical');
console.log('why:', dates.map((d) => String(d).slice(0, 3)).join(' '), '-> Fri, Sun, Thu sorts F, S, T');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('oldest first', oldestFirst.map(iso), ['2026-01-15', '2026-02-20', '2026-03-01']);
test('newest first', newestFirst.map(iso), ['2026-03-01', '2026-02-20', '2026-01-15']);
test('the source array was not touched', dates.map(iso), ['2026-03-01', '2026-01-15', '2026-02-20']);
test('no comparator sorts by the string form, which starts with the weekday', noComparator.map(iso), ['2026-02-20', '2026-03-01', '2026-01-15']);
test('so it is not chronological', same(noComparator.map(iso), oldestFirst.map(iso)), false);
test('comparing timestamps explicitly gives the same answer as subtraction', dates.slice().sort((x, y) => x.getTime() - y.getTime()).map(iso), oldestFirst.map(iso));
test('sorting the ISO strings as text also works', dates.map(iso).slice().sort(), oldestFirst.map(iso));
test('one date sorts to itself', [new Date(2026, 0, 15)].sort((x, y) => x - y).map(iso), ['2026-01-15']);
sort((a, b) => a - b) is the answer. Subtraction converts both dates to numbers, so the comparator is a plain numeric one.
The default sort() is the interesting failure. It converts each element with String() and compares the results as text, and a date's string form begins with the abbreviated weekday: Fri Feb 20 2026, Sun Mar 01 2026, Thu Jan 15 2026. Alphabetically that is F, S, T — so the array comes back sorted by the name of the day, which looks like a shuffle. The last assertion shows the alternative worth knowing: ISO strings sort correctly as plain text, which is why ISO is the right storage format.
Answers JS Date 30 in the original set.
31 The earliest and latest date in a list
fairFind the oldest and newest date in an array. Then say what your code does with an empty array, because the obvious version produces something surprising.
Stuck? Three hints, smallest first
- 1.
Math.minandMath.maxtake numbers, and a date converts to one. - 2. They return a number, so wrap the result in
new Date. - 3.
Math.min()with no arguments isInfinity. What isnew Date(Infinity)?
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const dates = [new Date(2026, 2, 1), new Date(2026, 0, 15), new Date(2026, 1, 20)];
// Math.min converts each date to a number, so spreading dates straight in works.
const earliest = new Date(Math.min(...dates));
const latest = new Date(Math.max(...dates));
// The empty case, guarded. Without the guard you get Infinity, and
// new Date(Infinity) is an Invalid Date that then spreads silently.
const earliestOf = (list) => (list.length === 0 ? null : new Date(Math.min(...list)));
console.log('earliest:', iso(earliest), '| latest:', iso(latest));
console.log('Math.min() with nothing ->', Math.min(), '-> new Date(Infinity) is', String(new Date(Math.min())));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the earliest date', iso(earliest), '2026-01-15');
test('the latest', iso(latest), '2026-03-01');
test('Math.min hands back a number, not a date', typeof Math.min(...dates), 'number');
test('a reduce does the same job without spreading', iso(dates.reduce((best, d) => (d < best ? d : best))), '2026-01-15');
test('one date is both earliest and latest', iso(new Date(Math.min(...[new Date(2026, 0, 15)]))), '2026-01-15');
test('the guarded version returns null for an empty list', earliestOf([]), null);
test('because the unguarded one gives an Invalid Date', Number.isNaN(new Date(Math.min()).getTime()), true);
test('and the source array is untouched', dates.length, 3);
Spreading dates into Math.min works because each one converts to its timestamp, and the result has to be wrapped back up in new Date — that is the step people forget, and it produces a number where a date is expected, which then formats as a nine-hundred-billion-something.
The empty-array case deserves the guard. Math.min() is Infinity by definition, and new Date(Infinity) is an Invalid Date rather than an error, so an empty list quietly produces a date-shaped value that fails much later and somewhere else. Also worth knowing: spreading is fine for tens of dates but throws on hundreds of thousands, because each element becomes an argument. Use reduce when the list might be large.
Answers JS Date 31 in the original set.
32 Are two dates on the same day?
fairDecide whether two dates fall on the same calendar day, ignoring the time. Show why comparing timestamps cannot answer this.
Stuck? Three hints, smallest first
- 1. Compare year, month and day. Three comparisons, not one.
- 2. All three are needed: the same day number in a different month is not the same day.
- 3. Two dates 90 minutes apart can be on different days, and two dates 23 hours apart can be on the same one.
const isSameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
const morning = new Date(2026, 0, 15, 8, 0);
const evening = new Date(2026, 0, 15, 22, 30);
const justBeforeMidnight = new Date(2026, 0, 15, 23, 59, 59, 999);
const justAfterMidnight = new Date(2026, 0, 16, 0, 0, 30);
console.log('08:00 and 22:30 on the 15th:', isSameDay(morning, evening));
console.log('23:59:59.999 and 00:00:30 :', isSameDay(justBeforeMidnight, justAfterMidnight), '<- 31ms apart');
console.log('their timestamps differ by', justAfterMidnight - justBeforeMidnight, 'ms');
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('fourteen hours apart, same day', isSameDay(morning, evening), true);
test('the very ends of the day still count', isSameDay(morning, justBeforeMidnight), true);
test('31 milliseconds apart, different days', isSameDay(justBeforeMidnight, justAfterMidnight), false);
test('a date is always the same day as itself', isSameDay(morning, morning), true);
test('the same day number in another month is not the same day', isSameDay(new Date(2026, 0, 15), new Date(2026, 1, 15)), false);
test('nor in another year', isSameDay(new Date(2026, 0, 15), new Date(2027, 0, 15)), false);
test('comparing timestamps answers a different question', morning.getTime() === evening.getTime(), false);
test('comparing the ISO date text is the same test, spelled differently', isSameDay(morning, evening), morning.toDateString() === evening.toDateString());
"Same day" is a question about calendar fields, not about how far apart two instants are. The two assertions in the middle make the point from both sides: 31 milliseconds can straddle a day boundary, and fourteen hours can sit comfortably inside one.
All three fields have to be compared. Checking only getDate() is a bug that hides for a month at a time, which is the worst duration for a bug to hide.
Answers JS Date 32 in the original set.
33 The start and the end of a day
fairReturn midnight at the start of a date's day, and the last millisecond of it, without changing the input.
Stuck? Three hints, smallest first
- 1.
setHourstakes minutes, seconds and milliseconds as further arguments, so one call does all four. - 2. The last millisecond is 23:59:59.999, not 24:00:00.
- 3. Copy first. These are the two most-copied helpers in any date module.
function startOfDay(date) {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0); // hours, minutes, seconds, milliseconds
return copy;
}
function endOfDay(date) {
const copy = new Date(date);
copy.setHours(23, 59, 59, 999);
return copy;
}
// 15 January 2026, mid-afternoon. A date with no clock change anywhere, which
// is what makes the length assertion below safe to write.
const afternoon = new Date(2026, 0, 15, 15, 42, 17, 88);
console.log('given :', afternoon.toString().slice(0, 24));
console.log('start :', startOfDay(afternoon).toString().slice(0, 24));
console.log('end :', endOfDay(afternoon).toString().slice(0, 24));
console.log('the input is still', afternoon.getHours() + ':' + afternoon.getMinutes());
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the start of the day is all zeroes', [startOfDay(afternoon).getHours(), startOfDay(afternoon).getMinutes(), startOfDay(afternoon).getSeconds(), startOfDay(afternoon).getMilliseconds()], [0, 0, 0, 0]);
test('the end of the day is 23:59:59.999', [endOfDay(afternoon).getHours(), endOfDay(afternoon).getMinutes(), endOfDay(afternoon).getSeconds(), endOfDay(afternoon).getMilliseconds()], [23, 59, 59, 999]);
test('the calendar date does not move', startOfDay(afternoon).getDate(), 15);
test('for either of them', endOfDay(afternoon).getDate(), 15);
test('the start is at or before the original', startOfDay(afternoon) <= afternoon, true);
test('and the end is at or after it', endOfDay(afternoon) >= afternoon, true);
test('on a day with no clock change, they are 86399999ms apart', endOfDay(afternoon) - startOfDay(afternoon), 86399999);
test('and the input was not modified', afternoon.getHours(), 15);
setHours(0, 0, 0, 0) in one call is the idiom — forgetting the milliseconds argument leaves a value between 0 and 999 in there, which then makes an otherwise-correct range check fail for one day in a thousand.
The length assertion is deliberately qualified. A local day is not always 86,400,000 milliseconds: in a zone that shifts its clocks, one day a year is an hour shorter and another an hour longer. 15 January is a day on which no zone changes its clocks, which is why the figure can be asserted at all. Never assume a day is 24 hours in local time; that assumption is what exercise 53 pulls apart.
Answers JS Date 33 in the original set.
34 The start and end of the week, both conventions
fairFind the first day of the week containing a date, for a week starting on Sunday and for a week starting on Monday. Then the last day.
Stuck? Three hints, smallest first
- 1. How far back you go is
getDay()minus the first day of the week. - 2. That can be negative, so add 7 and take the remainder to bring it into 0 to 6.
- 3. The end of the week is six days after the start, not seven.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
function startOfWeek(date, firstDay) {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
const shift = (copy.getDay() - firstDay + 7) % 7; // + 7 so it is never negative
copy.setDate(copy.getDate() - shift);
return copy;
}
function endOfWeek(date, firstDay) {
const copy = startOfWeek(date, firstDay);
copy.setDate(copy.getDate() + 6); // six, not seven
return copy;
}
const thursday = new Date(2026, 0, 15); // 15 January 2026
const sunday = new Date(2026, 0, 18); // 18 January 2026
console.log('Thu 15/01, week from Sunday:', iso(startOfWeek(thursday, 0)), 'to', iso(endOfWeek(thursday, 0)));
console.log('Thu 15/01, week from Monday:', iso(startOfWeek(thursday, 1)), 'to', iso(endOfWeek(thursday, 1)));
console.log('Sun 18/01, week from Monday:', iso(startOfWeek(sunday, 1)), '<- last Monday, not today');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('Sunday-first, the week of Thursday 15 January starts on the 11th', iso(startOfWeek(thursday, 0)), '2026-01-11');
test('Monday-first, it starts on the 12th', iso(startOfWeek(thursday, 1)), '2026-01-12');
test('Sunday-first, a Sunday is its own start', iso(startOfWeek(sunday, 0)), '2026-01-18');
test('Monday-first, that Sunday belongs to the previous week', iso(startOfWeek(sunday, 1)), '2026-01-12');
test('the end of the Monday-first week is the Sunday', iso(endOfWeek(thursday, 1)), '2026-01-18');
test('and of the Sunday-first week, the Saturday', iso(endOfWeek(thursday, 0)), '2026-01-17');
test('the start always lands on the requested weekday', startOfWeek(thursday, 1).getDay(), 1);
test('the time is set to midnight', startOfWeek(thursday, 1).getHours(), 0);
test('crossing a month end works', iso(startOfWeek(new Date(2026, 1, 3), 1)), '2026-02-02');
(getDay() - firstDay + 7) % 7 is the piece worth memorising. Without the + 7, a Sunday in a Monday-first week gives -1, and -1 % 7 in JavaScript is -1 rather than 6, so you move forward a day and land in the wrong week entirely.
The two conventions genuinely differ, and the fourth assertion is the case that catches it: a Sunday is the first day of its week in the American reading and the last day of the previous week in the ISO one. Whichever your product uses, pass it in rather than assuming — and note that the ISO week number in exercise 50 is defined only for Monday-first weeks.
Answers JS Date 34 in the original set.
35 The start and end of the month
fairReturn the first and last day of the month containing a date. The last day should be right for February in any year.
Stuck? Three hints, smallest first
- 1. The first is day 1 of the same month, which needs no arithmetic at all.
- 2. The last is day 0 of the next month.
- 3. Build a fresh date from the parts rather than mutating, so the time is midnight without extra work.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
// Built from parts, so both come back at local midnight with no setHours call.
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
const endOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0);
const midJanuary = new Date(2026, 0, 15, 14, 30);
console.log('January 2026 :', iso(startOfMonth(midJanuary)), 'to', iso(endOfMonth(midJanuary)));
console.log('February 2026:', iso(endOfMonth(new Date(2026, 1, 10))));
console.log('February 2024:', iso(endOfMonth(new Date(2024, 1, 10))), '<- leap year');
console.log('December 2026:', iso(endOfMonth(new Date(2026, 11, 5))));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the first of the month', iso(startOfMonth(midJanuary)), '2026-01-01');
test('the last of January', iso(endOfMonth(midJanuary)), '2026-01-31');
test('the last of February 2026', iso(endOfMonth(new Date(2026, 1, 10))), '2026-02-28');
test('and of February 2024', iso(endOfMonth(new Date(2024, 1, 10))), '2024-02-29');
test('December rolls the year in the arithmetic but not in the answer', iso(endOfMonth(new Date(2026, 11, 5))), '2026-12-31');
test('the end of the month is still in that month', endOfMonth(midJanuary).getMonth(), 0);
test('the time is midnight, because the date was built from parts', [startOfMonth(midJanuary).getHours(), startOfMonth(midJanuary).getMinutes()], [0, 0]);
test('1 January 2026 was a Thursday', startOfMonth(midJanuary).getDay(), 4);
test('and the last day number is the month length', endOfMonth(midJanuary).getDate(), 31);
new Date(year, month + 1, 0) is the whole answer for the end of the month, and it handles December — where month + 1 is 12 — by rolling into January of the next year and then stepping back one day. The result is 31 December of the original year, as the fifth assertion shows.
Building from parts rather than copying and mutating gives you midnight for free. It also sidesteps a subtle failure: if you copy a date on the 31st and call setMonth, you are back in the overflow trap from exercise 24.
Answers JS Date 35 in the original set.
36 Which quarter is this date in?
fairReturn 1 to 4 for a date's calendar quarter, then the first and last day of that quarter.
Stuck? Three hints, smallest first
- 1. Three months to a quarter, so integer-divide the zero-based month by 3.
- 2. Add one at the end, because quarters are numbered from 1.
- 3. The quarter’s first month is
(quarter - 1) * 3, and its last day is day 0 of the month after it.
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
const quarterOf = (date) => Math.floor(date.getMonth() / 3) + 1;
const startOfQuarter = (date) =>
new Date(date.getFullYear(), (quarterOf(date) - 1) * 3, 1);
const endOfQuarter = (date) =>
new Date(date.getFullYear(), quarterOf(date) * 3, 0);
const may = new Date(2026, 4, 15); // 15 May 2026
console.log('15/05/2026 is in Q' + quarterOf(may));
console.log('that quarter runs', iso(startOfQuarter(may)), 'to', iso(endOfQuarter(may)));
console.log('every month:', Array.from({ length: 12 }, (unused, m) => quarterOf(new Date(2026, m, 1))).join(''));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('January is in Q1', quarterOf(new Date(2026, 0, 15)), 1);
test('March is the last month of Q1', quarterOf(new Date(2026, 2, 31)), 1);
test('April starts Q2', quarterOf(new Date(2026, 3, 1)), 2);
test('December is in Q4', quarterOf(new Date(2026, 11, 25)), 4);
test('all twelve months map cleanly', Array.from({ length: 12 }, (unused, m) => quarterOf(new Date(2026, m, 1))), [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]);
test('Q2 begins on 1 April', iso(startOfQuarter(may)), '2026-04-01');
test('and ends on 30 June', iso(endOfQuarter(may)), '2026-06-30');
test('Q4 ends with the year', iso(endOfQuarter(new Date(2026, 11, 5))), '2026-12-31');
test('Q1 ends on 31 March, leap year or not', iso(endOfQuarter(new Date(2024, 1, 29))), '2024-03-31');
Math.floor(month / 3) + 1 works precisely because getMonth() is zero-based — one of the few places the zero-based month is a convenience rather than a nuisance. If you had a 1-to-12 month you would need Math.ceil(month / 3) instead.
These are calendar quarters. A financial year that starts in April, as the UK's does, has its Q1 running April to June, which is this function with the month shifted before the division. Do not assume the calendar quarter is the one being asked for.
Answers JS Date 36 in the original set.
37 Which day of the year is it?
fairReturn 1 for 1 January and 365 or 366 for 31 December. Then go the other way: turn a day-of-year number back into a date.
Stuck? Three hints, smallest first
- 1. Count the days from 1 January and add one, so that 1 January is day 1 rather than day 0.
- 2. Normalise both ends to
Date.UTCso a clock change cannot make the division fractional. - 3. For the inverse, remember that the constructor rolls day 60 of January into 1 March.
const MS_PER_DAY = 86400000;
function dayOfYear(date) {
const firstOfJanuary = Date.UTC(date.getFullYear(), 0, 1);
const thisDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
return (thisDay - firstOfJanuary) / MS_PER_DAY + 1; // + 1 so 1 January is day 1
}
// The inverse, leaning on the constructor's overflow: day 60 of January is
// 1 March in a common year, which is exactly the date wanted.
const fromDayOfYear = (year, day) => new Date(year, 0, day);
const iso = (d) => d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
console.log('01/01/2026 ->', dayOfYear(new Date(2026, 0, 1)));
console.log('01/03/2026 ->', dayOfYear(new Date(2026, 2, 1)));
console.log('01/03/2024 ->', dayOfYear(new Date(2024, 2, 1)), '<- one more, 29 February is behind it');
console.log('day 60 of 2026 ->', iso(fromDayOfYear(2026, 60)));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('1 January is day 1, not day 0', dayOfYear(new Date(2026, 0, 1)), 1);
test('15 January is day 15', dayOfYear(new Date(2026, 0, 15)), 15);
test('1 March in a common year is day 60', dayOfYear(new Date(2026, 2, 1)), 60);
test('and day 61 in a leap year', dayOfYear(new Date(2024, 2, 1)), 61);
test('31 December of a common year is 365', dayOfYear(new Date(2026, 11, 31)), 365);
test('and 366 of a leap year', dayOfYear(new Date(2024, 11, 31)), 366);
test('the inverse round-trips', iso(fromDayOfYear(2026, 60)), '2026-03-01');
test('and back again', dayOfYear(fromDayOfYear(2026, 200)), 200);
test('day 366 of a common year spills into the next', iso(fromDayOfYear(2026, 366)), '2027-01-01');
The + 1 is the only thing to get right, and it is the difference between an ordinal day number and an elapsed-days count. Day-of-year is defined so that 1 January is 1.
fromDayOfYear is a good illustration of overflow used deliberately: new Date(2026, 0, 200) means "the 200th day of January", which the constructor normalises to 19 July. The last assertion shows the boundary — day 366 of a common year is 1 January of the next, which is exactly what you want if you are stepping and exactly what you must guard against if you are validating.
Answers JS Date 37 in the original set.
38 Unix seconds and JavaScript milliseconds
fairConvert a date to a Unix timestamp in seconds and back. Then show what happens when a seconds value is handed to new Date by mistake.
Stuck? Three hints, smallest first
- 1. JavaScript counts milliseconds; almost every other system counts seconds. The factor is 1000.
- 2. Divide and floor going out, multiply coming back in.
- 3. A seconds value read as milliseconds gives a date in 1970, which is the classic symptom.
const toUnixSeconds = (date) => Math.floor(date.getTime() / 1000);
const fromUnixSeconds = (seconds) => new Date(seconds * 1000);
const instant = new Date(Date.UTC(2026, 0, 15));
// The mistake: a seconds value passed straight in.
const asSeconds = new Date(1000000000); // read as milliseconds -> 1970
const asMilliseconds = new Date(1000000000 * 1000);
console.log('date -> seconds:', toUnixSeconds(instant));
console.log('seconds -> date:', fromUnixSeconds(1768435200).toISOString());
console.log('1000000000 as ms:', asSeconds.toISOString(), '<- eleven days after the epoch');
console.log('1000000000 as s :', asMilliseconds.toISOString(), '<- September 2001');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('15 January 2026 UTC in Unix seconds', toUnixSeconds(instant), 1768435200);
test('and back again', fromUnixSeconds(1768435200).toISOString(), '2026-01-15T00:00:00.000Z');
test('the round trip is lossless for a whole second', toUnixSeconds(fromUnixSeconds(1768435200)), 1768435200);
test('but sub-second precision is discarded', toUnixSeconds(new Date(Date.UTC(2026, 0, 15, 0, 0, 0, 999))), 1768435200);
test('a seconds value read as milliseconds lands in 1970', asSeconds.getUTCFullYear(), 1970);
test('while the same number times 1000 is 2001', asMilliseconds.getUTCFullYear(), 2001);
test('so a "date" in 1970 usually means a missing multiplication', asSeconds.getUTCMonth(), 0);
test('floor, not trunc: before 1970 they differ', toUnixSeconds(new Date(-1500)), -2);
test('whereas Math.trunc would give -1 and lose half a second', Math.trunc(-1500 / 1000), -1);
The factor of 1000 is the single most common integration bug between JavaScript and anything else. A date that renders as 1 January 1970 or as a day in mid-January 1970 is almost always a seconds value that was never multiplied.
Math.floor rather than Math.trunc matters only for dates before 1970, where the timestamp is negative — and there the two disagree by a second, with trunc rounding towards the epoch. Unix seconds are defined as the floor, so use floor and the pre-1970 cases stay correct.
Answers JS Date 38 in the original set.
39 getTime, valueOf, and why adding two dates gives a string
fairShow the ways of getting a date's number out of it, and then the asymmetry: subtracting two dates gives a number, adding them gives text.
Stuck? Three hints, smallest first
- 1.
getTime(),valueOf(),Number(d)and+dall give the same number. - 2.
-converts both operands to numbers.+does not: it asks for a primitive with no preference. - 3. A date asked for a primitive with no preference returns its string, unlike almost every other object.
const start = new Date(Date.UTC(2026, 0, 15));
const end = new Date(Date.UTC(2026, 0, 16));
const difference = end - start; // number: - forces both sides to numbers
const joined = start + end; // string: + prefers the string form
console.log('getTime :', start.getTime());
console.log('valueOf :', start.valueOf());
console.log('Number(d):', Number(start));
console.log('end - start:', difference, typeof difference);
console.log('start + end:', typeof joined, '<- two long date strings glued together');
console.log('JSON :', JSON.stringify(start));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('valueOf is getTime', start.valueOf(), start.getTime());
test('and so is Number()', Number(start), start.getTime());
test('subtracting gives milliseconds, as a number', difference, 86400000);
test('typed as a number', typeof difference, 'number');
test('but adding gives a string, which is almost never what you want', typeof joined, 'string');
test('because a date prefers its string form', String(start), start.toString());
test('JSON.stringify uses toISOString instead, and quotes it', JSON.stringify(start), '"2026-01-15T00:00:00.000Z"');
test('so a date survives a JSON round trip as text, not as a Date', typeof JSON.parse(JSON.stringify(start)), 'string');
test('Date.parse undoes toISOString exactly', Date.parse(start.toISOString()), start.getTime());
Date is the one built-in whose Symbol.toPrimitive prefers a string when no preference is given. That is why date2 - date1 is a millisecond count and date1 + date2 is two long English date strings concatenated. If you want the sum of two timestamps, ask for the numbers: +date1 + +date2.
The JSON pair is worth remembering when data crosses a wire. JSON.stringify serialises a date through toISOString, so it goes out as UTC text — and JSON.parse does not turn it back into a Date. Whatever comes back is a string until you revive it yourself.
Answers JS Date 39 in the original set.
40 Does a date fall inside a range?
fairCheck whether a date lies between two others. Do it inclusively and half-openly, and show why a range ending at midnight excludes almost all of its last day.
Stuck? Three hints, smallest first
- 1. Compare timestamps, or use the relational operators directly — both work on dates.
- 2. Inclusive means
>=and<=; half-open means>=and<. - 3. A date range written as two calendar dates really means "from the start of the first to the END of the last".
const isWithin = (date, from, to) => date >= from && date <= to;
const isWithinHalfOpen = (date, from, to) => date >= from && date < to;
const endOfDay = (date) => {
const copy = new Date(date);
copy.setHours(23, 59, 59, 999);
return copy;
};
const from = new Date(2026, 0, 1);
const to = new Date(2026, 0, 15);
const lateOnTheLastDay = new Date(2026, 0, 15, 23, 0);
console.log('01/01 to 15/01 contains 10/01 :', isWithin(new Date(2026, 0, 10), from, to));
console.log('...contains 15/01 at 23:00 :', isWithin(lateOnTheLastDay, from, to), '<- surprise');
console.log('...with the end pushed to 23:59:59.999:', isWithin(lateOnTheLastDay, from, endOfDay(to)));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a date in the middle is inside', isWithin(new Date(2026, 0, 10), from, to), true);
test('the lower bound is included', isWithin(from, from, to), true);
test('so is the upper bound, at exactly midnight', isWithin(to, from, to), true);
test('but not in the half-open version', isWithinHalfOpen(to, from, to), false);
test('a date before the range is out', isWithin(new Date(2025, 11, 31), from, to), false);
test('and one after it', isWithin(new Date(2026, 0, 16), from, to), false);
test('23:00 on the last day is OUTSIDE, because the range ends at midnight', isWithin(lateOnTheLastDay, from, to), false);
test('extending the end to the last millisecond fixes it', isWithin(lateOnTheLastDay, from, endOfDay(to)), true);
test('a backwards range contains nothing', isWithin(new Date(2026, 0, 10), to, from), false);
The seventh assertion is the bug this exercise exists for. "1 January to 15 January" reads like fifteen whole days, but as timestamps it is 1 January 00:00:00.000 to 15 January 00:00:00.000, so everything after midnight on the 15th falls outside — a report that quietly drops its last day.
Two ways out, and both are fine as long as you choose: push the end to endOfDay, or use a half-open range [from, to) with to set to the start of the day after. Half-open is what most date libraries and most databases use, because adjacent ranges then tile with no gap and no overlap.
Answers JS Date 40 in the original set.
The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.