<?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>Modeling the Expected Value of a Sealed Card Box (and Where the Number Quietly Lies)</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Mon, 06 Jul 2026 03:18:19 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/modeling-the-expected-value-of-a-sealed-card-box-and-where-the-number-quietly-lies-5kk</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/modeling-the-expected-value-of-a-sealed-card-box-and-where-the-number-quietly-lies-5kk</guid>
      <description>&lt;p&gt;A friend messaged me a photo of a sealed booster box last month with one question: "worth it?" He'd already decided, really. The chase card in that set was all over his feed, so the box &lt;em&gt;felt&lt;/em&gt; like a good deal. I asked him to send me the pull rates instead of the hype, and we spent twenty minutes turning "worth it?" into something we could actually compute.&lt;/p&gt;

&lt;p&gt;That exercise is a small, self-contained data problem. It's also a good example of how a clean-looking model can hand you a confident number that doesn't survive contact with reality. If you like building little estimators, this one is worth doing carefully, because the interesting part isn't the formula. It's everything the formula assumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula is the easy part
&lt;/h2&gt;

&lt;p&gt;Expected value of a box is a weighted sum. Each card you can pull has a probability and a market value, and you multiply the two across every slot the box gives you. That's it. Undergrad probability.&lt;/p&gt;

&lt;p&gt;Here's a stripped-down version for a hypothetical set. I'm using made-up numbers so nobody mistakes this for real pull data — the point is the shape of the computation, not the specific set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# One "hit slot" in a box: probabilities cover the full outcome space.
# Values are illustrative market estimates in USD.
&lt;/span&gt;&lt;span class="n"&gt;hit_table&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alt-art chase&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0125&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;180.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Secret rare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.030&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;45.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Full-art rare&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;8.00&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Standard hit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No notable hit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4575&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hit_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;1e-9&lt;/span&gt;

&lt;span class="n"&gt;ev_per_slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hit_table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;hit_slots_per_box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;        &lt;span class="c1"&gt;# e.g. one meaningful slot per pack
&lt;/span&gt;&lt;span class="n"&gt;ev_box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ev_per_slot&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;hit_slots_per_box&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EV per slot: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ev_per_slot&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# $4.65
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EV per box:  $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ev_box&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# $167.31
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The box costs $150 sealed, the model says $167, so you're "up." My friend was thrilled for about four seconds, which is roughly how long that number deserves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it starts lying
&lt;/h2&gt;

&lt;p&gt;The math is correct. The inputs are where it falls apart, and they fall apart in ways that all push the same direction: optimism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The distribution is heavy-tailed, so the mean is a bad summary.&lt;/strong&gt; That $167 leans hard on the 1.25% alt-art chase, which alone contributes $2.25 of the $4.65 per-slot figure. Drop that single row and EV per box falls to about $86 — far below the sticker price. Almost half of the "expected" value lives in an outcome that lands in roughly one box out of eighty per slot. The typical buyer does much worse than the mean buyer, and the word "expected" hides that completely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Probability a whole box shows zero copies of the chase card
&lt;/span&gt;&lt;span class="n"&gt;p_miss_chase&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="mf"&gt;0.0125&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;hit_slots_per_box&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Chance of no chase in a box: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p_miss_chase&lt;/span&gt;&lt;span class="si"&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 63.6%
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly two out of three boxes never see the card carrying most of the EV. A mean that ignores that is technically true and practically misleading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The values decay while you're holding them.&lt;/strong&gt; &lt;code&gt;value&lt;/code&gt; isn't a constant, it's a snapshot. Singles prices for a freshly released set slide as supply floods in, and the chase you priced at $180 on release week is a different number a month later. A model built on today's prices silently assumes you can liquidate instantly at today's prices. You can't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The probabilities are the shakiest input of all.&lt;/strong&gt; Very few sets publish official per-slot pull rates. What circulates is a mix of manufacturer statements, community-aggregated opening logs, and outright guesses, and they rarely agree on the long tail — which, per the point above, is exactly the part that moves your answer most. Pinning down a defensible probability for that 1.25% row is harder than the entire rest of the calculation.&lt;/p&gt;

&lt;p&gt;That last problem is the one I actually spend time on. When I need a starting distribution I'll pull from a &lt;a href="https://tcgodds.com" rel="noopener noreferrer"&gt;set-odds reference like TCGOdds&lt;/a&gt; to see what pull-rate and set-composition data is available for a release, then treat those figures as a documented estimate to sanity-check against rather than as ground truth. It doesn't remove the uncertainty in the tail — nothing does — but it beats a screenshot of one person's box break, and it gives you a consistent baseline to plug into the model above instead of a number you felt was about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn the point estimate into a range
&lt;/h2&gt;

&lt;p&gt;Because the inputs are uncertain, a single EV figure is the wrong output type. A quick Monte Carlo makes the spread visible, which is the honest version of the answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;simulate_box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;outcomes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trials&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit_slots_per_box&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;cum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hit_table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;cum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;cum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                    &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;outcomes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;outcomes&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;outcomes&lt;/span&gt;

&lt;span class="n"&gt;box_values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;simulate_box&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;box_values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p10 $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | p50 $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | p90 $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.90&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the answer isn't "$167." It's a shape: the mean sits near $167, but the median box lands in the low $80s, a bottom tenth comes in under $50, and the top tenth clears $270 on the back of a chase pull. Against a $150 box, that reframes the decision entirely. You're not buying an expected $167. You're buying a wide distribution where the good outcomes are rare and the typical one is underwater before fees, shipping, and the price decay you haven't modeled yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model is actually for
&lt;/h2&gt;

&lt;p&gt;None of this tells you whether to buy the box. Expected value isn't profit, and it was never meant to be a purchase recommendation. For something you buy partly for the fun of opening it, the entertainment is a real part of the return and it doesn't show up anywhere in these numbers. Pull rates and market values are estimates, and a model built on estimates gives you a planning aid, not a promise. Treat any figure it produces as "here's the shape of the outcomes," not "here's what you'll get."&lt;/p&gt;

&lt;p&gt;The value of writing the estimator is that it makes your assumptions explicit. You can see, in code, that your optimism was concentrated in one 1.25% row and one stale price. That's a better position than "it felt worth it," even when the conclusion is identical.&lt;/p&gt;

&lt;p&gt;My friend bought the box. He'd run the numbers, he knew the median outcome, and he wanted to open it anyway. Completely defensible. It's just a different decision than the one he was about to make twenty minutes earlier, and the difference was a dozen lines of Python and a distribution he could finally look at.&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>python</category>
      <category>statistics</category>
      <category>probability</category>
    </item>
    <item>
      <title>Forecasting from a Government PDF: What I Learned Modeling the Visa Bulletin</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Sun, 05 Jul 2026 02:36:29 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/forecasting-from-a-government-pdf-what-i-learned-modeling-the-visa-bulletin-4abo</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/forecasting-from-a-government-pdf-what-i-learned-modeling-the-visa-bulletin-4abo</guid>
      <description>&lt;p&gt;Every month the U.S. State Department publishes the Visa Bulletin. If you have never had to read one, picture a set of grids: rows for green-card categories like EB-2 and EB-3, columns for a handful of countries, and inside each cell either a date or the letter "C" for current. That date is the cutoff. If your priority date is earlier than the cutoff for your category and country, your case can move forward. If it isn't, you wait.&lt;/p&gt;

&lt;p&gt;It looks like structured data. It mostly isn't. And the moment you try to turn a year of these bulletins into anything a person can plan around, you run straight into the reason immigration timing feels so unknowable: the source is a monthly snapshot that was never designed to be a time series.&lt;/p&gt;

&lt;p&gt;I spent a while building tooling around exactly this problem, and the interesting engineering was almost never where I expected it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parsing is annoying before it is hard
&lt;/h2&gt;

&lt;p&gt;The bulletins are published as web pages and PDFs whose layout drifts over the years. Column headers get reworded. A country gets its own column one year and gets folded into "All Chargeability Areas Except Those Listed" another. Dates appear as &lt;code&gt;08JAN20&lt;/code&gt;, sometimes &lt;code&gt;01JANUARY2020&lt;/code&gt;, sometimes just &lt;code&gt;C&lt;/code&gt;, and occasionally &lt;code&gt;U&lt;/code&gt; for unavailable, which is a different thing from a date being blank.&lt;/p&gt;

&lt;p&gt;None of that is intellectually deep. It is the ordinary tax of pulling a dataset out of documents meant for human eyes. The trap is treating it as a one-time scrape. Because the format shifts, a parser that passed on last year's files can silently mangle this month's, and a silent mangle in a forecast is worse than a crash. So the real work is validation: range-check every parsed date, assert that categories that existed last month still exist, and flag any cell that moved in a direction the rules say is unusual, so a human can look before it feeds anything downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Movement is not a smooth signal
&lt;/h2&gt;

&lt;p&gt;Once you have a clean series of cutoff dates per category and country, the instinct is to fit a line and extrapolate. Resist it. The line does not move the way a naive model assumes.&lt;/p&gt;

&lt;p&gt;A category can jump several months in a single bulletin and then sit frozen for the next two. It can go backward — retrogression — when demand outpaces the annual supply. And the whole system has a seasonal heartbeat: the government fiscal year resets in October, visa numbers refill, and the rhythm of movement early in a fiscal year often looks nothing like the rhythm in August or September when annual limits are running dry.&lt;/p&gt;

&lt;p&gt;Here is a concrete version of the problem. Say a category advanced 47 days in the most recent bulletin. Extrapolated blindly, that pace implies a cutoff sweeping through more than a year of priority dates every twelve months. But if you look back three or four years, you might find the same category averaged closer to 18 days a month, with two separate stretches where it went backward. The single most recent number is the least reliable input you have, and it is exactly the one an anxious applicant fixates on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a point estimate is the wrong output
&lt;/h2&gt;

&lt;p&gt;This is the part that changed how I thought about the whole project. For a while I treated "predict the date this person becomes current" as the goal. That framing is quietly dishonest, because it hands back a single day with an implied precision the data cannot support.&lt;/p&gt;

&lt;p&gt;A system with retrogression, seasonal resets, and demand that shifts by country is not something you forecast to the day. You forecast a distribution. The useful output is a window with a confidence range attached: given how this category has moved over the past several years, someone at this priority date has historically reached the front in somewhere between roughly this and that, and here is how wide the uncertainty is. A range that admits it might be wrong is more honest than a date that hides it.&lt;/p&gt;

&lt;p&gt;There is a discipline that follows from this, and it is easy to skip. If you publish forecasts, you should check them against what actually happened and keep the score. When I later wrote about the finished product, the thing I most wanted to get right was that accountability loop, and it is why &lt;a href="https://greencardradar.com" rel="noopener noreferrer"&gt;the immigration forecast hub I ended up building keeps its confidence ranges and its own historical accuracy visible together&lt;/a&gt; rather than printing a clean date and quietly moving on. A model you cannot grade is not a model. It is a guess with good production values.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits, stated plainly
&lt;/h2&gt;

&lt;p&gt;I want to be direct about what this kind of tooling can and cannot do, because the domain punishes overpromising.&lt;/p&gt;

&lt;p&gt;A forecast built from public data is a reading of patterns, not a decision from USCIS or the State Department. It cannot see inside anyone's case, it has no access to private government data, and it cannot guarantee an outcome. Immigration timing genuinely changes with agency policy, visa availability, and demand by country and category, and any of those can shift without warning. A category that advanced steadily for a year can stall. Treat any number the model produces as a planning aid with a wide margin, never a countdown clock.&lt;/p&gt;

&lt;p&gt;And to be equally plain: none of this is legal advice, and it does not replace an immigration attorney. If a real decision hangs on timing — a job change, a lease, a filing choice — an estimate can inform the conversation, but the decision belongs with someone who knows the full case.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually transfers
&lt;/h2&gt;

&lt;p&gt;The specifics here are immigration, but the shape of the problem is not. Anytime you build a forecast on top of a public dataset that was published for reading rather than analysis, the same three things decide whether the result is trustworthy: how carefully you validate a source that changes shape over time, whether you model the seasonality instead of extrapolating the last data point, and whether you output honest uncertainty instead of false precision.&lt;/p&gt;

&lt;p&gt;The Visa Bulletin will keep arriving once a month, a grid of dates that looks more certain than it is. The engineering worth doing is not making it look even more certain. It is turning it into a range someone can plan around, and being willing to check, out loud, how often that range was right.&lt;/p&gt;

</description>
      <category>data</category>
      <category>datascience</category>
      <category>devjournal</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>The Minecraft anvil is a tree-cost optimization problem in disguise</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Thu, 02 Jul 2026 05:23:17 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-minecraft-anvil-is-a-tree-cost-optimization-problem-in-disguise-9lp</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-minecraft-anvil-is-a-tree-cost-optimization-problem-in-disguise-9lp</guid>
      <description>&lt;p&gt;I fell down this rabbit hole because of a small, annoying moment in a survival game.&lt;/p&gt;

&lt;p&gt;I had a diamond pickaxe I liked, four enchanted books I wanted on it, and an anvil. I combined them in whatever order felt natural — book, book, book, book — and somewhere around the third combine the anvil flashed &lt;em&gt;"Too Expensive!"&lt;/em&gt; and refused to continue. Same items, same tool, and yet the order I'd picked had walked me straight into a wall.&lt;/p&gt;

&lt;p&gt;If you've played Minecraft you've probably hit this. What I didn't expect was that untangling &lt;em&gt;why&lt;/em&gt; it happens turns out to be a genuinely nice little computer-science exercise. The anvil isn't a flat price list. It's a cost function over a binary tree, and the "right answer" is a small optimization problem. This post is about that structure — and why it's the kind of thing worth building a tool around rather than solving in your head each time.&lt;/p&gt;

&lt;p&gt;A caveat before I start: Minecraft is a trademark of Mojang/Microsoft, and I'm writing as a player, not on behalf of anyone. Exact anvil numbers also shift between game versions and editions, so treat the specific figures below as illustrative of the &lt;em&gt;shape&lt;/em&gt; of the problem rather than a spec to memorize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rules that quietly interact
&lt;/h2&gt;

&lt;p&gt;Most of the anvil's behavior comes from two rules that are simple on their own and awkward together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: enchantments have a cost.&lt;/strong&gt; When you merge an enchanted book into an item, each enchantment adds some number of levels to the operation, roughly scaled by how "expensive" that enchantment is. Fine. Predictable. You could tally it on paper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: there's a prior-work penalty.&lt;/strong&gt; Every time an item passes through an anvil, it carries a growing tax on the &lt;em&gt;next&lt;/em&gt; time it's used. In many versions that penalty roughly doubles with each use — think of it as &lt;code&gt;2^n - 1&lt;/code&gt; extra levels, where &lt;code&gt;n&lt;/code&gt; is how many times that item has already been anvil-ed. The first combine is cheap. The second is a bit more. By the fourth or fifth, the penalty alone can blow past the game's hard cap.&lt;/p&gt;

&lt;p&gt;Rule 1 says "enchantments cost something." Rule 2 says "&lt;em&gt;touching the item repeatedly&lt;/em&gt; costs something, independent of what you're adding." The trap is that the second rule punishes a strategy the first rule makes feel natural: dumping every book onto your main item one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why order is the whole game
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes it interesting. Combining a set of books isn't a straight line — it's a tree. Each anvil operation takes two inputs and produces one output, and that output can be an input to a later operation. So four books plus one tool can be assembled in several different tree shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linear ("caterpillar"):&lt;/strong&gt; tool + book, then + book, then + book, then + book. Your tool gets anvil-ed four times, so it accumulates the full stacked prior-work penalty. This is the shape that hits &lt;em&gt;"Too Expensive!"&lt;/em&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balanced ("bracket"):&lt;/strong&gt; combine book A + book B into one book, combine book C + book D into another, then merge those two results, then apply the result to the tool. No single item gets anvil-ed more than a couple of times, so the penalty never compounds as hard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same four books. Same final gear. Wildly different total level cost, purely because of the tree you chose. That's the tell that you're looking at an optimization problem and not an arithmetic one.&lt;/p&gt;

&lt;p&gt;If it feels familiar, it should: minimizing the total cost of pairwise merges under a penalty for reuse is a cousin of classic optimal-merge and Huffman-style problems. You're deciding how to pair things up so the expensive operations sit near the leaves and the cheap accumulation stays balanced. It doesn't reduce to a single greedy rule, because the enchantment costs and the doubling penalty pull in different directions — sometimes the greedy "cheapest pair first" move sets up a worse penalty downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I stopped doing this in my head
&lt;/h2&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; brute-force it. With a handful of books the number of distinct combine trees is small enough that a search over orderings is trivial for a computer. The problem is that it is exactly &lt;em&gt;not&lt;/em&gt; trivial for a human standing at an anvil with limited XP, because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The penalty is exponential, so mistakes are punished disproportionately — one bad early combine can make an otherwise-fine plan illegal.&lt;/li&gt;
&lt;li&gt;The costs aren't all visible at once; you find out you've overspent only when the game blocks you.&lt;/li&gt;
&lt;li&gt;XP is a real, non-refundable resource in survival. A wrong tree isn't just slower — it can strand a good item halfway to being finished.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point is why I'm careful to frame any calculator here as a &lt;em&gt;planning aid&lt;/em&gt;, not a guarantee. It estimates a good combine order under a model of the rules; the actual game is the source of truth, and version differences are real. What a tool buys you is the search: it can enumerate the tree shapes and hand you an order that stays under the cap, so you spend your levels on the plan instead of on discovering the plan.&lt;/p&gt;

&lt;p&gt;That's the modeling job behind a small fan-made site I've been poking at, &lt;a href="https://mcanvil.com/" rel="noopener noreferrer"&gt;a Minecraft anvil cost planner called MCAnvil&lt;/a&gt;, which takes the set of enchantments you want and works out a combine order rather than making you feel it out one expensive mistake at a time. It's independent and fan-made — the interesting part, for me, was less the UI and more that the "obvious" flat-cost model is wrong, and getting the tree cost and prior-work penalty right is what makes the output actually match what the game does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway for builders
&lt;/h2&gt;

&lt;p&gt;Two things stuck with me from this.&lt;/p&gt;

&lt;p&gt;First, it's a clean reminder that a lot of game "mechanics" are really cost functions, and the moment a cost function has a reuse penalty and a branching structure, you've quietly crossed from &lt;em&gt;look-up&lt;/em&gt; into &lt;em&gt;search&lt;/em&gt;. Recognizing that boundary is most of the work — once you see the tree, the algorithm to explore it is the easy part.&lt;/p&gt;

&lt;p&gt;Second, it's a good argument for encoding fiddly rules into a small tool instead of a wiki page. A table that says "enchantment X costs Y" is accurate and still won't stop you overspending, because the cost that hurts is emergent from &lt;em&gt;order&lt;/em&gt;. The value of a calculator here isn't that the arithmetic is hard; it's that the interaction between two simple rules produces a result humans are bad at eyeballing.&lt;/p&gt;

&lt;p&gt;If you play and you've ever eaten a &lt;em&gt;"Too Expensive!"&lt;/em&gt; on a favorite item, it's worth planning the tree before you commit the levels. And if you don't play but you like modeling systems, the anvil is a surprisingly tidy example of an ordinary-looking mechanic that turns out to have a real optimization problem hiding underneath.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>computerscience</category>
      <category>programming</category>
    </item>
    <item>
      <title>The hardest part of a pre-release game tracker isn't the UI, it's the provenance field</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Wed, 01 Jul 2026 09:45:09 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-hardest-part-of-a-pre-release-game-tracker-isnt-the-ui-its-the-provenance-field-341h</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-hardest-part-of-a-pre-release-game-tracker-isnt-the-ui-its-the-provenance-field-341h</guid>
      <description>&lt;p&gt;I started sketching a small reference page for a game that hasn't launched yet, and the first schema I wrote was embarrassingly naive.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"character"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Unit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"element"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Support"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks fine until you ask a simple question: where did this come from?&lt;/p&gt;

&lt;p&gt;For a game that's still in beta or pre-release, "where did this come from" is not a nitpick. It's the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to underestimate
&lt;/h2&gt;

&lt;p&gt;Most content models for a wiki-style tracker assume the data is settled. You pick a schema for a character or item, fill in the fields, done. That works for a game that already shipped, because the publisher's own patch notes are the ground truth.&lt;/p&gt;

&lt;p&gt;Pre-release communities don't have that luxury. Information arrives in layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Official trailers and dev posts (solid, but sparse).&lt;/li&gt;
&lt;li&gt;Datamined values pulled from client files (often accurate, sometimes from a build that never ships).&lt;/li&gt;
&lt;li&gt;Community translation or paraphrase of the above (frequently where small errors creep in).&lt;/li&gt;
&lt;li&gt;Pure speculation dressed up as a "leak" because it gets more clicks that way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your tracker stores all of that in the same flat table with the same visual weight, you've built a rumor amplifier, not a reference site. And once a wrong number gets copied from your page onto three other fan wikis, it's basically impossible to walk back.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete case: the stat that wasn't final
&lt;/h2&gt;

&lt;p&gt;A recurring pattern in creature-collector and gacha communities is a skill value or character kit that circulates from an early build, gets treated as fact, and then quietly changes before release. Nobody posts a correction thread with the same energy as the original "leak."&lt;/p&gt;

&lt;p&gt;If a tracker's schema doesn't have a place to record "this came from a datamine of build X, not an official announcement," that context disappears the moment the entry gets copy-pasted somewhere else. The reader has no way to tell a confirmed detail from a snapshot of unfinished work.&lt;/p&gt;

&lt;p&gt;So the fix isn't a UI problem. It's a data modeling problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Unit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Support"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"unconfirmed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"datamine"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_checked"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pre-release build"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;source_type&lt;/code&gt; exist as first-class fields instead of afterthoughts, the rendering layer can do something useful with them: gray out unconfirmed rows, add a small label, sort official info above speculation instead of interleaving it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually gets built
&lt;/h2&gt;

&lt;p&gt;I've been using this as the underlying model for &lt;a href="https://chromaticanima.com" rel="noopener noreferrer"&gt;a small Honkai: Nexus Anima reference tracker&lt;/a&gt;, which exists mainly to organize public, official information about the game ahead of release. It's an independent fan project, not an official HoYoverse resource, and it isn't trying to be a leak aggregator or a prediction engine.&lt;/p&gt;

&lt;p&gt;That narrow scope is intentional. The whole point is to give people a place to see what's actually been confirmed, separated from what's still floating around as speculation, without pretending the site has some special pipeline into unreleased information it doesn't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits are part of the design
&lt;/h2&gt;

&lt;p&gt;A tracker like this is only as good as its restraint. It won't tell you what a character's kit will be before it's announced. It won't resolve a debate between two conflicting datamines. Pre-release game data changes, sometimes substantially, right up until launch, and any static page claiming otherwise is quietly lying to its readers.&lt;/p&gt;

&lt;p&gt;Practically, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Entries need a visible confirmed/unconfirmed distinction, not just a mental one the maintainer keeps in their head.&lt;/li&gt;
&lt;li&gt;"Last updated" matters more than usual, since the ground truth itself is moving.&lt;/li&gt;
&lt;li&gt;The maintenance cadence can be light early on and only needs to tighten up as the actual release window gets close.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway for anyone building something similar
&lt;/h2&gt;

&lt;p&gt;If you're building any kind of tracker for something that hasn't shipped yet — a game, a product roadmap, a spec still in draft — the interesting engineering problem isn't the list view or the search box. It's whether your schema can honestly represent "I don't fully know this yet" instead of forcing every field into a false certainty.&lt;/p&gt;

&lt;p&gt;That one field is the difference between a reference site and a rumor mill with better formatting.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>data</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The data model behind an employee cost calculator is messier than the UI</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Tue, 30 Jun 2026 02:01:58 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-data-model-behind-an-employee-cost-calculator-is-messier-than-the-ui-41ge</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-data-model-behind-an-employee-cost-calculator-is-messier-than-the-ui-41ge</guid>
      <description>&lt;p&gt;I thought the simplest part of an employee cost calculator would be the salary input.&lt;/p&gt;

&lt;p&gt;In the UI, it looks harmless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Salary: 85000
State: California
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the calculator should explain what that employee roughly costs the employer.&lt;/p&gt;

&lt;p&gt;The hard part is that the salary is not the model. It is just the starting value.&lt;/p&gt;

&lt;p&gt;The actual model is a stack of assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;employer payroll taxes&lt;/li&gt;
&lt;li&gt;wage bases&lt;/li&gt;
&lt;li&gt;state unemployment assumptions&lt;/li&gt;
&lt;li&gt;workers' compensation assumptions&lt;/li&gt;
&lt;li&gt;benefit assumptions&lt;/li&gt;
&lt;li&gt;retirement or other optional contributions&lt;/li&gt;
&lt;li&gt;pay period conversions&lt;/li&gt;
&lt;li&gt;rounding and display rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson I took from building around this problem is that a calculator like this should not pretend to be more certain than it is.&lt;/p&gt;

&lt;p&gt;It needs to be useful without acting like a payroll system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first product decision: show the assumptions
&lt;/h2&gt;

&lt;p&gt;For a lot of finance tools, the temptation is to return one clean number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Estimated employer cost: $X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is tidy, but it is not very helpful if the user cannot see why the number moved.&lt;/p&gt;

&lt;p&gt;For employer cost planning, the breakdown matters as much as the total. If the total changes because of an employer tax assumption, that is different from the total changing because someone added a benefits estimate.&lt;/p&gt;

&lt;p&gt;So the better output shape is closer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"salary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;85000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"payrollTaxes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stateUnemployment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"benefits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"insurance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estimatedTotalCost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact field names are less important than the principle: do not hide the cost stack.&lt;/p&gt;

&lt;p&gt;When a small business owner, founder, or ops person is budgeting a role, they need to know which parts are assumptions and which parts are direct salary math.&lt;/p&gt;

&lt;h2&gt;
  
  
  State-specific logic changes the shape of the code
&lt;/h2&gt;

&lt;p&gt;A naive version of the calculator can use one flat multiplier.&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;const&lt;/span&gt; &lt;span class="nx"&gt;estimatedCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;salary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That can be fine for a quick mental estimate, but it has a major flaw: it hides state-level differences.&lt;/p&gt;

&lt;p&gt;Two employees with the same salary may not create the same employer-side cost profile if they are in different states. Wage bases, employer-side programs, and unemployment assumptions can differ. Some costs are capped. Some are not. Some are optional assumptions rather than fixed rules.&lt;/p&gt;

&lt;p&gt;That pushed the implementation toward a more explicit model:&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;const&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateEmployerCost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;salary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;payFrequency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;assumptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;benefits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;insurance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;retirementMatch&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 interesting part is not the function call. It is the contract around it.&lt;/p&gt;

&lt;p&gt;The calculator has to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is required input?&lt;/li&gt;
&lt;li&gt;What is an optional assumption?&lt;/li&gt;
&lt;li&gt;What is state-specific?&lt;/li&gt;
&lt;li&gt;What should be itemized?&lt;/li&gt;
&lt;li&gt;What should be labeled as an estimate?&lt;/li&gt;
&lt;li&gt;What should not be claimed at all?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions matter more than the UI polish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Estimated does not mean sloppy
&lt;/h2&gt;

&lt;p&gt;One mistake I try to avoid in planning tools is using "estimate" as an excuse for vague output.&lt;/p&gt;

&lt;p&gt;An estimate can still be structured.&lt;/p&gt;

&lt;p&gt;An estimate can still show its components.&lt;/p&gt;

&lt;p&gt;An estimate can still make it clear that the user should not treat the result as payroll, tax, legal, or insurance advice.&lt;/p&gt;

&lt;p&gt;That distinction is important. A tool like &lt;a href="https://employercosts.com/" rel="noopener noreferrer"&gt;EmployerCosts&lt;/a&gt; is useful as a planning calculator because it makes the hidden employer cost stack visible. It should not pretend to replace a payroll provider, accountant, benefits broker, or employment advisor.&lt;/p&gt;

&lt;p&gt;For me, that means the product language has to be part of the implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"estimated" instead of "guaranteed"&lt;/li&gt;
&lt;li&gt;"planning" instead of "compliance"&lt;/li&gt;
&lt;li&gt;"assumptions" instead of "facts" when a value depends on the employer&lt;/li&gt;
&lt;li&gt;itemized output instead of a single unexplained number&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not just legal caution. It is better UX.&lt;/p&gt;

&lt;p&gt;Users trust a calculator more when it is honest about what it knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge case that keeps showing up
&lt;/h2&gt;

&lt;p&gt;The recurring edge case is not a strange salary number.&lt;/p&gt;

&lt;p&gt;It is a reasonable user expectation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I know the salary. Why can't the calculator tell me the exact cost?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because the salary is only one input.&lt;/p&gt;

&lt;p&gt;A real employer cost can depend on payroll setup, state rules, local rules, benefits, insurance classifications, workers' compensation details, company history, and other settings that a lightweight web calculator may not know.&lt;/p&gt;

&lt;p&gt;So the tool has to balance two jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make the first estimate fast enough to be useful.&lt;/li&gt;
&lt;li&gt;Avoid implying a level of precision it does not have.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That balance affects copy, field labels, component names, disclaimers, and result layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful abstraction
&lt;/h2&gt;

&lt;p&gt;The abstraction that helped me think about the product is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;salary + employer-side burden assumptions = planning estimate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;salary = employee cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;calculator result = payroll truth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds obvious, but it changes the product.&lt;/p&gt;

&lt;p&gt;It pushes the tool toward transparency. It makes the result page more educational. It prevents the calculator from turning into a black box with a dollar sign at the end.&lt;/p&gt;

&lt;p&gt;The more I work on small planning calculators, the more I think the hard part is rarely the arithmetic alone.&lt;/p&gt;

&lt;p&gt;The hard part is deciding what the calculator should be honest about.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>data</category>
      <category>softwaredevelopment</category>
      <category>ui</category>
    </item>
    <item>
      <title>The Unicode trap I had to design around in a LinkedIn formatter</title>
      <dc:creator>Mark</dc:creator>
      <pubDate>Mon, 29 Jun 2026 04:14:47 +0000</pubDate>
      <link>https://dev.to/mark_b5f4ffdd8e7cd58/the-unicode-trap-i-had-to-design-around-in-a-linkedin-formatter-2fn4</link>
      <guid>https://dev.to/mark_b5f4ffdd8e7cd58/the-unicode-trap-i-had-to-design-around-in-a-linkedin-formatter-2fn4</guid>
      <description>&lt;p&gt;I built a small tool for formatting LinkedIn posts, and the deceptively simple part was also the most important part:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;styled text is not formatting. It is Unicode substitution.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sounds obvious once you say it out loud, but it changes the product decision.&lt;/p&gt;

&lt;p&gt;When someone clicks &lt;strong&gt;bold&lt;/strong&gt; in a normal editor, the text usually remains the same text with a style applied around it. When someone uses a LinkedIn-style formatter, the tool often converts normal characters into mathematical Unicode characters that only look bold or italic.&lt;/p&gt;

&lt;p&gt;For example, a plain &lt;code&gt;A&lt;/code&gt; may become a styled Unicode character that visually resembles a bold &lt;code&gt;A&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works because LinkedIn accepts Unicode text in posts. It is also why these formatters are useful. You can create text that appears bold, italic, monospace, or decorative, then paste it into a post without needing LinkedIn to support rich text.&lt;/p&gt;

&lt;p&gt;But it comes with tradeoffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tempting bad version
&lt;/h2&gt;

&lt;p&gt;The easy version of a formatter is just a character map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A -&amp;gt; 𝐀
B -&amp;gt; 𝐁
C -&amp;gt; 𝐂
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you loop through the input string and swap each supported character.&lt;/p&gt;

&lt;p&gt;That gets you a working demo quickly. It also creates a product that quietly encourages people to overuse styled text everywhere.&lt;/p&gt;

&lt;p&gt;The first version of the product question was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many styles can this tool support?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The better question became:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do we help people use styles without making the final post worse?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a different problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unicode styled text is less harmless than it looks
&lt;/h2&gt;

&lt;p&gt;Styled Unicode can be useful in small doses. It can make a heading easier to spot or make a short list easier to scan.&lt;/p&gt;

&lt;p&gt;It can also create issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Screen readers may read styled characters poorly or inconsistently.&lt;/li&gt;
&lt;li&gt;Some search and parsing systems may not treat styled characters like normal text.&lt;/li&gt;
&lt;li&gt;Copying styled text into resumes, applications, or ATS-sensitive fields can be a bad idea.&lt;/li&gt;
&lt;li&gt;Over-styled posts can look noisy and less professional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That meant the formatter needed to avoid pretending this was normal rich-text formatting.&lt;/p&gt;

&lt;p&gt;The product copy had to be honest: use styled text sparingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UI choice: make restraint the default
&lt;/h2&gt;

&lt;p&gt;For &lt;a href="https://linkedinformatter.net/" rel="noopener noreferrer"&gt;LinkedIn Formatter&lt;/a&gt;, I wanted the tool to stay quick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste plain text.&lt;/li&gt;
&lt;li&gt;Choose a style.&lt;/li&gt;
&lt;li&gt;Preview the result.&lt;/li&gt;
&lt;li&gt;Copy it back into LinkedIn.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But the UI also needed to avoid making every decorative option feel equally recommended.&lt;/p&gt;

&lt;p&gt;So the strongest product pattern was not a giant wall of fonts. It was a workflow that makes common professional formatting easy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bold for short emphasis&lt;/li&gt;
&lt;li&gt;italic for light contrast&lt;/li&gt;
&lt;li&gt;bullets and line breaks for readability&lt;/li&gt;
&lt;li&gt;preview before copy&lt;/li&gt;
&lt;li&gt;no-login usage for quick one-off edits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Decorative styles can exist, but they should not be the main recommendation for a professional post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accessibility note is part of the product
&lt;/h2&gt;

&lt;p&gt;One of the best improvements was simply adding caveats directly into the content and product positioning.&lt;/p&gt;

&lt;p&gt;Not as a legal footnote. As product guidance.&lt;/p&gt;

&lt;p&gt;If a tool changes text into Unicode lookalikes, users should understand when that is fine and when it is risky.&lt;/p&gt;

&lt;p&gt;That is especially true for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;profile names&lt;/li&gt;
&lt;li&gt;resumes&lt;/li&gt;
&lt;li&gt;job applications&lt;/li&gt;
&lt;li&gt;heavily formatted posts&lt;/li&gt;
&lt;li&gt;content where screen reader clarity matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formatter is still useful. The honest framing makes it more useful, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would build differently next time
&lt;/h2&gt;

&lt;p&gt;If I were starting the tool from scratch again, I would treat the Unicode caveat as a first-class feature from day one.&lt;/p&gt;

&lt;p&gt;I would also test each style against three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does this style help a real post become easier to scan?&lt;/li&gt;
&lt;li&gt;Does it create avoidable accessibility or parsing problems?&lt;/li&gt;
&lt;li&gt;Is the UI encouraging people to use it in the right amount?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third question matters more than it first appears.&lt;/p&gt;

&lt;p&gt;Small tools shape user behavior. If a formatter makes loud styles feel like the main event, people will use loud styles. If it makes clean structure feel like the main event, people are more likely to create readable posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;The technical implementation was character mapping.&lt;/p&gt;

&lt;p&gt;The product work was deciding what not to encourage.&lt;/p&gt;

&lt;p&gt;That is the part I underestimated.&lt;/p&gt;

&lt;p&gt;A LinkedIn formatter is not just a Unicode conversion tool. It is also a tiny writing interface, and tiny writing interfaces still need product judgment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>unicode</category>
      <category>product</category>
    </item>
    <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>
  </channel>
</rss>
