Emmanuel Akinfulubi

JavaScript: Date, 41–57

Reading, formatting and comparing dates without a library.

Exercises 41 to 57 of 57

41 Do two date ranges overlap?

fair

Given two ranges, each with a start and an end, decide whether they overlap. Two comparisons are enough.

Stuck? Three hints, smallest first
  1. 1. Do not enumerate the cases. There are four ways to overlap and only two ways not to.
  2. 2. They miss each other only if one ends before the other starts.
  3. 3. So overlap is aStart <= bEnd && bStart <= aEnd.

The answer

// Inclusive ranges: two comparisons, derived by negating "they miss".
const overlaps = (aStart, aEnd, bStart, bEnd) => aStart <= bEnd && bStart <= aEnd;

// Half-open ranges, where touching does not count as overlapping.
const overlapsHalfOpen = (aStart, aEnd, bStart, bEnd) => aStart < bEnd && bStart < aEnd;

const d = (day) => new Date(2026, 0, day);      // January 2026, for brevity

// A 1-15, B 10-20, C 20-25, inside 5-8, touching 15-20.
console.log('1-15 vs 10-20:', overlaps(d(1), d(15), d(10), d(20)));
console.log('1-15 vs 20-25:', overlaps(d(1), d(15), d(20), d(25)));
console.log('1-15 vs 15-20:', overlaps(d(1), d(15), d(15), d(20)), '<- touching, inclusive');
console.log('1-15 vs 15-20:', overlapsHalfOpen(d(1), d(15), d(15), d(20)), '<- touching, half-open');

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.

9 checks

test('ranges that share days overlap', overlaps(d(1), d(15), d(10), d(20)), true);
test('ranges that do not, do not', overlaps(d(1), d(15), d(20), d(25)), false);
test('one range wholly inside another overlaps', overlaps(d(1), d(15), d(5), d(8)), true);
test('and it does not matter which is passed first', overlaps(d(5), d(8), d(1), d(15)), true);
test('a range overlaps itself', overlaps(d(1), d(15), d(1), d(15)), true);
test('ranges that touch at one point overlap, inclusively', overlaps(d(1), d(15), d(15), d(20)), true);
test('but not on the half-open reading', overlapsHalfOpen(d(1), d(15), d(15), d(20)), false);
test('a single-day range inside a longer one overlaps', overlaps(d(10), d(10), d(1), d(15)), true);
test('and outside it does not', overlaps(d(30), d(30), d(1), d(15)), false);

The temptation is to enumerate: A starts inside B, A ends inside B, A contains B, B contains A. That is four conditions, easy to get wrong, and unnecessary — two ranges fail to overlap only when one finishes before the other begins, so negating that gives aStart <= bEnd && bStart <= aEnd and nothing else is needed.

Inclusive or half-open is a real decision, not a detail. For meeting rooms, a booking that ends at 10:00 and one that starts at 10:00 do not clash, so half-open is right. For "which days did this promotion run", inclusive is right. The touching assertions above are the two readings side by side.

42 Clamp a date into a range

fair

Given a date and a minimum and maximum, return the date if it is inside the range and the nearest bound if it is not.

Stuck? Three hints, smallest first
  1. 1. Math.min and Math.max on the timestamps, then wrap the result.
  2. 2. Nest them: clamp the low end first, then the high end.
  3. 3. Return a new date rather than the argument, so the caller cannot mutate your bounds by accident.

The answer

const iso = (d) => d.getFullYear() + '-' +
  String(d.getMonth() + 1).padStart(2, '0') + '-' +
  String(d.getDate()).padStart(2, '0');

// Always a fresh Date, so nothing the caller holds can be mutated through it.
function clampDate(date, min, max) {
  return new Date(Math.min(Math.max(date.getTime(), min.getTime()), max.getTime()));
}

const min = new Date(2026, 0, 10);
const max = new Date(2026, 0, 20);

console.log('15/01 clamped to 10-20:', iso(clampDate(new Date(2026, 0, 15), min, max)));
console.log('01/01 clamped to 10-20:', iso(clampDate(new Date(2026, 0, 1), min, max)), '<- pulled up');
console.log('31/01 clamped to 10-20:', iso(clampDate(new Date(2026, 0, 31), min, max)), '<- pulled down');

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.

8 checks

test('a date inside the range is unchanged', iso(clampDate(new Date(2026, 0, 15), min, max)), '2026-01-15');
test('one below the minimum comes back as the minimum', iso(clampDate(new Date(2026, 0, 1), min, max)), '2026-01-10');
test('one above the maximum comes back as the maximum', iso(clampDate(new Date(2026, 0, 31), min, max)), '2026-01-20');
test('a date on the boundary is kept', iso(clampDate(min, min, max)), '2026-01-10');
test('and so is the other boundary', iso(clampDate(max, min, max)), '2026-01-20');
test('the result is a new object, never the argument', clampDate(min, min, max) === min, false);
test('though it holds the same instant', clampDate(min, min, max).getTime(), min.getTime());
test('bounds the wrong way round always give the maximum, so check them yourself', iso(clampDate(new Date(2026, 0, 15), max, min)), '2026-01-10');

Clamping is where a "last 30 days" control stops asking for data from before the account existed: clamp the computed start against the account's creation date and the awkward case disappears.

The last assertion is the honest caveat. If min and max arrive the wrong way round, the nested Math.min/Math.max silently returns whichever bound comes second in the nesting, with no complaint. If the bounds come from user input, check min <= max before you clamp; the function cannot tell a swapped pair from a deliberate one.

43 "3 days ago" and "in 2 hours"

fair

Describe one date relative to another in words. Handle past and future, get the plural right, and say "just now" for anything within the last few seconds.

Stuck? Three hints, smallest first
  1. 1. Work in seconds, keep the sign to tell past from future, and use the size for the wording.
  2. 2. A table of unit names and their length in seconds, largest first, beats a chain of ifs.
  3. 3. Build the offsets in milliseconds from one fixed "now", so the test cannot be affected by the clock or the zone.

The answer

const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

// Largest first, so the first match is the coarsest sensible unit.
const UNITS = [
  ['year', 31536000], ['month', 2592000], ['week', 604800],
  ['day', 86400], ['hour', 3600], ['minute', 60], ['second', 1],
];

function relativeTime(then, now) {
  const seconds = Math.round((now - then) / 1000);   // positive = in the past
  const size = Math.abs(seconds);
  if (size < 45) return 'just now';

  const [name, length] = UNITS.find((unit) => size >= unit[1]);
  const count = Math.floor(size / length);
  const label = count + ' ' + name + (count === 1 ? '' : 's');   // no "1 days"
  return seconds > 0 ? label + ' ago' : 'in ' + label;
}

// Offsets are built in milliseconds from one fixed instant, so this is the same
// in every time zone.
const now = new Date(2026, 0, 15, 12, 0, 0);
const off = (ms) => new Date(now.getTime() - ms);

console.log('3 days back   ->', relativeTime(off(3 * DAY), now));
console.log('1 day back    ->', relativeTime(off(DAY), now));
console.log('2 hours ahead ->', relativeTime(off(-2 * HOUR), now));
console.log('the live clock ->', relativeTime(off(90 * MINUTE), new Date(now.getTime())));

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.

9 checks

test('three days in the past', relativeTime(off(3 * DAY), now), '3 days ago');
test('one day is singular', relativeTime(off(DAY), now), '1 day ago');
test('the future reads the other way round', relativeTime(off(-2 * HOUR), now), 'in 2 hours');
test('and is singular too where it should be', relativeTime(off(-HOUR), now), 'in 1 hour');
test('anything within 45 seconds is just now', relativeTime(off(30 * SECOND), now), 'just now');
test('90 minutes rounds down to the hour', relativeTime(off(90 * MINUTE), now), '1 hour ago');
test('seven days is reported as a week', relativeTime(off(7 * DAY), now), '1 week ago');
test('and 400 days as a year', relativeTime(off(400 * DAY), now), '1 year ago');
test('the same instant is just now', relativeTime(now, now), 'just now');

Two things make this testable. The unit table replaces a ladder of ifs, and both dates are arguments — a relativeTime(then) that reads Date.now() internally can only be checked by eye.

The rounding is deliberately coarse and deliberately downward: 90 minutes is "1 hour ago", not "2 hours ago", because saying more time has passed than really has is the worse error. Note that the month and year lengths in the table are approximations, 30 and 365 days, which is fine for a phrase like "2 months ago" and not fine for anything you would put in an invoice. When a real month boundary matters, use monthsBetween from exercise 28.

Intl.RelativeTimeFormat does this properly, with translations and each language's plural rules. Reach for it in production; write this once to understand what it is doing.

44 Ordinal day numbers: 1st, 2nd, 3rd, 11th, 21st

fair

Add the English ordinal suffix to a day number, then use it in a date. 11, 12 and 13 do not follow the pattern the last digit suggests.

Stuck? Three hints, smallest first
  1. 1. The last digit gives st, nd, rd, th — except in the teens.
  2. 2. Check the last two digits for 11 to 13 first, and return "th" for those before looking at the last digit.
  3. 3. Test 11, 12 and 13 explicitly. They are the whole exercise.

The answer

const MONTH_NAMES = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];

function ordinal(n) {
  const lastTwo = n % 100;
  if (lastTwo >= 11 && lastTwo <= 13) return n + 'th';   // eleventh, twelfth, thirteenth

  switch (n % 10) {
    case 1: return n + 'st';
    case 2: return n + 'nd';
    case 3: return n + 'rd';
    default: return n + 'th';
  }
}

const withOrdinal = (date) =>
  ordinal(date.getDate()) + ' ' + MONTH_NAMES[date.getMonth()] + ' ' + date.getFullYear();

console.log('1 to 5  :', [1, 2, 3, 4, 5].map(ordinal).join(' '));
console.log('11 to 13:', [11, 12, 13].map(ordinal).join(' '), '<- the exception');
console.log('21 to 23:', [21, 22, 23].map(ordinal).join(' '), '<- back to the pattern');
console.log('15/01/2026 ->', withOrdinal(new Date(2026, 0, 15)));

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.

9 checks

test('the first three follow the last digit', [1, 2, 3].map(ordinal), ['1st', '2nd', '3rd']);
test('four onwards is th', [4, 5, 6].map(ordinal), ['4th', '5th', '6th']);
test('the teens break the pattern', [11, 12, 13].map(ordinal), ['11th', '12th', '13th']);
test('but the twenties restore it', [21, 22, 23].map(ordinal), ['21st', '22nd', '23rd']);
test('the last day of a long month', ordinal(31), '31st');
test('checking the last two digits also handles 111', ordinal(111), '111th');
test('a date, day first', withOrdinal(new Date(2026, 0, 15)), '15th January 2026');
test('with no leading zero on a single-digit day', withOrdinal(new Date(2026, 1, 1)), '1st February 2026');
test('across a month, only 1, 21 and 31 take st', Array.from({ length: 31 }, (unused, i) => i + 1).filter((n) => ordinal(n).endsWith('st')), [1, 21, 31]);

n % 10 === 1 ? 'st' is right for 1, 21 and 31 and wrong for 11, which is why the last-two-digits check has to come first. Testing the modulo 100 rather than a literal 11 || 12 || 13 costs nothing and stays correct for 111 and 112, which matters the moment you use the same helper for anything other than a day of the month.

Suffixes like this are English-only. There is no locale-independent way to build them by hand, which is a good argument for keeping ordinals out of any string that has to be translated.

45 A long, readable date by hand

fair

Produce Thursday, 15 January 2026 and a version with the time. Day-first order, since that is what most of the world writes.

Stuck? Three hints, smallest first
  1. 1. You already have the weekday and month arrays; this is assembly.
  2. 2. Do not pad the day of the month in a long format — 15 January, not 15 with a leading zero.
  3. 3. The locale-aware version is one call, and its output depends on the machine, so it can be shown but not asserted.

The answer

const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTH_NAMES = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December',
];

const pad2 = (n) => String(n).padStart(2, '0');

// Day first: "Thursday, 15 January 2026".
const longDate = (date) =>
  DAY_NAMES[date.getDay()] + ', ' + date.getDate() + ' ' +
  MONTH_NAMES[date.getMonth()] + ' ' + date.getFullYear();

const shortDate = (date) =>
  DAY_NAMES[date.getDay()].slice(0, 3) + ', ' + date.getDate() + ' ' +
  MONTH_NAMES[date.getMonth()].slice(0, 3) + ' ' + date.getFullYear();

function longDateTime(date) {
  const h24 = date.getHours();
  const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
  const suffix = h24 < 12 ? 'AM' : 'PM';
  return longDate(date) + ' at ' + h12 + ':' + pad2(date.getMinutes()) + ' ' + suffix;
}

const afternoon = new Date(2026, 0, 15, 14, 30);

console.log(longDate(afternoon));
console.log(shortDate(afternoon));
console.log(longDateTime(afternoon));

// The built-in, which translates and reorders for the reader's locale. Shown
// only: on a machine set to fr-FR this prints "jeudi 15 janvier 2026".
console.log('toLocaleDateString:', afternoon.toLocaleDateString(undefined, { dateStyle: 'full' }));

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.

8 checks

test('the long form, day first', longDate(afternoon), 'Thursday, 15 January 2026');
test('a Sunday in March', longDate(new Date(2026, 2, 1)), 'Sunday, 1 March 2026');
test('no leading zero on the day', longDate(new Date(2026, 1, 5)), 'Thursday, 5 February 2026');
test('the short form', shortDate(afternoon), 'Thu, 15 Jan 2026');
test('with the time, in twelve-hour form', longDateTime(afternoon), 'Thursday, 15 January 2026 at 2:30 PM');
test('midnight reads as 12 AM', longDateTime(new Date(2026, 0, 15, 0, 0)), 'Thursday, 15 January 2026 at 12:00 AM');
test('the locale version returns a string, which is all that can be promised', typeof afternoon.toLocaleDateString(undefined, { dateStyle: 'full' }), 'string');
test('and it is not empty', afternoon.toLocaleDateString().length > 0, true);

Assembly, with one judgement call: no leading zero. 05 February looks like machine output, 5 February looks like writing.

The last two assertions are as far as a test can honestly go with toLocaleDateString. Its output depends on the machine's locale and on which languages the runtime's ICU data includes, so asserting 'Thursday, 15 January 2026' against it would pass on the machine that wrote it and fail somewhere else. In production it is the right tool, because it translates and reorders for each reader; in a test suite, assert your own formatter and let the built-in be shown.

46 Working days between two dates

hard

Count the Monday-to-Friday days from one date to another, counting both ends. A reversed range counts as none.

Stuck? Three hints, smallest first
  1. 1. Work out how many days the range covers, then look at the weekday of each one.
  2. 2. Step through the days in UTC. A local-midnight cursor is not safe: in a zone that changes its clocks at midnight it drifts to 01:00 and the last day gets missed.
  3. 3. getUTCDay() on a UTC-midnight instant is the weekday of that calendar date.

The answer

const MS_PER_DAY = 86400000;
const isWeekendDay = (weekday) => weekday === 0 || weekday === 6;

// Both ends re-expressed as UTC midnight, so every step below is exactly 24 hours.
const utcDay = (date) => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());

function workingDaysBetween(from, to) {
  const start = utcDay(from);
  const end = utcDay(to);
  if (end < start) return 0;                        // a backwards range spans nothing

  const span = (end - start) / MS_PER_DAY + 1;      // + 1 because both ends count
  let count = 0;
  for (let i = 0; i < span; i++) {
    if (!isWeekendDay(new Date(start + i * MS_PER_DAY).getUTCDay())) count++;
  }
  return count;
}

// The version that looks equivalent and is not: a local cursor compared against
// a local end date. Correct in a fixed-offset zone, one day short in Santiago or
// Havana, where the clocks change AT midnight and the cursor drifts to 01:00.
function localCursorVersion(from, to) {
  const last = new Date(to.getFullYear(), to.getMonth(), to.getDate());
  const cursor = new Date(from.getFullYear(), from.getMonth(), from.getDate());
  let count = 0;
  while (cursor <= last) {
    if (!isWeekendDay(cursor.getDay())) count++;
    cursor.setDate(cursor.getDate() + 1);
  }
  return count;
}

// January 2026: the 12th is a Monday, the 16th a Friday, the 17th a Saturday.
const monday = new Date(2026, 0, 12);
const friday = new Date(2026, 0, 16);

console.log('Mon 12 to Fri 16:', workingDaysBetween(monday, friday));
console.log('Mon 12 to Sun 18:', workingDaysBetween(monday, new Date(2026, 0, 18)));
console.log('all of January   :', workingDaysBetween(new Date(2026, 0, 1), new Date(2026, 0, 31)));
console.log('all of 2026, in UTC:', workingDaysBetween(new Date(2026, 0, 1), new Date(2026, 11, 31)));
console.log('all of 2026, local cursor:', localCursorVersion(new Date(2026, 0, 1), new Date(2026, 11, 31)), '<- 260 in America/Santiago');

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.

9 checks

test('a full working week', workingDaysBetween(monday, friday), 5);
test('adding the weekend adds nothing', workingDaysBetween(monday, new Date(2026, 0, 18)), 5);
test('the Monday after brings it to six', workingDaysBetween(monday, new Date(2026, 0, 19)), 6);
test('one weekday counts as one, because both ends are included', workingDaysBetween(friday, friday), 1);
test('a single Saturday counts as none', workingDaysBetween(new Date(2026, 0, 17), new Date(2026, 0, 17)), 0);
test('January 2026 has 22 working days', workingDaysBetween(new Date(2026, 0, 1), new Date(2026, 0, 31)), 22);
test('a backwards range is zero, not negative', workingDaysBetween(friday, monday), 0);
test('the whole of 2026 has 261', workingDaysBetween(new Date(2026, 0, 1), new Date(2026, 11, 31)), 261);
test('and the times of day are ignored', workingDaysBetween(new Date(2026, 0, 12, 23, 59), new Date(2026, 0, 16, 0, 1)), 5);

Stepping day by day is O(n) and completely clear, which for a range of days or months is the right trade. There is a closed-form version — whole weeks times five, plus the leftover days that are not weekends — worth writing once, but fiddly enough that most people get it wrong in one direction or the other.

localCursorVersion is in the panel because it is the version almost everyone writes first, and it is wrong. On a machine set to Santiago — or in Node with TZ=America/Santiago — it returns 260 working days for 2026 instead of 261. Chile moves its clocks at midnight, so local midnight on one day of the year does not exist; the cursor is normalised to 01:00, keeps that hour for the rest of the year through setDate, and then fails the cursor <= last test on the final day. One day silently lost, in one time zone, at one time of year — the hardest class of bug to find by testing on your own machine.

Stepping in UTC removes the possibility: every UTC day is 24 hours, and getUTCDay() of a UTC midnight is the weekday of that calendar date. Both ends are reduced to their calendar date first, so 23:59 on the first day and 00:01 on the last still give five days rather than three. Note finally that real working days need a holiday list; this counts weekends only, and a business-day calculation without holidays is wrong in every country that has any.

47 Add business days, skipping weekends

hard

"Five working days from Friday" should be the following Friday, not the Wednesday. Write addBusinessDays, and make negative counts go backwards.

Stuck? Three hints, smallest first
  1. 1. Step one day at a time and only count down when the day you land on is a weekday.
  2. 2. Adding 7 to the count and dividing by 5 does not work: the answer depends on which day you start.
  3. 3. Zero should return the same date, even if it is a Saturday.

The answer

const iso = (d) => d.getFullYear() + '-' +
  String(d.getMonth() + 1).padStart(2, '0') + '-' +
  String(d.getDate()).padStart(2, '0');

const isWeekend = (date) => date.getDay() === 0 || date.getDay() === 6;

function addBusinessDays(date, count) {
  const copy = new Date(date);
  const step = count < 0 ? -1 : 1;
  let remaining = Math.abs(count);

  while (remaining > 0) {
    copy.setDate(copy.getDate() + step);   // move first, then decide whether it counted
    if (!isWeekend(copy)) remaining--;
  }
  return copy;
}

// January 2026: Mon 12, Fri 16, Sat 17, Mon 19, Fri 23, Mon 26.
const friday = new Date(2026, 0, 16);

console.log('Fri 16 + 1 ->', iso(addBusinessDays(friday, 1)), '<- Monday');
console.log('Fri 16 + 5 ->', iso(addBusinessDays(friday, 5)), '<- the next Friday');
console.log('Sat 17 + 1 ->', iso(addBusinessDays(new Date(2026, 0, 17), 1)));
console.log('Mon 19 - 1 ->', iso(addBusinessDays(new Date(2026, 0, 19), -1)), '<- back to Friday');

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.

9 checks

test('one business day past a Friday is the Monday', iso(addBusinessDays(friday, 1)), '2026-01-19');
test('five is the following Friday, not the Wednesday', iso(addBusinessDays(friday, 5)), '2026-01-23');
test('five from a Monday is the Monday after', iso(addBusinessDays(new Date(2026, 0, 12), 5)), '2026-01-19');
test('ten is two weeks on', iso(addBusinessDays(new Date(2026, 0, 12), 10)), '2026-01-26');
test('starting on a Saturday, one lands on the Monday', iso(addBusinessDays(new Date(2026, 0, 17), 1)), '2026-01-19');
test('a negative count goes backwards over the weekend', iso(addBusinessDays(new Date(2026, 0, 19), -1)), '2026-01-16');
test('zero returns the same date, weekend or not', iso(addBusinessDays(new Date(2026, 0, 17), 0)), '2026-01-17');
test('the result of a positive count is never a weekend', isWeekend(addBusinessDays(friday, 3)), false);
test('and the input is not modified', iso(friday), '2026-01-16');

The loop moves first and counts second, which is what makes the weekend-start case work: from a Saturday, the first step lands on Sunday and counts nothing, the second lands on Monday and counts one.

The arithmetic shortcut people reach for — five business days is seven calendar days — is only right when you start on a weekday and land before the following weekend. The Saturday and negative cases break it, which is why the assertions include both. Zero deliberately returns the date unchanged rather than "the next working day"; if you need that behaviour, make it a separate function with a name that says so.

This local cursor is safe where the one in exercise 46 was not, and the difference is worth naming: here the loop ends when a counter reaches zero, so it never compares one instant against another. setDate keeps the calendar day correct even when a clock change moves the hour, and getDay only ever reads the day. Comparing instants is what introduced the daylight-saving hazard, not stepping through days.

48 The next Monday

hard

Find the next occurrence of a given weekday after a date. Then write the variant that returns the date itself when it already is that weekday.

Stuck? Three hints, smallest first
  1. 1. The gap is (wanted - current + 7) % 7, which lands in 0 to 6.
  2. 2. A gap of 0 means the date already is that weekday, so "next" has to jump a whole week.
  3. 3. || 7 turns that 0 into 7, which is exactly the rule you want.

The answer

const iso = (d) => d.getFullYear() + '-' +
  String(d.getMonth() + 1).padStart(2, '0') + '-' +
  String(d.getDate()).padStart(2, '0');

const justTheDate = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());

// Strictly after: today never counts, so a matching weekday jumps a week.
function nextWeekday(from, weekday) {
  const copy = justTheDate(from);
  const gap = (weekday - copy.getDay() + 7) % 7 || 7;   // 0 becomes 7
  copy.setDate(copy.getDate() + gap);
  return copy;
}

// On or after: today counts if it matches.
function weekdayOnOrAfter(from, weekday) {
  const copy = justTheDate(from);
  copy.setDate(copy.getDate() + ((weekday - copy.getDay() + 7) % 7));
  return copy;
}

const thursday = new Date(2026, 0, 15);   // 15 January 2026

console.log('from Thu 15/01, next Monday  :', iso(nextWeekday(thursday, 1)));
console.log('from Thu 15/01, next Friday  :', iso(nextWeekday(thursday, 5)));
console.log('from Thu 15/01, next Thursday:', iso(nextWeekday(thursday, 4)), '<- a week on');
console.log('from Thu 15/01, Thursday on or after:', iso(weekdayOnOrAfter(thursday, 4)), '<- 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.

9 checks

test('the next Monday', iso(nextWeekday(thursday, 1)), '2026-01-19');
test('the next Friday is tomorrow', iso(nextWeekday(thursday, 5)), '2026-01-16');
test('the next Wednesday is nearly a week away', iso(nextWeekday(thursday, 3)), '2026-01-21');
test('the next Thursday is a full week on, not today', iso(nextWeekday(thursday, 4)), '2026-01-22');
test('whereas "on or after" returns today', iso(weekdayOnOrAfter(thursday, 4)), '2026-01-15');
test('the answer always has the weekday asked for', nextWeekday(thursday, 1).getDay(), 1);
test('it crosses a month end without help', iso(nextWeekday(new Date(2026, 0, 30), 1)), '2026-02-02');
test('and a year end', iso(nextWeekday(new Date(2026, 11, 31), 5)), '2027-01-01');
test('from a Sunday, the next Sunday is seven days on', iso(nextWeekday(new Date(2026, 0, 18), 0)), '2026-01-25');

(wanted - current + 7) % 7 is the same modular step as the start of the week in exercise 34, run forwards. The + 7 keeps it out of negative territory, because -3 % 7 is -3 in JavaScript, not 4.

The || 7 is the interesting line, and it is only correct because the value it is guarding is 0. Two functions rather than a boolean flag is the better shape here: "next Monday" and "Monday on or after today" are different questions, and a caller reading nextWeekday(d, 1, true) has no idea which one it is asking.

49 The third Thursday of the month

hard

Return the nth occurrence of a weekday in a month — the third Thursday, the first Monday — and null when there is no such date. Then the last one, which needs the opposite approach.

Stuck? Three hints, smallest first
  1. 1. Find the first matching weekday in the month, then add whole weeks.
  2. 2. The fifth of a weekday often does not exist, so check against the month length and return null.
  3. 3. For the last one, start from the last day of the month and step backwards.

The answer

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 nthWeekdayOfMonth(year, month, weekday, n) {
  const firstOfMonth = new Date(year, month, 1);
  const firstMatch = 1 + ((weekday - firstOfMonth.getDay() + 7) % 7);
  const day = firstMatch + (n - 1) * 7;
  if (day > daysInMonth(year, month)) return null;      // there is no fifth one
  return new Date(year, month, day);
}

function lastWeekdayOfMonth(year, month, weekday) {
  const last = new Date(year, month + 1, 0);            // the last day of the month
  last.setDate(last.getDate() - ((last.getDay() - weekday + 7) % 7));
  return last;
}

console.log('third Thursday of January 2026:', iso(nthWeekdayOfMonth(2026, 0, 4, 3)));
console.log('first Monday   of January 2026:', iso(nthWeekdayOfMonth(2026, 0, 1, 1)));
console.log('fifth Monday   of January 2026:', nthWeekdayOfMonth(2026, 0, 1, 5), '<- there is not one');
console.log('last Friday    of January 2026:', iso(lastWeekdayOfMonth(2026, 0, 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.

9 checks

test('the third Thursday of January 2026', iso(nthWeekdayOfMonth(2026, 0, 4, 3)), '2026-01-15');
test('the first Monday', iso(nthWeekdayOfMonth(2026, 0, 1, 1)), '2026-01-05');
test('the fifth Thursday exists in January 2026', iso(nthWeekdayOfMonth(2026, 0, 4, 5)), '2026-01-29');
test('the fifth Monday does not', nthWeekdayOfMonth(2026, 0, 1, 5), null);
test('the last Friday of January 2026', iso(lastWeekdayOfMonth(2026, 0, 5)), '2026-01-30');
test('the fourth Thursday of November 2026, for the American holiday', iso(nthWeekdayOfMonth(2026, 10, 4, 4)), '2026-11-26');
test('the last Saturday of February 2026 is also the last day of it', iso(lastWeekdayOfMonth(2026, 1, 6)), '2026-02-28');
test('the answer always has the weekday asked for', nthWeekdayOfMonth(2026, 0, 4, 3).getDay(), 4);
test('and so does the last-of-month version', lastWeekdayOfMonth(2026, 0, 5).getDay(), 5);

Recurring rules are written this way — "the second Tuesday of every month", "the last Friday of the quarter" — and the two directions need different code. Counting forwards from the first of the month can overrun, so it has to be able to say null; counting back from the last day cannot overrun, because every weekday occurs at least four times in every month.

Returning null rather than clamping to the fourth is the honest choice: "the fifth Monday of January 2026" has no answer, and quietly substituting a different date would put a meeting in the wrong week. Note the "last" form is not the same as the fifth: February 2026's last Saturday is its fourth.

50 The ISO week number

hard

Compute the ISO 8601 week number. Week 1 is the week containing the first Thursday of the year, weeks run Monday to Sunday, and a year can have 53 of them.

Stuck? Three hints, smallest first
  1. 1. Move the date to the Thursday of its own week first. Whatever year that Thursday is in owns the week.
  2. 2. getDay() || 7 converts Sunday from 0 to 7, giving the ISO numbering where Monday is 1.
  3. 3. Work in UTC throughout, so a clock change cannot make a day 23 hours long and skew the division.

The answer

const MS_PER_DAY = 86400000;

function isoWeek(date) {
  // Re-express the local calendar date as UTC, where every day is 24 hours.
  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));

  const dayNumber = d.getUTCDay() || 7;              // Monday 1 ... Sunday 7
  d.setUTCDate(d.getUTCDate() + 4 - dayNumber);      // jump to this week's Thursday

  // That Thursday's year is the week-numbering year, by definition.
  const firstOfYear = Date.UTC(d.getUTCFullYear(), 0, 1);
  return Math.ceil(((d.getTime() - firstOfYear) / MS_PER_DAY + 1) / 7);
}

// 1 January 2026 is a Thursday, so week 1 of 2026 starts on Monday 29 December 2025.
console.log('29/12/2025 ->', isoWeek(new Date(2025, 11, 29)), '<- already week 1 of 2026');
console.log('28/12/2025 ->', isoWeek(new Date(2025, 11, 28)), '<- still week 52 of 2025');
console.log('15/01/2026 ->', isoWeek(new Date(2026, 0, 15)));
console.log('31/12/2026 ->', isoWeek(new Date(2026, 11, 31)), '<- 2026 has 53 weeks');

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.

9 checks

test('15 January 2026 is in week 3', isoWeek(new Date(2026, 0, 15)), 3);
test('1 January 2026 is in week 1, because it is a Thursday', isoWeek(new Date(2026, 0, 1)), 1);
test('the Sunday of that week is still week 1', isoWeek(new Date(2026, 0, 4)), 1);
test('the Monday after starts week 2', isoWeek(new Date(2026, 0, 5)), 2);
test('29 December 2025 belongs to week 1 of 2026', isoWeek(new Date(2025, 11, 29)), 1);
test('while 28 December 2025 is week 52 of 2025', isoWeek(new Date(2025, 11, 28)), 52);
test('31 December 2026 is in week 53, which not every year has', isoWeek(new Date(2026, 11, 31)), 53);
test('every day from 12 to 18 January 2026 is in the same week', Array.from({ length: 7 }, (unused, i) => isoWeek(new Date(2026, 0, 12 + i))), [3, 3, 3, 3, 3, 3, 3]);
test('and the day before that block is in week 2', isoWeek(new Date(2026, 0, 11)), 2);

Everything about this algorithm follows from one definition: the week belongs to whichever year contains its Thursday. Move to that Thursday first and the rest is arithmetic — no special cases for the start or end of the year, and the 53-week years fall out on their own.

Two details are easy to miss. getDay() || 7 is needed because ISO counts Monday as 1 and Sunday as 7, while JavaScript counts Sunday as 0. And the whole calculation is done in UTC: exercise 11 showed that a local day can be 23 or 25 hours long, which would make the division by 86,400,000 land on the wrong side of an integer boundary and shift a week number by one for anyone in a daylight-saving zone.

The most quoted consequence is in the fifth and sixth assertions: 29 December 2025 is in week 1 of 2026, and the last days of December can carry the following year's week number. Never pair an ISO week number with the calendar year — pair it with the week-numbering year, which is the next exercise.

51 The full ISO week date, 2026-W03-4

hard

Produce the complete ISO week date: week-numbering year, week number and weekday, as 2026-W03-4. Show a date whose week-numbering year is not its calendar year.

Stuck? Three hints, smallest first
  1. 1. The Thursday trick gives you the week-numbering year for free: it is that Thursday’s year.
  2. 2. Weekdays are 1 for Monday through 7 for Sunday, so getDay() || 7 again.
  3. 3. Pad the week number to two digits. 2026-W3-4 is not valid ISO.

The answer

const MS_PER_DAY = 86400000;

function isoWeekParts(date) {
  const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
  const weekday = d.getUTCDay() || 7;                 // 1 Monday ... 7 Sunday

  d.setUTCDate(d.getUTCDate() + 4 - weekday);         // this week's Thursday
  const weekYear = d.getUTCFullYear();                // which owns the week

  const firstOfYear = Date.UTC(weekYear, 0, 1);
  const week = Math.ceil(((d.getTime() - firstOfYear) / MS_PER_DAY + 1) / 7);

  return { weekYear, week, weekday };
}

function isoWeekDate(date) {
  const p = isoWeekParts(date);
  return p.weekYear + '-W' + String(p.week).padStart(2, '0') + '-' + p.weekday;
}

console.log('15/01/2026 ->', isoWeekDate(new Date(2026, 0, 15)));
console.log('29/12/2025 ->', isoWeekDate(new Date(2025, 11, 29)), '<- a 2025 date in the 2026 week year');
console.log('01/01/2027 ->', isoWeekDate(new Date(2027, 0, 1)), '<- a 2027 date in the 2026 week year');
console.log('parts:', isoWeekParts(new Date(2026, 0, 15)));

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.

9 checks

test('Thursday 15 January 2026', isoWeekDate(new Date(2026, 0, 15)), '2026-W03-4');
test('the Monday of that week', isoWeekDate(new Date(2026, 0, 12)), '2026-W03-1');
test('and its Sunday, which is 7 rather than 0', isoWeekDate(new Date(2026, 0, 18)), '2026-W03-7');
test('29 December 2025 is in the 2026 week year', isoWeekDate(new Date(2025, 11, 29)), '2026-W01-1');
test('28 December 2025 is the last day of the 2025 week year', isoWeekDate(new Date(2025, 11, 28)), '2025-W52-7');
test('1 January 2027 still belongs to the 2026 week year', isoWeekDate(new Date(2027, 0, 1)), '2026-W53-5');
test('the 2027 week year starts on Monday 4 January', isoWeekDate(new Date(2027, 0, 4)), '2027-W01-1');
test('the week number is padded to two digits', isoWeekDate(new Date(2026, 0, 15)).includes('-W03-'), true);
test('and the week year differs from the calendar year for that December date', isoWeekParts(new Date(2025, 11, 29)).weekYear, 2026);

The week-numbering year is a fourth kind of year, alongside the calendar, financial and academic ones, and the sixth assertion is why it matters: 1 January 2027 is 2026-W53-5. Store an ISO week and a calendar year together and one week in every two or three years is filed under the wrong heading.

The padding is not cosmetic — ISO 8601 requires two digits, and unpadded week numbers break exactly the text-sorting property that made ISO dates worth using in the first place.

52 Age in years, months and days

hard

Break an age into years, months and days: 15 June 1990 to 15 January 2026 is 35 years, 7 months and 0 days.

Stuck? Three hints, smallest first
  1. 1. Subtract each field on its own, then fix up the negatives from the smallest upwards.
  2. 2. When the days come out negative, borrow the length of the month before the later date.
  3. 3. new Date(year, month, 0).getDate() is the length of the previous month.

The answer

function ageParts(birth, on) {
  let years = on.getFullYear() - birth.getFullYear();
  let months = on.getMonth() - birth.getMonth();
  let days = on.getDate() - birth.getDate();

  if (days < 0) {
    months--;
    // Borrow from the month before `on`: day 0 of that month is its last day.
    days += new Date(on.getFullYear(), on.getMonth(), 0).getDate();
  }
  if (months < 0) {
    years--;
    months += 12;
  }
  return { years, months, days };
}

const birth = new Date(1990, 5, 15);      // 15 June 1990

console.log('15/06/1990 to 15/01/2026:', ageParts(birth, new Date(2026, 0, 15)));
console.log('20/06/1990 to 15/01/2026:', ageParts(new Date(1990, 5, 20), new Date(2026, 0, 15)));
console.log('today                   :', ageParts(birth, new Date()), '<- shown, never asserted');

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.

9 checks

test('35 years and 7 months exactly', ageParts(birth, new Date(2026, 0, 15)), { years: 35, months: 7, days: 0 });
test('a later birth day forces a borrow', ageParts(new Date(1990, 5, 20), new Date(2026, 0, 15)), { years: 35, months: 6, days: 26 });
test('the day of birth is all zeroes', ageParts(birth, birth), { years: 0, months: 0, days: 0 });
test('one day either side of new year', ageParts(new Date(2025, 11, 31), new Date(2026, 0, 1)), { years: 0, months: 0, days: 1 });
test('a whole month', ageParts(new Date(2026, 0, 1), new Date(2026, 1, 1)), { years: 0, months: 1, days: 0 });
test('a day short of a year', ageParts(new Date(2026, 0, 15), new Date(2027, 0, 14)), { years: 0, months: 11, days: 30 });
test('a leap-day birth measured to 1 March', ageParts(new Date(2000, 1, 29), new Date(2026, 2, 1)), { years: 26, months: 0, days: 0 });
test('and to 28 February, which borrows a whole January', ageParts(new Date(2000, 1, 29), new Date(2026, 1, 28)), { years: 25, months: 11, days: 30 });
test('the years agree with the simple whole-years calculation', ageParts(birth, new Date(2026, 5, 14)).years, 35);

Field-by-field subtraction with a borrow from the smallest unit upwards — the same shape as long subtraction on paper. The borrow has to take the length of the month before the later date, not of the birth month and not a flat 30, and that is the line most implementations get wrong.

The result is genuinely ambiguous at the edges and no arithmetic will settle it. 29 February 2000 to 28 February 2026 comes out here as 25 years, 11 months and 30 days, because the day count goes negative and borrows January's 31 days; you could equally argue for 26 years exactly, on the grounds that 28 February is the last day of that month. This implementation is consistent and documented, which is the most you can ask of a definition people disagree about.

53 Adding a day is not adding 24 hours

hard

Move a date on by one day two ways: by adding 86,400,000 milliseconds, and by setDate(getDate() + 1). Show what separates them, without assuming which zone the code is running in.

Stuck? Three hints, smallest first
  1. 1. Adding milliseconds moves the instant. setDate moves the calendar day and keeps the wall clock.
  2. 2. They differ by exactly the change in the zone offset between the two dates.
  3. 3. getTimezoneOffset() lets you write that as an identity, which is true in every zone including ones with no daylight saving.

The answer

const MS_PER_DAY = 86400000;

const noon = new Date(2026, 5, 15, 12, 0, 0);    // 15 June 2026, local midday

// Two different operations that people treat as the same one.
const byMilliseconds = new Date(noon.getTime() + MS_PER_DAY);

const byCalendar = new Date(noon);
byCalendar.setDate(byCalendar.getDate() + 1);

// A hundred calendar days on, still at midday whatever happens to the clocks.
const hundredDays = new Date(noon);
hundredDays.setDate(hundredDays.getDate() + 100);

const offsetChanged = byCalendar.getTimezoneOffset() !== noon.getTimezoneOffset();

console.log('start        :', noon.getHours() + ':00 on the', noon.getDate() + 'th');
console.log('+86400000ms  :', byMilliseconds.getHours() + ':00 on the', byMilliseconds.getDate() + 'th');
console.log('+1 calendar day:', byCalendar.getHours() + ':00 on the', byCalendar.getDate() + 'th');
console.log('did the offset change between them?', offsetChanged, '(false in a zone with no DST)');

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.

8 checks

test('adding milliseconds moves the instant by exactly that much', byMilliseconds.getTime() - noon.getTime(), MS_PER_DAY);
test('the calendar step keeps the wall clock at midday', byCalendar.getHours(), 12);
test('and lands on the next date', byCalendar.getDate(), 16);
test('the gap between the two is exactly the change in zone offset', byCalendar.getTime() - byMilliseconds.getTime(), (byCalendar.getTimezoneOffset() - noon.getTimezoneOffset()) * 60000);
test('so where the offset did not change, they are the same instant', offsetChanged ? true : byCalendar.getTime() === byMilliseconds.getTime(), true);
test('a hundred calendar days on is 23 September 2026', [hundredDays.getMonth(), hundredDays.getDate()], [8, 23]);
test('and the clock still reads midday', hundredDays.getHours(), 12);
test('while the input was never modified', [noon.getDate(), noon.getHours()], [15, 12]);

These are two different questions. "This time tomorrow" is a calendar operation and setDate answers it. "86,400,000 milliseconds from now" is a duration and adding to the timestamp answers that. In a zone with daylight saving they give different instants twice a year, and the one that is wrong depends on which question you were actually asking. A daily reminder at 09:00 must not drift to 08:00 in October, so it is a calendar operation.

The fourth assertion is how you test this without knowing where the code runs. Rather than asserting "they differ by an hour" — true only in some zones, on two days of the year — it asserts the relationship: the difference between the two results is exactly the change in getTimezoneOffset() across the boundary. In Lagos or in UTC that change is zero and both sides are zero; in London on the last Sunday of March it is 60 minutes and both sides are 3,600,000. The identity holds either way, which is what makes it worth asserting.

54 A monthly schedule that does not drift

hard

Generate the next four monthly dates from 31 January 2026. Do it twice: anchored to the original day of the month, and chained from each previous result. The two disagree, and one of them is a billing bug.

Stuck? Three hints, smallest first
  1. 1. Anchored means every date is computed from the ORIGINAL day number, clamped to the month it lands in.
  2. 2. Chained means each date is one month after the last result, so a clamp is permanent.
  3. 3. Start from the 31st, where February forces the clamp.

The answer

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();

// Anchored: the original day number is re-applied to each month, so a short
// February borrows nothing from March.
function monthlyAnchored(start, count) {
  const anchorDay = start.getDate();
  const out = [];
  for (let i = 0; i < count; i++) {
    const first = new Date(start.getFullYear(), start.getMonth() + i, 1);   // handles the year roll
    const year = first.getFullYear();
    const month = first.getMonth();
    out.push(new Date(year, month, Math.min(anchorDay, daysInMonth(year, month))));
  }
  return out;
}

// Chained: each step works from the previous RESULT, so the February clamp sticks.
function monthlyChained(start, count) {
  const out = [new Date(start)];
  while (out.length < count) {
    const previous = out[out.length - 1];
    const day = previous.getDate();
    const next = new Date(previous.getFullYear(), previous.getMonth() + 1, 1);
    next.setDate(Math.min(day, daysInMonth(next.getFullYear(), next.getMonth())));
    out.push(next);
  }
  return out;
}

const endOfJanuary = new Date(2026, 0, 31);

console.log('anchored:', monthlyAnchored(endOfJanuary, 4).map(iso).join(' '));
console.log('chained :', monthlyChained(endOfJanuary, 4).map(iso).join(' '), '<- stuck on the 28th');

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.

9 checks

test('anchored to the 31st, February clamps and March recovers', monthlyAnchored(endOfJanuary, 4).map(iso), ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30']);
test('chained, the clamp is permanent', monthlyChained(endOfJanuary, 4).map(iso), ['2026-01-31', '2026-02-28', '2026-03-28', '2026-04-28']);
test('every anchored date is the last day of its month', monthlyAnchored(endOfJanuary, 4).every((d) => d.getDate() === daysInMonth(d.getFullYear(), d.getMonth())), true);
test('which is not true of the chained ones', monthlyChained(endOfJanuary, 4).every((d) => d.getDate() === daysInMonth(d.getFullYear(), d.getMonth())), false);
test('for a day every month has, the two agree', same(monthlyAnchored(new Date(2026, 0, 15), 4).map(iso), monthlyChained(new Date(2026, 0, 15), 4).map(iso)), true);
test('and that schedule is unremarkable', monthlyAnchored(new Date(2026, 0, 15), 4).map(iso), ['2026-01-15', '2026-02-15', '2026-03-15', '2026-04-15']);
test('a schedule crossing the year end', monthlyAnchored(new Date(2025, 11, 31), 3).map(iso), ['2025-12-31', '2026-01-31', '2026-02-28']);
test('thirteen months on, the anchor is still the 31st', iso(monthlyAnchored(endOfJanuary, 13)[12]), '2027-01-31');
test('a count of one is just the start date', monthlyAnchored(endOfJanuary, 1).map(iso), ['2026-01-31']);

Both functions clamp February to the 28th, and after that they part company. The anchored one remembers that the customer signed up on the 31st and bills on the 31st again in March; the chained one has forgotten, and bills on the 28th for the rest of time. Four steps is enough to see it; twelve is enough for a customer to notice.

The rule generalises past dates: when you generate a sequence by repeatedly applying a lossy operation to its own output, the loss compounds. Keep the original input and derive each element from it. That also makes the nth element computable without generating the first n-1, which is worth having when the schedule is long.

55 Parse an ISO date yourself, as local time

hard

Parse 2026-01-15 and 2026-01-15T09:30 into a local date, rejecting anything malformed or impossible. Do not hand the string to new Date.

Stuck? Three hints, smallest first
  1. 1. A regular expression with capture groups for the parts, and an optional time section.
  2. 2. Build with new Date(y, m - 1, d, ...), which is local, unlike parsing the string.
  3. 3. Then check the built date reads back the parts you put in — that is what rejects 30 February.

The answer

// Date, optionally followed by T or a space and a time. Nothing else accepted.
const PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;

function parseLocalIso(text) {
  if (typeof text !== 'string') return null;
  const match = PATTERN.exec(text);
  if (!match) return null;

  const year = Number(match[1]);
  const month = Number(match[2]) - 1;
  const day = Number(match[3]);
  const hours = match[4] === undefined ? 0 : Number(match[4]);
  const minutes = match[5] === undefined ? 0 : Number(match[5]);
  const seconds = match[6] === undefined ? 0 : Number(match[6]);
  if (hours > 23 || minutes > 59 || seconds > 59) return null;

  const date = new Date(year, month, day, hours, minutes, seconds);

  // The constructor rolls impossible dates instead of refusing them, so read the
  // parts back: if they changed, the input was not a real date.
  if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) {
    return null;
  }
  return date;
}

const parsed = parseLocalIso('2026-01-15T09:30');

console.log("'2026-01-15'       ->", parseLocalIso('2026-01-15').toDateString());
console.log("'2026-01-15T09:30' ->", parsed.getHours() + ':' + parsed.getMinutes());
console.log("'2026-02-30'       ->", parseLocalIso('2026-02-30'), '<- refused');
console.log("'15/01/2026'       ->", parseLocalIso('15/01/2026'), '<- refused');

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.

9 checks

test('a date-only string gives the local calendar date', [parseLocalIso('2026-01-15').getFullYear(), parseLocalIso('2026-01-15').getMonth(), parseLocalIso('2026-01-15').getDate()], [2026, 0, 15]);
test('and it matches a date built from the parts, in any zone', parseLocalIso('2026-01-15').getTime(), new Date(2026, 0, 15).getTime());
test('a time is read too', [parsed.getHours(), parsed.getMinutes(), parsed.getSeconds()], [9, 30, 0]);
test('seconds are optional but accepted', parseLocalIso('2026-01-15T09:30:45').getSeconds(), 45);
test('30 February is refused, not rolled into March', parseLocalIso('2026-02-30'), null);
test('29 February is accepted in a leap year', parseLocalIso('2024-02-29').getDate(), 29);
test('month 13 is refused', parseLocalIso('2026-13-01'), null);
test('a day-first format and an hour of 24 are both refused', [parseLocalIso('15/01/2026'), parseLocalIso('2026-01-15T24:00')], [null, null]);
test('and this differs from new Date by the zone offset', parseLocalIso('2026-01-15').getTime() - new Date('2026-01-15').getTime(), new Date(2026, 0, 15).getTimezoneOffset() * 60000);

Two things are gained by not using the built-in parser. The result is local, so '2026-01-15' means the 15th where the reader is rather than UTC midnight — the last assertion measures exactly that difference. And impossible dates are refused: new Date('2026-02-30') is treated differently by different engines, some giving Invalid Date and some rolling it to 2 March, whereas this returns null everywhere.

The round-trip check is the part to reuse. Since the constructor normalises rather than rejects, the only way to know whether a date was real is to build it and read the parts back out.

56 Is this year, month and day a real date?

hard

Validate a year, a 1-to-12 month and a day before trusting them. new Date will not tell you: it rolls 31 April into 1 May.

Stuck? Three hints, smallest first
  1. 1. Reject non-integers and out-of-range months and days first, cheaply.
  2. 2. Then build the date and check all three fields come back unchanged.
  3. 3. The round trip is the only reliable test, because the constructor never refuses anything.

The answer

// month is 1-12 here, the way a form sends it.
function isRealDate(year, month, day) {
  if (![year, month, day].every(Number.isInteger)) return false;
  if (month < 1 || month > 12) return false;
  if (day < 1 || day > 31) return false;

  const date = new Date(year, month - 1, day);
  return date.getFullYear() === year
    && date.getMonth() === month - 1
    && date.getDate() === day;          // unchanged, so nothing was rolled
}

console.log('30/02/2026:', isRealDate(2026, 2, 30), '| new Date says', new Date(2026, 1, 30).toDateString());
console.log('29/02/2024:', isRealDate(2024, 2, 29));
console.log('31/04/2026:', isRealDate(2026, 4, 31), '| new Date says', new Date(2026, 3, 31).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.

9 checks

test('a real date passes', isRealDate(2026, 1, 15), true);
test('30 February does not', isRealDate(2026, 2, 30), false);
test('even though the constructor happily builds it', new Date(2026, 1, 30).getMonth(), 2);
test('29 February is real in a leap year', isRealDate(2024, 2, 29), true);
test('and not in a common one', isRealDate(2026, 2, 29), false);
test('31 April is not a date', isRealDate(2026, 4, 31), false);
test('month 13, day 0 and a fractional day are all refused', [isRealDate(2026, 13, 1), isRealDate(2026, 1, 0), isRealDate(2026, 1, 1.5)], [false, false, false]);
test('31 December is fine', isRealDate(2026, 12, 31), true);
test('a two-digit year is refused, because the constructor would read it as 19xx', isRealDate(26, 1, 1), false);

The round trip is the whole technique, and it works for the same reason exercise 19 was dangerous: the constructor normalises silently, so the only evidence that the input was impossible is that what came back is not what went in.

The cheap range checks in front are not redundant. Without the day < 1 guard, isRealDate(2026, 1, 0) would build 31 December 2025 and be caught by the round trip anyway — but relying on that makes the function harder to read and one refactor away from wrong.

The last assertion is a real limitation stated plainly rather than hidden: a two-digit year is rejected because new Date(26, 0, 1) is 1926, so the round trip fails. That is the right answer for form input, where 26 is a mistake. If you genuinely need years under 100, build with setFullYear as exercise 14 does.

57 A month laid out as a calendar grid

hard

Build the rows of a month calendar: an array of weeks, each with seven cells, null where the cell belongs to another month. Support Monday-first and Sunday-first.

Stuck? Three hints, smallest first
  1. 1. How many blanks come first is the modular gap between the 1st’s weekday and the first day of the week.
  2. 2. Pad the end until the total number of cells is a multiple of seven.
  3. 3. Then slice the flat list into rows of seven.

The answer

const daysInMonth = (year, month) => new Date(year, month + 1, 0).getDate();

function monthGrid(year, month, firstDayOfWeek) {
  const firstWeekday = new Date(year, month, 1).getDay();
  const lead = (firstWeekday - firstDayOfWeek + 7) % 7;      // blanks before the 1st

  const cells = [];
  for (let i = 0; i < lead; i++) cells.push(null);
  for (let day = 1; day <= daysInMonth(year, month); day++) cells.push(day);
  while (cells.length % 7 !== 0) cells.push(null);           // blanks after the last

  const weeks = [];
  for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
  return weeks;
}

const january = monthGrid(2026, 0, 1);        // Monday-first
const januarySunday = monthGrid(2026, 0, 0);  // Sunday-first

const row = january.findIndex((week) => week.includes(15));
const column = january[row].indexOf(15);

console.log('January 2026, Monday first:');
for (const week of january) {
  console.log(week.map((d) => (d === null ? '  ' : String(d).padStart(2))).join(' '));
}
console.log('the 15th sits at row', row, 'column', column);

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.

9 checks

test('January 2026 needs five rows', january.length, 5);
test('every row has seven cells', january.every((week) => week.length === 7), true);
test('Monday-first, the month starts with three blanks', january[0], [null, null, null, 1, 2, 3, 4]);
test('and the last row trails off after the 31st', january[4], [26, 27, 28, 29, 30, 31, null]);
test('Sunday-first shifts everything on by one', januarySunday[0], [null, null, null, null, 1, 2, 3]);
test('and then needs no trailing blank at all', januarySunday[4], [25, 26, 27, 28, 29, 30, 31]);
test('the 15th is in the third row, on the Thursday', [row, column], [2, 3]);
test('every day of the month appears exactly once', january.flat().filter((d) => d !== null), Array.from({ length: 31 }, (unused, i) => i + 1));
test('February 2026 starts on a Sunday, so Monday-first needs six blanks', monthGrid(2026, 1, 1)[0], [null, null, null, null, null, null, 1]);

This is the shape a calendar component wants: a rectangular grid, so the markup is a plain nested loop with no arithmetic in the template, and null for the cells that are not part of the month. Everything else in this section shows up here — the zero-based month, the modular weekday gap from exercise 34, and day 0 of the next month for the length.

Note that the row count is not fixed. Most months need five rows, February in a common year starting on the first day of the week needs four, and a 31-day month starting late in the week needs six. Hard-coding five is a layout bug that appears a few times a year, which is long enough for nobody to remember what changed.

If you would rather show the neighbouring month's days in the blanks, as most calendars do, replace the nulls with dates from addDays around the ends and keep a flag saying which month each cell belongs to. The grid structure does not change.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.