DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Four calculator pages behind one route, and two of them are the same component with different nouns

Nakodo has four free calculators. They are content pages with a widget in the middle, they are prerendered at build time, and the interactive part sends nothing to any server, ours included. This is how they are wired.

One route, a Record, and a build that fails loudly

// The calculator each tool page shows under its summary. A slug in
// src/content/tools without one here fails the build.
const WIDGETS: Record<string, () => React.ReactNode> = {
  "engagement-rate-calculator": EngagementRateCalculator,
  "sponsorship-cost-calculator": SponsorshipCostCalculator,
  "tiktok-engagement-rate-calculator": TiktokEngagementRateCalculator,
  "instagram-engagement-rate-calculator": InstagramEngagementRateCalculator,
};

export const dynamicParams = false;
export const generateStaticParams = () => entriesOf("tool").map((e) => ({ slug: e.page.slug }));

export default async function ToolPage({ params }: PageProps<"/tools/[slug]">) {
  const entry = requireEntry("tool", (await params).slug);
  const Widget = WIDGETS[entry.page.slug];
  if (!Widget) throw new Error(`No calculator for /tools/${entry.page.slug}`);
  return (
    <ContentPage entry={entry} extraLd={[/* WebApplication */]}>
      <Widget />
    </ContentPage>
  );
}
Enter fullscreen mode Exit fullscreen mode

Two failure modes, two different errors, both at build time rather than in production:

  • A slug with no page: requireEntry throws, because the page's words live in a separate array.
  • A page with no calculator: the explicit throw above.

dynamicParams = false is what turns the second one from a runtime 500 into a build error. With it, the only params that exist are the four from generateStaticParams, every one of them is rendered during the build, and a missing widget stops the build. Without it, a request for a fifth slug would be rendered on demand and the error would be a production incident on a page nobody has visited yet.

The ContentPage wrapper renders the page's heading, its "short version" card, then children, then the contents list and the body sections. So the calculator sits directly under the summary, above the prose that explains it, which is the order a visitor wants: the thing, then the instructions.

Two of the four are one component

The YouTube calculator and the TikTok one do identical arithmetic. Both platforms publish a view count per video, so engagement is per view on both, and the only differences are the words:

// YouTube and TikTok both show views, so engagement there is per view and the
// two calculators differ only in their words.
const PER_VIEW_COPY = {
  youtube: {
    heading: "Recent videos",
    help: "Views, likes and comments from the video pages. Skip videos less than a few days old.",
    audience: "Subscribers",
    audiencePlaceholder: "250,000",
    perAudience: "Views per subscriber",
  },
  tiktok: {
    heading: "Recent videos",
    help: "Views, likes and comments from each video. Skip pinned videos and any less than 3 days old.",
    audience: "Followers",
    audiencePlaceholder: "120,000",
    perAudience: "Views per follower",
  },
} as const;

export const EngagementRateCalculator = () => <PerViewCalculator platform="youtube" />;
export const TiktokEngagementRateCalculator = () => <PerViewCalculator platform="tiktok" />;
Enter fullscreen mode Exit fullscreen mode

The Instagram one is a separate component of its own, and the reason is the only interesting architectural decision in the file. Instagram does not show you other accounts' view counts on most post types, so there is no view count to divide by. Its engagement has to be per follower, the input rows are likes, comments and an optional Reel view count, and the median views result only appears when enough rows have one.

Same page layout, same inputs component, same result tiles, different denominator. The rule I ended up with: the same formula with different nouns is a prop, and a different formula is a different component. Trying to make one component cover both meant a platform union with branches inside the maths, and the branches were the whole function.

Everything is a string until it is a number

Every input is a controlled string and the parsing is five lines:

// "12,500" and "12 500" are both 12500. Empty, zero or nonsense is null.
function parse(s: string): number | null {
  const n = Number(s.replace(/[,\s]/g, ""));
  return Number.isFinite(n) && n > 0 ? n : null;
}

function median(xs: number[]): number | null {
  if (!xs.length) return null;
  const s = [...xs].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
Enter fullscreen mode Exit fullscreen mode

null rather than 0 or NaN is the decision that makes the rest of the component readable. Nobody types a view count by hand: they copy "1,284,003" off a page, or "1 284 003" from a locale that groups with spaces. Both have to work. Zero views is not a data point, it is an empty field, so it parses to null too.

Then null propagates all the way to the screen, where it becomes "n/a" with a sentence saying what is missing:

<Result
  label="Fee"
  value={fee === null ? "n/a" : money(fee)}
  note={fee === null ? "Needs views and a CPM." : `${count(v!)} views ÷ 1,000 × ${money(parse(cpm)!)}`}
/>
Enter fullscreen mode Exit fullscreen mode

An empty calculator showing $0.00 and 0% looks like an answer. "n/a" plus "Needs views and a CPM" is a result that tells you what to do. And when it does have an answer, the note shows the sum that produced it, which is the one thing I want from somebody else's calculator and almost never get.

The median is deliberate too. One video that got picked up by a recommendation algorithm doubles a mean and tells you nothing about the next video. The sponsorship cost calculator says so in a field hint: "Use the median views of the creator's recent videos."

The accessibility bits, which were free

None of this needed a library.

function Field({ label, value, onChange, placeholder, hint, compact }: Props) {
  const id = useId();
  return (
    <div>
      <Label htmlFor={id} className={cn(compact && "sm:sr-only")}>{label}</Label>
      <Input id={id} inputMode="decimal" autoComplete="off" value={value} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} className="tabular-nums" />
      {hint && <p className="text-[13px]">{hint}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useId means every field in a repeated row gets a unique, stable id that survives server rendering, which is the entire reason that hook exists and the one place I reach for it without thinking.

compact is the row specific trick. On a wide screen the rows form a table, and a visible "Views" label above every row would be three labels repeated ten times. So rows after the first get sm:sr-only: the label is still in the DOM and still associated with the input, it is just not drawn once the layout makes the column headings obvious. On a phone the grid collapses, the rows stack, and the labels come back, because there is no longer a column heading to inherit meaning from. One conditional class, and a screen reader never sees a nameless input:

/** Label only for screen readers from sm up, for rows under a first row that shows it. */
compact?: boolean;
Enter fullscreen mode Exit fullscreen mode

The results block is aria-live="polite", so a value computed as you type is announced without stealing focus. The remove buttons are named by position, aria-label={Remove video ${i + 1}}, and disabled when one row is left rather than letting you empty the list. inputMode="decimal" gets the numeric keypad on a phone without type="number", which I avoid because it brings spinners, scroll-wheel-eats-your-value behaviour, and a validation model that fights a field where "1,284,003" is legitimate input.

The currency switch on the cost calculator is three buttons rather than a select, and it is the only part here that needs ARIA to be correct:

<div role="radiogroup" aria-label="Currency" className="flex rounded-md border p-0.5">
  {CURRENCIES.map((c) => (
    <button key={c.code} type="button" role="radio" aria-checked={currency.code === c.code} aria-label={c.code} onClick={() => setCurrency(c)}>
      {c.symbol}
    </button>
  ))}
</div>
Enter fullscreen mode Exit fullscreen mode

The visible label is a symbol, so the accessible name is the code: "$" is ambiguous across a dozen currencies and "USD" is not.

Formatting goes through Intl, with one judgement in it:

const money = (n: number) =>
  new Intl.NumberFormat(currency.locale, { style: "currency", currency: currency.code, maximumFractionDigits: n < 100 ? 2 : 0 }).format(n);
Enter fullscreen mode Exit fullscreen mode

Pennies matter on an effective CPM of $7.43 and are noise on a fee of $1,250. One ternary, and no rounding helper of my own.

The honest React smell

Rows are keyed by index, and rows can be removed:

{rows.map((r, i) => (
  <li key={i} className="grid ...">
Enter fullscreen mode Exit fullscreen mode

That is the pattern every React guide tells you not to use, and here it is correct, for one specific reason: a row holds no state of its own. Every value lives in the rows array in the parent, and the inputs are fully controlled. Delete the second of three rows and React reuses the DOM nodes of rows two and three with new props, which is fine, because the props are the whole truth.

It would break the moment a row contained anything uncontrolled: a defaultValue, an open/closed toggle, an uncontrolled focus state. Then deleting the middle row would leave the third row wearing the second row's internal state. I know this because that is exactly the bug the rule exists to prevent, and the honest reason the keys are indexes here is that there is no id to key by, inventing one would mean a crypto.randomUUID() per row, and I decided a comment was cheaper than a dependency on uniqueness I would then have to maintain.

If this file ever grows a row that remembers anything, the keys change first.

Nothing is sent anywhere

There is no <form>, no action, no fetch, no debounce that posts a draft, and no analytics event on a keystroke. The URL does not change as you type, so there is no shareable link to a filled in calculator, which is a feature I have considered and not built: putting a creator's numbers in a query string makes them history entries and referrer headers.

The page's structured data says what the page is:

{
  "@type": "WebApplication",
  applicationCategory: "BusinessApplication",
  operatingSystem: "Web",
  isAccessibleForFree: true,
  offers: { "@type": "Offer", price: 0, priceCurrency: "USD" },
}
Enter fullscreen mode Exit fullscreen mode

isAccessibleForFree: true and a zero price are the two properties that stop a tool page being treated as a paywalled product page. They are also true, which is the only reason they are there: the rule for structured data here is that no node may claim more than the page shows.

One result tile does import from the app: a small module of our own thresholds, shared so the number a visitor sees matches the number the product would use. That module is constants, I am not going to explain its contents here, and importing it into a client component means those constants are in the bundle. That was a decision rather than an oversight. The alternative was an API route, which would mean the calculator posts your numbers to us, which is the one thing these pages promise not to do.

What this cost

376 lines in one widgets.tsx, four content files for the words, and 47 lines in the route. No form library, no state manager, no validation schema, no charting. The whole thing is useState, useId, two pure functions and Intl.

The reason I am happy with it a few weeks on is the part that is not code: because the maths is in the browser, the page is static, and a static page is one that cannot be slow, cannot be rate limited, and cannot leak what somebody typed into it.

Top comments (0)