Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Our break-even number is a loop over sixty integers, because the line it sits on has a kink in it

PubTrivia has a handful of free tools that run entirely in the browser, and the newest one is a quiz night profit calculator: a landlord types in their room, their entry fee and their margin, and it says what a weekly qu

PubTrivia has a handful of free tools that run entirely in the browser, and the newest one is a quiz night profit calculator: a landlord types in their room, their entry fee and their margin, and it says what a weekly quiz is worth per night, per year, and how many teams it needs before it stops losing money.

It is 114 lines of arithmetic in one module, 259 lines of component around it, and 8 tests. No API route, no server action, nothing stored. The interesting part is not the code volume, it is that two of the lines are opinions rather than sums, and the third interesting line is a loop where the algebra should be.

The opinion in the middle of the sums

A room with forty people in it does not represent forty people's worth of extra trade, because some of those forty drink in the pub on a Tuesday whether or not a quiz is on. So the calculator asks for that number and counts bar profit only above it:

const players = teams * playersPerTeam
const extraPlayers = Math.max(0, players - regularsAnyway)

const entryTaken = players * entryFee          // everyone pays to enter
const extraBarProfit = extraPlayers * spendPerPlayer * margin   // only the extra ones count

Entry money is counted on everybody, because nobody pays to enter a quiz that is not on. Bar profit is counted only on the players above the regulars line. The asymmetry is the whole point of the tool, and it is the number most people get wrong in their heads, in both directions: either counting the whole room as new trade, or counting the entry fee and the prize and forgetting that the bar is where the night actually pays for itself.

With the defaults the page ships (ten teams of four, six regulars, 2 entry all going back out as the prize, 12 spend, 60 per cent margin) that reads as 40 players, of whom 34 are the quiz's doing.

A prize costs what it costs you, not what it says on the menu

The other judgement is on the way out. Cash prizes are easy. A prize that is a bar tab is not a cash cost at all:

const barTabCost = barTabPrize * (1 - margin)

A 50 bar tab at a 60 per cent margin costs the pub 20. Getting this wrong in the pessimistic direction is why some landlords believe a quiz night cannot pay for itself: they are costing their own product at retail.

The last cost line is a units conversion that is easy to fumble:

const NIGHTS_PER_YEAR = 52
const NIGHTS_PER_MONTH = NIGHTS_PER_YEAR / 12      // 4.333..., not 4

const softwarePerNight = softwarePerMonth / NIGHTS_PER_MONTH

52 a month over a weekly quiz is 12 a night, not 13. There is a test for exactly that, because the obvious wrong answer is within rounding distance of the right one and a reviewer would not catch it by eye.

Why the break-even is searched and not solved

The headline figure the page leads with is the one you cannot get from a closed-form rearrangement without thinking:

export const BREAK_EVEN_TEAM_CAP = 60

let breakEvenTeams: number | null = null
for (let teams = 1; teams <= BREAK_EVEN_TEAM_CAP; teams += 1) {
  if (netFor(inputs, teams).netPerNight >= 0) {
    breakEvenTeams = teams
    break
  }
}

netFor is a pure function of the inputs and a team count, so the search is just calling it up to sixty times and stopping at the first non-negative answer. Solving it algebraically is not hard, but it is not one expression either, because Math.max(0, players - regularsAnyway) puts a kink in the line. Below the regulars threshold the night earns entry money only; above it, every extra player adds entry money and bar profit. That makes net profit piecewise linear in the number of teams, so the inverse needs case analysis, and the case analysis needs its own tests.

Sixty evaluations of a dozen multiplications, inside a useMemo, on a keystroke, is free. Timed over 10,000 calls in Node: 0.25 microseconds with the shipped defaults, where the loop exits on the first iteration, and 0.41 microseconds in the worst case, where it runs all sixty and reports no break-even. The trade is explicit in a comment in the file: a loop over sixty integers is easier to trust than the algebra, and it stays easy to trust when somebody adds a twelfth input next year.

Two details fell out of writing it that way:

  • The cap has to mean something. breakEvenTeams is number | null, and null renders as the word "Never" with a note that the host fee or the prize is the number to look at. A room that needs more than sixty teams is not going to get them, so there is no point searching further or showing a number that large.
  • Break-even is >= 0, so zero counts. With the shipped defaults there are no running costs at all, so the first team already fails to lose money and the answer is 1 team. The page says so in words rather than leaving a bare 1 to be interpreted: "with these costs the night pays for itself from the first team".

Eight tests, and what they are actually for

 Test Files  1 passed (1)
      Tests  8 passed (8)

Four of the eight assert an arithmetic identity I could have checked by hand. The ones that earn their keep are the other four:

  • the break-even case with a 100 host fee, which asserts 5 teams and spells the reasoning out in a comment: each player above the six regulars adds 7.20, 100 needs 14 of them, 14 rounds up to 20 players, which is 5 teams of 4
  • the unreachable case, where entry and spend are both zero and a host fee is not, asserting null rather than a large number
  • a full-costs case asserting one number, 177.80 a night, which is the only test that exercises every line at once
  • and the nonsense case:
const result = quizNightProfit({ ...BASE, entryFee: Number.NaN, marginPercent: 250 })
expect(Number.isNaN(result.netPerNight)).toBe(false)
expect(result.entryTaken).toBe(0)

That one exists because the inputs are <input type="number"> values passed through Number(), and an empty field is not zero, it is NaN. Two guards absorb it before any arithmetic:

function clampPercent(value: number): number {
  return Math.min(100, Math.max(0, value)) / 100
}

function nonNegative(value: number): number {
  return Number.isFinite(value) ? Math.max(0, value) : 0
}

A margin of 250 per cent becomes 100 per cent, a negative team count becomes zero, and a cleared field produces 0 rather than a page full of NaN. The clamping lives in the arithmetic module rather than in the component on purpose: the max attributes on the inputs are a hint to the browser, not a guarantee, and the function has to be safe on its own because the tests call it directly.

Why the maths is not in the component

The split is the only real architectural decision here, and the comment at the top of the module gives the reason plainly: this is a page somebody will make a real decision from, and a sign error would tell a landlord that a losing night was a winning one. A pure function with its own test file can be wrong, but it can be wrong visibly. The same arithmetic inlined into a 259-line React component would be tested by looking at it.

The component's own job turns out to be entirely presentational: three fieldsets built from a field table, a currency symbol switch between three symbols, an aria-live="polite" results panel so a screen reader hears the new figures, and Math.round with toLocaleString("en-GB") for every money value. Nothing in it knows what a margin is.

Check it yourself

Open the calculator with the Network tab recording, and change every field in it: there are no requests, because the whole thing is one pure function and a useMemo. The shipped defaults read 245 a night and 12,730 a year with a break-even of one team.

Then reproduce the test: put the host fee at 100 and watch the break-even land on 5 teams. Set the entry fee and the spend to zero at the same time and it will say Never.

If you want to see the arguments the numbers are wrapped in, the entry fee guide and the one on hiring a quizmaster are the two pages that feed the two fields people most often guess at.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.