Emmanuel Akinfulubi

JavaScript: Basic, 121–150

The on-ramp: numbers, text, dates, conditionals and loops.

Exercises 121 to 150 of 150

121 Change in naira notes

fair

Give change for ₦3,780 using the fewest notes: ₦1,000, ₦500, ₦200, ₦100, ₦50, ₦20, ₦10 and ₦5. Work in kobo, report what cannot be paid in notes, and then show a set of denominations where taking the biggest note first is not the fewest.

Stuck? Three hints, smallest first
  1. 1. Walk the denominations from largest to smallest, taking as many of each as fit.
  2. 2. Math.floor(left / note) is the count; subtract before moving on.
  3. 3. Whatever is left when the smallest note is done is the part you cannot pay.

The answer

const NOTES = [100000, 50000, 20000, 10000, 5000, 2000, 1000, 500];   // kobo
function change(kobo, notes = NOTES) {
  let left = kobo;
  const given = [];
  for (const note of notes) {
    const count = Math.floor(left / note);
    if (count > 0) given.push([note, count]);
    left -= count * note;
  }
  return { given, unpaid: left };
}
const totalOf = (given) => given.reduce((sum, [note, count]) => sum + note * count, 0);
const noteCount = (given) => given.reduce((sum, [, count]) => sum + count, 0);
const paid = change(378000);
console.log(paid.given.map(([note, count]) => count + ' x ' + note / 100).join(', '));
console.log('50 kobo owing:', change(378050).unpaid);

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 thousand seven hundred and eighty naira takes eight notes', noteCount(paid.given), 8);
test('and this is the breakdown, in kobo', paid.given, [[100000, 3], [50000, 1], [20000, 1], [5000, 1], [2000, 1], [1000, 1]]);
test('the notes add back to exactly the amount', [totalOf(paid.given), paid.unpaid], [378000, 0]);
test('fifty kobo cannot be paid in notes and is reported, not lost', change(378050).unpaid, 50);
test('while the payable part is still fully covered', totalOf(change(378050).given), 378000);
test('nothing owed needs no notes', change(0), { given: [], unpaid: 0 });
test('a note larger than the amount is skipped, not forced', change(30000).given, [[20000, 1], [10000, 1]]);
test('but biggest-first is not always fewest', noteCount(change(600, [400, 300, 100]).given), 3);
test('because two 300s would have done it', 300 * 2, 600);

Kobo integers throughout, for the reason exercise 63 argues: ₦3,780 held as 3780.00 makes left / note a float division and the remainder a number like 0.00000000004, which is neither zero nor payable. In integer kobo the remainder is exact, so unpaid is trustworthy — and reporting it is the point, because 50 kobo cannot be handed over when the smallest note is ₦5. Dropping it silently is how a till goes out by a few kobo a day.

The last two assertions are the honest caveat. Taking the biggest note first is optimal for the naira ladder, and for any system where each denomination divides the next, but it is not optimal in general: with notes of 400, 300 and 100, greedy pays 600 as 400+100+100 while two 300s would do. Genuinely minimal change is a dynamic-programming problem. Greedy is the right code here because the denominations are known and well behaved — a fact worth writing in a comment, since the day someone adds a ₦300 note the algorithm silently becomes wrong.

122 A password strength meter, and what it misses

fair

Score a password out of six: at least 8 characters, at least 12, a lowercase letter, an uppercase letter, a digit, a symbol. Label it weak, fair or strong — then score a long passphrase and see whether you believe the answer.

Stuck? Three hints, smallest first
  1. 1. Build an object of named booleans, then count the true ones.
  2. 2. A symbol is anything that is not a letter or a digit, so a space counts.
  3. 3. Return the individual checks too, or the form cannot say which rule failed.

The answer

function strength(password) {
  const pw = String(password);
  const checks = {
    length: pw.length >= 8,
    long: pw.length >= 12,
    lower: /[a-z]/.test(pw),
    upper: /[A-Z]/.test(pw),
    digit: /\d/.test(pw),
    symbol: /[^A-Za-z0-9]/.test(pw),
  };
  const score = Object.values(checks).filter(Boolean).length;
  const label = score <= 2 ? 'weak' : score <= 4 ? 'fair' : 'strong';
  return { score, label, checks };
}
for (const pw of ['lagos', 'Lagos123', 'Lagos123!', 'correcthorsebattery']) {
  console.log(pw.padEnd(22), strength(pw).score, strength(pw).label);
}

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 short lowercase word scores one', [strength('lagos').score, strength('lagos').label], [1, 'weak']);
test('case and digits get it to fair', strength('Lagos123').label, 'fair');
test('one symbol tips it to strong', strength('Lagos123!').label, 'strong');
test('a nineteen-character passphrase scores lower than that', [strength('correcthorsebattery').score, strength('Lagos123!').score], [3, 5]);
test('nothing typed scores nothing', strength('').score, 0);
test('a space is a symbol, and is allowed', strength('my pass phrase').checks.symbol, true);
test('the failed rule is named, so the form can point at it', strength('lagos123!').checks.upper, false);
test('six is the ceiling', strength('Lagos-Ibadan-2026!').score, 6);
test('and length alone carries two of those six', [strength('Lagos123!xxx').score, strength('Lagos123!xxx').label], [6, 'strong']);

The fourth assertion is the lesson, and it is uncomfortable: correcthorsebattery scores 3 while Lagos123! scores 5, and the nineteen-character phrase is enormously harder to guess. Composition rules measure whether you followed the rules, not how hard the password is to find — and they push people towards Password1!, which every cracking dictionary already holds. Current NIST guidance is to require length, allow everything including spaces, screen against known-breached lists, and stop mandating character classes.

So treat a meter like this as a hint in the interface, never as the gate. Two implementation notes. The checks are returned as named booleans rather than folded into a number, because "must contain an uppercase letter" is only actionable if the form can say which rule failed. And [^A-Za-z0-9] counts anything else as a symbol, which correctly includes the space — silently stripping or rejecting spaces breaks passphrases and password managers, and the user finds out at the login screen, not here.

123 Who won the tic-tac-toe game

hard

Given nine cells holding 'X', 'O' or '', report the winner, or a draw, or that the game is still going. Make sure a board of empty cells is not declared a win by three matching blanks.

Stuck? Three hints, smallest first
  1. 1. List the eight winning lines once, as index triples, and loop over them.
  2. 2. A line only counts if the first cell is actually occupied.
  3. 3. Full board and no line is a draw; an unfinished board is neither.

The answer

const LINES = [
  [0, 1, 2], [3, 4, 5], [6, 7, 8],       // rows
  [0, 3, 6], [1, 4, 7], [2, 5, 8],       // columns
  [0, 4, 8], [2, 4, 6],                  // diagonals
];
function winner(board) {
  for (const [a, b, c] of LINES) {
    const mark = board[a];
    if (mark && mark === board[b] && mark === board[c]) return mark;
  }
  return board.every((cell) => cell) ? 'draw' : null;
}
// The same loop without the occupancy check, to show what it was for.
function naive(board) {
  for (const [a, b, c] of LINES) {
    if (board[a] === board[b] && board[b] === board[c]) return board[a];
  }
  return null;
}
const empty = Array(9).fill('');
const row = ['X', 'X', 'X', 'O', 'O', '', '', '', ''];
const column = ['X', 'O', '', 'X', 'O', '', '', 'O', ''];
const diagonal = ['X', 'O', 'O', '', 'X', '', '', '', 'X'];
const playing = ['X', 'O', '', '', '', '', '', '', ''];
const drawn = ['X', 'O', 'X', 'X', 'O', 'O', 'O', 'X', 'X'];
const wonAndFull = ['X', 'X', 'X', 'O', 'O', 'X', 'O', 'X', 'O'];
console.log('row', winner(row), '/ column', winner(column), '/ diagonal', winner(diagonal));
console.log('empty board, naive version:', JSON.stringify(naive(empty)));

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 completed row wins', winner(row), 'X');
test('a completed column wins', winner(column), 'O');
test('so does a diagonal', winner(diagonal), 'X');
test('an unfinished board is neither a win nor a draw', winner(playing), null);
test('a full board with no line is a draw', winner(drawn), 'draw');
test('a full board with a line is still a win', winner(wonAndFull), 'X');
test('an empty board is undecided', winner(empty), null);
test('but without the occupancy check three blanks are a line', naive(empty), '');
test('there are eight lines and every cell is on one', [LINES.length, new Set(LINES.flat()).size], [8, 9]);

if (mark && ...) is the entire difficulty. Three empty cells satisfy board[a] === board[b] === board[c], so the check without it returns '' for an empty board — falsy, so if (naive(board)) happens to behave, which is worse than failing: the function returns a winner of '' and the bug survives until someone renders it or compares it to null. Ask whether the cell is occupied before asking whether the line matches.

The other ordering that matters is win before draw. A full board can contain a winning line, so checking "every cell filled" first would report a draw for a won game — the wonAndFull assertion is there to keep that fixed. Three distinct outcomes need three distinct values, so this returns a mark, 'draw', or null for still playing; folding "no winner yet" and "drawn" into one falsy answer is how a game announces a draw on move two. Listing the eight lines as data rather than writing eight ifs is what keeps the function four lines long and makes it trivially extensible to a 4×4 board.

124 A paginator that survives the last page

hard

97 tickets, 10 to a page. Work out the number of pages, the slice indices for the page being viewed, whether previous and next exist, and a window of at most five page numbers that stays five wide at both ends. Clamp a page number that is out of range.

Stuck? Three hints, smallest first
  1. 1. The page count is Math.ceil, and zero items should still be one page.
  2. 2. The slice is (page - 1) * perPage to start + perPage, capped at the total.
  3. 3. Compute the window end first, then pull the start back from it, so the window cannot shrink at the far edge.

The answer

function paginate(total, perPage, page, window = 5) {
  const pages = Math.max(1, Math.ceil(total / perPage));
  const current = Math.min(Math.max(1, page), pages);
  const start = (current - 1) * perPage;
  const end = Math.min(start + perPage, total);
  const half = Math.floor(window / 2);
  let from = Math.max(1, current - half);
  const to = Math.min(pages, from + window - 1);
  from = Math.max(1, to - window + 1);          // pull back, so the width holds at the end
  const numbers = [];
  for (let p = from; p <= to; p++) numbers.push(p);
  return { pages, current, start, end, numbers, hasPrev: current > 1, hasNext: current < pages };
}
const items = Array.from({ length: 97 }, (_, i) => 'ticket ' + (i + 1));
const p1 = paginate(97, 10, 1);
const p10 = paginate(97, 10, 10);
const zero = paginate(0, 10, 1);
console.log('page 1 of', p1.pages, p1.numbers, 'page 10', p10.numbers);
console.log('last page holds', items.slice(p10.start, p10.end).length, 'tickets');

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('97 tickets at 10 a page is 10 pages', p1.pages, 10);
test('the first page slices from the start and has nothing before it', [p1.start, p1.end, p1.hasPrev], [0, 10, false]);
test('the last page is the short one, and its first ticket is 91', [p10.start, p10.end, items.slice(p10.start, p10.end).length, items.slice(p10.start, p10.end)[0]], [90, 97, 7, 'ticket 91']);
test('the window starts at one and there is no page before it', [p1.numbers, p1.hasNext], [[1, 2, 3, 4, 5], true]);
test('at the far end it is still five wide, not one', [p10.numbers, p10.hasNext], [[6, 7, 8, 9, 10], false]);
test('in the middle it centres on the current page', paginate(97, 10, 6).numbers, [4, 5, 6, 7, 8]);
test('a page number out of range is clamped at both ends', [paginate(97, 10, 99).current, paginate(97, 10, 0).current], [10, 1]);
test('no items is still one page, with an empty slice', [zero.pages, zero.start, zero.end, zero.numbers], [1, 0, 0, [1]]);
test('fewer pages than the window gives only the pages there are', paginate(20, 10, 1).numbers, [1, 2]);

Two lines carry the weight. Math.ceil for the page count, because 97 items at 10 a page is 10 pages and Math.round or integer division gives 9 and hides the last seven tickets — a data-loss bug that only appears when the total is not a multiple of the page size. And Math.min(start + perPage, total) for the end, so the final page reports 7 rather than 10; slice would have clamped it anyway, but the number is also shown to the user as "91 to 100 of 97".

The window is the part worth studying. Centring naively — current - 2 to current + 2 — gives three numbers on page 9 and two on page 10, so the control changes width as you approach the end and the layout jumps. Computing to first, then pulling from back to to - window + 1, keeps the width constant until there genuinely are not that many pages, which the last assertion checks. Clamping current matters because the page number arrives from a URL a user can edit: unclamped, ?page=99 gives a negative-free but empty slice and a paginator with no way back.

125 A receipt with columns that line up

hard

Print a three-item receipt as fixed-width text: item name in a 16-character column, quantity right-aligned in 3, amount right-aligned in 11, then a rule and a total row. Keep every line the same width even when an item name is too long for its column.

Stuck? Three hints, smallest first
  1. 1. padEnd for the left column, padStart for the numbers.
  2. 2. Padding never truncates, so a long name must be cut before it is padded.
  3. 3. Total in kobo as integers, and format only at the moment of printing.

The answer

const SIGN = '\u20a6';
const NAME_WIDTH = 16;
const AMOUNT_WIDTH = 11;
const group = (n) => String(n).replace(/\B(?=(\d{3})+$)/g, ',');
const naira = (kobo) => SIGN + group(Math.trunc(kobo / 100)) + '.' + String(kobo % 100).padStart(2, '0');
function row(name, qty, kobo) {
  const label = name.length > NAME_WIDTH ? name.slice(0, NAME_WIDTH - 1) + '\u2026' : name;
  return label.padEnd(NAME_WIDTH) + String(qty).padStart(3) + naira(kobo).padStart(AMOUNT_WIDTH);
}
const ITEMS = [
  { name: 'Jollof rice', qty: 2, unit: 150055 },              // kobo
  { name: 'Suya', qty: 3, unit: 50015 },
  { name: 'Chapman with extra ice', qty: 1, unit: 120005 },
];
const lines = ITEMS.map((it) => row(it.name, it.qty, it.qty * it.unit));
const total = ITEMS.reduce((sum, it) => sum + it.qty * it.unit, 0);
const totalRow = row('TOTAL', ITEMS.reduce((n, it) => n + it.qty, 0), total);
const receipt = [...lines, '-'.repeat(NAME_WIDTH + 3 + AMOUNT_WIDTH), totalRow].join('\n');
console.log(receipt);

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('every line is exactly the same width', [...new Set(receipt.split('\n').map((l) => l.length))], [30]);
test('the amount column is right-aligned inside its width', lines[0].slice(NAME_WIDTH + 3), '  ' + SIGN + '3,001.10');
test('a name too long for the column is cut, with an ellipsis', lines[2].slice(0, NAME_WIDTH), 'Chapman with ex\u2026');
test('padding it instead would have overflowed by six', 'Chapman with extra ice'.padEnd(NAME_WIDTH).length, 22);
test('the total is an exact integer number of kobo', total, 570160);
test('printed with a thousands separator and two places', naira(total), SIGN + '5,701.60');
test('the same total added up as naira floats is not that number', 2 * 1500.55 + 3 * 500.15 + 1200.05, 5701.599999999999);
test('an amount under one naira keeps both places', naira(50), SIGN + '0.50');
test('and the quantity column carries the item count', totalRow.slice(NAME_WIDTH, NAME_WIDTH + 3), '  6');

Alignment is subtraction, and the trap is that padEnd only ever adds. A name of 22 characters comes back 22 characters long, the quantity column starts six places late, and every row below it in that column is crooked — so the name must be cut to fit and then padded (exercises 37 and 103 are the two halves of that). Cutting to NAME_WIDTH - 1 leaves room for the ellipsis, which is itself one character.

The float assertion is the reason the units are kobo. The same three amounts added as naira decimals come to 5701.599999999999, so a receipt built that way prints ₦5,701.5999999999995 or, once rounded for display, a total that does not equal the sum of the lines shown above it — the complaint arrives from the customer, not the test suite (exercise 61 is the underlying arithmetic; exercise 74 the formatting). Integers throughout, formatted once at the edge.

Two limits worth stating. String(kobo % 100) assumes a non-negative amount, so a refund line needs the sign handling from exercise 74. And fixed-width columns only line up in a monospaced font: the same receipt in a proportional font is ragged no matter how carefully the spaces are counted, which is why the browser version of this is a table or a grid, not padding.

126 How many days that month has

fair

Work out the number of days in any month of any year without a lookup table and without your own leap-year rule. Then show what happens when you ask for a day the month does not have, such as the 31st of February, and clamp it instead.

Stuck? Three hints, smallest first
  1. 1. Day 0 of a month is the last day of the month before it.
  2. 2. So new Date(year, month + 1, 0) lands on the last day of month.
  3. 3. A day number out of range does not throw. It counts forward from the first of the month.

The answer

// Day 0 of the next month is the last day of this one, and the engine already
// knows every leap-year rule, so this needs no table and no arithmetic.
function daysInMonth(year, month) {
  return new Date(year, month + 1, 0).getDate();
}

// Out-of-range day numbers are not an error: they walk forward. Clamping keeps
// the answer inside the month the caller named.
function safeDate(year, month, day) {
  return new Date(year, month, Math.min(day, daysInMonth(year, month)));
}

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

const lengths2026 = Array.from({ length: 12 }, (_, m) => daysInMonth(2026, m));
const rolled = new Date(2026, 1, 31);          // there is no 31 February
console.log('2026 month lengths:', lengths2026.join(' '));
console.log('asked for 31 February 2026, got', iso(rolled));
console.log('clamped instead:', iso(safeDate(2026, 1, 31)));

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('February 2026 has 28 days', daysInMonth(2026, 1), 28);
test('February 2028 has 29, without a leap rule of your own', daysInMonth(2028, 1), 29);
test('and 2100 is not a leap year, which the naive divide-by-four gets wrong', [daysInMonth(2100, 1), 2100 % 4], [28, 0]);
test('the twelve lengths of 2026', lengths2026, [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
test('which add up to a year, and 2028 to one day more', [lengths2026.reduce((a, b) => a + b, 0), Array.from({ length: 12 }, (_, m) => daysInMonth(2028, m)).reduce((a, b) => a + b, 0)], [365, 366]);
test('asking for 31 February silently gives 3 March', iso(rolled), '2026-03-03');
test('clamping keeps it in the month that was named', iso(safeDate(2026, 1, 31)), '2026-02-28');
test('day 0 of January is 31 December of the year before', [iso(new Date(2026, 0, 0)), new Date(2026, 0, 0).getMonth()], ['2025-12-31', 11]);
test('and a negative day keeps counting backwards', iso(new Date(2026, 0, -1)), '2025-12-30');

The whole exercise rests on one property of the Date constructor: the day argument is an offset from the first of the month, not a validated day number. Day 0 is therefore the day before the first, which is the last day of the previous month — that is why new Date(year, month + 1, 0) is the idiomatic length-of-month. It also means the engine's leap-year handling is doing the work, including the century rule that catches out hand-written versions: 2100 is divisible by 4 but is not a leap year.

The same permissiveness is the trap. new Date(2026, 1, 31) does not throw and does not return an invalid date; it quietly returns 3 March, because February 2026 runs out after 28 and the extra three days keep counting. A subscription renewal or a due date built that way slips into the next month and nobody notices until a customer is charged early. Decide which you want — clamp with Math.min, or reject the input — but do not let the constructor decide for you.

127 Add a month and stay in the right month

fair

Add a number of months to a date. Adding one month to 31 January should give the last day of February, not the 3rd of March. Show what setMonth does on its own, and make the version that crosses a year boundary work too.

Stuck? Three hints, smallest first
  1. 1. The month argument may be out of 0 to 11: month 12 is January of the next year, month -1 is December of the last.
  2. 2. setMonth keeps the day of the month, which is exactly the problem.
  3. 3. Find the length of the target month first, then clamp the day into it.

The answer

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

// A month index outside 0..11 rolls the year for free, so no year arithmetic is
// needed. The only work is clamping the day into the month it is landing in.
function addMonths(date, n) {
  const year = date.getFullYear();
  const month = date.getMonth() + n;
  const last = daysInMonth(year, month);
  return new Date(year, month, Math.min(date.getDate(), last));
}

// What setMonth does by itself, which is where the bug comes from.
const naive = new Date(2026, 0, 31);
naive.setMonth(naive.getMonth() + 1);

const endOfJanuary = new Date(2026, 0, 31);
console.log('31 Jan 2026 plus a month, clamped:', iso(addMonths(endOfJanuary, 1)));
console.log('the same thing with setMonth:   ', iso(naive));

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('31 January plus one month is the end of February', iso(addMonths(endOfJanuary, 1)), '2026-02-28');
test('setMonth on its own overshoots into March', iso(naive), '2026-03-03');
test('in a leap year the clamp lands on the 29th', iso(addMonths(new Date(2028, 0, 31), 1)), '2028-02-29');
test('a month with room for the day keeps it', iso(addMonths(new Date(2026, 0, 15), 1)), '2026-02-15');
test('past December the year rolls on its own', iso(addMonths(new Date(2026, 11, 15), 1)), '2027-01-15');
test('and a negative count rolls back the same way', iso(addMonths(new Date(2026, 0, 15), -1)), '2025-12-15');
test('going back from 31 March clamps too', iso(addMonths(new Date(2026, 2, 31), -1)), '2026-02-28');
test('twelve months is the same day next year', iso(addMonths(new Date(2026, 1, 28), 12)), '2027-02-28');
test('and adding one month twice is not the same as adding two', [iso(addMonths(addMonths(endOfJanuary, 1), 1)), iso(addMonths(endOfJanuary, 2))], ['2026-03-28', '2026-03-31']);

Two facts do the work. A month index outside 0 to 11 is legal — month 12 means January of the next year, month -1 means December of the last — so adding months needs no year arithmetic at all. And the day must be clamped before the date is built, because Date will happily accept a 31st in a 28-day month and walk forward into March.

The last assertion is the one to sit with: adding a month twice gives 28 March, adding two months at once gives 31 March. Month arithmetic is not associative once clamping is involved, because the clamp throws information away. That is not a bug in this function, it is the shape of the problem — which is why a billing system stores the original anniversary day (31) and clamps at render time, instead of clamping once and then adding to the clamped result month after month, sliding every subsequent date to the 28th.

128 Which ISO week a date falls in

hard

Give the ISO-8601 week number of a date. Week 1 is the week containing the first Thursday of January, weeks start on Monday, and a date in early January can belong to week 52 or 53 of the year before.

Stuck? Three hints, smallest first
  1. 1. getDay() counts from Sunday, so (getDay() + 6) % 7 renumbers it Monday-first.
  2. 2. Every ISO week is identified by its Thursday. Move the date to the Thursday of its own week first.
  3. 3. Then count whole weeks from the Thursday of week 1, which is the week containing 4 January of the Thursday’s year.

The answer

const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
const stamp = (d) =>
  d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');

// Monday is 0 and Sunday is 6, which is what ISO counts.
const isoDay = (d) => (d.getDay() + 6) % 7;

function isoWeek(date) {
  // Copy, and drop the time, so the original is untouched and DST cannot creep in.
  const thursday = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  thursday.setDate(thursday.getDate() - isoDay(thursday) + 3);

  // Week 1 is the week holding 4 January OF THE THURSDAY'S YEAR, not of the
  // date's year. That one word is what makes 1 January 2027 come out as week 53.
  const firstThursday = new Date(thursday.getFullYear(), 0, 4);
  firstThursday.setDate(firstThursday.getDate() - isoDay(firstThursday) + 3);

  return 1 + Math.round((thursday - firstThursday) / WEEK_MS);
}

const dates = [new Date(2025, 11, 29), new Date(2026, 0, 1), new Date(2026, 0, 5), new Date(2026, 5, 15), new Date(2026, 11, 31), new Date(2027, 0, 1)];
for (const d of dates) console.log(d.getDate() + '/' + (d.getMonth() + 1) + '/' + d.getFullYear(), '-> week', isoWeek(d));

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('1 January 2026 is a Thursday, so it is in week 1', [new Date(2026, 0, 1).getDay(), isoWeek(new Date(2026, 0, 1))], [4, 1]);
test('week 1 of 2026 starts on 29 December 2025', isoWeek(new Date(2025, 11, 29)), 1);
test('Sunday 4 January is the last day of that week', isoWeek(new Date(2026, 0, 4)), 1);
test('and the Monday after it opens week 2', isoWeek(new Date(2026, 0, 5)), 2);
test('mid-June is week 25', isoWeek(new Date(2026, 5, 15)), 25);
test('2026 starts on a Thursday, so it is a 53-week year', isoWeek(new Date(2026, 11, 31)), 53);
test('which is why 1 January 2027 is week 53, not week 1', isoWeek(new Date(2027, 0, 1)), 53);
test('the date passed in is not moved, only the copy is', (() => { const d = new Date(2026, 5, 15); isoWeek(d); return stamp(d); })(), '2026-06-15');
test('and every day of one ISO week reports the same number', [...new Set(Array.from({ length: 7 }, (_, i) => isoWeek(new Date(2026, 5, 15 + i))))], [25]);

ISO weeks are defined by Thursdays, and once you know that the algorithm is short: shift the date to the Thursday of its own week, shift 4 January to the Thursday of week 1, and divide the gap by seven days. Everything else is bookkeeping. (getDay() + 6) % 7 is the renumbering from JavaScript's Sunday-first days to ISO's Monday-first, and forgetting it puts every Sunday in the wrong week.

The subtle line is new Date(thursday.getFullYear(), 0, 4). It uses the year of the Thursday, not the year of the input date, and that is deliberate: 1 January 2027 sits in the week whose Thursday is 31 December 2026, so it belongs to week 53 of 2026. Calendar year and ISO year are different things, and any report grouped by "week 1" without also carrying the ISO year will merge two different weeks.

Math.round rather than a plain division is protection against clocks that shift. Local dates a few weeks apart are not always an exact multiple of 24 hours when daylight saving intervenes, so the quotient comes out at 24.958 and truncation loses a week. Nigeria has no DST, but the reader's machine might.

129 An age in years, months and days

hard

Given a date of birth and a date to measure on, report the age as whole years, months and days. Subtracting the years alone must not turn a 29-year-old into a 30-year-old two months before their birthday.

Stuck? Three hints, smallest first
  1. 1. Subtract the three parts separately, then fix up the ones that came out negative.
  2. 2. A negative day count borrows the length of the month before the measuring date.
  3. 3. A negative month count borrows twelve months from the year.

The answer

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

  if (days < 0) {
    months -= 1;
    days += new Date(on.getFullYear(), on.getMonth(), 0).getDate();   // days in the month before
  }
  if (months < 0) {
    years -= 1;
    months += 12;
  }
  return { years, months, days };
}

const TODAY = new Date(2026, 0, 15);         // pinned, so this can be asserted at all
const ada = new Date(1996, 2, 20);           // 20 March 1996
console.log('Ada on 15 January 2026:', JSON.stringify(ageOn(ada, TODAY)));
console.log('year subtraction alone would say', TODAY.getFullYear() - ada.getFullYear());

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('birthday already passed this year', ageOn(new Date(1996, 0, 10), TODAY), { years: 30, months: 0, days: 5 });
test('birthday still to come, so the borrow happens', ageOn(ada, TODAY), { years: 29, months: 9, days: 26 });
test('and subtracting the years alone overstates it by one', [TODAY.getFullYear() - ada.getFullYear(), ageOn(ada, TODAY).years], [30, 29]);
test('a birthday on the day itself is a whole number of years', ageOn(new Date(2000, 0, 15), TODAY), { years: 26, months: 0, days: 0 });
test('the day before is still the year before', ageOn(new Date(2000, 0, 16), TODAY), { years: 25, months: 11, days: 30 });
test('a whole number of months', ageOn(new Date(1996, 2, 15), TODAY), { years: 29, months: 10, days: 0 });
test('a newborn is zero of everything', ageOn(TODAY, TODAY), { years: 0, months: 0, days: 0 });
test('the borrow uses December, so 31 days, not 30', ageOn(new Date(2000, 0, 20), TODAY).days, 26);
test('a 29 February birthday reads as not yet due on 28 February', ageOn(new Date(2000, 1, 29), new Date(2026, 1, 28)), { years: 25, months: 11, days: 30 });

Age is not a division. (now - birth) / 31536000000 is wrong because years are not all the same length, and subtracting the year numbers is wrong because it ignores whether the birthday has happened yet — the third assertion is that bug, and it is the one that puts an under-18 on the wrong side of a form. The correct shape is subtract each part, then borrow, exactly like long subtraction on paper.

Which month to borrow from is the detail people get wrong. new Date(on.getFullYear(), on.getMonth(), 0) is the last day of the month before the measuring date, so measuring in January borrows 31 days from December. Borrowing a flat 30, or borrowing from the birth month instead, shifts the answer by a day or two — small enough to survive review and large enough to matter on the day a tenancy or a loan term ends.

The last assertion has no objectively right answer. Someone born on 29 February 2000 has no birthday in 2026, and this code says they are still 25 on 28 February and turn 26 on 1 March. Different jurisdictions choose differently; the point is to choose deliberately and write it down, rather than discover your choice from a bug report.

130 Working days between two dates

fair

Count the working days from one date to another inclusive, skipping Saturdays, Sundays and a list of public holidays. Show that a holiday landing on a Saturday costs nothing.

Stuck? Three hints, smallest first
  1. 1. getDay() returns 0 for Sunday and 6 for Saturday.
  2. 2. Walk day by day with a copy of the start date, so the caller’s date is not mutated.
  3. 3. Hold the holidays as a Set of yyyy-mm-dd keys and look each day up.

The answer

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

// Two of these fall on a weekday, one on a Saturday.
const HOLIDAYS = new Set(['2026-01-01', '2026-01-10']);

function workdays(from, to, holidays) {
  const day = new Date(from.getFullYear(), from.getMonth(), from.getDate());   // a copy
  const last = new Date(to.getFullYear(), to.getMonth(), to.getDate());
  let count = 0;
  const skipped = [];
  while (day <= last) {
    const dow = day.getDay();
    if (dow === 0 || dow === 6) skipped.push('weekend');
    else if (holidays.has(iso(day))) skipped.push(iso(day));
    else count++;
    day.setDate(day.getDate() + 1);          // rolls month and year on its own
  }
  return { count, holidaysTaken: skipped.filter((s) => s !== 'weekend') };
}

const january = workdays(new Date(2026, 0, 1), new Date(2026, 0, 31), HOLIDAYS);
console.log('January 2026:', january.count, 'working days; lost to holidays:', january.holidaysTaken);

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 has 21 working days once the holidays are out', january.count, 21);
test('without any holiday list it is 22', workdays(new Date(2026, 0, 1), new Date(2026, 0, 31), new Set()).count, 22);
test('so only one of the two holidays cost anything', january.holidaysTaken, ['2026-01-01']);
test('because 10 January 2026 is a Saturday, already skipped', new Date(2026, 0, 10).getDay(), 6);
test('Monday to Friday is five, and both ends are counted', workdays(new Date(2026, 0, 5), new Date(2026, 0, 9), new Set()).count, 5);
test('a weekend on its own is none', workdays(new Date(2026, 0, 3), new Date(2026, 0, 4), new Set()).count, 0);
test('one weekday is one, so from and to may be the same date', workdays(new Date(2026, 0, 15), new Date(2026, 0, 15), new Set()).count, 1);
test('a fortnight is ten, and the walk crosses a month boundary happily', [workdays(new Date(2026, 0, 5), new Date(2026, 0, 16), new Set()).count, workdays(new Date(2026, 0, 26), new Date(2026, 1, 6), new Set()).count], [10, 10]);
test('and the caller\u2019s start date is not moved by the loop', (() => { const start = new Date(2026, 0, 5); workdays(start, new Date(2026, 0, 9), new Set()); return iso(start); })(), '2026-01-05');

Three decisions are worth naming out loud. The range is inclusive of both ends, which is what a human means by "from Monday to Friday" and not what slice or a naive difference gives; state it in the function's name or its documentation, because the off-by-one is invisible otherwise. The weekend is Saturday and Sunday, which is a cultural assumption, not a fact — it needs to be a parameter the moment the calendar covers a country where the week ends elsewhere. And holidays are checked only after the weekend, so a public holiday on a Saturday costs nothing, which is the truth in Nigeria unless the government declares the Monday in lieu.

The loop itself teaches the other half: day.setDate(day.getDate() + 1) steps across month ends and year ends with no special handling, because setDate takes an offset from the first of the month. And it mutates, which is why the function copies the incoming date before touching it — the last assertion is there because a helper that quietly moves its caller's Date is a bug that surfaces three functions away.

131 Say how long ago that was, in words

fair

Turn the gap between two moments into the kind of label a feed shows: just now, 5 minutes ago, yesterday, 3 days ago. Take the current moment as an argument, get the singular right, and do something sensible when the timestamp is in the future.

Stuck? Three hints, smallest first
  1. 1. Work in seconds, and pick the largest unit whose count is at least one.
  2. 2. One is the only singular, so the plural is a suffix decided by the count.
  3. 3. A negative gap is not an error. Servers and phones disagree about the time.

The answer

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

const count = (n, unit) => n + ' ' + unit + (n === 1 ? '' : 's');

function spell(seconds) {
  const minutes = Math.round(seconds / MINUTE);
  if (minutes < 60) return count(minutes, 'minute');
  const hours = Math.round(seconds / HOUR);
  if (hours < 24) return count(hours, 'hour');
  const days = Math.round(seconds / DAY);
  if (days < 30) return count(days, 'day');
  const months = Math.round(days / 30);
  if (months < 12) return count(months, 'month');
  return count(Math.round(days / 365), 'year');
}

function ago(then, now) {
  const seconds = Math.round((now - then) / 1000);
  if (Math.abs(seconds) < 45) return 'just now';
  if (seconds < 0) return 'in ' + spell(-seconds);            // the phone is ahead of the server
  if (seconds >= DAY && seconds < 2 * DAY) return 'yesterday';
  return spell(seconds) + ' ago';
}

// The clock is an argument. A function that reads the real one can never be tested.
const NOW = new Date(2026, 0, 15, 12, 0, 0);
const secondsBefore = (s) => new Date(NOW.getTime() - s * 1000);
for (const s of [5, 60, 3 * HOUR, 25 * HOUR, 3 * DAY, 40 * DAY]) {
  console.log(String(s).padStart(7), 'seconds ago reads as', ago(secondsBefore(s), NOW));
}

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 few seconds is not worth a number', ago(secondsBefore(5), NOW), 'just now');
test('one minute is singular', ago(secondsBefore(60), NOW), '1 minute ago');
test('two and a half minutes rounds up, and pluralises', ago(secondsBefore(150), NOW), '3 minutes ago');
test('hours once the minutes reach sixty', ago(secondsBefore(3 * HOUR), NOW), '3 hours ago');
test('a day and a bit is yesterday', ago(secondsBefore(25 * HOUR), NOW), 'yesterday');
test('but two days is counted again', ago(secondsBefore(3 * DAY), NOW), '3 days ago');
test('thirty days rounds up into months rather than saying 30 days', [ago(secondsBefore(29 * DAY), NOW), ago(secondsBefore(40 * DAY), NOW)], ['29 days ago', '1 month ago']);
test('past a year it says years', ago(secondsBefore(400 * DAY), NOW), '1 year ago');
test('and a timestamp from the future reads forwards, not as a negative', ago(new Date(NOW.getTime() + 5 * MINUTE * 1000), NOW), 'in 5 minutes');

The design decision that makes this testable is the second parameter. Every version of this function that calls Date.now() inside can only be checked by eye, and the one case you most want to check — a timestamp slightly in the future — is impossible to stage. Pass the clock in; the caller supplies new Date() at the edge.

Future timestamps are not hypothetical. Browser clocks drift, phones are set by hand, and a record created with the client's time and rendered against the server's time routinely arrives a few seconds ahead. Without the negative branch a fresh comment reads -3 seconds ago or, worse, 1 year ago once the arithmetic goes through Math.abs somewhere careless. Absorbing anything under 45 seconds into "just now" hides most skew for free.

Two things are deliberately imprecise. Math.round, not floor, so 59 minutes reads as an hour rather than "59 minutes"; and a month is 30 days, because nobody reading a feed is counting. Precision here is not accuracy — the label is a rough anchor, and the exact timestamp belongs in a tooltip.

132 Read a date the way a Nigerian form writes it

hard

Parse dd/mm/yyyy into a date, and reject anything that is not really a date: 31/02/2026, 15/01/26, letters, the wrong separator. Do not hand the string to new Date() and hope.

Stuck? Three hints, smallest first
  1. 1. Split on the slash and check you got three pieces of the right length, all digits.
  2. 2. Build the date, then read it back: a rolled-over date will not match what you asked for.
  3. 3. That round-trip is the only check you need for month lengths and leap years.

The answer

const digitsOnly = (s) => s.length > 0 && [...s].every((c) => c >= '0' && c <= '9');
const stamp = (d) =>
  d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');

function parseDMY(text) {
  const parts = String(text).split('/');
  if (parts.length !== 3) return null;

  const [dd, mm, yyyy] = parts;
  if (dd.length > 2 || mm.length > 2 || yyyy.length !== 4) return null;
  if (!digitsOnly(dd) || !digitsOnly(mm) || !digitsOnly(yyyy)) return null;

  const day = Number(dd);
  const month = Number(mm);
  const year = Number(yyyy);
  const date = new Date(year, month - 1, day);

  // The round-trip. Date rolls impossible days forward, so if the parts that come
  // back are not the parts that went in, the input was not a real date.
  const same = date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
  return same ? date : null;
}

const inputs = ['15/01/2026', '1/1/2026', '31/02/2026', '29/02/2028', '29/02/2026', '15/01/26', '15-01-2026', 'oga/01/2026'];
for (const text of inputs) {
  const parsed = parseDMY(text);
  console.log(text.padEnd(13), parsed ? stamp(parsed) : 'rejected');
}

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 well formed date parses', stamp(parseDMY('15/01/2026')), '2026-01-15');
test('single digit day and month are allowed', stamp(parseDMY('1/1/2026')), '2026-01-01');
test('29 February is real in 2028 and not in 2026', [stamp(parseDMY('29/02/2028')), parseDMY('29/02/2026')], ['2028-02-29', null]);
test('and this is what the round-trip is catching', new Date(2026, 1, 31).getDate(), 3);
test('a day that does not exist is rejected, not rolled', [parseDMY('31/02/2026'), parseDMY('32/01/2026'), parseDMY('15/13/2026')], [null, null, null]);
test('a two digit year is rejected rather than guessed', parseDMY('15/01/26'), null);
test('so are the wrong separator and letters', [parseDMY('15-01-2026'), parseDMY('oga/01/2026'), parseDMY('15/01/2026/9')], [null, null, null]);
test('the built-in parse of an unreadable string gives NaN, not null', Number.isNaN(new Date('oga').getTime()), true);
test('and a date-only ISO string is read as UTC, which is a different instant from local midnight', new Date('2026-01-15').toISOString(), '2026-01-15T00:00:00.000Z');

The round-trip is the whole technique. Rather than encoding month lengths and the leap-year rule, build the date and read it back: new Date(2026, 1, 31) comes back as 3 March, the day no longer matches the 31 that went in, so the input is rejected. Twelve month lengths, the four-hundred-year leap rule and every edge case collapse into three comparisons.

Everything else here is about refusing to guess. new Date('15/01/2026') is a non-standard format, so what it does is up to the engine; new Date('03/04/2026') is worse, because every engine reads it happily and reads it as the American month-first order, so the 3rd of April becomes the 4th of March with no error anywhere. A two-digit year is the same problem: 26 might be 2026, 1926, or the literal year 26, and the honest answer is to reject it and let the form ask again.

The last assertion is the one that catches people who did switch to ISO strings. '2026-01-15', date-only, is defined to be parsed as UTC, while '2026-01-15T00:00' is local — so in any timezone behind Greenwich the first one prints as the 14th. If a string carries no timezone, construct the date from its parts and keep the ambiguity out of the code.

133 A duration a person can read

fair

Turn a number of minutes into 2h 15m, 1d 1h, 45m. Show only the two largest units that are not zero, keep 0m for nothing, and handle a negative duration.

Stuck? Three hints, smallest first
  1. 1. Divide and take the remainder, largest unit first.
  2. 2. Collect the non-zero parts into an array, then take the first two.
  3. 3. Deal with the sign once, at the start, and work with the absolute value.

The answer

const MINUTES_IN_DAY = 24 * 60;

function parts(totalMinutes) {
  let left = Math.abs(Math.trunc(totalMinutes));
  const days = Math.floor(left / MINUTES_IN_DAY);
  left -= days * MINUTES_IN_DAY;
  const hours = Math.floor(left / 60);
  const minutes = left - hours * 60;
  const out = [];
  if (days) out.push(days + 'd');
  if (hours) out.push(hours + 'h');
  if (minutes) out.push(minutes + 'm');
  return out;
}

const full = (totalMinutes) => (totalMinutes < 0 ? '-' : '') + (parts(totalMinutes).join(' ') || '0m');

// The same thing, cut to the two units that matter to a reader.
const brief = (totalMinutes) =>
  (totalMinutes < 0 ? '-' : '') + (parts(totalMinutes).slice(0, 2).join(' ') || '0m');

for (const m of [0, 45, 60, 135, 1440, 1500, 1501, 1441]) {
  console.log(String(m).padStart(5), 'minutes ->', brief(m).padEnd(8), '(full: ' + full(m) + ')');
}

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('nothing is still worth printing', brief(0), '0m');
test('under an hour is minutes alone', brief(45), '45m');
test('a whole hour drops the empty minutes', brief(60), '1h');
test('the usual case', brief(135), '2h 15m');
test('a whole day, then a day and an hour', [brief(1440), brief(1500)], ['1d', '1d 1h']);
test('the third unit is dropped, and that is the point', [brief(1501), full(1501)], ['1d 1h', '1d 1h 1m']);
test('a zero in the middle lets minutes into the second slot', brief(1441), '1d 1m');
test('a negative duration keeps its sign', brief(-90), '-1h 30m');
test('and a fractional minute is truncated, not rounded', [brief(90.9), full(-0.5)], ['1h 30m', '-0m']);

1d 1h 1m 1s is precise and unreadable. Cutting to the two largest non-zero units is what makes a duration scannable, and the sixth assertion shows what is being thrown away deliberately. Note that the units dropped are the smallest ones, so the error is always small relative to the number shown — cutting the largest units instead would be a catastrophe.

The zero-unit case is the interesting one. Because parts are collected only when non-zero, 1441 minutes comes out as 1d 1m rather than 1d 0h, which is what a fixed days + 'd ' + hours + 'h' template would print. Whether you want that or a padded 1d 0h 1m is a product decision; the array-then-slice shape makes either one a one-line change.

-0m from full(-0.5) is the loose end. The sign is taken from the input while the digits come from the truncated absolute value, so a duration that rounds away to nothing keeps a minus it has not earned. If durations can arrive fractional, decide at the boundary whether to round or truncate, and do it before the formatter sees the number.

134 A month laid out as a calendar

hard

Print one month as a grid of weeks, Monday first, with the day numbers right-aligned in two characters. January 2026 starts on a Thursday, so the first row needs three blank cells. Every line must come out the same width.

Stuck? Three hints, smallest first
  1. 1. getDay() is Sunday-first, so (getDay() + 6) % 7 gives the number of blanks before the 1st.
  2. 2. Build one flat list of cells: the blanks, then the day numbers, then blanks to fill the last week.
  3. 3. Slice the flat list into sevens, and pad each cell with padStart(2).

The answer

const HEADER = 'Mo Tu We Th Fr Sa Su';

function monthGrid(year, month) {
  const first = new Date(year, month, 1);
  const lead = (first.getDay() + 6) % 7;                    // Monday-first offset
  const days = new Date(year, month + 1, 0).getDate();

  const cells = [];
  for (let i = 0; i < lead; i++) cells.push('');            // before the 1st
  for (let d = 1; d <= days; d++) cells.push(String(d));
  while (cells.length % 7 !== 0) cells.push('');            // after the last day

  const weeks = [];
  for (let i = 0; i < cells.length; i += 7) {
    weeks.push(cells.slice(i, i + 7).map((c) => c.padStart(2)).join(' '));
  }
  return { lead, days, weeks };
}

const january = monthGrid(2026, 0);
console.log(HEADER);
for (const week of january.weeks) console.log(week);

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('1 January 2026 is a Thursday, which is three cells in', [new Date(2026, 0, 1).getDay(), january.lead], [4, 3]);
test('a Sunday-first grid would have needed four', (new Date(2026, 0, 1).getDay() + 7) % 7, 4);
test('the month needs five rows', january.weeks.length, 5);
test('the first row is blank until the 1st', [january.weeks[0].trimStart(), january.weeks[0].indexOf('1')], ['1  2  3  4', 10]);
test('the last row is padded out rather than left short', january.weeks[4], '26 27 28 29 30 31   ');
test('so every line, header included, is exactly the same width', [...new Set([HEADER, ...january.weeks].map((l) => l.length))], [20]);
test('February 2026 starts on a Sunday, the widest possible lead', monthGrid(2026, 1).lead, 6);
test('February 2027 starts on a Monday and has 28 days, so it fits in four rows', [monthGrid(2027, 1).lead, monthGrid(2027, 1).weeks.length], [0, 4]);
test('and a 31-day month starting on a Saturday needs six', [monthGrid(2026, 7).lead, monthGrid(2026, 7).days, monthGrid(2026, 7).weeks.length], [5, 31, 6]);

The trick that makes this simple is refusing to think in rows. Build one flat list — blanks, then days, then blanks — and only slice it into weeks at the end. Trying to decide, inside a nested loop, whether cell (row, column) holds a day is where the off-by-one errors live.

(getDay() + 6) % 7 is the Monday-first conversion, and it is worth understanding rather than memorising: it rotates Sunday from 0 to 6 and pulls everything else down one. Nigerian calendars are usually Monday-first, US ones Sunday-first, and the difference is exactly this one expression — the second assertion shows the same date needing a different lead under each convention.

The row count is not fixed. Five is normal, four happens only for a non-leap February beginning on a Monday, and six is needed whenever the lead plus the day count passes 35 — a 31-day month starting Saturday or Sunday. A layout that assumes five rows will clip a week roughly twice a year, which the last two assertions pin down.

135 Pascal’s triangle

fair

Build the first n rows of Pascal's triangle, where every number is the sum of the two above it and the edges are 1. Then render it centred. Asking for zero rows should give nothing, not a row.

Stuck? Three hints, smallest first
  1. 1. Each row starts at 1 and is one longer than the row above.
  2. 2. The inner value at column c is previous[c - 1] + previous[c]; treat a missing neighbour as 0.
  3. 3. To centre a line, pad it to half the difference between its length and the widest line.

The answer

function pascal(rows) {
  const out = [];
  for (let r = 0; r < rows; r++) {
    const above = out[r - 1] || [];
    const row = [1];
    for (let c = 1; c <= r; c++) row.push((above[c - 1] || 0) + (above[c] || 0));
    out.push(row);
  }
  return out;
}

function render(rows) {
  const lines = pascal(rows).map((row) => row.join(' '));
  const width = lines.length ? lines[lines.length - 1].length : 0;
  // padStart to (width + length) / 2 puts half the shortfall on the left.
  return lines.map((l) => l.padStart(Math.floor((width + l.length) / 2)).padEnd(width));
}

const six = pascal(6);
for (const line of render(5)) console.log(line);
console.log('row sums:', pascal(8).map((r) => r.reduce((a, b) => a + b, 0)).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.

9 checks

test('the first six rows', six, [[1], [1, 1], [1, 2, 1], [1, 3, 3, 1], [1, 4, 6, 4, 1], [1, 5, 10, 10, 5, 1]]);
test('row r has r + 1 entries', six.map((r) => r.length), [1, 2, 3, 4, 5, 6]);
test('the ends are always 1', six.every((r) => r[0] === 1 && r[r.length - 1] === 1), true);
test('each row reads the same backwards', six[5], [...six[5]].reverse());
test('and each row adds up to a power of two', pascal(8).map((r) => r.reduce((a, b) => a + b, 0)), [1, 2, 4, 8, 16, 32, 64, 128]);
test('the entries are the binomial coefficients, so row 6 column 2 is 6 choose 2', [pascal(7)[6], pascal(7)[6][2]], [[1, 6, 15, 20, 15, 6, 1], 15]);
test('zero rows is an empty triangle, not a row containing one', [pascal(0), pascal(1)], [[], [[1]]]);
test('the render is centred and every line is the width of the last', render(5), ['    1    ', '   1 1   ', '  1 2 1  ', ' 1 3 3 1 ', '1 4 6 4 1']);
test('and an empty triangle renders as nothing at all', render(0), []);

The inner loop is the exercise: above[c - 1] + above[c], with a missing neighbour read as 0 so the edges fall out of the same expression instead of needing their own if. Note out[r - 1] || [] for the first row — out[-1] is undefined, and indexing into it would throw, which is the one place this can go wrong.

Row sums being powers of two is not decoration. Row r of the triangle is the binomial coefficients of r choose k, and they sum to 2r because that counts every subset of r things. So this loop is also a way to compute combinations without factorials, which matters because 21! already exceeds what a JavaScript number can hold exactly while every entry in these rows stays a small integer.

The centring is arithmetic worth reading twice. padStart((width + length) / 2) asks for a string whose length is the midpoint, which adds exactly half the shortfall on the left; padEnd(width) then fills the right so every line is the same length. Without the padEnd the lines look centred and are not the same width, which shows up the moment anything is aligned against them.

136 Every prime up to a limit, by crossing out

fair

List all the primes up to a limit. Instead of testing each number on its own, keep a list of flags and cross out the multiples of each prime you find. Start crossing out at the square of the prime and stop when the prime's square passes the limit.

Stuck? Three hints, smallest first
  1. 1. An array of true, one slot per number, with 0 and 1 set false to begin.
  2. 2. When you reach a number still marked true, it is prime; walk its multiples and mark them false.
  3. 3. Its multiples below p squared were already crossed out by a smaller prime, so start at p * p.

The answer

function sieve(limit) {
  if (limit < 2) return { primes: [], crossings: 0 };
  const prime = new Array(limit + 1).fill(true);
  prime[0] = false;
  prime[1] = false;

  let crossings = 0;
  for (let p = 2; p * p <= limit; p++) {
    if (!prime[p]) continue;                       // already crossed out, so not prime
    for (let m = p * p; m <= limit; m += p) {      // p*2 .. p*(p-1) are already gone
      prime[m] = false;
      crossings++;
    }
  }

  const out = [];
  for (let n = 2; n <= limit; n++) if (prime[n]) out.push(n);
  return { primes: out, crossings };
}

// The one-at-a-time version, for comparison.
const isPrime = (n) => {
  if (n < 2) return false;
  for (let d = 2; d * d <= n; d++) if (n % d === 0) return false;
  return true;
};

const hundred = sieve(100);
console.log('primes to 100:', hundred.primes.join(' '));
console.log('crossings used:', hundred.crossings);

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 primes under thirty', sieve(30).primes, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('there are 25 up to a hundred, and 97 is the last', [hundred.primes.length, hundred.primes[hundred.primes.length - 1]], [25, 97]);
test('and 168 up to a thousand', sieve(1000).primes.length, 168);
test('1 is not prime and 2 is', [sieve(1).primes, sieve(2).primes], [[], [2]]);
test('a limit below two has no primes at all rather than throwing', [sieve(0).primes, sieve(-5).primes], [[], []]);
test('it agrees with testing every number one at a time', sieve(100).primes, Array.from({ length: 101 }, (_, n) => n).filter(isPrime));
test('crossing out from p squared still catches every composite', hundred.primes.filter((n) => n % 2 === 0), [2]);
test('74 composites took 104 crossings, so 30 were crossed twice', [99 - hundred.primes.length, hundred.crossings], [74, 104]);
test('and only four primes ever ran the inner loop, because 11 squared is past 100', [Math.floor(Math.sqrt(100)), sieve(100).primes.filter((p) => p * p <= 100)], [10, [2, 3, 5, 7]]);

The sieve inverts the question. Trial division asks "is this number prime" once per number and redoes the same divisions over and over; the sieve asks "what is definitely not prime" and crosses composites off in one pass. For a range, that is much less work — and the crossings count makes it concrete.

Two bounds are doing the optimising, and both are easy to get wrong. The outer loop runs while p * p <= limit, because any composite up to the limit has a factor at or below its square root, so nothing above that can cross out anything new — for a limit of 100 only 2, 3, 5 and 7 ever run the inner loop. The inner loop starts at p * p rather than 2 * p, because every multiple of p below p² has a smaller prime factor and was already crossed out; starting at 2 * p still gives the right answer, just with more wasted writes. Wasted writes do not disappear either way: 74 composites take 104 crossings here, because a number with several prime factors gets crossed out once per factor.

The cost is memory: an array the size of the limit. That is fine for a hundred thousand and wrong for a trillion, which is the honest trade — trial division is O(1) space and answers about one number, the sieve is O(n) space and answers about a range. Pick by the question you are actually asking.

137 A hex colour, taken apart and put back

fair

Turn #008751, the green on the Nigerian flag, into its three red-green-blue numbers, and turn three numbers back into a hex string. Support the three-character shorthand, clamp values that are out of range, and return null for anything that is not a colour.

Stuck? Three hints, smallest first
  1. 1. parseInt(text, 16) reads base 16; without the second argument it does not.
  2. 2. Shorthand doubles each character: #0b0 means #00bb00.
  3. 3. Going back, toString(16) then padStart(2, '0'), because 11 must be written 0b.

The answer

function hexToRgb(hex) {
  let body = hex.startsWith('#') ? hex.slice(1) : hex;
  if (body.length === 3) body = body[0] + body[0] + body[1] + body[1] + body[2] + body[2];
  if (!/^[0-9a-f]{6}$/i.test(body)) return null;
  return [0, 2, 4].map((i) => parseInt(body.slice(i, i + 2), 16));
}

const channel = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
const rgbToHex = (r, g, b) => '#' + channel(r) + channel(g) + channel(b);

const GREEN = '#008751';
console.log(GREEN, '->', hexToRgb(GREEN).join(', '));
console.log('and back ->', rgbToHex(...hexToRgb(GREEN)));

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 flag green comes apart', hexToRgb(GREEN), [0, 135, 81]);
test('and goes back together', rgbToHex(0, 135, 81), GREEN);
test('shorthand doubles each digit', [hexToRgb('#0b0'), hexToRgb('#fff')], [[0, 187, 0], [255, 255, 255]]);
test('the hash is optional and the case does not matter', [hexToRgb('008751'), hexToRgb('#008751'.toUpperCase())], [[0, 135, 81], [0, 135, 81]]);
test('every channel is padded to two digits, so 11 is 0b not b', [(11).toString(16), channel(11)], ['b', '0b']);
test('out of range values are clamped and fractions rounded', rgbToHex(300, -5, 12.6), '#ff000d');
test('anything that is not a colour is rejected', [hexToRgb('#12345'), hexToRgb('#gggggg'), hexToRgb('white')], [null, null, null]);
test('parseInt without a radix reads 1f as 1 and stops', [parseInt('1f'), parseInt('1f', 16)], [1, 31]);
test('and every round trip of a full hex string returns the same string', ['#000000', '#ffffff', '#0b6e4f'].map((h) => rgbToHex(...hexToRgb(h))), ['#000000', '#ffffff', '#0b6e4f']);

Two base-16 conversions and one padding rule. parseInt(pair, 16) reads hex into a number, number.toString(16) writes it back, and the padStart(2, '0') is not cosmetic: 11 becomes 'b', and three single-character channels concatenate into a string of the wrong length that no browser will accept.

The eighth assertion is the classic. parseInt('1f') is not an error and not NaN — it reads as far as it understands and returns 1, so a missing radix produces a plausible wrong colour instead of a crash. Always pass the base. Note the two functions are not perfect inverses either: hexToRgb('#0b0') then rgbToHex gives '#00bb00', the same colour spelled the long way, which is why the round-trip assertion uses full-length inputs.

The clamp belongs in the writing direction, not the reading direction. Hex can only express 0 to 255, so a computed channel of 300 or -5 — easy to produce when lightening a colour — has to be brought into range before it is formatted, or (300).toString(16) quietly emits 12c and the string is seven characters long.

138 Permissions packed into one number

hard

Hold four permissions in a single integer, one bit each. Write the helpers to test, add, remove and toggle a permission, print the number in binary, and read a binary string back. Then show why if (flags & WRITE) is not the same as a boolean.

Stuck? Three hints, smallest first
  1. 1. Give each permission a distinct power of two, so no two share a bit.
  2. 2. | sets a bit, & tests it, & ~flag clears it, ^ flips it.
  3. 3. toString(2) writes binary and parseInt(text, 2) reads it, and both need padding to look right.

The answer

const READ = 1;      // 0001
const WRITE = 2;     // 0010
const PUBLISH = 4;   // 0100
const ADMIN = 8;     // 1000
const NAMES = [[ADMIN, 'admin'], [PUBLISH, 'publish'], [WRITE, 'write'], [READ, 'read']];

const bits = (flags) => flags.toString(2).padStart(4, '0');
const has = (flags, flag) => (flags & flag) !== 0;
const grant = (flags, flag) => flags | flag;
const revoke = (flags, flag) => flags & ~flag;
const toggle = (flags, flag) => flags ^ flag;
const listOf = (flags) => NAMES.filter(([bit]) => has(flags, bit)).map(([, name]) => name);

const editor = grant(grant(0, READ), WRITE);
console.log('editor is', editor, '=', bits(editor), listOf(editor));
console.log('promoted:', bits(grant(editor, PUBLISH)), listOf(grant(editor, PUBLISH)));

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('read plus write is 3, which is 0011', [editor, bits(editor)], [3, '0011']);
test('the bits it holds, named', listOf(editor), ['write', 'read']);
test('granting sets one bit and leaves the rest', [grant(editor, ADMIN), bits(grant(editor, ADMIN))], [11, '1011']);
test('revoking clears exactly one', [revoke(editor, WRITE), bits(revoke(editor, WRITE))], [1, '0001']);
test('revoking something not held changes nothing', revoke(editor, ADMIN), 3);
test('toggling twice is where you started', toggle(toggle(editor, ADMIN), ADMIN), 3);
test('a masked test returns the bit, not true', [editor & WRITE, editor & PUBLISH, has(editor, WRITE)], [2, 0, true]);
test('binary in and out, and 15 is all four', [parseInt('1011', 2), bits(15), listOf(15).length], [11, '1111', 4]);
test('but bitwise operators work on 32 bits, so a big flag wraps to negative', (2 ** 31) | 0, -2147483648);

One bit per permission is why the values are 1, 2, 4, 8 and not 1, 2, 3, 4. Powers of two never overlap, so READ | WRITE is 3 and can still be taken apart; with 1, 2, 3 the combination of the first two is the third and the information is gone. & ~flag for removal reads oddly at first: ~WRITE is a number with every bit set except that one, so the & keeps everything else and drops it.

The seventh assertion is the one that bites. editor & WRITE is 2, not true, and editor & PUBLISH is 0, not false. Inside an if that works by truthiness and reads fine, but returned from a function, stored in JSON, or compared with === true, it is a number pretending to be a boolean. Hence the explicit !== 0 in has.

The last assertion is the hard ceiling. Every bitwise operator converts its operands to signed 32-bit integers first, so bit 31 lands on the sign bit and bit 32 disappears entirely. Thirty-one flags is the practical limit; past that use BigInt, a Set of names, or several numbers. And be honest about the trade: a packed integer is compact and fast, and it is also unreadable in a database column, so most application code is better off with named booleans unless the packing is a protocol requirement.

139 Does that card number pass the Luhn check

hard

Check a card number with the Luhn formula: from the right, double every second digit, subtract 9 from any result above 9, add everything up, and the total must divide by 10. Ignore spaces. Then compute the check digit that would make a partial number valid.

Stuck? Three hints, smallest first
  1. 1. Walk the digits from the right so the doubling starts in the same place whatever the length.
  2. 2. Doubling 7 gives 14, and 1 + 4 is 5, which is the same as 14 - 9.
  3. 3. For the check digit, the digit you are about to append shifts the doubling, so the parity flips.

The answer

const digitsOf = (text) => [...String(text)].filter((c) => c >= '0' && c <= '9').map(Number);

function luhn(text) {
  const digits = digitsOf(text);
  if (digits.length < 2) return false;
  let sum = 0;
  for (let i = digits.length - 1, place = 0; i >= 0; i--, place++) {
    let d = digits[i];
    if (place % 2 === 1) {              // every second digit from the right
      d *= 2;
      if (d > 9) d -= 9;                // 14 -> 5, the same as adding its digits
    }
    sum += d;
  }
  return sum % 10 === 0;
}

// The digit that would complete a number. The parity flips because the appended
// digit takes place 0 and pushes everything else along one.
function checkDigit(partial) {
  const digits = digitsOf(partial);
  let sum = 0;
  for (let i = digits.length - 1, place = 0; i >= 0; i--, place++) {
    let d = digits[i];
    if (place % 2 === 0) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return (10 - (sum % 10)) % 10;
}

const CARD = '5060 9912 3456 7895';
console.log(CARD, luhn(CARD) ? 'passes' : 'fails');
console.log('check digit for 506099123456789:', checkDigit('506099123456789'));

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 valid number passes, spaces and all', luhn(CARD), true);
test('change the last digit and it fails', luhn('5060 9912 3456 7894'), false);
test('the textbook example passes too', luhn('79927398713'), true);
test('the check digit completes a partial number', [checkDigit('506099123456789'), luhn('506099123456789' + checkDigit('506099123456789'))], [5, true]);
test('subtracting 9 is the same as adding the digits of the double', [(7 * 2) - 9, 1 + 4], [5, 5]);
test('every single-digit typo in that card is caught', digitsOf(CARD).flatMap((d, i) => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].filter((n) => n !== d).map((n) => { const copy = digitsOf(CARD); copy[i] = n; return luhn(copy.join('')); })).filter(Boolean).length, 0);
test('and so is swapping most neighbouring pairs', luhn('5060 9921 3456 7895'), false);
test('but a 0 and a 9 swapped slips straight through', [luhn('5069 0912 3456 7895'), 0 * 2 - 0, 9 * 2 - 9], [true, 0, 9]);
test('too short to have a check digit at all', [luhn(''), luhn('5')], [false, false]);

Walking from the right is what makes the formula length-independent: a 16-digit Verve number and a 15-digit Amex have their doubled digits in different absolute positions but the same positions counted backwards. And d -= 9 is not a trick — doubling a digit can only reach 18, and for any two-digit result subtracting 9 equals summing its digits.

Luhn catches every single-digit mistake, which the sixth assertion checks exhaustively across all 16 positions and all 9 wrong digits. It also catches most transpositions of neighbours, but not all: the pair 0 and 9 is invisible to it, because doubling 0 contributes 0 either way and doubling 9 gives 18 which folds back to 9. The eighth assertion swaps a 0 and a 9 and the number still passes.

So Luhn is a typo filter, not a validity check. It tells you the digits are internally consistent, which is worth doing in the browser before a payment request goes anywhere. It tells you nothing about whether the card exists, has money on it, or belongs to the person typing — only the issuer knows that. Treat a pass as "worth sending" and never as "accepted".

140 A letter shift that wraps round the alphabet

fair

Shift every letter in a message along the alphabet by a number of places, wrapping z round to a. Leave punctuation, spaces and digits alone, keep upper and lower case as they were, and make a negative shift undo a positive one.

Stuck? Three hints, smallest first
  1. 1. Work with character codes: charCodeAt out, String.fromCharCode back.
  2. 2. Subtract the code of A or a first so the letter is 0 to 25, then add it back.
  3. 3. % can return a negative, so ((x % 26) + 26) % 26 is what actually wraps.

The answer

const A = 'A'.charCodeAt(0);      // 65
const a = 'a'.charCodeAt(0);      // 97

function shift(text, by) {
  let out = '';
  for (const ch of text) {
    const code = ch.charCodeAt(0);
    const base = code >= A && code <= A + 25 ? A : code >= a && code <= a + 25 ? a : null;
    if (base === null) {
      out += ch;                                             // not a letter, left alone
      continue;
    }
    const moved = (((code - base + by) % 26) + 26) % 26;     // the double modulo is the wrap
    out += String.fromCharCode(base + moved);
  }
  return out;
}

const rot13 = (text) => shift(text, 13);
const message = 'Meet me at Ojuelegba, 6pm!';
console.log(message);
console.log('shift 3 ->', shift(message, 3));
console.log('back    ->', shift(shift(message, 3), -3));

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 classic three-place shift', shift('Lagos', 3), 'Odjrv');
test('and shifting back returns the message', shift(shift(message, 3), -3), message);
test('case survives, and anything that is not a letter is untouched', shift(message, 3), 'Phhw ph dw Rmxhohjed, 6sp!');
test('z wraps to a and Z to A', shift('zZ', 1), 'aA');
test('a negative shift wraps the other way', shift('aA', -1), 'zZ');
test('a whole alphabet of shift changes nothing', shift(message, 26), message);
test('so shifting by 29 is shifting by 3', shift(message, 29), shift(message, 3));
test('rot13 is its own inverse', rot13(rot13(message)), message);
test('and without the double modulo the wrap would break', [-1 % 26, ((-1 % 26) + 26) % 26], [-1, 25]);

The base variable is the idea: subtract the code of the case's first letter to get a position 0 to 25, do the arithmetic there, add the base back. That is what keeps upper and lower case separate without two copies of the logic, and it generalises to any contiguous alphabet.

((x % 26) + 26) % 26 looks like superstition until you check the last assertion. JavaScript's % is a remainder, not a mathematician's modulo, so it keeps the sign of the left operand: -1 % 26 is -1, which added to the base of 'a' gives the backtick character. Every wrapping index — clock faces, circular buffers, carousels stepping backwards — needs the same guard.

Nothing here is encryption. A shift cipher has 25 possible keys, so it is broken by trying all of them, and English letter frequencies give it away instantly. It is a puzzle and a good exercise in character arithmetic. Anything that actually needs to be secret goes through crypto.subtle, not through this.

141 Squash repeats with run-length encoding

fair

Compress a string by replacing each run of identical characters with the character and how many times it repeated, then write the decoder. Show a string the format makes longer, and a string it cannot survive a round trip.

Stuck? Three hints, smallest first
  1. 1. Walk forward, counting how far the current character continues.
  2. 2. Decoding, a run length can be more than one digit, so read digits until they stop.
  3. 3. Ask what happens when the text being encoded already contains digits.

The answer

function encode(text) {
  let out = '';
  let i = 0;
  while (i < text.length) {
    let run = 1;
    while (i + run < text.length && text[i + run] === text[i]) run++;
    out += text[i] + run;
    i += run;
  }
  return out;
}

function decode(text) {
  let out = '';
  let i = 0;
  while (i < text.length) {
    const ch = text[i];
    let j = i + 1;
    while (j < text.length && text[j] >= '0' && text[j] <= '9') j++;   // multi-digit runs
    out += ch.repeat(Number(text.slice(i + 1, j)));
    i = j;
  }
  return out;
}

const chant = 'lllaaagggooosss';
console.log(chant, '->', encode(chant));
console.log('and back ->', decode(encode(chant)));
console.log('but encode("a1") is', encode('a1'), 'which decodes to', decode(encode('a1')).length, 'characters');

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('runs collapse', encode('aaabbc'), 'a3b2c1');
test('and come back', decode('a3b2c1'), 'aaabbc');
test('a full round trip on a repetitive string', decode(encode(chant)), chant);
test('a run longer than nine digits stays one run', [encode('a'.repeat(12)), decode('a12').length], ['a12', 12]);
test('nothing in, nothing out', [encode(''), decode('')], ['', '']);
test('with no repeats at all it doubles the length', [encode('abc'), encode('abc').length], ['a1b1c1', 6]);
test('so it only pays on runs, and this text has none worth having', encode('Lagos').length > 'Lagos'.length, true);
test('a digit in the input breaks the round trip badly', [encode('a1'), decode(encode('a1')).length], ['a111', 111]);
test('and the damage is silent: nothing throws, the answer is just wrong', decode(encode('a1')) === 'a1', false);

The encoder is two nested walks with one pointer, which is the shape worth learning: the inner loop measures the run, the outer loop jumps past it, so every character is looked at once. The decoder needs the digit loop because a run of 12 is two characters — reading a single character as the count is the bug that makes a12 decode to one a followed by a stray 2.

The last two assertions are the real lesson, and it is about formats rather than loops. This encoding has no way to tell a literal digit in the input from a count, so 'a1' encodes to 'a111' and decodes to 111 copies of a. Nothing throws. The output is a plausible string of the wrong length, which is how corruption spreads. A format that can express any input needs an escape character, or a length prefix, or a restricted alphabet stated up front.

And compression is not free. Run-length encoding doubles the size of text with no repeats, which is most text — it earns its keep on scanned black-and-white images, simple bitmaps and sensor readings that hold steady, not on prose. Measuring before and after, as the sixth assertion does, is the habit.

142 Binary search, and the version that never stops

hard

Find a value in a sorted array by halving the range each time. Report where it is, or where it would go if it is missing, and count the steps. Then show what happens when the range is narrowed to mid instead of mid + 1, and what happens on an array that is not sorted.

Stuck? Three hints, smallest first
  1. 1. Two markers, low and high, and a loop that runs while low is not past high.
  2. 2. If the middle is too small, the answer is above it: low becomes mid + 1, not mid.
  3. 3. When the loop ends without a hit, low is exactly the index the value would be inserted at.

The answer

function search(sorted, target) {
  let low = 0;
  let high = sorted.length - 1;
  let steps = 0;
  while (low <= high) {
    steps++;
    const mid = Math.floor((low + high) / 2);
    if (sorted[mid] === target) return { index: mid, steps, insertAt: mid };
    if (sorted[mid] < target) low = mid + 1;
    else high = mid - 1;
  }
  return { index: -1, steps, insertAt: low };      // low is where it would belong
}

// The same loop with mid instead of mid + 1. The cap is the only reason this returns.
function spinning(sorted, target, cap = 40) {
  let low = 0;
  let high = sorted.length - 1;
  let steps = 0;
  while (low <= high && steps < cap) {
    steps++;
    const mid = Math.floor((low + high) / 2);
    if (sorted[mid] === target) return { index: mid, steps };
    if (sorted[mid] < target) low = mid;
    else high = mid;
  }
  return { index: -1, steps };
}

const ids = Array.from({ length: 1000 }, (_, i) => (i + 1) * 3);   // 3, 6, 9 ... 3000
console.log('3000 is at', search(ids, 3000).index, 'found in', search(ids, 3000).steps, 'steps');
console.log('3001 is missing; it would go at', search(ids, 3001).insertAt);
console.log('the mid-instead-of-mid+1 version, hunting 4:', JSON.stringify(spinning([1, 3, 5, 7], 4)));

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 and last of a thousand are found in ten steps or fewer', [search(ids, 3).index, search(ids, 3000).index, search(ids, 3000).steps], [0, 999, 10]);
test('a missing value reports where it would be inserted', [search(ids, 3001).index, search(ids, 3001).insertAt, search(ids, 4).insertAt], [-1, 1000, 1]);
test('a linear scan would have compared a thousand times for the last one', [ids.indexOf(3000), ids.length], [999, 1000]);
test('ten steps because two to the tenth is past a thousand', [2 ** 10 > 1000, search(ids, 1500).steps <= 10], [true, true]);
test('an empty array takes no steps at all', search([], 5), { index: -1, steps: 0, insertAt: 0 });
test('one element, hit and miss', [search([7], 7).index, search([7], 8).index, search([7], 8).insertAt], [0, -1, 1]);
test('narrowing to mid instead of mid + 1 spins until the cap saves it', spinning([1, 3, 5, 7], 4), { index: -1, steps: 40 });
test('and it looks fine whenever the value happens to be there', spinning([1, 3, 5, 7], 5), { index: 2, steps: 2 });
test('on an unsorted array it misses a value that is present', [search([5, 1, 9, 3], 3).index, [5, 1, 9, 3].indexOf(3)], [-1, 3]);

low = mid + 1 is the whole exercise. The + 1 is what guarantees the range shrinks: once the middle has been compared and rejected it must be excluded, or a range of two elements can produce the same mid for ever. The seventh assertion is that infinite loop, held down by a step cap so this page still finishes — and the eighth shows why the bug survives review, because the same broken function returns correct answers whenever the target happens to be at a midpoint.

Returning low on failure is worth the extra line. When the loop ends, low is the index where the value would keep the array sorted, so the same function powers "insert in order" and "find the nearest" without a second pass. That is what Array.prototype.findIndex cannot give you cheaply.

The last assertion states the precondition that makes any of this legal. Binary search on unsorted data is not slow, it is wrong: it reports -1 for a value sitting in the array, because every comparison it makes assumes an order that is not there. The speed is real — ten comparisons against a thousand — but it is bought entirely with that assumption, and the cost of sorting has to be counted against however many searches you plan to run.

143 Sort labels that have numbers in them

hard

['Hall 10', 'Hall 2', 'Hall 1'] sorted the ordinary way puts Hall 10 before Hall 2. Write a comparator that splits each label into runs of digits and runs of anything else, and compares the digit runs as numbers.

Stuck? Three hints, smallest first
  1. 1. Default sort compares strings, so '10' < '2' because '1' < '2'.
  2. 2. Break each label into chunks: digits together, non-digits together.
  3. 3. Compare chunk by chunk; when both are digit chunks, compare Number(a) - Number(b).

The answer

const chunksOf = (text) => String(text).toLowerCase().match(/\d+|\D+/g) || [];
const isDigits = (chunk) => chunk[0] >= '0' && chunk[0] <= '9';

function natural(a, b) {
  const left = chunksOf(a);
  const right = chunksOf(b);
  const shared = Math.min(left.length, right.length);
  for (let i = 0; i < shared; i++) {
    if (left[i] === right[i]) continue;
    if (isDigits(left[i]) && isDigits(right[i])) return Number(left[i]) - Number(right[i]);
    return left[i] < right[i] ? -1 : 1;
  }
  return left.length - right.length;               // one is a prefix of the other
}

const halls = ['Hall 10', 'Hall 2', 'Hall 1', 'Hall 20', 'Hall 3'];
console.log('default: ', [...halls].sort().join(', '));
console.log('natural: ', [...halls].sort(natural).join(', '));
console.log('chunks of "Hall 10":', JSON.stringify(chunksOf('Hall 10')));

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 default sort is lexicographic, so 10 lands before 2', [...halls].sort(), ['Hall 1', 'Hall 10', 'Hall 2', 'Hall 20', 'Hall 3']);
test('the comparator fixes it', [...halls].sort(natural), ['Hall 1', 'Hall 2', 'Hall 3', 'Hall 10', 'Hall 20']);
test('a label splits into text and number chunks', chunksOf('Hall 10'), ['hall ', '10']);
test('numbers alone have the same problem, and need the same treatment', [[10, 9, 100].sort(), [10, 9, 100].sort((x, y) => x - y)], [[10, 100, 9], [9, 10, 100]]);
test('case is folded, so lower and upper interleave properly', ['hall 3', 'Hall 10', 'HALL 2'].sort(natural), ['HALL 2', 'hall 3', 'Hall 10']);
test('leading zeros compare as equal numbers', [natural('Hall 02', 'Hall 2'), Number('02')], [0, 2]);
test('a shorter label that is a prefix comes first', ['Hall 2 upper', 'Hall 2'].sort(natural), ['Hall 2', 'Hall 2 upper']);
test('several numbers in one label are each compared as numbers', ['Room 2-10', 'Room 2-9', 'Room 10-1'].sort(natural), ['Room 2-9', 'Room 2-10', 'Room 10-1']);
test('and sort mutates, which is why every check above copies first', (() => { const original = ['b', 'a']; const returned = original.sort(); return [original, returned === original]; })(), [['a', 'b'], true]);

sort with no comparator converts everything to strings and compares them character by character, which is why [10, 9, 100] comes back as [10, 100, 9]. That is not a quirk to work around once; it is the default, and every sort of anything numeric needs a comparator.

The natural comparator is the same idea applied to mixed text: split into digit and non-digit runs, then compare like with like. Returning Number(a) - Number(b) for digit chunks is what puts Hall 2 before Hall 10, and returning -1 or 1 for text chunks avoids the trap of subtracting strings. The fallback left.length - right.length handles one label being a prefix of another.

Two things to know before shipping it. sort mutates the array it is called on and returns that same array, so [...list].sort(...) is the habit unless you meant to reorder the original. And for human names, especially with accents, a.localeCompare(b) is the right tool — it knows that é sorts with e, which no amount of chunking will tell you. This comparator is for labels with numbers, not for a phone book.

144 A USSD menu driven by switch

hard

Model a bank's *901# menu as a function of the screen you are on and the key pressed, returning the next screen and its prompt. Use switch, group the screens that behave the same way, and make an unrecognised key leave you where you are with a complaint rather than crashing.

Stuck? Three hints, smallest first
  1. 1. One switch on the screen, and inside the menu screen another on the key.
  2. 2. Every branch returns, so no break is needed — and none may be forgotten.
  3. 3. switch compares with ===, so case '1' will not match the number 1.

The answer

const MENU = '1 Balance / 2 Airtime / 3 Transfer';

function step(screen, key) {
  switch (screen) {
    case 'menu':
      switch (key) {
        case '1': return { screen: 'balance', prompt: 'Balance: N12,450.00' };
        case '2': return { screen: 'airtime', prompt: 'Enter amount in naira' };
        case '3': return { screen: 'transfer', prompt: 'Enter 10-digit account' };
        default: return { screen: 'menu', prompt: 'Invalid choice. ' + MENU };
      }

    case 'airtime': {
      const amount = Number(key);
      if (!Number.isInteger(amount) || amount < 50) return { screen: 'airtime', prompt: 'Minimum is N50' };
      return { screen: 'confirm', prompt: 'Send N' + amount + ' airtime? 1 Yes / 2 No' };
    }

    case 'confirm':
      switch (key) {
        case '1': return { screen: 'done', prompt: 'Request sent. You will get an SMS.' };
        case '2': return { screen: 'menu', prompt: MENU };
        default: return { screen: 'confirm', prompt: 'Answer 1 or 2' };
      }

    // Three screens with nothing left to do, grouped rather than repeated.
    case 'balance':
    case 'transfer':
    case 'done':
    default:
      return { screen: 'menu', prompt: MENU };
  }
}

const walk = (keys) => keys.reduce((at, key) => step(at.screen, key), { screen: 'menu', prompt: MENU });
console.log('*901# then 2, 500, 1 ->', JSON.stringify(walk(['2', '500', '1'])));
console.log('a mistyped key ->', step('menu', '9').prompt);

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('pressing 1 shows the balance', step('menu', '1'), { screen: 'balance', prompt: 'Balance: N12,450.00' });
test('an unknown key keeps you on the menu and says so', [step('menu', '9').screen, step('menu', '9').prompt.startsWith('Invalid')], ['menu', true]);
test('the number 1 is not the string 1, because switch uses strict equality', [step('menu', 1).screen, 1 === '1'], ['menu', false]);
test('airtime takes an amount and asks for confirmation', step('airtime', '500'), { screen: 'confirm', prompt: 'Send N500 airtime? 1 Yes / 2 No' });
test('too little, or not a number, and it asks again', [step('airtime', '20').prompt, step('airtime', 'abeg').prompt], ['Minimum is N50', 'Minimum is N50']);
test('the whole journey ends at done', walk(['2', '500', '1']).screen, 'done');
test('saying no at the confirmation drops back to the menu', step('confirm', '2').screen, 'menu');
test('the three finished screens all fall through to the same handling', ['balance', 'transfer', 'done'].map((s) => step(s, '').screen), ['menu', 'menu', 'menu']);
test('and a screen nobody wrote a case for does not crash', step('nonsense', '1'), { screen: 'menu', prompt: MENU });

Modelling a menu as (screen, key) => nextScreen is the useful idea here. There is no state hidden in variables, so any journey is a fold over the keys pressed — the walk helper is three lines and can replay a whole session. That is what makes a flow like this testable at all, and it is the same shape a reducer takes.

Every branch returns, which is deliberate. switch falls through by default, so a break left out anywhere silently runs the next case's code; returning from each branch makes falling through impossible rather than merely unlikely. Where fallthrough is wanted, as with the three finished screens stacked above one shared return, it is doing real work: three labels, one behaviour, no repetition.

Two details that matter more in production than they look. switch compares with ===, so a key that arrives as a number matches no case '1' — USSD input is always text, and coercing at the boundary is the fix, not case 1: as well. And the default in the outer switch is a real requirement: a session can resume on a screen an older version of the code wrote, and a menu that returns undefined for an unknown screen crashes the next line up.

145 Rounding a half, and which way the money goes

hard

Math.round sends every exact half upwards, which means -2.5 becomes -2 and a column of halves all drift the same way. Write a symmetric half-up rounder and a half-to-even one, and measure the drift each produces over a set of halves.

Stuck? Three hints, smallest first
  1. 1. Math.round rounds towards positive infinity on a tie, so it is not symmetric about zero.
  2. 2. Round the absolute value and put the sign back for symmetric half-up.
  3. 3. Half-to-even only changes anything when the fraction is exactly 0.5 and the rounded result is odd.

The answer

// Symmetric: -2.5 goes to -3, the mirror of 2.5 going to 3.
const halfUp = (x) => (x < 0 ? -Math.round(-x) : Math.round(x));

// Half to even, the rule most accounting standards ask for.
function halfEven(x) {
  const rounded = Math.round(x);
  const isTie = Math.abs(x % 1) === 0.5;
  return isTie && rounded % 2 !== 0 ? rounded - 1 : rounded;
}

// Eight half-kobo interest credits. Their exact total is a whole number.
const HALVES = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5];
const sum = (list) => list.reduce((a, b) => a + b, 0);
const exact = sum(HALVES);
const drifted = sum(HALVES.map(halfUp));
const balanced = sum(HALVES.map(halfEven));
console.log('exact', exact, '/ half up', drifted, '/ half even', balanced);
console.log('Math.round(2.5) =', Math.round(2.5), 'but Math.round(-2.5) =', Math.round(-2.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 built-in sends both halves towards positive infinity', [Math.round(2.5), Math.round(-2.5)], [3, -2]);
test('symmetric half-up mirrors instead', [halfUp(2.5), halfUp(-2.5)], [3, -3]);
test('half to even moves ties to the nearest even number', HALVES.map(halfEven), [0, 2, 2, 4, 4, 6, 6, 8]);
test('so half-up gains four kobo over eight credits and half-even gains nothing', [exact, drifted, balanced], [32, 36, 32]);
test('and half to even is symmetric too', [halfEven(-2.5), halfEven(-3.5)], [-2, -4]);
test('anything that is not a tie is untouched by either rule', [halfUp(2.4), halfEven(2.4), halfUp(2.6), halfEven(2.6)], [2, 2, 3, 3]);
test('Math.round(-0.5) is negative zero, which hides behind a comparison with 0', [Object.is(Math.round(-0.5), -0), Math.round(-0.5) === 0], [true, true]);
test('a tie the float cannot hold never reaches either rule', [1.005 * 100, halfUp(1.005 * 100), halfEven(1.005 * 100)], [100.49999999999999, 100, 100]);
test('and scaling by 100 disagrees with toFixed about the very same number', [2.675 * 100, halfUp(2.675 * 100), (2.675).toFixed(2)], [267.5, 268, '2.67']);

Math.round is documented as rounding a tie towards positive infinity, not away from zero. For positive money that is the half-up everyone expects; for refunds and reversals it is not, and a column containing both drifts in one direction. The fix is one line — round the magnitude and reattach the sign.

Half-to-even exists because half-up is biased. Over the eight halves above it gains four kobo out of thirty-two, and that bias is systematic: every tie moves the same way, so a million rounded interest credits leave a real hole in the ledger. Sending ties to the nearest even number splits them evenly and the totals come out level, which is why it is the default in most financial standards and in Intl.NumberFormat's more careful cousins. The trade-off is that it surprises people reading a single row.

The last three assertions are the reminders. Math.round(-0.5) is -0, which prints as 0 and compares === 0, so it slides through every check except Object.is — then shows up as -0 in a JSON payload or as -Infinity after a division.

And a rounding rule can only act on a tie that survives being stored. 1.005 * 100 is 100.49999999999999, so both rules round it down and the tie is never considered; 2.675 * 100 lands on exactly 267.5 and rounds up to 268, while (2.675).toFixed(2) — working from the stored value, which is a hair under — says '2.67'. Two defensible routes to the same rounding, two different answers, on a number a person typed as three decimal places. Rounding rules decide what to do with a tie. Only integer kobo decides whether the tie is real.

146 toFixed does not round the number you typed

hard

(1.005).toFixed(2) gives '1.00', not '1.01'. Show why, find the other things toFixed does that surprise people — its return type, its behaviour on negative halves, what it does to very large numbers — and show one way to nudge it into agreeing.

Stuck? Three hints, smallest first
  1. 1. Print the number to twenty decimal places and read what is actually stored.
  2. 2. toFixed returns a string, which changes what + means.
  3. 3. The stored value is a hair below the half, so no rounding rule can round it up.

The answer

// Three amounts a person would type, and what toFixed makes of them.
for (const n of [1.005, 2.675, 8.575]) {
  console.log(n, '-> toFixed(2) ->', n.toFixed(2), '   stored as', n.toFixed(20));
}

// The nudge: re-read the scaled number at 12 significant digits, which throws
// away the error, then round the integer.
const kobo = (naira) => Math.round(Number((naira * 100).toPrecision(12)));
console.log('1.005 as kobo, nudged:', kobo(1.005));
console.log('1.005 as kobo, plain: ', Math.round(1.005 * 100));

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 amounts that all round down when you expect up', [(1.005).toFixed(2), (2.675).toFixed(2), (8.575).toFixed(2)], ['1.00', '2.67', '8.57']);
test('because 1.005 is not stored as 1.005', (1.005).toFixed(20), '1.00499999999999989342');
test('and the error survives scaling, so plain rounding gets it wrong too', [1.005 * 1000, Math.round(1.005 * 100)], [1004.9999999999999, 100]);
test('a half that IS representable rounds away from zero, both signs', [(0.5).toFixed(0), (1.5).toFixed(0), (2.5).toFixed(0), (-2.5).toFixed(0)], ['1', '2', '3', '-3']);
test('which is not what Math.round does with negatives', [String(Math.round(-2.5)), String(Math.round(-0.5)), (-0.5).toFixed(0)], ['-2', '0', '-1']);
test('the result is a string, so + concatenates instead of adding', [typeof (10).toFixed(2), (10).toFixed(2) + 1], ['string', '10.001']);
test('it pads as well as rounds, and zero places drops the point', [(3).toFixed(2), (3).toFixed(0)], ['3.00', '3']);
test('past 1e21 it gives up and returns exponential notation', (1e21).toFixed(2), '1e+21');
test('re-reading at 12 significant digits first does round the way a person expects', [kobo(1.005), kobo(2.675), kobo(0.145)], [101, 268, 15]);

The second assertion is the whole explanation. Typing 1.005 does not store 1.005; the nearest double is 1.00499999999999989342, which is genuinely below the halfway point, so toFixed is rounding correctly and the input was already wrong. No rounding mode fixes this, because there is nothing to decide — it is not a tie.

Everything else is a separate surprise worth knowing. toFixed returns a string, so total.toFixed(2) + shipping concatenates and you ship '10.001' to the customer. It rounds halves away from zero, unlike Math.round, so the two disagree on every negative half. And past 1021 it abandons fixed notation entirely and hands back '1e+21', which no currency field wants.

The toPrecision(12) nudge works by re-reading the scaled number with fewer significant digits than the error hides in, so 100.49999999999999 becomes 100.5 and rounds up. Use it if you inherit float money and cannot change the storage. Understand that it is a patch: it moves the boundary where the problem appears rather than removing it, and the actual fix is the one from exercise 63 — hold money as an integer number of kobo, and format only at the edge.

147 First, second, third: the ordinal suffix

fair

Write 1 as 1st, 2 as 2nd, 3 as 3rd and the rest as th. The teens are the trap: 11, 12 and 13 take th, not st, nd and rd. Then use it to write a date out in full.

Stuck? Three hints, smallest first
  1. 1. The last digit chooses the suffix, except in the teens.
  2. 2. Look at the last two digits first: 11 to 13 are always th.
  3. 3. % 100 then % 10, and a switch reads better than a chain of ifs here.

The answer

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

function ordinal(n) {
  const lastTwo = Math.abs(n) % 100;
  if (lastTwo >= 11 && lastTwo <= 13) return n + 'th';      // the teens, before anything else
  switch (lastTwo % 10) {
    case 1: return n + 'st';
    case 2: return n + 'nd';
    case 3: return n + 'rd';
    default: return n + 'th';
  }
}

// The version that forgets the teens, which is what most first attempts look like.
const SUFFIX = ['th', 'st', 'nd', 'rd'];
const naive = (n) => n + (SUFFIX[n % 10] || 'th');

const longDate = (d) => ordinal(d.getDate()) + ' ' + MONTHS[d.getMonth()] + ' ' + d.getFullYear();

console.log([1, 2, 3, 4, 11, 12, 13, 21, 22, 23].map(ordinal).join(' '));
console.log('the teens, done naively:', [11, 12, 13].map(naive).join(' '));
console.log(longDate(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 four', [1, 2, 3, 4].map(ordinal), ['1st', '2nd', '3rd', '4th']);
test('the teens all take th', [11, 12, 13, 14].map(ordinal), ['11th', '12th', '13th', '14th']);
test('and without the teen check they read like this', [11, 12, 13].map(naive), ['11st', '12nd', '13rd']);
test('the twenties pick the suffix up again', [21, 22, 23].map(ordinal), ['21st', '22nd', '23rd']);
test('one hundred and eleven is th, one hundred and one is st', [100, 101, 111, 112].map(ordinal), ['100th', '101st', '111th', '112th']);
test('the last two digits are all that matter, at any size', [1001, 1011, 1111, 2021].map(ordinal), ['1001st', '1011th', '1111th', '2021st']);
test('zero takes th', ordinal(0), '0th');
test('and a negative uses its magnitude to choose, keeping the sign', [ordinal(-1), ordinal(-11)], ['-1st', '-11th']);
test('which is all a long date needs', [longDate(new Date(2026, 0, 15)), longDate(new Date(2026, 7, 1)), longDate(new Date(2026, 10, 22))], ['15th January 2026', '1st August 2026', '22nd November 2026']);

The order of the two checks is the exercise. Test the last two digits for the 11-13 range before looking at the last digit, because the teens are exceptions to the rule the last digit would otherwise give — the third assertion shows what happens when they are checked the other way round, or not at all.

% 100 is what makes it work at any size: 111 ends in 11 and takes th, while 101 ends in 01 and takes st. A version that special-cases the literal numbers 11, 12 and 13 is correct up to 100 and wrong from 111 onwards, which is exactly the sort of bug that ships because nobody tests page 111.

Math.abs before the modulo matters for a reason worth remembering beyond ordinals: -11 % 100 is -11 in JavaScript, not 89, so a negative would fall past the teen check and come out as -11st. Any range test on a remainder needs to know the sign rule.

148 One ticket, two tickets, and a list read aloud

fair

Write 0 tickets, 1 ticket, 2 tickets without an if at every call site, allow an irregular plural for words like bus, and join a list of names into Ada, Bola and Chidi.

Stuck? Three hints, smallest first
  1. 1. One is the only singular. Zero is plural.
  2. 2. Take the irregular plural as an optional argument and default it to the word plus s.
  3. 3. Joining: everything but the last with commas, then the word "and", then the last one.

The answer

const plural = (n, one, many) => (Math.abs(n) === 1 ? one : many || one + 's');
const countOf = (n, one, many) => n + ' ' + plural(n, one, many);

function joinWords(items, conjunction = 'and') {
  if (items.length === 0) return '';
  if (items.length === 1) return items[0];
  return items.slice(0, -1).join(', ') + ' ' + conjunction + ' ' + items[items.length - 1];
}

const riders = ['Ada', 'Bola', 'Chidi'];
const sentence = countOf(3, 'ticket') + ' for ' + joinWords(riders) + ', on ' + countOf(2, 'bus', 'buses') + '.';
console.log(sentence);
console.log([0, 1, 2].map((n) => countOf(n, 'ticket')).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.

9 checks

test('zero is plural, one is not, two is', [0, 1, 2].map((n) => countOf(n, 'ticket')), ['0 tickets', '1 ticket', '2 tickets']);
test('an irregular plural is passed in', [countOf(1, 'bus', 'buses'), countOf(4, 'bus', 'buses')], ['1 bus', '4 buses']);
test('and the default would have been wrong for it', plural(2, 'bus'), 'buss');
test('minus one is singular too', [countOf(-1, 'day'), countOf(-2, 'day')], ['-1 day', '-2 days']);
test('a list of three reads properly, with no comma before the and', joinWords(riders), 'Ada, Bola and Chidi');
test('two names take no comma at all, one takes no conjunction', [joinWords(['Ada', 'Bola']), joinWords(['Ada'])], ['Ada and Bola', 'Ada']);
test('an empty list is an empty string, not the word and', joinWords([]), '');
test('the conjunction is a parameter, because sometimes it is or', joinWords(['cash', 'transfer', 'card'], 'or'), 'cash, transfer or card');
test('and the two together make a sentence', sentence, '3 tickets for Ada, Bola and Chidi, on 2 buses.');

Two rules people get wrong. Zero is plural in English — 0 tickets, not 0 ticket — so the test is n === 1 and not n > 1. And it is the magnitude that decides, so -1 is singular; Math.abs costs nothing and covers refunds and countdowns.

many || one + 's' is a deliberate compromise, and the third assertion is honest about it: the default turns bus into buss. Adding s covers most English nouns and fails on sibilants, words ending in y, and every irregular, so the parameter is not optional in practice — it is the escape hatch that keeps the helper usable. If a screen has more than a handful of these, the real answer is a message catalogue with plural forms, because languages like Arabic and Polish have three to six plural categories and no amount of n === 1 will reach them.

The list join has one interesting case: two items take no comma, which a naive items.join(', ').replace(/,([^,]*)$/, ' and$1') also gets right but by accident. Slicing off the last item states the rule directly. Whether a comma belongs before the and is a house style question, not a correctness one — this version omits it, which is the British and Nigerian convention.

149 The Collatz chain, and why it needs a guard

fair

Start at a number. If it is even, halve it; if it is odd, treble it and add one. Count how many steps it takes to reach 1, record the highest number reached on the way, and find which start below 30 takes the longest. Put a cap on the loop and say why it is there.

Stuck? Three hints, smallest first
  1. 1. One while loop, one branch on n % 2.
  2. 2. Track the peak as you go, because it can go far above where you started.
  3. 3. Nobody has proved every start reaches 1, so the loop needs a way out.

The answer

function collatz(start, cap = 500) {
  let n = start;
  let steps = 0;
  let peak = start;
  const path = [start];
  while (n !== 1 && steps < cap) {
    n = n % 2 === 0 ? n / 2 : 3 * n + 1;
    if (n > peak) peak = n;
    path.push(n);
    steps++;
  }
  return { steps, peak, capped: n !== 1, path };
}

let longest = 1;
for (let start = 1; start <= 30; start++) {
  if (collatz(start).steps > collatz(longest).steps) longest = start;
}
console.log('from 6:', collatz(6).path.join(' -> '));
console.log('longest start under 30 is', longest, 'at', collatz(longest).steps, 'steps, peaking at', collatz(longest).peak);

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 chain from 6', collatz(6).path, [6, 3, 10, 5, 16, 8, 4, 2, 1]);
test('which is eight steps, one fewer than the numbers listed', [collatz(6).steps, collatz(6).path.length], [8, 9]);
test('one is already there', [collatz(1).steps, collatz(1).path], [0, [1]]);
test('a small odd start wanders a long way up first', [collatz(7).steps, collatz(7).peak], [16, 52]);
test('27 is the worst start under thirty by a distance', [longest, collatz(27).steps, collatz(30).steps], [27, 111, 18]);
test('and it climbs to 9232, over three hundred times where it began', [collatz(27).peak, Math.floor(collatz(27).peak / 27)], [9232, 341]);
test('a powers-of-two start is the easy case: nothing but halving', collatz(16).path, [16, 8, 4, 2, 1]);
test('the cap stops it early rather than lying about the answer', [collatz(27, 5).steps, collatz(27, 5).capped, collatz(27).capped], [5, true, false]);
test('and zero never terminates, which is exactly what the cap is for', [collatz(0, 20).capped, collatz(0, 20).path[20]], [true, 0]);

The loop is four lines and the interesting part is the shape of the numbers. A chain does not descend steadily: from 7 it climbs to 52 before coming down, and from 27 it reaches 9232 — more than three hundred times its start — after 111 steps. That is why peak is tracked separately. Anything that allocated an array sized by the starting number would be badly wrong.

The cap is not defensive padding, it is intellectual honesty. The Collatz conjecture — that every positive integer reaches 1 — has been checked past 268 and never proved, so a loop written as while (n !== 1) is a loop whose termination nobody can guarantee. And you do not need an unproven conjecture to hit it: start at 0 and the sequence is 0, 0, 0 for ever, which the last assertion shows. Returning a capped flag rather than a wrong number is the difference between "no answer yet" and "the answer is 5".

Two practical notes. 3 * n + 1 grows fast, so a large start can pass Number.MAX_SAFE_INTEGER and the halving stops being exact — BigInt is the fix if you are exploring seriously. And repeated calls like collatz(start).steps > collatz(longest).steps recompute whole chains; that is fine for thirty starts and would need memoising for thirty thousand, since every chain ends up walking the tail of a shorter one.

150 A repayment schedule that clears to zero

hard

Last one, and it uses most of the section. Spread a ₦120,000 loan over seven monthly instalments at 2% interest a month on the balance outstanding. Work in kobo, put the due date on the same day of each month with February clamped, let the last instalment absorb the rounding, and print a statement whose columns line up and whose balance ends at exactly zero.

Stuck? Three hints, smallest first
  1. 1. Divide the principal with Math.floor, then give the remainder to the final instalment.
  2. 2. Interest is charged on the balance still outstanding, so it falls every month.
  3. 3. Keep every figure an integer number of kobo and format only when printing.

The answer

const LOAN = 12000000;              // ₦120,000 in kobo
const MONTHS = 7;
const RATE_BP = 200;                // 2% a month, in basis points
const START = new Date(2025, 11, 31);

const group = (n) => String(n).replace(/\B(?=(\d{3})+$)/g, ',');
const naira = (kobo) => '\u20a6' + group(Math.trunc(kobo / 100)) + '.' + String(kobo % 100).padStart(2, '0');
const stamp = (d) =>
  d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');

// From exercise 127: clamp the day into the month it lands in.
function addMonths(date, n) {
  const month = date.getMonth() + n;
  const last = new Date(date.getFullYear(), month + 1, 0).getDate();
  return new Date(date.getFullYear(), month, Math.min(date.getDate(), last));
}

function schedule(loan, months, rateBp, start) {
  const flat = Math.floor(loan / months);        // the even share, rounded down
  let balance = loan;
  const rows = [];
  for (let i = 1; i <= months; i++) {
    const interest = Math.round((balance * rateBp) / 10000);
    const principal = i === months ? balance : flat;   // the last one takes what is left
    balance -= principal;
    rows.push({ i, due: stamp(addMonths(start, i)), principal, interest, pay: principal + interest, balance });
  }
  return rows;
}

const rows = schedule(LOAN, MONTHS, RATE_BP, START);
const lines = rows.map(
  (r) => String(r.i).padStart(2) + '  ' + r.due + '  ' + naira(r.pay).padStart(12) + '  ' + naira(r.balance).padStart(13)
);
console.log(' #  due date       instalment        balance');
for (const line of lines) console.log(line);
console.log('total repaid', naira(rows.reduce((s, r) => s + r.pay, 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.

9 checks

test('seven instalments, and the balance ends at exactly zero', [rows.length, rows[6].balance], [7, 0]);
test('the principal repaid adds back to the loan, to the kobo', rows.reduce((s, r) => s + r.principal, 0), LOAN);
test('an even split falls five kobo short, so the last instalment carries them', [Math.floor(LOAN / MONTHS) * MONTHS, rows[0].principal, rows[6].principal], [11999995, 1714285, 1714290]);
test('interest is charged on what is outstanding, so it falls every month', [rows[0].interest, rows[6].interest, rows.every((r, i) => i === 0 || r.interest < rows[i - 1].interest)], [240000, 34286, true]);
test('the first month costs 2% of the whole loan', naira(rows[0].interest), '\u20a62,400.00');
test('the due dates keep the 31st where the month has one, and clamp where it does not', rows.map((r) => r.due), ['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30', '2026-05-31', '2026-06-30', '2026-07-31']);
test('every figure is a whole number of kobo, so nothing can drift', rows.every((r) => Number.isInteger(r.principal) && Number.isInteger(r.interest) && Number.isInteger(r.balance)), true);
test('each balance is the one above it less that month\u2019s principal', rows.every((r, i) => r.balance === (i === 0 ? LOAN : rows[i - 1].balance) - r.principal), true);
test('and the statement lines all come out the same width, ending at zero', [[...new Set(lines.map((l) => l.length))], lines[6].endsWith('\u20a60.00')], [[43], true]);

Everything in this section is here. Integer kobo so the totals close (exercise 63), Math.floor plus a remainder handed to the final row so the parts sum to the whole (exercise 82), the month-end clamp so a loan taken on the 31st does not fall into March (exercise 127), padStart columns so the statement reads (exercise 103), and thousands grouping only at the moment of printing (exercise 74).

The one design decision worth arguing about is where the remainder goes. ₦120,000 does not divide into seven equal kobo amounts — the even share is ₦1,714.285 and seven of those leave five kobo unaccounted for. Giving them to the last instalment keeps every earlier row identical and makes the final balance exactly zero, which is the property that matters: a schedule whose last line reads ₦0.05 outstanding will generate a dunning letter, a late fee, and a phone call. The alternative, spreading the five kobo over the first five rows, is equally correct and harder to explain to the customer.

Interest on the reducing balance, rather than a flat fee, is why the instalments are not equal: the first month charges 2% of ₦120,000 and the last charges 2% of what is nearly nothing. That is simple interest on the outstanding amount, which is not the same as the annuity formula a bank uses to make every payment identical — that needs powers and a proper rounding policy on the final row, and the honest version of it starts exactly here, with integers and a remainder that has somewhere to go.

Newsletter

New writing, when it lands.

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