<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mark</title>
    <description>The latest articles on DEV Community by Mark (@mark_b5f4ffdd8e7cd58).</description>
    <link>https://dev.to/mark_b5f4ffdd8e7cd58</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3911889%2F1a12ec5a-45e3-424e-ac9b-770cb7c80938.png</url>
      <title>DEV Community: Mark</title>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mark_b5f4ffdd8e7cd58"/>
    <language>en</language>
    <item>
      <title>A roof calculator that multiplies length by width is lying to you</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Thu, 18 Jun 2026 01:58:53 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/a-roof-calculator-that-multiplies-length-by-width-is-lying-to-you-4ibb</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/a-roof-calculator-that-multiplies-length-by-width-is-lying-to-you-4ibb</guid>
      <description>&lt;p&gt;I started building a roof area calculator thinking it would be a weekend thing. You take the length, you take the width, you multiply, you show a number. Roofs are rectangles. How hard can it be.&lt;/p&gt;

&lt;p&gt;It is not a rectangle. That was the whole project, really — slowly understanding all the ways "length times width" is wrong, and that the wrongness compounds the moment someone tries to actually buy materials with your number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roof you measure isn't the roof you walk on
&lt;/h2&gt;

&lt;p&gt;Here's the thing that took me embarrassingly long to internalize. The footprint of a house — the shadow it casts at noon — is flat. But the roof is tilted. The shingles cover the slanted surface, not the shadow. So the area you care about is always bigger than the footprint, and how much bigger depends entirely on the slope.&lt;/p&gt;

&lt;p&gt;Roofers express slope as "rise over run" — a 6:12 roof goes up 6 inches for every 12 inches it goes sideways. To get from footprint to actual surface area you multiply by what's called the pitch factor, which is just the hypotenuse of that little triangle divided by the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// rise per 12" of run -&amp;gt; area multiplier&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pitchFactor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pitchX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pitchX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 6:12 roof that's &lt;code&gt;sqrt(144 + 36) / 12 ≈ 1.118&lt;/code&gt;. So a house with a 1,500 sq ft footprint actually has about 1,677 sq ft of roof. That's 177 square feet you'd have just... not ordered. Almost two full squares of shingles (a "square" is 100 sq ft — roofing has its own units for everything). On a steeper 12:12 roof the factor is 1.414 and you'd be short by 40%.&lt;/p&gt;

&lt;p&gt;The naive calculator doesn't return a slightly-off answer. It returns an answer that gets someone to the supply yard one delivery short, on a Saturday, with the old roof already torn off and rain in the forecast. The error has a cost and the cost lands on a specific bad afternoon.&lt;/p&gt;

&lt;p&gt;So the geometry is the easy part once you see it. I put all the conversions in one place so a pitch could never mean two different things in two different spots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculatePitch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;riseInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;riseInches&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;runInches&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angleDeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pitchPer12&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;slope&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rafterPer12&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pitchPer12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pitchPer12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;angleDegrees&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;angleDeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// 6:12 -&amp;gt; 26.57°&lt;/span&gt;
    &lt;span class="na"&gt;pitchFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rafterPer12&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 6:12 -&amp;gt; 1.118&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I figured that was the project. It was not the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Area is necessary and completely insufficient
&lt;/h2&gt;

&lt;p&gt;The moment I had a correct area number, I tried to answer the question people actually have, which is "okay, what do I buy." And area gets you exactly one line of the shopping list: the field shingles.&lt;/p&gt;

&lt;p&gt;Everything else on a roof is sold by the &lt;em&gt;edge&lt;/em&gt;, not the surface. Walk a roof in your head:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Drip edge&lt;/strong&gt; runs along the eaves and rakes — the bottom and the slanted sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter strip&lt;/strong&gt; runs along those same perimeters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ridge cap&lt;/strong&gt; runs along the ridges and hips — the top lines and the diagonal corners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ice &amp;amp; water shield&lt;/strong&gt; runs in a band along the eaves and up the valleys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those scale with area. A long skinny ranch house and a compact two-story can have identical square footage and wildly different amounts of edge. So a "roofing calculator" that only knows area literally cannot tell you how many boxes of drip edge to buy. It's not that it's imprecise. It's missing the input.&lt;/p&gt;

&lt;p&gt;Which means to do the real job, you can't ask for area at all. You have to ask for the &lt;em&gt;shape&lt;/em&gt; and derive both the area and every edge length from it. So the calculator that started as one multiplication became a little parametric model of a roof:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateRoofShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pitchX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pitchX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pitchX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roofArea&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gable&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;roofArea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;eaves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;rakes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// rakes follow the slope, so they get the factor too&lt;/span&gt;
        &lt;span class="na"&gt;ridges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;hips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;valleys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// hip and shed each have their own edge geometry...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detail I love in that snippet, and the one I got wrong the first time: the rakes get the pitch factor and the eaves don't. The eave runs horizontally along the bottom — it's a true plan-view length. The rake climbs the slope from eave to ridge, so its real length is the plan length stretched by the same &lt;code&gt;pf&lt;/code&gt;. Two edges of the same triangle, one scaled and one not. Get that backwards and your drip-edge count is fine but your starter-strip count drifts, and nobody notices until they're 8 feet short on the last run.&lt;/p&gt;

&lt;p&gt;Once the shape gives you every edge, the bill of materials falls out of dividing lengths by coverage rates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;capBundles&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ridges&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hips&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// ~25 lf per bundle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;starterBundles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;eaves&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rakes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ~100 lf per bundle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dripEdgePieces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;eaves&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rakes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 10 ft pieces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Math.ceil&lt;/code&gt; everywhere, because you can't buy 0.3 of a bundle, and rounding down is the same Saturday problem in a smaller font.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring decision that mattered most
&lt;/h2&gt;

&lt;p&gt;There's a calculator page, and there are written guides with worked examples ("a 1,500 sq ft 6:12 roof needs..."), and there are little reference tables. Early on, the worked examples were prose. I'd typed the numbers by hand. And of course one of them disagreed with what the calculator produced for the same inputs, because I'd updated a coverage assumption in the code and not in the paragraph I'd written three weeks earlier.&lt;/p&gt;

&lt;p&gt;That's the failure mode that actually erodes trust in a tool like this. Not being wrong — being inconsistently wrong, where the calculator says one thing and the explanation under it says another, and now the reader has no idea which to believe. For a tool whose entire pitch is "trust this number," two numbers is worse than a wrong one.&lt;/p&gt;

&lt;p&gt;So the rule became: there is exactly one file that knows how to do roofing math, and every surface — the calculator, the worked examples, the reference tables — imports from it. If a constant changes, it changes in one place and everything downstream moves together. The published assumptions (bundle coverage, waste factor, nails per square) live next to the functions that use them, so the "how we calculate this" page can't drift from the calculation either. It's not clever. It's just refusing to keep the same fact in two places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway, if there is one
&lt;/h2&gt;

&lt;p&gt;The lesson I keep relearning on these little domain tools: the naive model isn't a simpler version of the real thing, it's a different thing that happens to return a number in the same units. "Length times width" and "the actual surface area of a tilted plane with this much edge perimeter" aren't 80% the same answer. They diverge exactly where it costs the user money.&lt;/p&gt;

&lt;p&gt;The interesting work was never the trigonometry. It was noticing that the question "how big is my roof" is secretly the question "what do I buy," and that the second one needs you to model the shape, not just scale a rectangle.&lt;/p&gt;

&lt;p&gt;I put the whole thing online as a set of free calculators — pitch, area, the full material takeoff — at &lt;a href="https://roofing-calculator.io" rel="noopener noreferrer"&gt;roofing-calculator.io&lt;/a&gt; if you want to poke at it, and there's a &lt;a href="https://roofing-calculator.io/methodology" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt; that lays out every assumption so you can argue with the numbers. If you've built tools in a domain where the obvious formula turns out to be quietly lying, I'd genuinely like to hear what tipped you off.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>math</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The worked example that disagreed with its own calculator</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Wed, 17 Jun 2026 01:17:24 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-worked-example-that-disagreed-with-its-own-calculator-4cp9</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-worked-example-that-disagreed-with-its-own-calculator-4cp9</guid>
      <description>&lt;p&gt;I run a small site of home-improvement cost calculators (&lt;a href="https://costto.build" rel="noopener noreferrer"&gt;costto.build&lt;/a&gt;) — pick a project, get a low / average / high estimate with a materials-and-labor breakdown. Boring on the surface. The interesting part is that every page has the &lt;em&gt;same number&lt;/em&gt; in four different places, and for a while, those four places didn't agree.&lt;/p&gt;

&lt;p&gt;This is a post about that bug, why it was inevitable, and the one rule that made it impossible to repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four sources of truth for one number
&lt;/h2&gt;

&lt;p&gt;A single calculator page — say, the room-addition one — shows a cost in four spots:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The interactive widget, driven by a &lt;code&gt;calculate()&lt;/code&gt; function (low/avg/high).&lt;/li&gt;
&lt;li&gt;A static "cost by size" reference table above the fold.&lt;/li&gt;
&lt;li&gt;A couple of worked examples written out in prose ("a 20×20 in-law suite runs about…").&lt;/li&gt;
&lt;li&gt;The FAQ, with its own cost bands.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When I built the first batch, I wrote all four by hand. The widget had its formula. The table had numbers I'd typed from research. The prose examples had numbers I'd reasoned out while writing the paragraph. They were close enough that nothing looked wrong.&lt;/p&gt;

&lt;p&gt;Then I ran a consistency pass and found this: the in-law-suite worked example said &lt;strong&gt;$62,300&lt;/strong&gt;. The widget, fed the exact same square footage and finish level, returned &lt;strong&gt;$56,779&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Same project. Two pages of the same page. A $5,500 gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happened (and why it was always going to)
&lt;/h2&gt;

&lt;p&gt;The numbers weren't random — they came from different &lt;em&gt;assumptions&lt;/em&gt; that nobody had written down.&lt;/p&gt;

&lt;p&gt;The prose example assumed a brand-new HVAC unit for the addition. The &lt;code&gt;calculate()&lt;/code&gt; function assumed you extend the existing system. Both are defensible. But one lived in my head as I wrote a paragraph at 11pm, and the other lived in a TypeScript function I'd written two weeks earlier. Nothing connected them.&lt;/p&gt;

&lt;p&gt;This is the ordinary failure mode of derived data: &lt;strong&gt;the moment you store the same fact in two spots, they start drifting the instant you look away.&lt;/strong&gt; It's the DRY principle, except the duplicated thing isn't code — it's a &lt;em&gt;value a function already knows how to produce&lt;/em&gt;. I'd just chosen to retype it.&lt;/p&gt;

&lt;p&gt;A static table of "costs by size" is, definitionally, a table of &lt;code&gt;calculate()&lt;/code&gt; outputs. A worked example is &lt;code&gt;calculate()&lt;/code&gt; plus a sentence explaining it. I had been hand-copying a function's return value into prose and calling it content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: compute the page from the calculator
&lt;/h2&gt;

&lt;p&gt;The rule I landed on — I call it source-first — has two halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half one: &lt;code&gt;calculate()&lt;/code&gt; is the only place a cost is &lt;em&gt;born&lt;/em&gt;.&lt;/strong&gt; Everything else is derived from it at build time. The reference table isn't typed; it's generated by calling &lt;code&gt;calculate()&lt;/code&gt; across a range of sizes. Same for the example numbers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: a hand-typed table that rots independently&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;costBySize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// typed from "research"&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;58000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;high&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// ...probably&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// After: the table IS the calculator, sampled at build time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;costBySize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="c1"&gt;// single source of truth&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Static export makes this clean — it all runs once at build, ships as plain HTML, and there's no way for the table to disagree with the widget because they're the same function. (This is Next.js 16 with &lt;code&gt;output: export&lt;/code&gt;, so the page is fully rendered text by the time Google or a user sees it. That matters more than it sounds — but that's a different post.)&lt;/p&gt;

&lt;p&gt;The worked examples got the same treatment. Instead of writing "$62,300," the example pulls the real figure and the prose explains the &lt;em&gt;assumption&lt;/em&gt; around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inLaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sqft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hvac&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;extend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// example text references inLaw.avg, and states the "extend existing" assumption out loud&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The $62,300 figure didn't get corrected to $56,800. It stopped existing. There was no longer a number to be wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half two: every born number carries where it came from.&lt;/strong&gt; A &lt;code&gt;calculate()&lt;/code&gt; is only as honest as its anchor, so each calculator now ships a small provenance object that renders on the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;costBasis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Mid-range room addition, national average&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;nationalAvg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;56800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Industry cost data, 2026&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;accessed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-06&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reviewBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-12&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;reviewBy&lt;/code&gt; date is the part I'd push on anyone building this kind of site. Cost data isn't a fact, it's a &lt;em&gt;perishable&lt;/em&gt; fact. Putting an expiry on it — visible to the reader, and a reminder to me — is the difference between "numbers I sourced once" and "numbers I maintain."&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterpoint, because there is one
&lt;/h2&gt;

&lt;p&gt;Generating everything from one function has a failure mode: if the anchor is wrong, it's now wrong &lt;em&gt;consistently&lt;/em&gt;, across all four spots, very convincingly. Hand-written numbers at least disagree loudly enough to tip you off — which is exactly how I caught this bug in the first place.&lt;/p&gt;

&lt;p&gt;So source-first only pays off if you pair it with the provenance half. The single anchor has to be sourced and dated, not vibes. Otherwise you've just made it easier to be uniformly wrong. I'd still take that trade — a contradiction a user can spot destroys trust faster than an error they can't — but it's a trade, not a free win.&lt;/p&gt;

&lt;p&gt;There's also real content this approach can't generate: the &lt;em&gt;why&lt;/em&gt;. A function can tell you a 400 sq ft addition averages $56,800. It can't tell you the permit will hold you up six weeks or that the HVAC decision is where people overspend. That part stays hand-written. The rule is narrow: stop hand-writing numbers a function already computes — not stop writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If two places on a page show the same number, one of them should be importing it, not retyping it. This is just DRY, but it's weirdly easy to forget that data is code.&lt;/li&gt;
&lt;li&gt;Build-time generation (static export, or any SSG) turns "keep these in sync" from a discipline problem into an impossibility-of-drift property. Let the build do the copying.&lt;/li&gt;
&lt;li&gt;Any number that can go stale should ship with a visible source and a review date. It costs five lines and it's the most honest thing on the page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing was maybe a day of refactoring across the calculators on &lt;a href="https://costto.build" rel="noopener noreferrer"&gt;costto.build&lt;/a&gt;, most of it deleting numbers rather than writing them. The codebase got smaller and stopped lying about itself. That's a good day.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The third enum variant that stopped my tax calculator from lying</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Tue, 16 Jun 2026 01:56:54 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-third-enum-variant-that-stopped-my-tax-calculator-from-lying-2nho</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-third-enum-variant-that-stopped-my-tax-calculator-from-lying-2nho</guid>
      <description>&lt;p&gt;I built a small thing that estimates what a US pay raise actually lands in your pocket after federal tax, FICA, state tax, and inflation. The arithmetic is the boring part. The part that took three rewrites was deciding how to represent fifty states plus DC when you don't actually have clean data for all of them on day one.&lt;/p&gt;

&lt;p&gt;This is a writeup of that one decision, because it generalizes well beyond taxes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't the math, it's the gaps
&lt;/h2&gt;

&lt;p&gt;Federal brackets are easy. There's one set of numbers, the IRS publishes them, and progressive tax is a five-line loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;taxFromBrackets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bracket&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prevCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevCap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;prevCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State tax is the same loop with different numbers. The trouble is that "different numbers" hides a lot. Nine states don't tax wage income at all. A couple (New Hampshire, Tennessee) don't tax wages but have historically taxed other things, so you can't just lump them with Texas without a footnote. Some states publish 2026 figures early; others inflation-index their brackets and won't publish the new thresholds until mid-year. And when I started, I had &lt;em&gt;verified&lt;/em&gt; numbers for about ten states and unverified-but-plausible numbers for the rest.&lt;/p&gt;

&lt;p&gt;That last category is the dangerous one. The easy move is to ship the plausible numbers and fix them later. The problem is that a wrong tax estimate doesn't look wrong. It looks like a number. Someone in Oregon types in their salary, sees a confident dollar figure, and has no way to know it was a placeholder I never got around to checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two variants felt like enough. It wasn't.
&lt;/h2&gt;

&lt;p&gt;My first model was the obvious one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StateTax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taxed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt; &lt;span class="nl"&gt;standardDeduction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is clean and it compiles and it's a lie. It forces every state to be either "no tax" or "here are the exact brackets," which means the moment I add a state to the union, I'm implicitly claiming I've verified it. There's no way for the type to say &lt;em&gt;this state taxes income, but I haven't confirmed the numbers yet.&lt;/em&gt; So either I block the entire feature until all 51 are done, or I quietly promote guesses to facts.&lt;/p&gt;

&lt;p&gt;The fix was a third variant whose only job is to represent honesty about the gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Taxing state not yet data-verified — selectable, but calc returns 0 with a UI caveat. */&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PendingState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StateTax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NoneState&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;PendingState&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;TaxedState&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pending&lt;/code&gt; means: yes, this state taxes wages, no, I will not pretend to know how much. The calculator returns 0 for it, and the UI renders a visible caveat instead of a clean dollar amount. It's selectable so the dropdown still lists every state, but it can't masquerade as a verified result.&lt;/p&gt;

&lt;p&gt;The calculation function gets to stay honest because the type makes the gap unrepresentable as a real value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calcStateTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FilingStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STATES_2026&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;st&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taxed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// none AND pending both fall through here&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;standardDeduction&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;taxFromBrackets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single line &lt;code&gt;st.kind !== "taxed"&lt;/code&gt; is the whole point. There is exactly one branch that produces a state-tax number, and it's only reachable when the data has been verified. A &lt;code&gt;pending&lt;/code&gt; state and a no-tax state both return 0, but they mean completely different things, and the UI is allowed to treat them differently because the variant carries that distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Verified" needed a definition I could check later
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;pending&lt;/code&gt; existed, I had to define what it took for a state to &lt;em&gt;leave&lt;/em&gt; pending. Hand-wavy "I looked it up" doesn't survive contact with a tax year that changes under you. So every taxed state carries its own provenance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TaxedState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taxed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Filing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Bracket&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;standardDeduction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Filing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;provenance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Provenance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// where each number came from + a dated note&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I held myself to: a state's numbers only graduate to &lt;code&gt;taxed&lt;/code&gt; when they're cross-checked against two independent sources — the state's own Department of Revenue, and an outside table (I used the Tax Foundation's bracket data). Both agree, or it stays &lt;code&gt;pending&lt;/code&gt;. The &lt;code&gt;provenance.note&lt;/code&gt; records the date and any caveat, like "state inflation-indexes brackets and hasn't published 2026 thresholds, so rates are exact and thresholds are the latest complete schedule."&lt;/p&gt;

&lt;p&gt;That sounds like bureaucracy for a side project. But it's the difference between a number I can defend and a number I'm hoping is right. Six months from now when a state changes its top rate, the provenance tells me which states to recheck instead of re-auditing all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The inflation formula people get wrong
&lt;/h2&gt;

&lt;p&gt;Separate gotcha, same theme of "the obvious version is subtly false." A "real" raise — what your raise is worth after inflation eats some of it — is &lt;em&gt;not&lt;/em&gt; nominal minus inflation. A 5% raise in 4% inflation is not a 1% real raise.&lt;/p&gt;

&lt;p&gt;It's multiplicative, because you're dividing two ratios of purchasing power:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calcRealRaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nominalPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inflationPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nominalPct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inflationPct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ≈ 0.96%, not 1%&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap is small at low inflation and embarrassing at high inflation. Subtraction says a 10% raise in 9% inflation keeps 1% of growth; the real figure is about 0.92%. At the inflation levels of the last few years that error is big enough to flip "you beat inflation" into "you didn't." I shipped the subtraction version first. A spreadsheet caught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take to the next project
&lt;/h2&gt;

&lt;p&gt;The reusable lesson has nothing to do with taxes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model the absence of data as a first-class state, not a falsy placeholder.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;undefined&lt;/code&gt; all collapse "no value" and "value I haven't verified" into the same shape. A named variant keeps them apart and lets the UI tell the user which one they're looking at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a number is going to be presented as authoritative, make "unverified" unrepresentable as that number.&lt;/strong&gt; The type system can enforce that the only code path producing a real figure runs on real data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach provenance to data that decays.&lt;/strong&gt; Tax tables, prices, anything time-sensitive — a dated source note turns "is this still right?" from a re-audit into a lookup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The calculator is at &lt;a href="https://raise-calculator.com" rel="noopener noreferrer"&gt;raise-calculator.com&lt;/a&gt; if you want to poke at the output (the take-home page is where all of this surfaces). But honestly the interesting artifact was the tagged union, not the website. I've started reaching for a &lt;code&gt;pending&lt;/code&gt;-style variant in unrelated code now — anywhere I'm tempted to ship a guess that looks like a fact.&lt;/p&gt;

&lt;p&gt;If you've got a cleaner way to model "taxing state, unknown brackets" than a third variant, I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>datamodeling</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What I learned building an after-tax raise calculator</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Mon, 15 Jun 2026 03:02:16 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/what-i-learned-building-an-after-tax-raise-calculator-485h</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/what-i-learned-building-an-after-tax-raise-calculator-485h</guid>
      <description>&lt;p&gt;I built a &lt;a href="https://raise-calculator.com" rel="noopener noreferrer"&gt;pay raise calculator&lt;/a&gt; because I got a raise, multiplied my salary by 1.05, and then watched my actual paycheck go up by a lot less than I expected. The gap between "5% raise" and "the extra money that shows up" turned out to be wide enough that I wanted to understand it properly — and once I started writing the code, "what's my raise worth?" split into three completely different questions.&lt;/p&gt;

&lt;p&gt;This is a write-up of the small TypeScript engine behind it, and the three gotchas that made the math less obvious than &lt;code&gt;salary * 1.05&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 1: the gross number (the easy one)
&lt;/h2&gt;

&lt;p&gt;This is the only part that &lt;em&gt;is&lt;/em&gt; &lt;code&gt;salary * 1.05&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newSalary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentSalary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;raisePct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A $80,000 salary with a 5% raise is $84,000. A $4,000 raise. Done. This is the number everyone quotes, and it's the least useful one, because you never see $4,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: the after-tax number (where the marginal bracket bites)
&lt;/h2&gt;

&lt;p&gt;The first thing people get wrong — including me, before I wrote this — is assuming a raise is taxed at your &lt;em&gt;average&lt;/em&gt; rate. It isn't. A raise lands entirely on top of your existing income, so it's taxed at your &lt;strong&gt;marginal&lt;/strong&gt; rate: the bracket your last dollar falls into.&lt;/p&gt;

&lt;p&gt;The other thing people get wrong is the opposite, and it's a myth I wanted the tool to kill: &lt;em&gt;"a raise can bump me into a higher bracket and leave me with less money."&lt;/em&gt; That can't happen with progressive brackets — only the dollars &lt;strong&gt;inside&lt;/strong&gt; the higher band are taxed at the higher rate. The brackets are bands, not switches.&lt;/p&gt;

&lt;p&gt;So the engine computes total tax at the old and new salary and just subtracts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Bracket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;taxFromBrackets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Bracket&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prevCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;brackets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;prevCap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// only THIS band's dollars get THIS rate&lt;/span&gt;
    &lt;span class="nx"&gt;prevCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;upTo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;netRaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentGross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newGross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxBefore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;totalTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentGross&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// federal + FICA + state&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxAfter&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;totalTax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newGross&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newGross&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;currentGross&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taxAfter&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;taxBefore&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The federal part is easy once you have &lt;code&gt;taxFromBrackets&lt;/code&gt;. &lt;strong&gt;FICA is where the cliffs live&lt;/strong&gt;, and it's the part most back-of-envelope calculators skip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calcFica&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Social Security: 6.2%, but ONLY up to the annual wage base.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SS_WAGE_BASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.062&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Medicare: 1.45% on everything...&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;medicare&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.0145&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...plus a 0.9% surtax on dollars above a high threshold.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ADDITIONAL_MEDICARE_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;medicare&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ADDITIONAL_MEDICARE_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.009&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ss&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;medicare&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Math.min(gross, SS_WAGE_BASE)&lt;/code&gt; is the interesting line. Social Security stops being withheld once you cross the wage base for the year — so the &lt;em&gt;same&lt;/em&gt; 5% raise keeps a wildly different fraction of itself depending on where you sit relative to that cap. A raise that straddles the wage base is partly free of the 6.2%; a raise entirely below it isn't. A flat "multiply by 0.92 for taxes" can't express that.&lt;/p&gt;

&lt;p&gt;Worked example, $80,000 → $84,000, single filer, no state tax:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gross raise&lt;/td&gt;
&lt;td&gt;$4,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra federal tax (22% marginal band)&lt;/td&gt;
&lt;td&gt;−$880&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra FICA (6.2% + 1.45%)&lt;/td&gt;
&lt;td&gt;−$306&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Net raise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$2,814&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You keep about &lt;strong&gt;70%&lt;/strong&gt; of it. The headline "$4,000" was never going to hit your account. And note the federal hit is 22% even though this person's &lt;em&gt;effective&lt;/em&gt; federal rate is around 11% — that's the marginal-vs-average gap in one number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: the real number (inflation eats the rest)
&lt;/h2&gt;

&lt;p&gt;Here's the question that actually matters and almost no calculator answers: &lt;strong&gt;did your purchasing power go up?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If prices rose 3.3% over the year and your raise was 5%, you are not 5% richer and you're not even 1.7% richer (the naive subtraction). The correct relationship is multiplicative, because you're deflating next year's dollars back to today's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;realRaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nominalPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inflationPct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nominalPct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inflationPct&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;realRaise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// → 1.645...  not 1.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the $4,000-that's-really-$2,814 raise is, in purchasing-power terms, about a &lt;strong&gt;1.6% real raise&lt;/strong&gt;. And the uncomfortable corollary the tool makes very visible: any nominal raise &lt;em&gt;below&lt;/em&gt; the inflation rate is a real pay cut, even though the number on the letter is positive. A "3% raise" in a 3.3% year means you can buy less than you could last year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture decision that mattered most: one source of truth
&lt;/h2&gt;

&lt;p&gt;The boring part turned out to be the most important. Early on I had tax constants and "current inflation" hardcoded into page copy — &lt;code&gt;$168,600&lt;/code&gt; here, "3.2%" in one blog post, "3.3%" in another. They drifted. A site whose entire pitch is &lt;em&gt;accurate numbers&lt;/em&gt; had inconsistent numbers, which is the worst possible failure mode.&lt;/p&gt;

&lt;p&gt;The fix was to make every displayed figure — including the ones baked into prose and reference tables — derive from a single constants module, and to &lt;em&gt;compute the example tables at build time from the same engine that powers the live calculator&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// data/tax-2026.ts — the only place a tax/inflation number is written down,&lt;/span&gt;
&lt;span class="c1"&gt;// each one cross-checked against two independent sources before it lands.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CPI_U_LATEST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.033&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;periodLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;12 months ending March 2026&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SS_WAGE_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;184&lt;/span&gt;&lt;span class="nx"&gt;_500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...brackets, deductions, FICA rates...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a reference table like "what a 3% / 5% / 7% raise nets at $60k / $80k / $100k" isn't typed out by hand — it's a &lt;code&gt;.map()&lt;/code&gt; over the same &lt;code&gt;calcRaiseAfterTax&lt;/code&gt; the interactive tool calls. The numbers in the article body &lt;strong&gt;cannot&lt;/strong&gt; disagree with the calculator, because they're the same function. Updating for next tax year is a one-line edit to the constants, and the whole site re-derives. (It's a Next.js static export, so all of this runs at build time and ships as plain HTML.)&lt;/p&gt;

&lt;p&gt;If I'd known one thing starting out, it's this: for any "calculator" content site, the moment a number exists in two places it's already wrong. Treat the constants as code, compute the prose from them, and never let a human re-type a figure that a function can produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A raise is taxed at your &lt;strong&gt;marginal&lt;/strong&gt; rate, not your average — but progressive brackets mean a raise can never &lt;em&gt;lower&lt;/em&gt; your take-home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FICA wage-base and surtax thresholds&lt;/strong&gt; create cliffs that flat "multiply by 0.9" estimates silently miss.&lt;/li&gt;
&lt;li&gt;Real (inflation-adjusted) raise is &lt;code&gt;(1+n)/(1+i) − 1&lt;/code&gt;, not &lt;code&gt;n − i&lt;/code&gt;, and it's the only one of the three numbers that tells you whether you're actually better off.&lt;/li&gt;
&lt;li&gt;Derive every displayed figure from one constants module. If a number lives in two files, you have a bug waiting to surface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The live version with the interactive math is at &lt;a href="https://raise-calculator.com" rel="noopener noreferrer"&gt;raise-calculator.com&lt;/a&gt; if you want to plug in your own numbers — but honestly the three formulas above are the whole thing. Happy to talk through the bracket edge cases in the comments.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>webdev</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Generating valid .ics calendar feeds at build time</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Sun, 14 Jun 2026 03:45:33 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/generating-valid-ics-calendar-feeds-at-build-time-50lp</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/generating-valid-ics-calendar-feeds-at-build-time-50lp</guid>
      <description>&lt;p&gt;A few weeks ago I shipped a feature I'd been putting off because it &lt;em&gt;felt&lt;/em&gt; like it needed a backend: subscribable calendar feeds. "Add this holiday to Google Calendar." "Subscribe to all your country's public holidays so they show up in Apple Calendar forever."&lt;/p&gt;

&lt;p&gt;Every calendar competitor has this. My site had none. The catch: the whole thing is a &lt;strong&gt;static export&lt;/strong&gt; — &lt;code&gt;next build&lt;/code&gt; produces a folder of HTML/CSS/JS that I drop on Cloudflare Pages. No server, no API routes at request time, no ISR. So how do you serve a &lt;code&gt;.ics&lt;/code&gt; feed that a calendar app polls every few hours?&lt;/p&gt;

&lt;p&gt;Turns out you don't need a server at all. Here's the approach, the RFC 5545 gotchas that bit me, and the parts I'd tell my past self.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "aha": a feed is just a file
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.ics&lt;/code&gt; subscription feed is not a live API. It's a static text file that calendar clients re-fetch on a schedule. So for a static site, the idiomatic move is a &lt;strong&gt;post-build emitter&lt;/strong&gt;: after &lt;code&gt;next build&lt;/code&gt;, run a Node script that walks your data and writes assets straight into &lt;code&gt;out/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/deploy.sh&lt;/span&gt;
npx next build
node scripts/emit-feeds.mjs   &lt;span class="c"&gt;# writes .ics + .json into out/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire architecture. The emitter reads the same JSON the pages render from, so the feeds can never drift out of sync with the site — there's one source of truth. It emits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a per-year feed (&lt;code&gt;holidays-de-2026.ics&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;a per-holiday feed (one event, for the "download this day" button)&lt;/li&gt;
&lt;li&gt;an all-years subscription feed (the one you point &lt;code&gt;webcal://&lt;/code&gt; at)&lt;/li&gt;
&lt;li&gt;and, almost for free in the same loop, a JSON API under &lt;code&gt;out/api/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No new pages, no new routes. Just files.&lt;/p&gt;

&lt;h2&gt;
  
  
  RFC 5545: all-day events are sneakier than they look
&lt;/h2&gt;

&lt;p&gt;I assumed an all-day event on Jan 1 would be &lt;code&gt;DTSTART:20260101&lt;/code&gt;, &lt;code&gt;DTEND:20260101&lt;/code&gt;. Wrong. &lt;strong&gt;&lt;code&gt;DTEND&lt;/code&gt; is exclusive.&lt;/strong&gt; A one-day all-day event ends on Jan &lt;strong&gt;2&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEGIN:VEVENT
UID:de-2026-neujahr@calendana.com
DTSTAMP:20260614T101500Z
DTSTART;VALUE=DATE:20260101
DTEND;VALUE=DATE:20260102
SUMMARY:Neujahr
TRANSP:TRANSPARENT
CATEGORIES:Holiday
END:VEVENT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get this wrong and some clients render a zero-length event, or silently drop it. Other things the spec is quietly strict about, all of which I learned by importing broken files into Apple Calendar and watching nothing appear:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CRLF line endings.&lt;/strong&gt; Not &lt;code&gt;\n&lt;/code&gt;. &lt;code&gt;\r\n&lt;/code&gt;, everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;75-octet line folding.&lt;/strong&gt; Lines longer than 75 &lt;em&gt;bytes&lt;/em&gt; (not chars — bytes) must be folded, with continuation lines starting with a single space. The byte distinction matters the moment you have non-ASCII content; you must never split a multi-byte UTF-8 codepoint across the fold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TEXT escaping.&lt;/strong&gt; Commas, semicolons, backslashes and newlines in &lt;code&gt;SUMMARY&lt;/code&gt;/&lt;code&gt;DESCRIPTION&lt;/code&gt; have to be escaped (&lt;code&gt;\,&lt;/code&gt; &lt;code&gt;\;&lt;/code&gt; &lt;code&gt;\\&lt;/code&gt; &lt;code&gt;\n&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stable &lt;code&gt;UID&lt;/code&gt;.&lt;/strong&gt; If the UID changes between rebuilds, every subscriber gets duplicate events on the next poll. Mine is deterministic: &lt;code&gt;{locale}-{year}-{key}@domain&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The folding function is the bit worth copying, because the byte-vs-char trap is easy to miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;foldLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// back off if the cut lands mid-codepoint (UTF-8 continuation = 10xxxxxx)&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xc0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
    &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// continuation lines spend 1 octet on the leading space&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The zero-infra trick: deeplinks
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.ics&lt;/code&gt; files cover "download" and "subscribe." But the highest-intent button — &lt;strong&gt;"Add to Google Calendar"&lt;/strong&gt; — needs no file at all. Google and Outlook both accept a URL that pre-fills a new event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;googleHref&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextDay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// remember: end is exclusive&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TEMPLATE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`https://calendar.google.com/calendar/render?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;. No JS, no library, works on a static page. (Outlook's equivalent is &lt;code&gt;outlook.live.com/calendar/0/deeplink/compose&lt;/code&gt; — note the &lt;code&gt;deeplink&lt;/code&gt; segment; I shipped it once without it and the prefill silently failed.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving the right MIME type on a static host
&lt;/h2&gt;

&lt;p&gt;If you serve &lt;code&gt;.ics&lt;/code&gt; as &lt;code&gt;text/plain&lt;/code&gt;, some clients refuse it. On Cloudflare Pages a single &lt;code&gt;_headers&lt;/code&gt; file in &lt;code&gt;public/&lt;/code&gt; handles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;/*.&lt;span class="n"&gt;ics&lt;/span&gt;
  &lt;span class="n"&gt;Content&lt;/span&gt;-&lt;span class="n"&gt;Type&lt;/span&gt;: &lt;span class="n"&gt;text&lt;/span&gt;/&lt;span class="n"&gt;calendar&lt;/span&gt;; &lt;span class="n"&gt;charset&lt;/span&gt;=&lt;span class="n"&gt;utf&lt;/span&gt;-&lt;span class="m"&gt;8&lt;/span&gt;
  &lt;span class="n"&gt;Cache&lt;/span&gt;-&lt;span class="n"&gt;Control&lt;/span&gt;: &lt;span class="n"&gt;public&lt;/span&gt;, &lt;span class="n"&gt;max&lt;/span&gt;-&lt;span class="n"&gt;age&lt;/span&gt;=&lt;span class="m"&gt;86400&lt;/span&gt;
/&lt;span class="n"&gt;api&lt;/span&gt;/*
  &lt;span class="n"&gt;Content&lt;/span&gt;-&lt;span class="n"&gt;Type&lt;/span&gt;: &lt;span class="n"&gt;application&lt;/span&gt;/&lt;span class="n"&gt;json&lt;/span&gt;; &lt;span class="n"&gt;charset&lt;/span&gt;=&lt;span class="n"&gt;utf&lt;/span&gt;-&lt;span class="m"&gt;8&lt;/span&gt;
  &lt;span class="n"&gt;Access&lt;/span&gt;-&lt;span class="n"&gt;Control&lt;/span&gt;-&lt;span class="n"&gt;Allow&lt;/span&gt;-&lt;span class="n"&gt;Origin&lt;/span&gt;: *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part that's actually hard: 15 languages
&lt;/h2&gt;

&lt;p&gt;This is the bit I keep coming back to. The site runs in 15 locales, and the temptation with any multilingual feature is to write the English microcopy once and machine-translate it ×15. Don't. For a content/SEO site that's a fast track to thin, near-duplicate pages that search engines won't index — and for a &lt;em&gt;calendar&lt;/em&gt;, it's also just wrong. A Mexican user wants "Agregar a Google Calendar" for "días festivos"; a Spaniard wants "Añadir" for "festivos"; an Argentine says "feriados." Same language, three different words. Those got hand-written per locale, sharing one strings file that both the Node emitter and the React components read, so the button label and the feed's calendar name always agree.&lt;/p&gt;

&lt;p&gt;That single-source-of-everything theme — one holiday JSON feeding the pages, the feeds, and the API; one strings file feeding the emitter and the UI — is what kept this feature from becoming a maintenance swamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;p&gt;The site is &lt;a href="https://calendana.com" rel="noopener noreferrer"&gt;Calendana&lt;/a&gt; — printable calendars plus public-holiday and school-holiday data for a bunch of countries, all static, all free, ad-supported, no login. The calendar-export work is live on every holiday page now. If you just want to see the feed format, grab one and open it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://calendana.com/de/holidays/2026/holidays-de-2026.ics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or the JSON, if you're building something:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://calendana.com/api/holidays/de/2026.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Needs a backend" is often a reflex, not a requirement.&lt;/strong&gt; A subscription feed is a file. A "create event" button is a URL. Both fit a static site fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the RFC.&lt;/strong&gt; All-day &lt;code&gt;DTEND&lt;/code&gt; is exclusive, lines fold on &lt;em&gt;bytes&lt;/em&gt;, endings are CRLF. The spec is boring and it is right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate sibling artifacts from one source.&lt;/strong&gt; Pages, &lt;code&gt;.ics&lt;/code&gt;, and JSON all come from the same data in one build step, so they can't disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localize the words, not just the dates.&lt;/strong&gt; Especially when "the same language" means different words in different countries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions on the emitter or the folding/escaping details in the comments — that's where most of the sharp edges were.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Encoding FIFA’s 495 third-place scenarios for the 2026 World Cup</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Wed, 27 May 2026 08:04:24 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/encoding-fifas-495-third-place-scenarios-for-the-2026-world-cup-4814</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/encoding-fifas-495-third-place-scenarios-for-the-2026-world-cup-4814</guid>
      <description>&lt;p&gt;I expected the 2026 World Cup bracket to be a sorting problem.&lt;/p&gt;

&lt;p&gt;It turned out to be a sorting problem plus a lookup table.&lt;/p&gt;

&lt;p&gt;I recently worked on a small World Cup 2026 bracket project, and the strangest part was not building the knockout bracket itself. It was handling the third-placed teams.&lt;/p&gt;

&lt;p&gt;The new format has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;48 teams&lt;/li&gt;
&lt;li&gt;12 groups&lt;/li&gt;
&lt;li&gt;24 teams qualifying as group winners and runners-up&lt;/li&gt;
&lt;li&gt;8 more teams qualifying as the best third-placed teams&lt;/li&gt;
&lt;li&gt;a new Round of 32&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At first, this sounded straightforward.&lt;/p&gt;

&lt;p&gt;Rank the third-placed teams, take the best 8, then put them into the knockout bracket.&lt;/p&gt;

&lt;p&gt;But the last step is where it gets weird.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranking third-placed teams is the easy part
&lt;/h2&gt;

&lt;p&gt;Once every group has a table, each group gives you one third-placed team.&lt;/p&gt;

&lt;p&gt;That gives you 12 third-placed teams.&lt;/p&gt;

&lt;p&gt;From there, the basic ranking can be represented as a normal sorting problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;GroupId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;F&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;J&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;TeamStanding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;teamId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GroupId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;goalDifference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;goalsFor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;fairPlayPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simplified ranking function might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rankThirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TeamStanding&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goalDifference&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goalDifference&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goalsFor&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goalsFor&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fairPlayPoints&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fairPlayPoints&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not the complete official tie-breaker system, but it shows the shape of the problem.&lt;/p&gt;

&lt;p&gt;You filter the third-placed teams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thirdPlacedTeams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;groupTables&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;standings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you take the top 8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rankThirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So far, this still feels like a normal algorithm problem.&lt;/p&gt;

&lt;p&gt;But then comes the awkward part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bracket position is not simply “best third-place team goes here”
&lt;/h2&gt;

&lt;p&gt;My first assumption was that the best third-placed team would go into one slot, the second-best into another slot, and so on.&lt;/p&gt;

&lt;p&gt;That is not how it works.&lt;/p&gt;

&lt;p&gt;The Round of 32 matchup depends on &lt;strong&gt;which groups&lt;/strong&gt; the qualifying third-placed teams came from.&lt;/p&gt;

&lt;p&gt;For example, if the qualifying third-placed teams come from groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;F&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that combination maps to one specific set of Round of 32 positions.&lt;/p&gt;

&lt;p&gt;If the qualifying groups are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;F&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that maps differently.&lt;/p&gt;

&lt;p&gt;So the key is not only ranking the third-placed teams.&lt;/p&gt;

&lt;p&gt;The key is identifying the set of groups they came from.&lt;/p&gt;

&lt;p&gt;With 12 groups and 8 third-placed teams qualifying, the number of possible group combinations is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;C(12, 8) = 495
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means there are 495 possible sets of qualifying third-placed groups.&lt;/p&gt;

&lt;p&gt;This is where the implementation stops being a clean formula and becomes a data problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the qualified groups into a key
&lt;/h2&gt;

&lt;p&gt;The simplest way I found to model this was to turn the list of qualifying third-place groups into a stable key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getThirdPlaceCombinationKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TeamStanding&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;teams&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if the qualified third-placed teams came from groups C, D, E, F, G, I, K, and L, the key becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CDEFGIKL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This key can then be used to look up the official mapping for the Round of 32.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rankThirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;combinationKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getThirdPlaceCombinationKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we can use that key to answer the real question:&lt;/p&gt;

&lt;p&gt;Where does each third-placed team go in the bracket?&lt;/p&gt;

&lt;h2&gt;
  
  
  Representing the mapping as data
&lt;/h2&gt;

&lt;p&gt;Instead of trying to derive the Round of 32 slots every time, I treated the official scenarios as a lookup table.&lt;/p&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RoundOf32Slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`3&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;GroupId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceMapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RoundOf32Slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceSource&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thirdPlaceSlotMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceMapping&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;CDEFGIKL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3F&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="na"&gt;BDEFGIKL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1A&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1E&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3D&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1G&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3F&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1I&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1L&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3K&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="c1"&gt;// ...493 more combinations&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact values need to come from the official table, but the structure is the important part.&lt;/p&gt;

&lt;p&gt;The code is not trying to be clever.&lt;/p&gt;

&lt;p&gt;It is just making the official tournament rules usable inside an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filling the actual teams into the bracket
&lt;/h2&gt;

&lt;p&gt;Once the mapping is known, the rest is more mechanical.&lt;/p&gt;

&lt;p&gt;For each Round of 32 slot, find the third-placed team from the mapped group.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getThirdPlacedTeamFromGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TeamStanding&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceSource&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;GroupId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then apply the mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveThirdPlaceSlots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TeamStanding&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ThirdPlaceMapping&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getThirdPlacedTeamFromGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the full flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rankThirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thirdPlacedTeams&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;combinationKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getThirdPlaceCombinationKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;thirdPlaceSlotMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;combinationKey&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolvedSlots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveThirdPlaceSlots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qualifiedThirds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, the app can place the correct third-placed teams into the Round of 32.&lt;/p&gt;

&lt;h2&gt;
  
  
  What made this interesting
&lt;/h2&gt;

&lt;p&gt;I usually prefer deriving things from rules instead of hardcoding large tables.&lt;/p&gt;

&lt;p&gt;But this was a case where the table itself is part of the rule.&lt;/p&gt;

&lt;p&gt;The bracket is not just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sort teams → seed teams → generate matches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sort teams → identify qualified groups → use official mapping → generate matches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That small difference changes the architecture.&lt;/p&gt;

&lt;p&gt;The app needs both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;normal ranking logic&lt;/li&gt;
&lt;li&gt;a predefined scenario table&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is what made this more interesting than a standard tournament bracket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not want to hide all of this
&lt;/h2&gt;

&lt;p&gt;From the user’s point of view, none of this should feel complicated.&lt;/p&gt;

&lt;p&gt;They should be able to fill out groups, move into the knockout rounds, and understand what happened.&lt;/p&gt;

&lt;p&gt;But from the developer’s point of view, the Round of 32 is doing quite a lot behind the scenes.&lt;/p&gt;

&lt;p&gt;That also creates a UX question:&lt;/p&gt;

&lt;p&gt;How much of this logic should be visible?&lt;/p&gt;

&lt;p&gt;If you expose every rule, the page starts to feel like documentation.&lt;/p&gt;

&lt;p&gt;If you hide everything, the bracket can feel random.&lt;/p&gt;

&lt;p&gt;That balance was one of the harder parts of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The working version
&lt;/h2&gt;

&lt;p&gt;I used this logic in a small bracket predictor here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bracket2026.com" rel="noopener noreferrer"&gt;https://bracket2026.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The main goal was to make the new 48-team format easier to play with, especially the Round of 32.&lt;/p&gt;

&lt;p&gt;Building it reminded me that some “simple” sports tools are not simple because the UI is complex.&lt;/p&gt;

&lt;p&gt;They are complex because the real-world rules are strange.&lt;/p&gt;

&lt;p&gt;And sometimes the cleanest code is not the cleverest algorithm.&lt;/p&gt;

&lt;p&gt;Sometimes it is a boring lookup table that faithfully represents the rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;FIFA: &lt;a href="https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/groups-how-teams-qualify-tie-breakers" rel="noopener noreferrer"&gt;World Cup 2026 groups: how teams qualify and tie-breakers&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FIFA: &lt;a href="https://digitalhub.fifa.com/m/636f5c9c6f29771f/original/FWC2026_regulations_EN.pdf" rel="noopener noreferrer"&gt;Regulations for the FIFA World Cup 26&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>algorithms</category>
      <category>computerscience</category>
      <category>programming</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a shift schedule generator taught me that scheduling is harder than it looks</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Sat, 09 May 2026 14:15:20 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/building-a-shift-schedule-generator-taught-me-that-scheduling-is-harder-than-it-looks-5hh6</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/building-a-shift-schedule-generator-taught-me-that-scheduling-is-harder-than-it-looks-5hh6</guid>
      <description>&lt;p&gt;I recently built a small tool called &lt;strong&gt;Shift Schedule Maker&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At first, I thought shift scheduling would be a fairly straightforward problem.&lt;/p&gt;

&lt;p&gt;You have employees.&lt;br&gt;
You have shifts.&lt;br&gt;
You assign people to shifts.&lt;/p&gt;

&lt;p&gt;That sounds like a simple table.&lt;/p&gt;

&lt;p&gt;But once I started building it, I realized scheduling is less like filling out a calendar and more like solving a constraint problem.&lt;/p&gt;

&lt;p&gt;A valid schedule is not necessarily a good schedule.&lt;/p&gt;

&lt;p&gt;For example, the system has to think about things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does every shift have enough people?&lt;/li&gt;
&lt;li&gt;Is anyone assigned to two overlapping shifts?&lt;/li&gt;
&lt;li&gt;Is someone working too many days in a row?&lt;/li&gt;
&lt;li&gt;Are night shifts distributed fairly?&lt;/li&gt;
&lt;li&gt;Are weekends always falling on the same person?&lt;/li&gt;
&lt;li&gt;What happens when one person is unavailable?&lt;/li&gt;
&lt;li&gt;Can the schedule still work if the team size is small?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tricky part is that these rules often conflict with each other.&lt;/p&gt;

&lt;p&gt;You might want perfect fairness, but still need full coverage.&lt;br&gt;
You might want to respect everyone’s availability, but only have a limited number of people.&lt;br&gt;
You might want a simple repeating pattern, but real-life exceptions break the pattern very quickly.&lt;/p&gt;

&lt;p&gt;That made me think about scheduling differently.&lt;/p&gt;

&lt;p&gt;Instead of treating it as a calendar UI problem, I started treating it as a constraint-solving problem.&lt;/p&gt;

&lt;p&gt;Each shift becomes a slot that needs to be filled.&lt;br&gt;
Each employee has limits, availability, and workload history.&lt;br&gt;
Each rule adds pressure to the system.&lt;/p&gt;

&lt;p&gt;Some rules are hard constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A person cannot work two shifts at the same time.&lt;/li&gt;
&lt;li&gt;A shift cannot be left uncovered.&lt;/li&gt;
&lt;li&gt;An unavailable employee should not be assigned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other rules are softer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Try to balance total working hours.&lt;/li&gt;
&lt;li&gt;Try to distribute unpopular shifts.&lt;/li&gt;
&lt;li&gt;Try to avoid too many consecutive workdays.&lt;/li&gt;
&lt;li&gt;Try to keep the result understandable for a human manager.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part is easy to underestimate.&lt;/p&gt;

&lt;p&gt;A schedule can be mathematically valid and still feel wrong.&lt;/p&gt;

&lt;p&gt;For example, if one person gets all the night shifts, the algorithm might still satisfy coverage. But from a team perspective, the result is bad. So the goal is not just “generate something that works.” The goal is to generate something that feels reasonable.&lt;/p&gt;

&lt;p&gt;Another challenge was templates.&lt;/p&gt;

&lt;p&gt;Many industries already use known rotation patterns, like 12-hour shifts, 4-on/2-off, Pitman-style schedules, or 2-2-3 rotations. These patterns are useful because they give structure. But once you add employee availability or custom rules, the template becomes only the starting point.&lt;/p&gt;

&lt;p&gt;So the product became a mix of two ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start with common scheduling patterns.&lt;/li&gt;
&lt;li&gt;Adjust the actual result based on constraints and fairness rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’m still improving the logic, especially around edge cases where the input is technically possible but very tight.&lt;/p&gt;

&lt;p&gt;For example, if there are too few employees for too many required shifts, the system should not simply fail. It should explain what is making the schedule difficult.&lt;/p&gt;

&lt;p&gt;That is probably the most interesting part of the project so far: not just generating the schedule, but helping people understand why a schedule is hard to generate.&lt;/p&gt;

&lt;p&gt;I made the tool public here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shiftschedulemaker.net/" rel="noopener noreferrer"&gt;https://shiftschedulemaker.net/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is still evolving, but building it has made me appreciate how much hidden complexity exists behind something as ordinary as a weekly shift calendar.&lt;/p&gt;

&lt;p&gt;Curious if anyone here has worked on scheduling, constraint solving, workforce planning, or optimization problems.&lt;/p&gt;

&lt;p&gt;How would you approach fairness in a scheduling system?&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I built a World Cup 2026 bracket predictor for the new 48-team format</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Thu, 07 May 2026 13:14:25 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/i-built-a-world-cup-2026-bracket-predictor-for-the-new-48-team-format-1e11</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/i-built-a-world-cup-2026-bracket-predictor-for-the-new-48-team-format-1e11</guid>
      <description>&lt;h1&gt;
  
  
  I built a World Cup 2026 bracket predictor for the new 48-team format
&lt;/h1&gt;

&lt;p&gt;The 2026 FIFA World Cup format is surprisingly complicated.&lt;/p&gt;

&lt;p&gt;Instead of the old 32-team structure, the tournament now has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;48 teams&lt;/li&gt;
&lt;li&gt;12 groups&lt;/li&gt;
&lt;li&gt;a new Round of 32&lt;/li&gt;
&lt;li&gt;third-place qualification rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I realized that most existing bracket tools were still based on the old format and felt awkward once you tried modeling the new tournament structure.&lt;/p&gt;

&lt;p&gt;So I built this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bracket2026.com" rel="noopener noreferrer"&gt;https://bracket2026.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;predict all group-stage standings&lt;/li&gt;
&lt;li&gt;automatically generate the Round of 32&lt;/li&gt;
&lt;li&gt;continue predicting all knockout rounds&lt;/li&gt;
&lt;li&gt;share or export the completed bracket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One interesting challenge was handling the knockout path generation.&lt;/p&gt;

&lt;p&gt;With 12 groups and third-place advancement, the Round of 32 is no longer fixed in a straightforward way. The bracket depends on which third-place teams qualify, so the UI and logic both become more dynamic compared to previous World Cups.&lt;/p&gt;

&lt;p&gt;I also tried to keep the experience lightweight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no signup&lt;/li&gt;
&lt;li&gt;mobile friendly&lt;/li&gt;
&lt;li&gt;fast interactions&lt;/li&gt;
&lt;li&gt;easy sharing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was a fun side project to build, especially because sports tournament structures are basically a weird mix of UI design, state management, and combinatorics.&lt;/p&gt;

&lt;p&gt;Would love feedback from other developers or football fans — especially around the UX and whether the new format feels understandable.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>opensource</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
