One of the free tools on our marketing site is a round planner. You tell it a start time, how many rounds you want, what sort each round is and how many questions it has, and it prints a running order with a clock time against every line.
It is 313 lines of client-side React with no backend, no account and no storage. All it does is add up minutes. I went through it to write a post about the arithmetic and came out with two bugs instead, both of which you can reproduce on the live page faster than you can read this.
The model
Five round types, each with a per-question cost in minutes and a fixed overhead:
| Round type | Minutes per question | Overhead |
|---|---|---|
| Standard | 1.1 | 2 |
| Quickfire / true or false | 0.6 | 1.5 |
| Music | 1.3 | 3 |
| Picture | 0.8 | 4 |
| List or connections | 1.6 | 3 |
Plus five minutes of welcome and rules at the front, ten minutes of results and prize at the back, and an interval whose length and position you choose. The defaults are six rounds of ten questions, which comes out as 1 hr 51 min from 20:00, finishing about 21:51.
Those per-question figures are deliberately generous, because the thing every first-time host underestimates is not the answering, it is reading each question twice and waiting for the table that is still arguing.
Bug one: the rows do not add up to the clock
Open the planner and set all six rounds to "Quickfire / true or false". You get this:
20:05 Round 1: Quickfire (8 min)
20:13 Round 2: Quickfire (8 min)
20:20 Round 3: Quickfire (8 min)
20:28 Break (15 min)
20:43 Round 4: Quickfire (8 min)
20:50 Round 5: Quickfire (8 min)
20:58 Round 6: Quickfire (8 min)
21:05 Results and prize (10 min)
Two rows say 8 min and are 7 minutes apart. 20:13 to 20:20 is one of them, 20:43 to 20:50 is another, 20:58 to 21:05 is the third. Add the row labels up and you get 78 minutes. The summary line above the list says 1 hr 15 min.
The cause is one rounding decision in two places. A quickfire round of ten questions costs 1.5 + 0.6 * 10, which is 7.5 minutes. The offsets accumulate in full precision:
const minutes = type.overheadMinutes + type.minutesPerQuestion * round.questions
items.push({ label, detail, minutes, offset })
offset += minutes
And then each row renders its own duration rounded on its own:
({Math.round(item.minutes)} min)
while the clock column rounds the accumulated offset:
const total = Math.round(startMinutes + offset)
So the labels are rounded individually and the clock is rounded once, at a different point in the series, and at 7.5 minutes a round the two disagree every other row. The total has the same problem from the other end: 75 real minutes becomes "1 hr 15 min" while the six 7.5s become six 8s.
The fix is not more rounding. It is to round once, build the series out of the rounded values, and render both columns from that one series, so whatever the planner prints is internally consistent even when it is half a minute optimistic. A planner that is wrong by thirty seconds is fine. A planner whose own rows contradict its own clock gets closed.
Bug two: the interval you asked for silently disappears
This one costs a quiz night fifteen minutes.
- Leave the six default rounds in place.
- Set "Break after round" to 5. The interval appears between rounds 5 and 6, at 21:18, and the summary still says 1 hr 51 min.
- Remove round 6 with the X button at the end of its row.
The interval is gone. Not moved, gone. The running order goes welcome, five rounds, results, and the summary now says 1 hr 28 min. Deleting one 8-minute round took 23 minutes off the night. The input still reads 5. Its max attribute has quietly changed to 4.
The input clamps on change, and only on change:
onChange={(event) =>
setBreakAfter(
Math.max(1, Math.min(Math.max(1, rounds.length - 1), Math.round(Number(event.target.value) || 1)))
)
}
Deleting a round is not a change event on that input, so the clamp never runs, and breakAfter stays at 5 while there are only 5 rounds. The renderer then asks for an interval that cannot exist:
if (index + 1 === breakAfter && index + 1 < rounds.length) {
With five rounds and breakAfter of 5, the first half is true on the last round and the second half is false, so nothing is emitted and nothing complains. The guard is right, by the way: an interval after the final round is just the end of the quiz. The mistake is clamping user input at the edge instead of clamping the value where it is used.
The repair is to derive the effective position rather than trust the stored one, which also makes the input's max honest rather than decorative.
The third thing, which is not a bug
let nextId = 1
function newRound(type = "standard", questions = 10): PlannedRound {
nextId += 1
return { id: nextId, type, questions }
}
The counter increments before the object is built, so the first round ever created has id 2 and id 1 never exists. The counter also lives at module scope, not in the component, so it keeps climbing for the life of the tab and is shared by every instance of the planner on the page. Both facts are harmless here, because the ids are React keys and update targets and nothing else, and you only ever get one planner. It is still the sort of thing that is a real bug the first time somebody renders two of these side by side and wonders why the second one's ids start at 8.
What it does not do
Fill the planner in, change every round, then reload the page. You are back to six rounds, break after 3, starting at 20:00.
There is no localStorage key, no sessionStorage key and no cookie. On a fresh visit to that page Object.keys(localStorage) is an empty array and document.cookie is an empty string. There is no save, no share link, and no account, which is the deliberate part: the tool exists to be useful to somebody who has never heard of us, and the shortest path to that is a page with no backend to talk to.
Open the Network tab and work the controls and you will see zero requests coming from the tool. The entries that do appear after load are the App Router prefetching the pages our own header and footer link to.
Worth trying yourself
Both bugs are findable in about forty seconds on the live planner:
- set every round to Quickfire and compare the clock column with the minute labels
- set the interval after the last round but one, then delete the last round
The timings themselves came out of the writing in how many rounds a pub quiz should have, and the other three tools in that cluster are at pub-trivia.app/tools. None of them asks for an email address.
Two bugs in 313 lines whose only job is addition. The lesson I keep relearning is that clamping at the input is clamping in the wrong place: validate where the value is consumed, because that is the only place that knows what the rest of the state currently looks like.
Top comments (0)