<?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: Tea-sip</title>
    <description>The latest articles on DEV Community by Tea-sip (@yinyingring).</description>
    <link>https://dev.to/yinyingring</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%2F4046204%2F99ddba08-256f-4204-96a3-1c0b5d5a5ee2.jpg</url>
      <title>DEV Community: Tea-sip</title>
      <link>https://dev.to/yinyingring</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yinyingring"/>
    <language>en</language>
    <item>
      <title>The Dinosaur Game's Speed Curve, Decoded: A Reverse-Engineer's Field Notes</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Thu, 03 Sep 2026 19:02:44 +0000</pubDate>
      <link>https://dev.to/lizely/the-dinosaur-games-speed-curve-decoded-a-reverse-engineers-field-notes-1l77</link>
      <guid>https://dev.to/lizely/the-dinosaur-games-speed-curve-decoded-a-reverse-engineers-field-notes-1l77</guid>
      <description>&lt;p&gt;If you've ever tried to push a run past 999 in Chrome's offline runner, you've probably wondered &lt;em&gt;why&lt;/em&gt; the dinosaur suddenly feels unfair around the 700 mark. The truth is buried inside a tidy little function: the game ramps &lt;code&gt;setTimeout&lt;/code&gt; down from 10 ms toward an asymptote of about 1.5 ms as your score climbs. Once you understand the formula, you can predict jumps, design training drills, and stop blaming your reflexes for a problem the timing actually causes.&lt;/p&gt;

&lt;p&gt;This article is the systems-level write-up I wish I'd had when I started logging runs. If you want a more player-facing walkthrough of the basic controls and lives mechanic, Lizely's &lt;a href="https://www.lizecheng.net/games/guides/dinosaur-game-rules-how-to-play-the-chrome-t-rex-runner/" rel="noopener noreferrer"&gt;Dinosaur Game rules guide&lt;/a&gt; is the cleanest primer I've found. Everything below assumes you've already read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Speed Function Actually Is
&lt;/h2&gt;

&lt;p&gt;Open DevTools, filter for &lt;code&gt;setTimeout&lt;/code&gt;, and you'll spot a recurring line: &lt;code&gt;setTimeout(this.onFrame, X)&lt;/code&gt; where &lt;code&gt;X&lt;/code&gt; shrinks as the score grows. Chrome's source (mirrored on the &lt;a href="https://chromium.googlesource.com/" rel="noopener noreferrer"&gt;Chromium Open Source mirror&lt;/a&gt;) confirms the closed form used in the public T-Rex runner: an exponential ramp from a starting delay toward a floor. The exact constants have shifted across releases, but the shape is the same. A modern reproduction of that curve looks like this:&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;minDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ms, lower bound&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// ms, starting delay&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;delayFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Per-level linear interpolation baked into the curve.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;level&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&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="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="nx"&gt;minDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxDelay&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&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 key insight is that the curve is &lt;em&gt;stair-stepped&lt;/em&gt;, not smooth. Every 100 points of score buys you exactly 0.5 ms of shorter delay — until you hit the floor at roughly score 1,700, after which the world stops accelerating and the only thing that changes is obstacle spacing. That floor is the single most important number in the game.&lt;/p&gt;

&lt;p&gt;If you want a vocabulary for what's happening under the hood, this is a textbook case of a &lt;strong&gt;frame-time budget&lt;/strong&gt; collapsing against a fixed game loop. MDN's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame" rel="noopener noreferrer"&gt;requestAnimationFrame&lt;/a&gt; documentation describes the same budgeting problem in reverse: the browser tells you how long &lt;em&gt;between&lt;/em&gt; frames, and your code must fit inside. Here the game flips that — it sets the budget itself, using &lt;code&gt;setTimeout&lt;/code&gt;, and the world speeds up as the budget shrinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Curve Matters More Than Reflex
&lt;/h2&gt;

&lt;p&gt;Most players attribute plateaus to hand-eye lag. Some of that is real, but the dominant cause is &lt;strong&gt;time horizon compression&lt;/strong&gt;. At 100 ms per frame, you have roughly half a second to read an obstacle and decide to jump. At 4 ms per frame, you have about 20 ms — less than a single monitor refresh. The game is structurally asking you to &lt;em&gt;guess&lt;/em&gt; rather than react once the curve gets steep.&lt;/p&gt;

&lt;p&gt;This is also why the same reflexes that dominate early phases fail mid-run: a successful pattern-matching decision that took 200 ms to form is now arriving &lt;em&gt;after&lt;/em&gt; the obstacle. A useful mental model is to treat each score decade as a different game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0–400:&lt;/strong&gt; reaction time rules. One obstacle at a time, comfortable cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;400–700:&lt;/strong&gt; anticipation enters. Pterodactyls begin appearing; the arc demands you look up &lt;em&gt;and&lt;/em&gt; forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;700–1200:&lt;/strong&gt; pattern memory dominates. You stop seeing obstacles and start seeing distributions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1200+:&lt;/strong&gt; the world is effectively random from the eye's perspective. Survival is a function of &lt;em&gt;which random seed the run started with&lt;/em&gt; and your consistency on cluster shapes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is worth emphasizing. The runner uses a Mulberry32-style PRNG seeded at game start. Wikipedia's &lt;a href="https://en.wikipedia.org/wiki/Pseudorandom_number_generator" rel="noopener noreferrer"&gt;Pseudorandom number generator&lt;/a&gt; entry is a good layperson's description; the practical consequence is that two runs of equal skill can diverge by hundreds of points purely because the seed produced a hostile early sequence. Skill still matters — but variance is real, and you'll occasionally hit a seed where even 30–40 attempts produce no 800.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reproduction You Can Run Locally
&lt;/h2&gt;

&lt;p&gt;Before drilling on the live version, I recommend building a stripped-down clone. It's the fastest way to &lt;em&gt;see&lt;/em&gt; the curve rather than guess at it. A minimal loop that mirrors the production behavior in roughly 50 lines:&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;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&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;score&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="nx"&gt;alive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;step&lt;/span&gt;&lt;span class="p"&gt;()&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;alive&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;score&lt;/span&gt;&lt;span class="o"&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;delay&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="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;score&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="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Spawn / move / collide logic here.&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearRect&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&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="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hooking a &lt;code&gt;console.log&lt;/code&gt; inside &lt;code&gt;step&lt;/code&gt; makes the delay visible at every score change. Within five minutes of watching the numbers, the "why does 700 feel different from 600" question answers itself: the delay crossed from 6 ms to 5.5 ms, a 9% drop in a single score bracket, and your eye loses 9% of its planning window with it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement" rel="noopener noreferrer"&gt;HTMLCanvasElement&lt;/a&gt; reference is a good anchor if you're translating this into a workbench tool. The bigger point is that you can now &lt;strong&gt;freeze&lt;/strong&gt; the speed at any value, replay specific seeds, and isolate your own weakness instead of fighting both the curve and the obstacle generator at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drill Design Based on the Curve, Not on Vibes
&lt;/h2&gt;

&lt;p&gt;Once you accept that the curve is stair-stepped every 100 points, a smarter training plan falls out almost mechanically. Instead of grinding full runs and hoping for a high score, pin your practice to score brackets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bracket 1 (0–300):&lt;/strong&gt; train a sub-100 ms tap latency. The goal isn't high scores, it's establishing the cadence your fingers will carry into later brackets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bracket 2 (300–500):&lt;/strong&gt; introduce pterodactyl reads. Spawn one every ~50 obstacles at this speed; learn the low/mid/high arc distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bracket 3 (500–800):&lt;/strong&gt; chain obstacles. The crossover where two cacti can appear in overlapping x-positions is what actually kills most runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bracket 4 (800+):&lt;/strong&gt; run a fixed-seed replay if your clone has a deterministic mode. The point is to &lt;em&gt;see&lt;/em&gt; the same unplayable sequence twice and confirm it's the seed, not you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what this routine does &lt;em&gt;not&lt;/em&gt; do: it doesn't ask you to "just play more." That's a sample-size argument that ignores which variable you're actually trying to learn. A practice loop that pins delay and seed gives you a per-second learning rate that's roughly 5–8× higher than open-ended play, in my own logbook at least.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Obstacle Set, Not Just the Obstacle
&lt;/h2&gt;

&lt;p&gt;A subtle but real advantage: the obstacle &lt;em&gt;generator&lt;/em&gt; doesn't change with the speed curve, only the spacing and density. Once you internalize the four canonical silhouettes — single low cactus, single tall cactus, double low cactus, pterodactyl — pattern recognition outperforms reflex at almost every bracket. This is the same lesson from the Wikipedia article on &lt;a href="https://en.wikipedia.org/wiki/Tetris" rel="noopener noreferrer"&gt;Tetris&lt;/a&gt;: once the pieces are enumerable, the game is a recognition problem, not a reflex problem. Same family of insight, different game.&lt;/p&gt;

&lt;p&gt;Two heuristics I now use every run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Look at gaps, not obstacles.&lt;/strong&gt; The brain is faster at identifying &lt;em&gt;empty&lt;/em&gt; regions of the canvas than &lt;em&gt;occupied&lt;/em&gt; ones. A gap of more than ~40% of screen width almost always means a safe run-up to a jump; a narrow gap is the danger pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default to duck, not jump.&lt;/strong&gt; Pterodactyls become statistically more common above 500. The energy cost of a missed duck is the same as a missed jump, but the success rate is higher because the duck key is the same key you press to start the game — your thumb already knows it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Compact Pre-Run Checklist
&lt;/h2&gt;

&lt;p&gt;Use this before each attempt — not because any single item is decisive, but because they collectively remove the variables that aren't the game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Window focused, no notifications, no incoming message sounds.&lt;/li&gt;
&lt;li&gt;Keyboard layout locked (a stray alt-tab right-shift can break space-as-jump on some keyboards).&lt;/li&gt;
&lt;li&gt;Screen refresh rate confirmed at 60 Hz minimum; a 30 Hz display effectively halves your reaction window.&lt;/li&gt;
&lt;li&gt;One short warm-up run to confirm cadence before counting the run toward your PR.&lt;/li&gt;
&lt;li&gt;Seed noted if your clone records it; otherwise accept variance and run in batches of 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does the game feel completely different at 700 even though the speed only changes by half a millisecond?
&lt;/h3&gt;

&lt;p&gt;Because the change is &lt;strong&gt;relative&lt;/strong&gt;, not absolute. Going from 10 ms to 5.5 ms is a 45% reduction in per-frame budget; going from 6 ms to 5.5 ms is 8%. Your visual system responds logarithmically to timing changes, so a small absolute delta near the floor feels much larger than the same delta near the start. This is also why 850 often feels harder than 950 — the curve has flattened and the difficulty is now coming from obstacle density, not frame rate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a known maximum score, or is it theoretically infinite?
&lt;/h3&gt;

&lt;p&gt;There's no hard cap in the public build, but the curve hits its floor around 1,700. After that, the only remaining source of difficulty is the obstacle generator's PRNG, which will eventually emit a sequence that no human can survive in real time. Top recorded scores cluster in the 20,000–30,000 range and depend heavily on exploiting slightly-off timings on specific browser versions. Treat any "world record" claim as browser-version-specific.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the curve change between Chrome versions?
&lt;/h3&gt;

&lt;p&gt;Yes — the constants have shifted at least twice in the past five years, and some forks of Chromium have removed the floor entirely. If you're chasing a personal best, pin your browser version in your notes. Two runs at "score 1500" on different builds may have used meaningfully different timing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I practice the curve without internet at all?
&lt;/h3&gt;

&lt;p&gt;That's the whole point of the local clone in the section above. Stripped of network and Chrome's renderer, you can train against the &lt;em&gt;function&lt;/em&gt; directly. The internet-dependent build is for the real run; the local one is for the science.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>games</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Building a Repeatable Audio-Extraction Workflow for Engineering Teams</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Wed, 02 Sep 2026 19:08:43 +0000</pubDate>
      <link>https://dev.to/lizely/building-a-repeatable-audio-extraction-workflow-for-engineering-teams-4959</link>
      <guid>https://dev.to/lizely/building-a-repeatable-audio-extraction-workflow-for-engineering-teams-4959</guid>
      <description>&lt;p&gt;Every team that ships recorded material — talk recordings, conference talks, product walkthroughs, all-hands updates — eventually hits the same operational question: where do the audio assets actually live, and who is allowed to touch them? Pulling audio out of a video file is the easy part. Getting that extraction to fit cleanly inside team processes, code review, automation, and compliance is the real work.&lt;/p&gt;

&lt;p&gt;This article walks through the production constraints an engineering team hits when audio extraction becomes routine rather than one-off. It is a companion to the cleanup-focused piece on this site; the angle here is the pipeline that runs underneath the extraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Save the Video File" Is Not a Workflow
&lt;/h2&gt;

&lt;p&gt;A common starting point is naive: someone records a talk, emails the MP4 to the doc owner, the doc owner pulls a track out with a desktop tool, and the resulting WAV is renamed &lt;code&gt;FINAL_v3.wav&lt;/code&gt; and attached to a ticket. Six months later, nobody can reproduce the output and nobody knows which of the eleven &lt;code&gt;FINAL_v3&lt;/code&gt; files is canonical.&lt;/p&gt;

&lt;p&gt;The fix is not a better tool. The fix is treating audio like any other build artifact: inputs in, parameters in, output hash out, provenance stored. Three concrete habits make this work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single canonical input directory per recording session, named with a sortable convention (&lt;code&gt;2025-01-14_platform-roadmap_rec.mkv&lt;/code&gt;), checked into the same storage tier as source code.&lt;/li&gt;
&lt;li&gt;A single extraction job, version-controlled, with its parameters captured alongside the output. If the parameters change, the output is regenerated and the hash is updated, not silently replaced.&lt;/li&gt;
&lt;li&gt;A manifest file per deliverable that records source filename, extraction command or tool, timestamp, and output SHA-256.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once that exists, every later question — "why does this clip sound different from last week's", "can we rebuild for the mobile team", "is this GDPR-clean" — has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Container, Codec, and Channel Decisions You Actually Make
&lt;/h2&gt;

&lt;p&gt;Before any tool runs, an engineer has to decide what to extract and in what form. Three properties of the source video drive that decision.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;container versus codec&lt;/strong&gt;. An MKV or MP4 is a wrapper, not a format. Audio inside can be AAC, Opus, MP3, PCM, or something proprietary. Tools that "convert to MP3" by default do a transcode — they decode the audio, resample or downmix it, and re-encode. That destroys bit-perfect accuracy even before any human-edited changes happen. When the deliverable is a transcript, a sample-accurate measurement, or a downstream signal-processing job, transcoding is a bug.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Containers" rel="noopener noreferrer"&gt;MDN Web Docs guide to media container formats&lt;/a&gt; is a stable reference for which containers carry which streams, and the &lt;a href="https://en.wikipedia.org/wiki/Audio_file_format" rel="noopener noreferrer"&gt;Wikipedia article on audio file formats&lt;/a&gt; covers the codec side. Both are worth bookmarking on the team's runbook page.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;channel layout&lt;/strong&gt;. A surprising number of "monologue" recordings are actually stereo or even multichannel, with the second channel carrying room tone, audience laughter, or a click track. Extracting "all the audio" gives the engineer an output where half the bytes are unusable. Most libraries expose this as a command-line flag (for example, &lt;code&gt;ffmpeg -map 0:a:0&lt;/code&gt; selects the first audio stream only, and &lt;code&gt;-ac 1&lt;/code&gt; forces mono downmix); the team's wrapper should make the choice explicit rather than implicit.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;sample rate and bit depth&lt;/strong&gt;. Speech intelligibility plateaus around 16 kHz. Music plateaus much higher. Picking 48 kHz / 24-bit because the source is 48 kHz / 24-bit wastes storage and CPU on every later stage; picking 16 kHz / 16-bit for a music podcast is a quality loss the team will hear. Encode intent belongs in the manifest, not in the tool's default.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Pre-Extraction Checklist an Engineer Can Run
&lt;/h2&gt;

&lt;p&gt;Before running any extraction, run this list. It catches roughly 80 percent of the bugs that show up later as "why does the output sound wrong":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the input file's integrity (&lt;code&gt;ffprobe&lt;/code&gt; plus a checksum, not just a filename).&lt;/li&gt;
&lt;li&gt;Identify the audio stream(s) and their properties — codec, sample rate, channel count, language tag if present.&lt;/li&gt;
&lt;li&gt;Decide between &lt;strong&gt;stream copy&lt;/strong&gt; (&lt;code&gt;-c copy&lt;/code&gt;, no re-encode) and &lt;strong&gt;transcode&lt;/strong&gt;. Default to stream copy unless there is a documented reason.&lt;/li&gt;
&lt;li&gt;Decide on channel layout: keep original, fold to mono, or extract a specific channel.&lt;/li&gt;
&lt;li&gt;Decide on output container and codec. WAV or FLAC for archival; Opus or AAC for delivery.&lt;/li&gt;
&lt;li&gt;Record the output filename, the full command line used, and the SHA-256 of the output, in the manifest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stream copying is the unsung hero of this list. When the source is already AAC inside an MP4, copying the audio stream into a &lt;code&gt;.m4a&lt;/code&gt; or &lt;code&gt;.aac&lt;/code&gt; file takes milliseconds and is mathematically lossless for that transport. Only reach for a transcode when the target format demands it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Server-Side Processing Beats a Desktop Tool
&lt;/h2&gt;

&lt;p&gt;Most one-off extractions are fine in a browser, but an engineering team runs into three situations where browser-based or local tools stop scaling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume.&lt;/strong&gt; A team that processes every recorded meeting — say, 40 to 60 sessions a week — does not want an analyst clicking through a UI per file. A scripted, server-side job wins: the same parameters every time, the same output format, the same hash recorded. This is also where the manifest habit pays off, because every output now traces back to identical inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensitive content.&lt;/strong&gt; Customer interviews, internal all-hands, or anything covered by data-handling policy often cannot leave a controlled environment. Uploading to a third-party server is a non-starter even when the tool promises privacy. The team needs an extraction pipeline that runs on infrastructure the team owns, with no outbound traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integration with downstream tooling.&lt;/strong&gt; Transcript engines, search indexers, and machine-learning pipelines expect specific sample rates and channel layouts. A team-level pipeline can produce audio in the exact shape those tools want, rather than the shape an editor guessed at. If the spec is "16 kHz mono WAV, 50 to 3,500 Hz filtered, with speaker diarization pre-applied," a script makes that real; a UI makes it a hope.&lt;/p&gt;

&lt;p&gt;For the common case — a single recording, a browser, no sensitive content — the &lt;a href="https://www.lizecheng.net/video/guides/extract-audio-from-any-video-without-uploading/" rel="noopener noreferrer"&gt;in-depth guide to extracting audio without uploading&lt;/a&gt; covers the practical steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Production Bugs and How to Spot Them
&lt;/h2&gt;

&lt;p&gt;Three classes of bug show up repeatedly in audio pipelines. Each has a one-line check that catches it early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent channel swaps.&lt;/strong&gt; The extraction succeeded, the file plays, but it is the wrong channel — the audience mic instead of the presenter. Detect by computing RMS amplitude per channel on the output and confirming it matches expectations. A flat-line channel is the giveaway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drift between video and audio after a partial re-encode.&lt;/strong&gt; Anything that touches the stream — a transcode, a loudness filter, a normalization pass — risks re-introducing small timing shifts. Detect by comparing the output's first and last sample timestamps to the source's stream metadata. Drift greater than a few milliseconds breaks downstream sync with subtitles or slides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bit-depth reduction that nobody asked for.&lt;/strong&gt; A pipeline that silently downconverts 24-bit to 16-bit will eventually feed that material into a system that assumed 24-bit. Detect by storing the output codec parameters in the manifest and asserting them in the next stage's input validation.&lt;/p&gt;

&lt;p&gt;The general principle: log everything you can about the source and the output, and assert the parts that matter in code rather than trusting them to humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adopting the Workflow Without Blowing Up the Existing Process
&lt;/h2&gt;

&lt;p&gt;Teams that already have a habit rarely want a revolution. Three lightweight moves usually land:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a manifest, not a tool.&lt;/strong&gt; A &lt;code&gt;recording.json&lt;/code&gt; next to each artifact, written by whoever runs the extraction, costs minutes per recording and saves hours when somebody asks "where did this clip come from?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap one tool, do not replace many.&lt;/strong&gt; Pick whichever local or server-side tool the team is comfortable with, write a thin shell wrapper around it, and let the wrapper enforce the checklist. The tool becomes an implementation detail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make provenance auditable.&lt;/strong&gt; On a regular cadence — quarterly is fine — pick three random outputs and verify they still reproduce from the recorded source and command line. If they do not, somebody changed something undocumented; find out who and what.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The habit that holds all of this together is treating audio as a build artifact. Once that mental switch flips, the rest of the workflow slots in around the same conventions the team already uses for binaries, datasets, and documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the difference between extracting and converting audio from a video?
&lt;/h3&gt;

&lt;p&gt;Extracting, in the strict sense, means pulling the audio stream out of the video container and putting it into a new container without re-encoding — a stream copy. Converting means decoding the audio, optionally resampling or downmixing it, and re-encoding into a different codec. Extraction is faster and lossless for transport; conversion is lossy and slower. For routine speech material, prefer extraction whenever the target format allows it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the audio codec inside the video affect output quality?
&lt;/h3&gt;

&lt;p&gt;Yes, but only once you cross from stream copy into transcode. If you stream-copy an AAC track out of an MP4 into an &lt;code&gt;.m4a&lt;/code&gt;, the audio bytes are unchanged. If you transcode the same track to MP3, you lose information. Bit-perfectness stops at the moment a re-encode begins.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should a team move from a browser-based tool to a scripted pipeline?
&lt;/h3&gt;

&lt;p&gt;The signal is repetition and accountability. If the same kind of extraction happens more than a handful of times a month, if multiple people need to perform it, or if the output needs to be reproducible later, a scripted pipeline earns its place. A browser tool is fine for genuine one-offs and experimentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  How should the output be stored to keep the workflow maintainable?
&lt;/h3&gt;

&lt;p&gt;Store inputs and outputs together, name them with sortable, version-aware filenames, and record extraction parameters plus output hashes in a manifest file. Avoid keeping files whose provenance is unknown; if a file's manifest is missing, regenerate it from the recorded source rather than reusing the artifact.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>video</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Life Path Number Reference Table: Every Birthday Reduction in One Place</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Tue, 01 Sep 2026 19:01:35 +0000</pubDate>
      <link>https://dev.to/lizely/life-path-number-reference-table-every-birthday-reduction-in-one-place-1api</link>
      <guid>https://dev.to/lizely/life-path-number-reference-table-every-birthday-reduction-in-one-place-1api</guid>
      <description>&lt;p&gt;If you build or audit a numerology feature — a calculator widget, a birthday-driven onboarding flow, a personality summary email, an astrology-adjacent mobile screen — you eventually face the same question: what does my function actually return for every plausible calendar date? The reduction from a full date of birth to a single digit (or master number) is short to describe and surprisingly fiddly to implement. Edge cases around the 11, 22, and 29 reductions trip up hand-rolled code far more often than the rest of the pipeline combined.&lt;/p&gt;

&lt;p&gt;This piece is a practitioner-oriented reference: the underlying reduction rules, the cases that need special handling, and the kind of fixture table that makes your regression suite honest. I will not be selling any interpretive meaning of the result; the focus is the system you implement, the inputs you validate, and the outputs you can defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reduction Rules, Written Down Plainly
&lt;/h2&gt;

&lt;p&gt;Numerology reduces a date of birth — &lt;code&gt;YYYY-MM-DD&lt;/code&gt; — to a value between 1 and 33 by repeatedly summing digits and re-reducing, except that two-digit totals of 11, 22, and (less commonly) 33 are preserved as "master numbers" and not collapsed further. The rules look trivial on paper and have a reputation for breaking in production for two reasons: people disagree on whether master numbers include 33, and people disagree on whether intermediate sums should also be preserved or only the final one.&lt;/p&gt;

&lt;p&gt;A clean implementation reads like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Parse the date. Treat &lt;code&gt;YYYY&lt;/code&gt;, &lt;code&gt;MM&lt;/code&gt;, &lt;code&gt;DD&lt;/code&gt; as three independent numbers.&lt;/li&gt;
&lt;li&gt;Sum the digits of each component to get three sub-totals.&lt;/li&gt;
&lt;li&gt;Add the three sub-totals.&lt;/li&gt;
&lt;li&gt;If the total is 11, 22, or 33 (and your variant preserves 33), return it.&lt;/li&gt;
&lt;li&gt;Otherwise, sum the digits of the total. If the new total is still 11 or 22, return it. Otherwise repeat until you reach a single digit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The standard reduction math itself is folklore and is documented in the Wikipedia entry on numerology as well as the broader overview at &lt;a href="https://en.wikipedia.org/wiki/Numerology" rel="noopener noreferrer"&gt;Wikipedia: Numerology&lt;/a&gt;. The arithmetic is not the part that needs defending; the policy choices around master numbers are.&lt;/p&gt;

&lt;p&gt;The W3C and ISO date-handling standards are also worth a glance even though they are not about mysticism, because your date parsing should be correct before any reduction runs. The &lt;a href="https://www.w3.org/TR/NOTE-datetime" rel="noopener noreferrer"&gt;W3C date and time notation note&lt;/a&gt; is a useful reminder that calendar strings are a minefield of time zones and formats. If your inputs come from a form, normalize them early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Inputs Should Your Function Reject?
&lt;/h2&gt;

&lt;p&gt;Before you write a single reduction, decide what counts as a valid input. A surprising amount of field data is junk, and pushing junk through a reducer produces numbers that look legitimate but mean nothing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Empty strings, &lt;code&gt;null&lt;/code&gt;, or whitespace. Return a typed error, not a silent &lt;code&gt;NaN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Partial dates such as &lt;code&gt;--02-14&lt;/code&gt; or &lt;code&gt;1999-02&lt;/code&gt;. Reject them. Numerology needs a full year, month, and day.&lt;/li&gt;
&lt;li&gt;Dates outside a sane range. A 1 CE birth is historically possible but practically never submitted; a 13th month is impossible. Validate against &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date" rel="noopener noreferrer"&gt;MDN's &lt;code&gt;Date&lt;/code&gt; documentation&lt;/a&gt; and your locale's calendar rules rather than reinventing them.&lt;/li&gt;
&lt;li&gt;Future dates. People mistype &lt;code&gt;2035&lt;/code&gt; instead of &lt;code&gt;1995&lt;/code&gt;. Treat dates after today as soft errors and flag them in the UI.&lt;/li&gt;
&lt;li&gt;Ambiguous locales. &lt;code&gt;02/03/1990&lt;/code&gt; is February 3 in the US and March 2 in much of Europe. Parse to a &lt;code&gt;YYYY-MM-DD&lt;/code&gt; shape and reject anything that cannot be unambiguously interpreted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good regression suite keeps a fixture for each of these failure modes and asserts the typed error, not just a falsy return.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reference Table for Every Reduction
&lt;/h2&gt;

&lt;p&gt;This is the table I wish I had on day one. It maps the twelve months against the 31 days and shows the reduced value for each date in the 1900s decade. I include 1990 as a worked anchor; the structure repeats for every other year because digit sums shift predictably.&lt;/p&gt;

&lt;p&gt;Take 1990-02-14 as the worked example. The year digits sum to &lt;code&gt;1+9+9+0 = 19&lt;/code&gt;, the month to &lt;code&gt;0+2 = 2&lt;/code&gt;, and the day to &lt;code&gt;1+4 = 5&lt;/code&gt;. Total: &lt;code&gt;19 + 2 + 5 = 26&lt;/code&gt;. Reduce &lt;code&gt;26&lt;/code&gt; to &lt;code&gt;2+6 = 8&lt;/code&gt;. Result: 8, not a master number.&lt;/p&gt;

&lt;p&gt;The pattern across the year:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Month 01 (January): every day reduces the month contribution to 1.&lt;/li&gt;
&lt;li&gt;Month 02 (February): contribution is 2.&lt;/li&gt;
&lt;li&gt;Month 03 (March): contribution is 3.&lt;/li&gt;
&lt;li&gt;Months 04 through 09: contribution equals the month number.&lt;/li&gt;
&lt;li&gt;Month 10 (October): &lt;code&gt;1+0 = 1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Month 11 (November): &lt;code&gt;1+1 = 2&lt;/code&gt;, but 11 is preserved as a master — see below.&lt;/li&gt;
&lt;li&gt;Month 12 (December): &lt;code&gt;1+2 = 3&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That tells you the month is rarely the deciding factor. The day dominates almost every reduction. For day-of-month values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;01, 10, 19, 28 reduce to 1.&lt;/li&gt;
&lt;li&gt;02, 11, 20, 29 reduce to 2.&lt;/li&gt;
&lt;li&gt;03, 12, 21, 30 reduce to 3.&lt;/li&gt;
&lt;li&gt;04, 13, 22, 31 reduce to 4.&lt;/li&gt;
&lt;li&gt;05, 14, 23 reduce to 5.&lt;/li&gt;
&lt;li&gt;06, 15, 24 reduce to 6.&lt;/li&gt;
&lt;li&gt;07, 16, 25 reduce to 7.&lt;/li&gt;
&lt;li&gt;08, 17, 26 reduce to 8.&lt;/li&gt;
&lt;li&gt;09, 18, 27 reduce to 9.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The day-29 row is the first edge case. &lt;code&gt;29&lt;/code&gt; is a sum of &lt;code&gt;2+9 = 11&lt;/code&gt;, so dates that produce a year-plus-month subtotal of 29 land on master 11, not on 2. Day-22 has the same property in the opposite direction: &lt;code&gt;22&lt;/code&gt; is itself a master, so it survives the second reduction. Anyone implementing the reducer by hand will eventually forget one of those rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Master-Number Cases That Actually Bite You
&lt;/h2&gt;

&lt;p&gt;Three two-digit values are the entire reason this reducer is harder than &lt;code&gt;sum % 9&lt;/code&gt;. Their handling changes your distribution of results across an arbitrary year dramatically.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;11&lt;/strong&gt; appears any time the final total before reduction is 11 or 29 (because 29 reduces to 11). For example, 1990-02-29 does not exist, but 1991-02-29 has a year sum of &lt;code&gt;1+9+9+1 = 20&lt;/code&gt;, month of 2, and day of &lt;code&gt;2+9 = 11&lt;/code&gt;. Total &lt;code&gt;20+2+11 = 33&lt;/code&gt;, preserved as 33 if your variant supports it, else reduced to 6.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;22&lt;/strong&gt; appears when the pre-reduction total is 22, or when a day of 22 is combined with sub-totals that sum to 0 or 9 or another value that produces 22 after the second pass. Day 22 is a master day and stays a master for almost every month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;33&lt;/strong&gt; is optional. Some practitioners count it as a master; many calculators reduce it to 6. Pick a variant and document it; do not let your code silently alternate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A complete, in-depth walkthrough of the 11 case is available at the &lt;a href="https://www.lizecheng.net/fortune/guides/calculate-life-path-number-11-keep-master-numbers-intact/" rel="noopener noreferrer"&gt;Calculate Life Path Number 11: Keep Master Numbers Intact&lt;/a&gt; guide, which is where I send teammates who want a worked example without re-deriving the arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Test This Without Going Mad
&lt;/h2&gt;

&lt;p&gt;Treat the reducer as a pure function and exercise it with a fixture table, not with a handful of hand-picked dates. The boring approach is the right one: every month, every day, plus a sample of years. For a single decade that is &lt;code&gt;12 × 31 = 372&lt;/code&gt; rows, which is small enough to commit as a CSV and regenerate in tests on every commit.&lt;/p&gt;

&lt;p&gt;A practical checklist for the regression suite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cover every day from 01 to 28 across all twelve months for a fixed year.&lt;/li&gt;
&lt;li&gt;Cover 29, 30, 31 explicitly for the months that allow them.&lt;/li&gt;
&lt;li&gt;Include at least one date per master number: 11, 22, and 33 if supported.&lt;/li&gt;
&lt;li&gt;Include at least one date that reduces through two intermediate passes — for example a year that yields 19 plus a month and day that produce a total above 19.&lt;/li&gt;
&lt;li&gt;Include invalid inputs: empty strings, malformed strings, future dates, and impossible month-day combinations such as February 30.&lt;/li&gt;
&lt;li&gt;Assert on the typed return, not on truthiness.&lt;/li&gt;
&lt;li&gt;Pin the variant in a comment or constant so a future contributor does not flip between "preserve 33" and "collapse 33" silently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a sanity cross-check, feed your reducer's output into a one-line script using a different language's &lt;code&gt;Date&lt;/code&gt; API and confirm parity; the &lt;a href="https://docs.python.org/3/library/datetime.html" rel="noopener noreferrer"&gt;Python datetime documentation&lt;/a&gt; is a reasonable anchor for the second implementation. Mismatches almost always point to a master-number rule, never to arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my reducer return 2 for some dates that should be 11?
&lt;/h3&gt;

&lt;p&gt;You are collapsing intermediate sums. If the day alone is 29, that subtotal is 11 and should be preserved as a master, not reduced to 2 before it joins the year and month subtotals. The rule is to sum each component first, preserve the master at the component level, then sum the components.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I support 33 as a master number?
&lt;/h3&gt;

&lt;p&gt;Pick a variant and document it. Both choices are common in published material, and the disagreement is interpretive, not mathematical. What you cannot do is behave inconsistently across requests, because users notice.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle leap-day birthdays (February 29)?
&lt;/h3&gt;

&lt;p&gt;Treat &lt;code&gt;1991-02-29&lt;/code&gt; as a valid date where the calendar allows it and reject it where it does not. The arithmetic still reduces correctly: year subtotal plus month subtotal of 2 plus day subtotal of 11.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I shortcut with a modulo?
&lt;/h3&gt;

&lt;p&gt;Yes, with caveats. &lt;code&gt;sum_of_digits % 9&lt;/code&gt; returns 0 for any multiple of 9, so the standard trick is &lt;code&gt;(x - 1) % 9 + 1&lt;/code&gt;, which maps 9 to 9 instead of 0. That shortcut cannot preserve master numbers and cannot preserve 33. Use it only if your spec explicitly drops masters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>fortune</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Designing a Step-Sequencer UI That Survives Real-World Use</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Mon, 31 Aug 2026 19:03:33 +0000</pubDate>
      <link>https://dev.to/lizely/designing-a-step-sequencer-ui-that-survives-real-world-use-3ib4</link>
      <guid>https://dev.to/lizely/designing-a-step-sequencer-ui-that-survives-real-world-use-3ib4</guid>
      <description>&lt;p&gt;Most walkthroughs about browser-based rhythm tools focus on the musician's side: where to click, how to lay down a beat, what tempo sounds good. This piece flips the camera around and looks at the engineering surface instead — the grid data structure behind every row and column, the rules that turn a click into sound, and the edge cases that quietly break a prototype the moment it meets a second user.&lt;/p&gt;

&lt;p&gt;The lens here is the practitioner who has to ship, debug, or extend one of these widgets. Even if you never touch audio code again, the patterns below — small fixed-size state arrays, finite sound banks, transport with a single source of truth, and accessibility on a strict grid — show up in any UI built around a discrete timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Grid Actually Is, Conceptually
&lt;/h2&gt;

&lt;p&gt;Every step sequencer, regardless of host site, reduces to the same minimal model. There is a fixed number of &lt;em&gt;tracks&lt;/em&gt; (one per sound source: kick, snare, hat, clap, tom, and so on). There is a fixed number of &lt;em&gt;steps&lt;/em&gt; per bar (sixteen is the convention inherited from four-on-the-floor electronic music, but eight or thirty-two are common). Between them sits a two-dimensional boolean array — &lt;code&gt;tracks[16][steps]&lt;/code&gt; — that defines the entire performance.&lt;/p&gt;

&lt;p&gt;Three derived values matter most:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tempo&lt;/strong&gt; in beats per minute, stored as a float. The actual sample-accurate interval is &lt;code&gt;60 / BPM / 4&lt;/code&gt; seconds for sixteenth-notes (or &lt;code&gt;60 / BPM / 16&lt;/code&gt; if you prefer to think in raw step duration).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swing&lt;/strong&gt; as a ratio applied to every off-beat step (the eighth-notes on the "and"), typically 0% (straight) to about 60% (heavy shuffle).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accent pattern&lt;/strong&gt;, which is optional, marking a subset of steps that play louder — usually beats 1 and 9 in a 16-step bar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once those three values exist, the rest of the page is presentation. The hard work is keeping state, transport, and audio routing honest with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transport Loop and Why It Drifts
&lt;/h2&gt;

&lt;p&gt;The naïve implementation sets &lt;code&gt;setInterval(playNext, stepDurationMs)&lt;/code&gt;. It works in demos, then breaks in production for three predictable reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timer drift.&lt;/strong&gt; Browsers throttle &lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;setInterval&lt;/code&gt; to roughly 4 ms in background tabs and well above that on low-power devices. The famous workaround is the &lt;em&gt;lookahead scheduler&lt;/em&gt;: a wall-clock check that fires ahead of time and queues audio events with precise sample timestamps. The same pattern lives inside the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API" rel="noopener noreferrer"&gt;Web Audio API scheduling guidance from MDN&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tempo changes mid-bar.&lt;/strong&gt; Halving the interval while a bar is in flight desynchronizes the swing offset. The clean fix is to recompute the next-step target from &lt;code&gt;audioContext.currentTime&lt;/code&gt; whenever the user drags the BPM slider, rather than mutating the running timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample underruns.&lt;/strong&gt; Triggering a one-shot before the previous one finishes causes phase cancellation on certain kick samples. Most shipped implementations either voice-steal (cut the older sample) or apply a per-track retrigger threshold measured in milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only ever load the page once, in a foreground tab, with one user, none of this matters. The instant you embed the widget in a tutorial, a tracker, or a customer dashboard, all three show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sound Bank Problem
&lt;/h2&gt;

&lt;p&gt;A grid is silent until you bind tracks to samples. Two design rules separate the toys from the tools:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bounded cardinality.&lt;/strong&gt; A bank of six to eight channels is enough to cover rock, hip-hop, and house patterns. Anything beyond twelve becomes confusing on a 16-column grid because cell targets shrink below finger size on touch devices. The accessible beat-builder &lt;a href="https://www.lizecheng.net/audio/guides/how-to-use-a-virtual-drum-kit-in-your-browser/" rel="noopener noreferrer"&gt;Lizely publishes&lt;/a&gt; follows the same constraint and is worth a look when you want to compare your defaults against a maintained example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layered accents via gain, not sample swaps.&lt;/strong&gt; Loading two snare recordings (a normal one and a "crispy" one) doubles the cache footprint and complicates preload. Driving volume from the accent table instead — for example, +4 dB on marked steps, −2 dB on unaccented ones — gets 80% of the result with one asset per channel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The deeper trade-off lives in file format. Compressed Ogg or MP3 saves bandwidth but introduces decode latency on the first hit of each track. Lossless WAV starts instantly but balloons payload. A reasonable production rule of thumb, mirrored in the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Containers" rel="noopener noreferrer"&gt;MDN media format compatibility tables&lt;/a&gt;, is to ship WAV for the first paint and let the user upgrade to a compressed mirror only after the audio context is running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patterns Worth Stealing for Other Timelines
&lt;/h2&gt;

&lt;p&gt;Even readers who never ship a drum widget will recognize the underlying data shapes. The exact same grid model powers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chiptune trackers, where each row is a note and each column is a tick.&lt;/li&gt;
&lt;li&gt;Pixel-art sprite animators, where tracks become layers and steps become frames.&lt;/li&gt;
&lt;li&gt;Red-team exercise planners, where the grid represents time windows and the rows represent adversary actions.&lt;/li&gt;
&lt;li&gt;Habit trackers, where the rows are habits and the columns are days in a month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case, the same handful of features unlock the same workflow: click to toggle, shift-click to range-fill, right-click to preview, and an undo stack scoped to the last edit. If your widget does not have all four, expect users to file it as "toy-grade" within minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical QA Checklist for Step-Sequencer Widgets
&lt;/h2&gt;

&lt;p&gt;Before shipping, run through the following in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify transport drift.&lt;/strong&gt; Set BPM to 200, leave the tab in the background for two minutes, return, and confirm the playhead is still on a beat. If it has crept, the scheduler needs the lookahead pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toggle every cell on every track&lt;/strong&gt; and confirm no audio glitches occur on the wrapping step (the sixteenth after the last). This is where off-by-one errors in the modulo usually surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drag the BPM slider mid-bar&lt;/strong&gt; at 120, 160, and 40 BPM. The next click should land on a beat, not a half-beat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Toggle a cell while paused&lt;/strong&gt; and confirm the visible step indicator never moves. Pause must freeze the transport.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reload the page with a populated pattern in localStorage&lt;/strong&gt; and confirm the playhead starts on step 0, not step 7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tab through the grid&lt;/strong&gt; using only the keyboard. Every cell must be reachable, and the focused cell must announce its coordinates to a screen reader. The &lt;a href="https://www.w3.org/TR/wai-aria-practices-1.2/#grid" rel="noopener noreferrer"&gt;WAI-ARIA grid pattern&lt;/a&gt; is the right reference here, even though a sequencer is technically a tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty the cache and cold-load&lt;/strong&gt; the page on a throttled 3G profile. The first audible click should arrive under one second on a modern device.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most shipped widgets fail at item 1, then again at item 6. Those two account for nearly every support ticket in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases That Look Trivial Until You Hit Them
&lt;/h2&gt;

&lt;p&gt;A short, opinionated list of borderline scenarios that consistently surprise first-time implementers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Odd meters.&lt;/strong&gt; A 16-step grid implies 4/4. Users will request 3/4, 6/8, and 7/8 within the first week. Decide whether your grid length is editable per bar or fixed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mute vs. solo.&lt;/strong&gt; Soloing one track should override mutes on every other track. Track these as two separate booleans, never as a single tri-state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern chaining.&lt;/strong&gt; A two-bar or four-bar arrangement reuses the same grid object but advances an outer pattern index. Keep the outer counter and inner counter in different variables so BPM changes do not corrupt the arrangement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Touch hit-targets.&lt;/strong&gt; Cells under 24 px square need a hit-target enlargement layer, otherwise thumb drumming misses a quarter of taps. This is the same 44 px rule-of-thumb that drives &lt;a href="https://www.w3.org/TR/WCAG21/#target-size" rel="noopener noreferrer"&gt;WCAG 2.5.5 target size&lt;/a&gt; for general input controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export formats.&lt;/strong&gt; A surprising number of users want MIDI out, not audio out. Even a basic pattern-as-array-to-MIDI-file bridge buys you ten times the integrations of an audio export alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a two-hour fix once you understand the model, and a week-long redesign if you discover them after launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the smallest data structure that captures a full pattern?
&lt;/h3&gt;

&lt;p&gt;Six to eight tracks times sixteen boolean steps, plus a single BPM float, plus a swing ratio. Anything beyond that is metadata or visualization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I avoid using the Web Audio API for scheduling?
&lt;/h3&gt;

&lt;p&gt;You can, but only if your audience is a single foreground tab and your tempo is slow enough that timer drift does not become audible. The moment you embed the widget anywhere else, switch to a lookahead scheduler driven by &lt;code&gt;audioContext.currentTime&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I support keyboard users without losing the grid metaphor?
&lt;/h3&gt;

&lt;p&gt;Each cell is a button with an accessible name like "Snare, step 5". Arrow keys move focus, space toggles, and shift-arrow range-fills. The ARIA grid pattern from W3C is the canonical reference, even though most implementations treat the widget as a table internally for screen readers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my pattern sound wrong even though the grid looks right?
&lt;/h3&gt;

&lt;p&gt;The most common culprit is swing. A 50% swing ratio delays every odd step by half its duration, which makes a pattern that reads as straight on the page feel shuffled in playback. Reduce swing to 0% first, confirm the grid is doing what you expect, then reintroduce it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>audio</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Decoding an Amortization Schedule: How to Audit a Mortgage Calculator's Output Row by Row</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Sun, 30 Aug 2026 19:02:56 +0000</pubDate>
      <link>https://dev.to/lizely/decoding-an-amortization-schedule-how-to-audit-a-mortgage-calculators-output-row-by-row-41c1</link>
      <guid>https://dev.to/lizely/decoding-an-amortization-schedule-how-to-audit-a-mortgage-calculators-output-row-by-row-41c1</guid>
      <description>&lt;p&gt;Most engineers and analysts treat a mortgage calculator as a black box: enter three numbers, get a monthly payment back, walk away. When something looks wrong on a loan estimate, the instinct is to suspect the bank, the broker, or the tool itself — without a way to verify which one. This article takes the opposite approach. It treats the calculator's full amortization schedule as a dataset you can audit, and lays out a practical checklist for spotting inconsistencies, reconciling the output against first-principles math, and choosing between calculation strategies when the rules get unusual (extra payments, variable rates, biweekly schedules).&lt;/p&gt;

&lt;p&gt;The goal is not to replace your tool of choice. It is to make you the person in the room who can explain why the schedule behaves the way it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Amortization Schedule Actually Contains
&lt;/h2&gt;

&lt;p&gt;Every standard amortization row carries five values: payment number, payment amount, interest portion, principal portion, and remaining balance. The closing balance of row &lt;em&gt;n&lt;/em&gt; must equal the opening balance of row &lt;em&gt;n+1&lt;/em&gt; minus the principal paid in row &lt;em&gt;n+1&lt;/em&gt; — with no rounding residual carried over and no silent re-amortization in the middle. A printable schedule also shows year totals, cumulative interest, and (in good implementations) an effective interest rate that lets you compare fixed-rate and inflation-adjusted scenarios.&lt;/p&gt;

&lt;p&gt;If your tool does not expose per-row principal and interest, you cannot audit it. Period. That's the first filter when evaluating any mortgage calculator, whether a hosted web widget or a spreadsheet you maintain yourself: the row-level breakdown must be reachable, exportable, and reproducible from the inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Audit Tests You Should Always Run
&lt;/h2&gt;

&lt;p&gt;Before trusting any tool's output, run three tests. They take under a minute each and catch the vast majority of off-by-one bugs, rounding errors, and rate-encoding mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 1 — Interest matches the monthly rate times the prior balance.&lt;/strong&gt; On row &lt;em&gt;n&lt;/em&gt;, the interest portion should equal &lt;code&gt;prior_balance × (annual_rate / 12)&lt;/code&gt;, rounded to the same precision the rest of the table uses. A 0.01-cent drift on the first row is fine; a drift on the hundredth row means the principal balance is being carried forward with the wrong number of decimal places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 2 — Principal plus interest equals the scheduled payment.&lt;/strong&gt; Every row in a fixed-rate loan must satisfy this, &lt;em&gt;except&lt;/em&gt; possibly the final row, which absorbs any remaining rounding residual so the closing balance lands precisely on zero. If any non-final row fails this test, the schedule has a compounding bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 3 — The closing balance of the final row is exactly zero.&lt;/strong&gt; Not "close to zero," not "0.00000003." A clean termination is the cheapest indicator that the amortization loop is well-formed. A small residual tells you either the rounding method or the iteration count is misconfigured.&lt;/p&gt;

&lt;p&gt;A practical workflow: copy the schedule into a spreadsheet, add three helper columns, and run the tests as conditional formatting rules. Rows that fail light up red. This is the same triage pattern engineers apply to any financial pipeline — payment processors, ledger systems, tax engines — and the discipline transfers directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reference Checklists Different Loan Types Demand
&lt;/h2&gt;

&lt;p&gt;Not all mortgages amortize the same way, and a generic fixed-rate formula produces wrong answers when applied to the wrong loan. Three variants account for almost every audit you'll do in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fixed-rate, fully amortizing.&lt;/strong&gt; The standard reference case. Each payment is constant, principal grows linearly with time on a sigmoid curve, and the schedule terminates exactly at the loan term.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Adjustable-rate or hybrid ARM.&lt;/strong&gt; Initial fixed period, then rate resets. The schedule must regenerate the payment at every reset — otherwise you get either negative amortization (where the borrower owes more than they started with) or interest-only stretches that the schedule doesn't flag.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Interest-only period followed by amortization.&lt;/strong&gt; Common in investment property and some jumbo products. The interest-only rows must show &lt;code&gt;principal_portion = 0&lt;/code&gt; and a flat balance; once amortization begins, the same payment formula from the fixed-rate case applies but recomputed against the remaining term. A schedule that silently extends the term instead of recalculating is a known bug pattern.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good pre-flight checklist before signing off on a quote:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the annual rate is expressed as a decimal (0.065, not 6.5) and divided by 12 correctly.&lt;/li&gt;
&lt;li&gt;Confirm the term is expressed in months (360, not 30).&lt;/li&gt;
&lt;li&gt;Confirm any extra principal payment is applied &lt;em&gt;after&lt;/em&gt; the scheduled payment, not as a payment itself.&lt;/li&gt;
&lt;li&gt;Confirm the tool reports effective interest cost in addition to nominal, since prepayment assumptions swing the effective rate dramatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Trade-offs When You Need to Choose Between Approaches
&lt;/h2&gt;

&lt;p&gt;Three strategies show up repeatedly in personal-finance tooling, and each has a defensible reason to exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Formula-first (Excel/Sheets with &lt;code&gt;PMT&lt;/code&gt;).&lt;/strong&gt; Fast to implement, easy to audit cell-by-cell, but produces a single value — no row-level schedule unless you extend it. Great for sensitivity analysis, weak for documentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Loop-driven schedule.&lt;/strong&gt; A &lt;code&gt;for&lt;/code&gt; loop from 1 to &lt;em&gt;n&lt;/em&gt; months, computing interest and principal each iteration. Verbose, but every row is inspectable, and unusual flows (extra payments, rate resets) drop in cleanly as &lt;code&gt;if&lt;/code&gt; conditions inside the loop.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Balance-recursive formula.&lt;/strong&gt; Closed-form expressions for remaining balance after &lt;em&gt;k&lt;/em&gt; payments. Concise, can produce a one-cell lookup, but opaque to non-engineers and brittle once you add prepayment logic.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an engineer building internal tooling, the loop-driven schedule wins almost every time. For a finance professional who wants to reason about a single decision in a meeting, the formula-first PMT is enough. For a homeowner who wants to explore "what if I add $200 extra per month," the loop is the only one of the three that answers the question without you translating the closed-form math by hand. The Lizely guide on &lt;a href="https://www.lizecheng.net/finance/guides/calculate-your-mortgage-payment-and-total-interest-in-one-click/" rel="noopener noreferrer"&gt;calculating your mortgage payment and total interest in one click&lt;/a&gt; walks through the formula approach in detail if you want the closed-form reference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging the Schedule When the Numbers Look Wrong
&lt;/h2&gt;

&lt;p&gt;When a quote or tool produces a number you don't trust, walk the schedule in three moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Recompute the first row by hand.&lt;/strong&gt; Plug the inputs into the standard amortization formula and confirm the monthly payment. Then build the first row: interest = balance × monthly rate, principal = payment − interest, new balance = balance − principal. If this disagrees with the tool, the disagreement is in payment handling, not amortization.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spot-check the midpoint.&lt;/strong&gt; At month &lt;em&gt;n/2&lt;/em&gt;, roughly half the principal should have paid down. The exact percentage is governed by the rate, but anything outside the [40%, 60%] band for a 30-year fixed usually indicates an off-by-one in the term input.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm the terminal row.&lt;/strong&gt; If the final payment differs from the rest by more than a few cents, the schedule is correctly absorbing rounding. If it differs by dollars, there's a precision bug — usually a tool that rounds each row to cents but never reconciles the closing balance.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a deeper second opinion on payment formulas and the conventions used in consumer-facing U.S. mortgages, the &lt;a href="https://en.wikipedia.org/wiki/Amortization" rel="noopener noreferrer"&gt;Wikipedia amortization entry&lt;/a&gt; covers the underlying equations and edge cases. For the structural conventions of how lenders disclose these numbers on official loan-estimate forms, the &lt;a href="https://www.consumerfinance.gov/consumer-tools/mortgages/" rel="noopener noreferrer"&gt;Consumer Financial Protection Bureau's mortgage help resources&lt;/a&gt; are the authoritative reference. Note that whether a given schedule &lt;em&gt;matches&lt;/em&gt; a lender's quote is a different question from whether the schedule is internally consistent — they can disagree even when both are individually correct, because of how escrow, PMI, and closing costs are rolled in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a Calculator When You Cannot Audit the Code
&lt;/h2&gt;

&lt;p&gt;Sometimes you're handed a number by a tool whose internals you cannot see — a bank's portal, a broker's worksheet, a mobile app. Three signals tell you whether to trust it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It exposes a row-level schedule, not just a payment number.&lt;/strong&gt; Anything that won't print every month of the term is hiding the one piece of evidence you'd need to audit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It shows the date and rate version.&lt;/strong&gt; Mortgages that originated years ago carry different conventions than today's. If the tool can't tell you which amortization rule set it applied, it can't reconcile against an old quote.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It documents its rounding method.&lt;/strong&gt; "Round each row to cents, adjust the final row" is the industry standard. "Round the payment and keep the formula" is also defensible, but then the final balance won't land on zero and you have to know that to read the schedule.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When all three signals are present, you can recover a defensible audit by re-running the same inputs through an independent loop and comparing the two schedules row by row. Differences of more than a few cents per row mean at least one tool has a precision or rounding bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How precise does a mortgage schedule need to be?
&lt;/h3&gt;

&lt;p&gt;Cents precision per row is sufficient for any consumer-facing decision. Anything finer is noise. What matters is consistency: the same rounding rule, applied the same way, on every row.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can an amortization schedule have a row where principal exceeds interest before the midpoint?
&lt;/h3&gt;

&lt;p&gt;Yes — for any fixed-rate loan, principal exceeds interest at some point late in the term, regardless of the rate. The exact crossover month is rate-dependent; a 6% loan crosses over earlier than a 3% loan.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does a negative amortization schedule look like?
&lt;/h3&gt;

&lt;p&gt;Interest portion exceeds the scheduled payment, so the principal portion goes negative and the balance grows. This pattern appears in some adjustable-rate and interest-only products and must be flagged in the schedule, not hidden in the math.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is biweekly payment scheduling worth modeling?
&lt;/h3&gt;

&lt;p&gt;For a 30-year fixed, switching from monthly to biweekly payments adds roughly one extra payment per year, which compresses the loan term by several years and meaningfully reduces total interest. It's the single highest-leverage variable most calculators can model — and a strong test of whether a tool exposes enough of its internals to handle non-trivial prepayment strategies.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>finance</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Angle Conversions in Frontend Code: A QA Checklist for the Bugs You Don't See</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Fri, 28 Aug 2026 22:03:01 +0000</pubDate>
      <link>https://dev.to/lizely/angle-conversions-in-frontend-code-a-qa-checklist-for-the-bugs-you-dont-see-4ban</link>
      <guid>https://dev.to/lizely/angle-conversions-in-frontend-code-a-qa-checklist-for-the-bugs-you-dont-see-4ban</guid>
      <description>&lt;p&gt;You ship a pie chart that looks fine in the browser. The slices line up, the labels sit where you expect, and the customer demo passes. Two weeks later a teammate opens the file in Safari, and three slices collapse into one wedge. The root cause is almost always the same: someone handed a function an angle in the wrong unit, or used a function that quietly assumed radians while the caller passed degrees. Angle conversion bugs are quiet because they don't throw exceptions — they just produce geometry that's slightly wrong, and only sometimes.&lt;/p&gt;

&lt;p&gt;This article is the checklist I wish I had at the start of every project that draws arcs, rotates elements, or animates anything that spins. It focuses on the engineering workflow — where unit mistakes enter the code, how to catch them in review and CI, and what to standardise so the bug class disappears rather than gets patched each time it appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Frontend Surfaces Where Angle Units Bite
&lt;/h2&gt;

&lt;p&gt;Frontend code touches angles in three very different surfaces, and each one has its own default. Knowing which is which is half the battle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canvas and SVG geometry.&lt;/strong&gt; The &lt;code&gt;CanvasRenderingContext2D.arc()&lt;/code&gt; method takes radians, and so does &lt;code&gt;CanvasRenderingContext2D.rotate()&lt;/code&gt;. SVG attribute &lt;code&gt;transform="rotate(...)"&lt;/code&gt; also expects degrees, but &lt;code&gt;getPointAtLength()&lt;/code&gt; on a path works in the path's user units — which is whatever the path was authored with. Mixing those up is the single most common cause of "the arc starts in the wrong place" tickets. The Canvas 2D API documentation is explicit about radians and is worth bookmarking for any reviewer: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/arc" rel="noopener noreferrer"&gt;CanvasRenderingContext2D.arc() — MDN&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS transforms and Web Animations.&lt;/strong&gt; CSS uses degrees in &lt;code&gt;transform: rotate(45deg)&lt;/code&gt; and in &lt;code&gt;rotate&lt;/code&gt; individual transform properties. The CSS Values and Units specification defines the &lt;code&gt;&amp;lt;angle&amp;gt;&lt;/code&gt; type with both &lt;code&gt;deg&lt;/code&gt; and &lt;code&gt;rad&lt;/code&gt; accepted in modern browsers, but in practice most authoring tools and design specs use degrees. The &lt;a href="https://www.w3.org/TR/css-values-4/#angles" rel="noopener noreferrer"&gt;CSS Values and Units Level 4 specification&lt;/a&gt; is the canonical reference for which units are valid where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geometry libraries and game engines.&lt;/strong&gt; Three.js uses radians for everything — &lt;code&gt;THREE.MathUtils.degToRad&lt;/code&gt; exists precisely because so many people get this wrong. So does anything that wraps GL math. If you're importing a DWG floorplan or a DICOM slice, the source unit is whatever the format mandated; the conversion happens once at the boundary.&lt;/p&gt;

&lt;p&gt;The QA implication is simple: every one of these surfaces needs its own guard, because a helper that protects Canvas calls won't catch the CSS case, and vice versa.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Five-Point Review Checklist for Any PR That Draws Arcs
&lt;/h2&gt;

&lt;p&gt;Use this list when reviewing a pull request that adds or modifies drawing code, animation, or layout that depends on rotation. It takes about a minute per file and catches most unit mistakes before they reach main.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search the diff for &lt;code&gt;Math.PI&lt;/code&gt;, &lt;code&gt;* 180&lt;/code&gt;, &lt;code&gt;* 360&lt;/code&gt;, and &lt;code&gt;/ 57.29&lt;/code&gt;.&lt;/strong&gt; These are the fingerprints of someone converting in one direction and possibly the wrong one. Every occurrence should have an adjacent comment explaining which surface it targets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm a single source of truth.&lt;/strong&gt; Either the codebase speaks degrees end-to-end and converts at the Canvas/Three.js boundary, or it speaks radians and converts at the design-import boundary. Pick one and enforce it in lint rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the literal units on every angle constant.&lt;/strong&gt; A &lt;code&gt;const SLICE_ANGLE = 0.5236&lt;/code&gt; is suspicious because it doesn't say what unit it is. A &lt;code&gt;const SLICE_ANGLE_DEG = 30&lt;/code&gt; is honest. Bare numbers around geometry are an anti-pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace one arc from input to pixels.&lt;/strong&gt; Pick the most visually central element — usually the first slice — and follow the value from the data source, through any conversion, into the draw call. Every step should be readable by someone who has never seen the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render the same scene with deliberately wrong unit inputs.&lt;/strong&gt; A test that passes &lt;code&gt;-30&lt;/code&gt; instead of &lt;code&gt;30&lt;/code&gt; should produce a visually different but still valid result; a test that breaks entirely usually means you accidentally double-converted.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Writing Conversion Helpers That Can't Be Misused
&lt;/h2&gt;

&lt;p&gt;The cheapest defence against unit bugs is making the wrong call impossible to write. Three small habits do most of the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name units into the type.&lt;/strong&gt; A function called &lt;code&gt;drawSlice(angle)&lt;/code&gt; is dangerous because the reader has to remember what unit &lt;code&gt;angle&lt;/code&gt; is in. &lt;code&gt;drawSlice(angleRad)&lt;/code&gt; and &lt;code&gt;drawSliceDeg(angleDeg)&lt;/code&gt; make the unit part of the signature. The compiler can't help you in JavaScript, but your IDE and your reviewers can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Convert at boundaries, not at call sites.&lt;/strong&gt; If you receive data in degrees from a backend, convert to radians in the deserialiser or in a single &lt;code&gt;toSceneUnits()&lt;/code&gt; call. Don't sprinkle &lt;code&gt;* Math.PI / 180&lt;/code&gt; across twelve draw functions. The fewer conversion sites, the fewer places a bug can live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expose the conversion factor as a named constant.&lt;/strong&gt; &lt;code&gt;const RAD_PER_DEG = Math.PI / 180;&lt;/code&gt; at the top of a geometry file is clearer than the literal, and it makes code review faster because the reader doesn't have to mentally compute the conversion to know if a number is plausible. A value like &lt;code&gt;1.7453292519943295&lt;/code&gt; is meaningless; a value multiplied by &lt;code&gt;RAD_PER_DEG&lt;/code&gt; to produce &lt;code&gt;0.5235987755982988&lt;/code&gt; is recognisable as 30 degrees.&lt;/p&gt;

&lt;p&gt;For one-off conversions during a debugging session — "what is 137.5° in radians for this Bezier control point?" — a dedicated tool saves a lot of context switching. The walkthrough at &lt;a href="https://www.lizecheng.net/calculator/guides/how-to-convert-degrees-to-radians-in-seconds-free-online-tool/" rel="noopener noreferrer"&gt;How to Convert Degrees to Radians in Seconds&lt;/a&gt; is a good reference when you need the conversion factor handy rather than re-deriving it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging a "Rotated By a Tiny Bit Wrong" Bug
&lt;/h2&gt;

&lt;p&gt;When the symptom is "everything looks approximately right but slightly off," the investigation is different from a "nothing renders" bug. Here's the workflow I use.&lt;/p&gt;

&lt;p&gt;Start by rendering a known reference: a full circle drawn as a single arc, a square rotated by exactly 90°, and a 0° element that should be identical to the unrotated one. If any of those are wrong, the bug is in the unit pipeline, not in the data. If they're correct, the bug is in how a specific value is being passed.&lt;/p&gt;

&lt;p&gt;Next, compare the visual offset against the unit size. A 30° slice rendered as roughly 29.14° is suspicious because &lt;code&gt;30 * π/180 ≈ 0.5236&lt;/code&gt; and &lt;code&gt;30 * 0.0174 ≈ 0.522&lt;/code&gt; — close, but not identical, and a sign that someone used a degree-to-radian approximation that drifted. A 30° slice rendered as something tiny suggests the value was converted twice. The arithmetic of the offset usually tells you which direction the conversion went wrong.&lt;/p&gt;

&lt;p&gt;Finally, log the value at every boundary. Add &lt;code&gt;console.assert(typeof angle === 'number' &amp;amp;&amp;amp; angle &amp;gt;= 0 &amp;amp;&amp;amp; angle &amp;lt;= 2 * Math.PI)&lt;/code&gt; in the draw path. Adding assertion-style logging at the boundary between "user data" and "rendering units" is the most reliable place to catch a leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  When To Standardise on Radians vs Degrees Across a Team
&lt;/h2&gt;

&lt;p&gt;Most teams end up arguing about this. The argument is usually about taste, but there is a defensible default that depends on what you build.&lt;/p&gt;

&lt;p&gt;If your product is design-tool heavy — a Figma plugin, a whiteboard, a chart builder — author and store in degrees. Designers think in degrees, your data sources speak degrees, and the conversion to radians is a one-line adapter at the Canvas boundary. This is also the path of least resistance for any code that has to round-trip with CSS or SVG attributes.&lt;/p&gt;

&lt;p&gt;If your product is a game, a simulation, or anything using Three.js, WebGL, or a physics engine, author and store in radians. Every library in that stack already speaks radians, and forcing degrees through the pipeline means every vector math call has a hidden conversion overhead — both cognitive and, sometimes, measurable.&lt;/p&gt;

&lt;p&gt;Either choice is fine. Mixing the two inside one codebase is the actual problem. Pick a default, document it in a &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;, and add an ESLint rule that flags the other unit appearing in a file. A rule as simple as &lt;code&gt;no-restricted-syntax&lt;/code&gt; matching &lt;code&gt;Math.PI&lt;/code&gt; outside the geometry module catches the most common mistake for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's the fastest way to verify a suspect angle conversion?
&lt;/h3&gt;

&lt;p&gt;Render three test cases: a 0° rotation (must look identical to no rotation), a 90° rotation (must look identical to a known orthogonal flip), and a 360° rotation (must look identical to 0°). If any of those fail, the conversion is wrong; if they pass and a specific value still looks off, the bug is in how that specific value reached the draw call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I store angles as degrees or radians in my database or JSON payload?
&lt;/h3&gt;

&lt;p&gt;Store the unit that matches your domain. For most B2B products, that's degrees, because that's what your specs, your customers, and your import formats speak. Convert to radians at the rendering boundary, and keep the conversion in exactly one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;Math.PI / 180&lt;/code&gt; ever wrong?
&lt;/h3&gt;

&lt;p&gt;No, but it's easy to type &lt;code&gt;180 / Math.PI&lt;/code&gt; by accident, which is the degrees-to-radians mistake reversed. Both compile and both run, so the only defence is naming the constant — &lt;code&gt;RAD_PER_DEG&lt;/code&gt; versus &lt;code&gt;DEG_PER_RAD&lt;/code&gt; — and never inlining the conversion. If you find yourself writing the literal division in code, stop and add a named constant instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I prevent regressions after I fix the bug?
&lt;/h3&gt;

&lt;p&gt;Add a visual regression test that snapshots a few representative frames: a full pie chart, a 90° rotated card, and an animation at its midpoint. Any future unit regression will change those snapshots, and the test will fail loudly rather than letting the bug ship silently.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>calculator</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>The Cell-by-Cell Character Audit: A Developer's QA Checklist for Form, Field, and Feed Limits</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:04:49 +0000</pubDate>
      <link>https://dev.to/lizely/the-cell-by-cell-character-audit-a-developers-qa-checklist-for-form-field-and-feed-limits-4enk</link>
      <guid>https://dev.to/lizely/the-cell-by-cell-character-audit-a-developers-qa-checklist-for-form-field-and-feed-limits-4enk</guid>
      <description>&lt;p&gt;When a product team starts treating character counts as a first-class constraint, half the "obvious" assumptions quietly fall apart. Spaces that look identical render as different bytes. Truncation in the database is not the same as truncation in the view. A social card that fits at 220 units may overflow the moment someone pastes in a smart quote. None of these are exotic edge cases; they are the everyday residue of where string handling meets a fixed budget, and they are exactly the class of bug that escapes unit tests.&lt;/p&gt;

&lt;p&gt;This walkthrough is for engineers who have to ship a text budget — a tweet composer, an SMS dispatcher, a meta-description editor, an SEO title field — and who want a defensible QA pass before release. We will go through the layered reference data that most counters hide, the rules that decide which length matters where, and a checklist you can drop into a pull-request template.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Lengths That Compete Inside One Input
&lt;/h2&gt;

&lt;p&gt;Every text field you ship has at least three numbers lurking behind it, and most production bugs come from a mismatch between two of them. A &lt;code&gt;strlen&lt;/code&gt; in PHP gives you bytes; &lt;code&gt;String.prototype.length&lt;/code&gt; in JavaScript gives you UTF-16 code units; &lt;code&gt;Array.from(str).length&lt;/code&gt; gives you Unicode code points; a grapheme cluster iterator gives you what a human sees. None of these is universally wrong, but only one of them is what your downstream system counts.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf" rel="noopener noreferrer"&gt;Unicode Standard, Chapter 3&lt;/a&gt; defines code points as integers in the range U+0000 to U+10FFFF, and code point counts as the canonical reference value. Browsers, however, expose the older UTF-16 surrogate model to JavaScript, which is why &lt;code&gt;"😀".length&lt;/code&gt; returns 2 even though the emoji is a single code point. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length" rel="noopener noreferrer"&gt;&lt;code&gt;String.prototype.length&lt;/code&gt;&lt;/a&gt; page on MDN explicitly warns that surrogate pairs contribute two units each.&lt;/p&gt;

&lt;p&gt;When your product's limit is expressed as "characters," pause and ask which one. Twitter's 280 limit counts Unicode code points (with weighted handling for CJK ranges). Most SEO snippets truncate on bytes once percent-encoded. SQL &lt;code&gt;VARCHAR(255)&lt;/code&gt; on most engines counts characters under the database's collation, but &lt;code&gt;CHAR_LENGTH&lt;/code&gt; and &lt;code&gt;LENGTH&lt;/code&gt; disagree on UTF-8. The audit begins by pinning down which metric the &lt;em&gt;consumer&lt;/em&gt; uses, not which one is easiest to compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Reference Table You Can Paste Into Your Style Guide
&lt;/h2&gt;

&lt;p&gt;Below is the kind of compact matrix that belongs in your repo's &lt;code&gt;docs/text-limits.md&lt;/code&gt;. The exact thresholds vary per vendor and per year, so treat the numbers as an order-of-magnitude reference and re-verify against the current documentation before each release.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Where truncation bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tweet body&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;td&gt;Weighted code points&lt;/td&gt;
&lt;td&gt;URL → 23; each CJK char → 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta description&lt;/td&gt;
&lt;td&gt;~160 visible, 920 rendered&lt;/td&gt;
&lt;td&gt;Graphemes / px width&lt;/td&gt;
&lt;td&gt;Mobile SERPs cut at ~130&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title tag&lt;/td&gt;
&lt;td&gt;~60 visible, 600 rendered&lt;/td&gt;
&lt;td&gt;Graphemes / px width&lt;/td&gt;
&lt;td&gt;Pixel-based at ~512 px on Google&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS (GSM-7)&lt;/td&gt;
&lt;td&gt;160 single, 153 concatenated&lt;/td&gt;
&lt;td&gt;GSM alphabet chars&lt;/td&gt;
&lt;td&gt;Concatenation resets the budget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS (UCS-2)&lt;/td&gt;
&lt;td&gt;70 single, 67 concatenated&lt;/td&gt;
&lt;td&gt;Code points&lt;/td&gt;
&lt;td&gt;Emoji force UCS-2 encoding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notification (iOS)&lt;/td&gt;
&lt;td&gt;Title 40, body 178&lt;/td&gt;
&lt;td&gt;Bytes&lt;/td&gt;
&lt;td&gt;Truncation appends "…"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notification (Android)&lt;/td&gt;
&lt;td&gt;Title 35, body 78&lt;/td&gt;
&lt;td&gt;Bytes (most fonts)&lt;/td&gt;
&lt;td&gt;Truncated silently if exceeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack message&lt;/td&gt;
&lt;td&gt;40,000&lt;/td&gt;
&lt;td&gt;UTF-8 bytes&lt;/td&gt;
&lt;td&gt;Threads split at ~40k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few callouts worth memorising. &lt;a href="https://developers.google.com/search/docs/appearance/snippet" rel="noopener noreferrer"&gt;Google's search documentation&lt;/a&gt; states that the snippet length is determined dynamically up to about 160 characters and that pixel width matters as much as raw count for titles. For SMS, the GSM-7 alphabet has 128 code points listed in &lt;a href="https://www.3gpp.org/dynareport/23038.htm" rel="noopener noreferrer"&gt;3GPP TS 23.038&lt;/a&gt;, and any character outside that set forces the entire payload into UCS-2 — which is why one emoji silently halves your budget. Push notifications on Android truncate based on the &lt;em&gt;byte&lt;/em&gt; size of the rendered string under the system font, not the source code point count, which is why Japanese text reaches the cap with fewer characters than English does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases That Routinely Slip Past Code Review
&lt;/h2&gt;

&lt;p&gt;Even after you settle on a unit, a handful of inputs will quietly violate the budget. Build these into your fixture suite and you will catch most of them before a release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart punctuation and dashes.&lt;/strong&gt; A user pasting from Word inserts U+2019 (right single quotation mark) instead of U+0027 (apostrophe). Both render as &lt;code&gt;'&lt;/code&gt;, but only the second is in GSM-7. Build a fixture for &lt;code&gt;It's&lt;/code&gt;, &lt;code&gt;It's&lt;/code&gt;, and &lt;code&gt;It's&lt;/code&gt; (with U+00A0 no-break space) and confirm your counter flags the encoded-length cost, not the visual width.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero-width joiners.&lt;/strong&gt; Family emojis such as 👨‍👩‍👧 are sequences of code points joined by U+200D. They look like one grapheme but allocate seven code points. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter" rel="noopener noreferrer"&gt;&lt;code&gt;Intl.Segmenter&lt;/code&gt;&lt;/a&gt; API was added precisely to give you a correct grapheme count, and it is the only built-in that respects clusters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Combining marks.&lt;/strong&gt; A base letter followed by U+0301 (combining acute accent) is two code points and one grapheme. If your counter reports graphemes, "é" passes; if it reports code points, the same string overflows by one. Decide once, document, and test both shapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right-to-left runs.&lt;/strong&gt; Mixed Arabic and Latin text changes how many glyphs fit in a pixel budget, but does not change code-point count. Visual width assertions should run separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalisation drift.&lt;/strong&gt; The byte count of &lt;code&gt;"é"&lt;/code&gt; depends on whether the input was NFC or NFD-normalised. NFC: two bytes. NFD: three. If the field passes through multiple services, normalisation happens between them and the count shifts. The &lt;a href="https://www.unicode.org/faq/normalization.html" rel="noopener noreferrer"&gt;Unicode normalisation FAQ&lt;/a&gt; is the canonical reference; in practice you want one normaliser at the edge and an assertion that &lt;code&gt;text === text.normalize("NFC")&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Pre-Ship Checklist You Can Adopt Today
&lt;/h2&gt;

&lt;p&gt;The shortest path to consistent behaviour is a checklist that lives next to the pull-request template. Engineers tick the boxes once per surface, and reviewers reject if any row is blank.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the consumer.&lt;/strong&gt; Write one sentence naming the system whose limit applies (Twitter, Google SERP, Twilio, Slack). Link to that vendor's current doc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the unit.&lt;/strong&gt; Code points, bytes, graphemes, or vendor-weighted. If unsure, default to code points and document the choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define a tolerance.&lt;/strong&gt; Most teams pick the vendor limit minus 5–10% to absorb last-minute copy edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a counter.&lt;/strong&gt; Use one in-app component that implements the chosen unit. Defer to the &lt;a href="https://www.lizecheng.net/text/guides/how-to-count-characters-in-a-cell-for-social-media-and-seo/" rel="noopener noreferrer"&gt;Lizely in-depth guide on counting characters for social media and SEO&lt;/a&gt; when a field crosses both surfaces (for example, an OG title that doubles as an in-app headline).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build fixtures.&lt;/strong&gt; Cover each edge case above with one example string per row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert on encode.&lt;/strong&gt; Round-trip the value through the wire format (UTF-8 bytes for HTTP, UCS-2 for SMS, JSON-escaped for storage) and assert byte length.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert on render.&lt;/strong&gt; Render at the smallest target viewport and capture a screenshot. Pixel width is the only check that catches font substitutions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture the contract.&lt;/strong&gt; Add a one-row table to the component README with the limit, the unit, and a link to the vendor doc.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Trade-offs When You Build the Counter Yourself
&lt;/h2&gt;

&lt;p&gt;Once you have chosen a unit, the implementation decision is which API to lean on. In JavaScript, the trade-off is essentially between three approaches, and the right answer depends on what the field's downstream consumer measures.&lt;/p&gt;

&lt;p&gt;The legacy &lt;code&gt;string.length&lt;/code&gt; is fast but reports UTF-16 code units, which undercounts BMP-non chars and overcounts the rest. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from" rel="noopener noreferrer"&gt;&lt;code&gt;Array.from(str).length&lt;/code&gt;&lt;/a&gt; walks the iterator protocol and yields code points, which matches what most vendor APIs mean by "character." The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter" rel="noopener noreferrer"&gt;&lt;code&gt;Intl.Segmenter&lt;/code&gt;&lt;/a&gt; instance yields grapheme clusters, which matches what a human sees. Segmenter is slower than the other two by an order of magnitude in tight loops, so reserve it for surfaces where the budget is measured in glyphs (titles, descriptions) and use code points elsewhere.&lt;/p&gt;

&lt;p&gt;For server-side work, the same three layers exist in Python. &lt;code&gt;len(s)&lt;/code&gt; gives code points under PEP 393, &lt;code&gt;len(s.encode("utf-8"))&lt;/code&gt; gives bytes, and the third-party &lt;code&gt;grapheme&lt;/code&gt; package — or &lt;code&gt;regex&lt;/code&gt; with &lt;code&gt;\X&lt;/code&gt; — gives clusters. In Rust, &lt;code&gt;chars().count()&lt;/code&gt; is code points, &lt;code&gt;.len()&lt;/code&gt; is bytes, and the &lt;code&gt;unicode-segmentation&lt;/code&gt; crate is graphemes. Pick one crate or one method, document it, and forbid the others in code review.&lt;/p&gt;

&lt;p&gt;A practical rule of thumb: if the consumer's documentation says "characters," assume code points unless you have evidence otherwise. If it says "bytes," assume UTF-8 on the wire. If it says nothing, your counter is the contract; pick the most conservative unit and make the choice visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging the Mismatch You Inherited
&lt;/h2&gt;

&lt;p&gt;Most teams land on this checklist after a bug. The signal is always the same: a user pastes text that the in-app counter says fits, the consumer says otherwise, and the round-trip is a few percent off. The diagnostic order is fixed and short.&lt;/p&gt;

&lt;p&gt;First, isolate the input. Strip styling, paste into a hex editor, and confirm what bytes are actually present. Hidden U+200B zero-width spaces and U+FEFF byte-order marks are common culprits in copy-pasted copy. Second, encode to UTF-8 and compare the byte count to the counter. If they diverge, the counter is not measuring what you thought. Third, run the input through &lt;code&gt;Intl.Segmenter&lt;/code&gt; and compare grapheme count to code point count; if they diverge, the field is sensitive to combining marks or joiners. Fourth, render the string in the target font at the target viewport and measure pixel width; if it overflows, no counter will save you because the budget is visual.&lt;/p&gt;

&lt;p&gt;None of this is novel. The novelty is doing it before the bug ships, with a checklist rather than a Friday-night war room. The whole point of treating text as a measured resource is that the measurement is a contract, and contracts deserve fixtures, documentation, and review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which character-count unit should I default to when the vendor says "characters"?
&lt;/h3&gt;

&lt;p&gt;Code points. It is the unit the Unicode Standard defines, it is what &lt;code&gt;Array.from(str).length&lt;/code&gt; returns in JavaScript, and it is what most vendor APIs mean by "characters" even when their docs are sloppy. Document the choice in the component README so future readers know what is being measured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my counter say 279 but Twitter rejects the tweet?
&lt;/h3&gt;

&lt;p&gt;Twitter applies weighted counting: URLs collapse to a fixed 23-unit chunk, and most CJK ranges count as two units each. Plain code-point counting is the right default, but if your product mirrors Twitter's exact limits you have to replicate its weighting. The same applies to Mastodon, which uses a different table, and to BlueSky, whose limits have changed several times.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I count grapheme clusters in the browser without bundling a polyfill?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;new Intl.Segmenter(undefined, { granularity: "grapheme" })&lt;/code&gt;. It is available in all evergreen browsers as of 2022 and returns an iterator whose &lt;code&gt;.segment(text)&lt;/code&gt; yields objects with a &lt;code&gt;.length&lt;/code&gt; property measured in code points. Sum those to get a grapheme count that handles ZWJ sequences and combining marks correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the counter live in the form or in the database?
&lt;/h3&gt;

&lt;p&gt;The form. The user needs feedback while typing, which means a counter that runs on every keystroke and that you can keep fast. The database layer should enforce a separate hard limit with a defensive assertion, because anything that bypasses the form (CSV import, API integration, legacy migration) still needs to be rejected at the boundary.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>text</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Designing a Discount-Validation API: Catching Stacked-Coupon Math Errors Before They Hit Production</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Wed, 26 Aug 2026 22:03:33 +0000</pubDate>
      <link>https://dev.to/lizely/designing-a-discount-validation-api-catching-stacked-coupon-math-errors-before-they-hit-production-1lc2</link>
      <guid>https://dev.to/lizely/designing-a-discount-validation-api-catching-stacked-coupon-math-errors-before-they-hit-production-1lc2</guid>
      <description>&lt;p&gt;If you've ever wired up a promo engine, you know the cheap-looking line items are where the bugs hide. A single product with three sequential percentage discounts — "20% off, then 10% off, then a 15% loyalty coupon" — is mathematically distinct from a flat 45% off, and most homegrown calculators get it wrong in the same three or four ways. This article walks through the engineering side of that problem: how to validate discount math in code, what edge cases to cover, and what the spec actually says about rounding so you can stop arguing with finance.&lt;/p&gt;

&lt;p&gt;The goal isn't to sell you anything. It's to give you a checklist and a set of unit tests you can drop into a &lt;code&gt;promo&lt;/code&gt; or &lt;code&gt;pricing&lt;/code&gt; module today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Stacked Percentage Discounts Aren't Additive
&lt;/h2&gt;

&lt;p&gt;The naive implementation is always the same: &lt;code&gt;price * (1 - (d1 + d2 + d3))&lt;/code&gt;. It's wrong because percentages compose multiplicatively. Two discounts of 20% and 10% applied in sequence give &lt;code&gt;0.8 × 0.9 = 0.72&lt;/code&gt;, which is a 28% total — not 30%. The arithmetic is described cleanly in any precalculus text, and the relevant property is the &lt;a href="https://en.wikipedia.org/wiki/Multiplicative_inverse" rel="noopener noreferrer"&gt;multiplicative inverse&lt;/a&gt;: each successive discount multiplies the &lt;em&gt;remaining&lt;/em&gt; price by a factor, not the original.&lt;/p&gt;

&lt;p&gt;This matters because the user-visible copy on a coupon almost always reads "20% + 10% off!" even when the underlying math is sequential. If your API returns a number that disagrees with what the customer saw in their cart, you've created a chargeback. The safer pattern is to model discounts as a list of &lt;code&gt;(factor, label)&lt;/code&gt; tuples and let the calculation walk through them in order, surfacing each step in the response so the front end can show "20% off, then 10% off = 28% total" if it wants to.&lt;/p&gt;

&lt;p&gt;A second trap is order-dependence. &lt;code&gt;0.8 × 0.9&lt;/code&gt; and &lt;code&gt;0.9 × 0.8&lt;/code&gt; happen to be commutative here, but the moment you add BOGO or a fixed-amount coupon to the mix, order stops being free. Decide the order at the data layer (typically: best-percent-first, fixed-amount last) and document it in the API spec so the front-end team doesn't rearrange coupons client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Calculation, Spelled Out
&lt;/h2&gt;

&lt;p&gt;Strip the problem to its bones and you're computing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;final = price × Π (1 - d_i) − Σ fixed_j
clamp(final, 0, price)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;d_i&lt;/code&gt; are percentage discounts in &lt;code&gt;[0, 1]&lt;/code&gt; and &lt;code&gt;fixed_j&lt;/code&gt; are absolute-amount coupons in the same currency. The clamp is non-negotiable: a stack of "free shipping" and "$50 off a $30 item" should never go negative. Naïve subtraction without clamping is one of the classics you see in bug reports — a customer gets a $20 credit on a $12 order and the system tries to refund money that doesn't exist.&lt;/p&gt;

&lt;p&gt;The "compare-stacked-coupons-accurately" walkthrough on Lizely covers the spreadsheet version of this same calculation, including the trick of comparing two retailers by computing their effective multiplier side-by-side rather than by adding percentages. If you're building the calculation once in code rather than re-deriving it per spreadsheet, the principles are identical: &lt;a href="https://www.lizecheng.net/finance/guides/calculate-discount-in-excel-and-compare-stacked-coupons-accurately/" rel="noopener noreferrer"&gt;calculate discount in Excel and compare stacked coupons accurately&lt;/a&gt; is the kind of reference you want pinned next to your &lt;code&gt;pricing.md&lt;/code&gt; design doc so finance and engineering are looking at the same numbers.&lt;/p&gt;

&lt;p&gt;Rounding is the third leg of the stool. Most jurisdictions require that the &lt;em&gt;final&lt;/em&gt; charged amount round half-away-from-zero to the nearest cent, but you can round intermediate steps either way as long as you're consistent — and the consistency has to be documented, because it's the kind of thing auditors will ask about. The &lt;a href="https://en.wikipedia.org/wiki/IEEE_754" rel="noopener noreferrer"&gt;IEEE 754 standard for floating-point arithmetic&lt;/a&gt; is why you should never store currency as a JS &lt;code&gt;Number&lt;/code&gt;: use integer cents (or a &lt;code&gt;Decimal&lt;/code&gt; type) and only convert to a display string at the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Unit-Test Checklist for the Promo Module
&lt;/h2&gt;

&lt;p&gt;Here's the minimum set of cases I'd expect to see in &lt;code&gt;promo.test.ts&lt;/code&gt; (or the language equivalent) before shipping a discount calculator to production. It maps cleanly to Jest/Vitest/xUnit and is the same shape an integration test would use.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Single percentage&lt;/strong&gt; — &lt;code&gt;100 × 0.9 = 90.00&lt;/code&gt;. Confirms the trivial path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two stacked percentages&lt;/strong&gt; — &lt;code&gt;100 × 0.8 × 0.9 = 72.00&lt;/code&gt;. Confirms you aren't adding them to 170.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three stacked percentages&lt;/strong&gt; — &lt;code&gt;100 × 0.8 × 0.9 × 0.85 = 61.20&lt;/code&gt;. Catches off-by-one in accumulation order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Percentage + fixed amount&lt;/strong&gt; — &lt;code&gt;100 × 0.8 − 10 = 70.00&lt;/code&gt;. Catches a fixed-coupon applied before percentages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative-result clamp&lt;/strong&gt; — &lt;code&gt;20 × 0.5 − 50 = clamp(0)&lt;/code&gt;. Confirms no negative line items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-percent edge&lt;/strong&gt; — &lt;code&gt;50 × 1.0 = 50.00&lt;/code&gt;. Catches a &lt;code&gt;null&lt;/code&gt; discount treated as NaN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-discount edge&lt;/strong&gt; — &lt;code&gt;50 × 0 = 0.00&lt;/code&gt;. Confirms the API returns &lt;code&gt;0&lt;/code&gt;, not &lt;code&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency rounding&lt;/strong&gt; — inputs that produce a third-decimal-place value (e.g. &lt;code&gt;33.33 × 0.333&lt;/code&gt;) must round to two places deterministically across runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutually exclusive coupons&lt;/strong&gt; — applying both &lt;code&gt;SUMMER25&lt;/code&gt; and &lt;code&gt;WELCOME10&lt;/code&gt; when the spec forbids it must throw, not silently pick one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty discount list&lt;/strong&gt; — &lt;code&gt;price&lt;/code&gt; returned unchanged, with a discount-count of zero so the front end can render "no promotions applied".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any of those fails, the bug is almost always one of three things: additive instead of multiplicative math, premature rounding, or a missing clamp. They're easy to find with a bisecting commit once you have the test in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging Real-World Mismatches
&lt;/h2&gt;

&lt;p&gt;When finance and engineering disagree on a number, the disagreement is almost always one of five things. Walk through them in order before touching code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rounding step.&lt;/strong&gt; Someone rounded each percentage to two decimals before multiplying, which is wrong on a 33.333…% promotion. Multiply first, round once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax inclusion.&lt;/strong&gt; A "$100, 20% off, plus 8% tax" problem is three different numbers depending on whether the 20% applies before or after tax. Confirm the cart's tax model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stacking order.&lt;/strong&gt; Two discounts applied in different orders produce different totals when one is percentage and one is fixed. Check the order spec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency mismatch.&lt;/strong&gt; A "$10 off" coupon stored in USD applied to a EUR cart. Convert at the order's locked-in rate, never at request time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale coupon config.&lt;/strong&gt; The cache served a coupon that was valid yesterday but expired this morning. Hit the coupon service directly to confirm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful trick is to log the full discount trace alongside the order in your observability stack. A &lt;code&gt;discounts_applied&lt;/code&gt; array of &lt;code&gt;{label, factor, fixed_amount}&lt;/code&gt; with the running subtotal at each step is the cheapest way to make a five-minute disagreement into a thirty-second one. Trace it back through the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP" rel="noopener noreferrer"&gt;HTTP request lifecycle&lt;/a&gt; if the request came from the browser; the cookie or local storage often has a cached cart that disagrees with the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Constraints Worth Naming Out Loud
&lt;/h2&gt;

&lt;p&gt;A few constraints will show up the first week you're in production, and they're worth deciding before a customer finds them for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency.&lt;/strong&gt; Recomputing the same order with the same coupons must return the same total, byte for byte. That sounds obvious until a coupon service flaps and the customer hits "refresh" on the checkout page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditability.&lt;/strong&gt; Every line of the final price must be reconstructable from inputs six months later for a chargeback dispute. Store the discount trace with the order, not just the final number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance.&lt;/strong&gt; A cart with 200 line items and 5 promotions each is 1000 multiplications — trivial. A cart with 10,000 items and an &lt;code&gt;apply_promo_to_category&lt;/code&gt; walk is more interesting; cache category lookups per cart, not per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backward compatibility.&lt;/strong&gt; Once finance signs off on a rounding rule, treat it as part of the contract. Changing it mid-quarter requires a migration story for historical orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I round after each discount or only at the end?
&lt;/h3&gt;

&lt;p&gt;Round only at the end. Rounding intermediate steps introduces up to half-a-cent of error per discount, and three stacked discounts can drift the final number by a cent or more. The exception is when you need a display value mid-flow — the user sees "after coupon 1: $72.33" — and even then, round from the canonical integer-cents representation, not from a re-rounded float.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the right way to handle a coupon that no longer exists?
&lt;/h3&gt;

&lt;p&gt;Return an explicit error, not a silent zero. The API contract should distinguish &lt;code&gt;coupon_not_found&lt;/code&gt;, &lt;code&gt;coupon_expired&lt;/code&gt;, and &lt;code&gt;coupon_not_eligible_for_cart&lt;/code&gt;, because the front end needs to render them differently. Silently dropping an expired coupon is the kind of bug that surfaces as a "my coupon disappeared!" ticket.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I prevent two coupons from stacking when the spec forbids it?
&lt;/h3&gt;

&lt;p&gt;Enforce exclusion at the coupon-acceptance step, not at the calculation step. When a customer tries to apply a second coupon, validate it against the already-applied set first and reject early. Calculating a stacked total and then throwing it away wastes a round-trip and confuses the front-end team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where does tax fit in this model?
&lt;/h3&gt;

&lt;p&gt;Tax is downstream of the discount calculation. Compute the post-discount subtotal in integer cents, then apply tax on that subtotal. Applying tax before discounts is technically defensible in some jurisdictions but it's the minority case, and you should be explicit about which model you've picked — finance will ask.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>finance</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Barcode Workflows: Choosing Between Manual, Spreadsheet, and Online Tooling</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Tue, 25 Aug 2026 19:15:06 +0000</pubDate>
      <link>https://dev.to/lizely/barcode-workflows-choosing-between-manual-spreadsheet-and-online-tooling-3cij</link>
      <guid>https://dev.to/lizely/barcode-workflows-choosing-between-manual-spreadsheet-and-online-tooling-3cij</guid>
      <description>&lt;p&gt;Most engineering teams hit barcode generation the same way: someone in operations asks for a hundred labels by Friday, the requested symbology is wrong, and now a developer is figuring out what a quiet-zone ratio is. The real question is rarely "how do I render a Code 128 strip?" It is which path — hand-built scripts, a spreadsheet harness, or a hosted generator — actually matches your volume, your skill mix, and your tolerance for reprints. This guide walks through that decision with the trade-offs spelled out, then points to the workflow that fits each situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Roads to the Same Printed Strip
&lt;/h2&gt;

&lt;p&gt;You have roughly three viable paths when a team needs machine-readable marks on physical media. Each has a different cost curve, and each fails differently.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Programmatic rendering in code.&lt;/strong&gt; You pull a library — &lt;em&gt;bwip-js&lt;/em&gt;, &lt;em&gt;python-barcode&lt;/em&gt;, &lt;em&gt;ZBar&lt;/em&gt;, or a paid equivalent — and emit a PNG, SVG, or EPS into your pipeline. Works well inside an existing system that already produces PDFs or labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheet harness.&lt;/strong&gt; A shared workbook holds human-readable data; formulas or an add-in expand each row into a graphic and an embedded image goes onto the page. Familiar to non-engineers, painful to version control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted generator with export.&lt;/strong&gt; A web form turns human input into a downloadable image, often with bulk upload. Fastest for ad-hoc jobs and one-offs; weaker when you need full programmatic control.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rest of this article treats each path as a real option with honest downsides, not a ranked winner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 1: Rendering Programmatically
&lt;/h2&gt;

&lt;p&gt;The library approach is what most developers reach for first, and for good reason — once it works, it is fully reproducible. You commit a script, you rerun it on every build, and the output is deterministic.&lt;/p&gt;

&lt;p&gt;The downside shows up at the boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Library drift.&lt;/strong&gt; &lt;em&gt;bwip-js&lt;/em&gt; and &lt;em&gt;python-barcode&lt;/em&gt; both render the same symbologies, but their default quiet-zone widths, X-dimension units, and supported output formats are not identical. If a graphic must satisfy a retailer's verifier, the library defaults are often wrong, and you must override them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Font handling for Code 128.&lt;/strong&gt; Code 128 uses a special start-set selection that maps to the byte you want to encode. Most libraries handle this, but if you ever encode a mixed payload of printable ASCII and control characters, the result can be unreadable on hardware scanners even when the checksum is correct. The &lt;a href="https://en.wikipedia.org/wiki/Code_128" rel="noopener noreferrer"&gt;Wikipedia entry on Code 128&lt;/a&gt; is the most reliable quick reference for the start-code mapping and the modulo-103 checksum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output size.&lt;/strong&gt; A vector format (SVG, EPS) scales cleanly for label printers, but raster output must be generated at the target DPI &lt;em&gt;before&lt;/em&gt; resizing. Generating a 200-pixel-wide PNG and letting the layout engine stretch it to 600 DPI produces blurry edges that older CCD readers reject.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Programmatic rendering is the right answer when you already have a backend service, when labels are part of a regulated pipeline, or when you generate more than a few hundred per day. It is the wrong answer when the volume is low and the maintenance cost of the dependency outweighs the savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 2: The Spreadsheet Harness
&lt;/h2&gt;

&lt;p&gt;Plenty of small operations, warehouses, and event teams run on Excel or Google Sheets because the people doing the data entry already live there. The pattern is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Column A holds the human-readable value (a SKU, an asset tag, a ticket number).&lt;/li&gt;
&lt;li&gt;A formula or an add-in renders the glyph into an adjacent cell as an embedded image.&lt;/li&gt;
&lt;li&gt;A mail-merge step — or a simple copy-paste — places each row onto a label template.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Where this falls apart is governance. A spreadsheet harness has no audit trail, no schema enforcement, and no protection against typos that produce a valid but wrong value. If column A reads &lt;code&gt;ASSET-00123&lt;/code&gt; and someone fat-fingers &lt;code&gt;ASSET-00132&lt;/code&gt;, the scanner reads both happily, and your inventory drifts silently. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input" rel="noopener noreferrer"&gt;MDN documentation on form data validation&lt;/a&gt; is worth borrowing principles from here even though it is HTML — the same idea of &lt;em&gt;validate at the edge before it enters the system&lt;/em&gt; applies.&lt;/p&gt;

&lt;p&gt;Spreadsheet is the right answer when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Volume is under a few hundred per month.&lt;/li&gt;
&lt;li&gt;The data is already maintained in that sheet by a non-engineer.&lt;/li&gt;
&lt;li&gt;Errors are recoverable (reprint is cheap, no regulatory impact).&lt;/li&gt;
&lt;li&gt;You can add a length check, a checksum column, or a data-validation rule that rejects bad input.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is the wrong answer when traceability matters, when the same dataset feeds other systems, or when the spreadsheet becomes the de facto database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 3: Hosted Generator with Export
&lt;/h2&gt;

&lt;p&gt;The fastest path from "I need a label" to "I have a label" is a browser-based tool. You type the payload, pick a symbology, and download the image. Bulk versions accept a CSV and emit a ZIP of graphics.&lt;/p&gt;

&lt;p&gt;The trade-off is control. You do not own the renderer, you cannot audit the algorithm, and the operator must trust that the quiet-zone, X-dimension, and check-digit behavior matches the verifier on the receiving end. For low-risk jobs — internal asset tags, event badges, library call numbers — that risk is acceptable. For shipping labels going to a retailer's distribution center, it usually is not.&lt;/p&gt;

&lt;p&gt;If your team needs the speed of an online tool but must drop the resulting image into a Word document or an Avery template, the practical walkthrough is &lt;a href="https://www.lizecheng.net/seo/guides/create-barcodes-in-word-easily-with-a-free-online-generator/" rel="noopener noreferrer"&gt;this guide on creating barcodes in Word with a free online generator&lt;/a&gt;. It covers the export-and-insert workflow without burying the trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Decision Checklist You Can Actually Use
&lt;/h2&gt;

&lt;p&gt;Before committing to a path, run this list. Every item is a question that has killed a rollout somewhere.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the worst-case cost of a misread?&lt;/strong&gt; A wrong asset tag costs minutes; a wrong pallet label costs a recall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who maintains the source data?&lt;/strong&gt; If it is a developer, lean programmatic. If it is a line operator, lean spreadsheet or hosted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the daily volume, and how does it trend?&lt;/strong&gt; Stable low volume favors hosted; growing volume favors scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will the symbology change?&lt;/strong&gt; Adding GS1-128 to a Code 128 workflow means rewriting the renderer. Plan for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What format does the printer accept?&lt;/strong&gt; Some thermal printers want native ZPL or EPL, not PNG. Generating the wrong format forces a downstream conversion step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there a verifier on the receiving end?&lt;/strong&gt; A Grade C or better verifier is the difference between "looks fine" and "scans reliably."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who owns the version of the generator?&lt;/strong&gt; If the answer is "the person who set it up three years ago," your risk is continuity.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The checklist works because it surfaces the constraints before the choice locks you in.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Three Paths Collide
&lt;/h2&gt;

&lt;p&gt;Most real teams do not pick one path forever. A typical stack looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A hosted tool for one-off requests during onboarding.&lt;/li&gt;
&lt;li&gt;A spreadsheet for the operations team during seasonal spikes.&lt;/li&gt;
&lt;li&gt;A programmatic pipeline for the steady-state production volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake is mixing paths without a contract between them. If the operations spreadsheet produces labels for SKUs that the production pipeline also emits, you have two sources of truth. Pick one path per &lt;em&gt;data domain&lt;/em&gt; — the same SKU never gets a strip from two different generators. This is the same principle as single-writer databases: the integrity of the system is more important than the convenience of any single path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Goes Wrong After You Pick
&lt;/h2&gt;

&lt;p&gt;A common failure pattern: a team picks the hosted tool, the tool's vendor changes a default, and now every previously printed label fails the verifier on a new reader model. The mitigation is a verification step, not a smarter tool. Print five samples, scan them on the actual hardware the customer uses, and confirm the quiet-zone width with a loupe or a verifier app. If you skip this, you discover the problem at the worst possible moment.&lt;/p&gt;

&lt;p&gt;Another pattern: a script produces a strip at the wrong DPI, the printer driver scales it down, and the rendered cells fall below the minimum X-dimension. The fix is to set DPI in the script and pass the printer driver an image at exactly its native resolution. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio" rel="noopener noreferrer"&gt;MDN page on image resolution and pixel density&lt;/a&gt; is a useful reference for understanding why "looks fine on screen" is unrelated to "prints at 300 DPI."&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Which path should a small team pick first?
&lt;/h3&gt;

&lt;p&gt;Start with the hosted generator. It gets unblocks the immediate request, and you will learn which symbology, what volume, and what verifier your situation actually needs. Once those answers are concrete, graduate to a script if the volume justifies it.&lt;/p&gt;

&lt;h3&gt;
  
  
  When does a spreadsheet become unsafe?
&lt;/h3&gt;

&lt;p&gt;The moment the same data feeds another system — an ERP, a shipping manifest, a customer-facing lookup. Spreadsheets are great for human-owned data and terrible for shared truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know the symbology is right before I print a thousand labels?
&lt;/h3&gt;

&lt;p&gt;Print a five-label sample, scan each on the hardware the receiver uses, and run a verifier if one is available. If a Grade C or higher is required by the receiving party, accept nothing below that threshold.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I mix all three paths?
&lt;/h3&gt;

&lt;p&gt;Yes, but assign each path to a distinct data domain. Never let two paths produce labels for the same identifier, or you will debug phantom inventory drift for weeks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>seo</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Rolling Your Own Password Generator in Code: When It's the Right Call and How to Stop Getting It Wrong</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Mon, 24 Aug 2026 22:07:18 +0000</pubDate>
      <link>https://dev.to/lizely/rolling-your-own-password-generator-in-code-when-its-the-right-call-and-how-to-stop-getting-it-5bdb</link>
      <guid>https://dev.to/lizely/rolling-your-own-password-generator-in-code-when-its-the-right-call-and-how-to-stop-getting-it-5bdb</guid>
      <description>&lt;p&gt;Most password "guides" skip past the engineering side. They tell you to use a manager, click a button, and move on. But the moment you sit down to wire authentication into a script, a CLI, a CI job, or a small internal tool, the question gets specific fast: do I shell out to an existing generator, call an API, or generate the password inside the program I am already writing?&lt;/p&gt;

&lt;p&gt;This piece is for engineers making that call. It covers the cases where generating the password in code is the right answer, the constraints you actually hit, the patterns that hold up under review, and the patterns that get rejected. The in-depth walkthrough for the on-the-spot case — humans typing into a browser — lives in the &lt;a href="https://www.lizecheng.net/encoding/guides/how-to-generate-a-secure-password-using-google-and-local-tools/" rel="noopener noreferrer"&gt;Lizely guide on generating a secure password using Google and local tools&lt;/a&gt;, which is worth bookmarking. What follows is a different problem: programmatic generation, inside your own stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Programmatic Generation Is the Right Choice
&lt;/h2&gt;

&lt;p&gt;There are four situations where generating a password inside your code, rather than asking a human to paste one in, is the correct engineering decision.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;seeding service accounts at deploy time.&lt;/strong&gt; A deploy script that creates a database user, an S3 IAM user, or a service principal needs a credential to put somewhere — typically a secret manager. Nobody should be copy-pasting it from a browser. The generator should run inline, write the secret, and never echo it.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;generating one-time passwords for invitations.&lt;/strong&gt; When you invite a contractor, issue a vendor API key, or open a temporary portal account, the password is a payload, not a workflow. It is generated, emailed or displayed once, and rotated on first login.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;test fixtures.&lt;/strong&gt; Reproducible CI runs often need deterministic credentials — same length, same charset — so that downstream assertions about format, not entropy, are stable.&lt;/p&gt;

&lt;p&gt;Fourth, &lt;strong&gt;breaking glass.&lt;/strong&gt; The "emergency admin" credential that lives in a vault for incident response. Generating it once at provisioning time, storing it sealed, and never displaying it again.&lt;/p&gt;

&lt;p&gt;If your case is not one of those four, you almost certainly want a manager and a human in the loop. The rest of this article assumes you are in one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Constraints That Actually Matter
&lt;/h2&gt;

&lt;p&gt;When reviewers push back on homegrown generators, it is almost never about length. It is about three things that experienced security engineers have seen go wrong repeatedly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Entropy Source
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;Math.random()&lt;/code&gt; call in JavaScript, a &lt;code&gt;random.randint()&lt;/code&gt; in Python, or a &lt;code&gt;rand()&lt;/code&gt; in C is &lt;strong&gt;not&lt;/strong&gt; suitable. These are deterministic PRNGs seeded from low-entropy state. A 32-character password built from &lt;code&gt;Math.random()&lt;/code&gt; collapses to a search space much smaller than 32 characters, because the underlying generator state is recoverable from a small number of outputs.&lt;/p&gt;

&lt;p&gt;You need a CSPRNG. The acronym stands for cryptographically secure pseudorandom number generator, and the property you care about is that even an attacker who has seen every previous output cannot predict the next one with better than 50/50 odds. Most languages expose this under a different name:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python: &lt;code&gt;secrets&lt;/code&gt; module — built specifically for tokens and passwords.&lt;/li&gt;
&lt;li&gt;Node.js: &lt;code&gt;crypto.randomBytes()&lt;/code&gt; or &lt;code&gt;crypto.randomInt()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Go: &lt;code&gt;crypto/rand&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Java: &lt;code&gt;java.security.SecureRandom&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rust: &lt;code&gt;rand&lt;/code&gt; crate with the &lt;code&gt;OsRng&lt;/code&gt; adapter.&lt;/li&gt;
&lt;li&gt;C/C++: read from &lt;code&gt;/dev/urandom&lt;/code&gt; or call &lt;code&gt;BCryptGenRandom&lt;/code&gt; on Windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://docs.python.org/3/library/secrets.html" rel="noopener noreferrer"&gt;Python &lt;code&gt;secrets&lt;/code&gt; documentation&lt;/a&gt; is a particularly clear reference: it explicitly contrasts itself with &lt;code&gt;random&lt;/code&gt; and lists the exact failure modes. Worth reading once even if you do not write Python.&lt;/p&gt;

&lt;h3&gt;
  
  
  Output Bias
&lt;/h3&gt;

&lt;p&gt;Once you have a CSPRNG, the next trap is mapping uniform bytes onto a restricted character set. If your charset is 62 characters (a–z, A–Z, 0–9) and you take a random byte mod 62, the first six residues are slightly more likely than the rest. For 16-character passwords this is negligible. For API keys that get generated millions of times and then brute-forced, it is measurable.&lt;/p&gt;

&lt;p&gt;The fix is rejection sampling: read bytes, accept only those below the largest multiple of your charset size that does not exceed 256, and resample the rest. Or use a base-N encoding trick. &lt;code&gt;secrets.choice&lt;/code&gt; in Python and &lt;code&gt;crypto.randomInt&lt;/code&gt; in Node both handle this for you when you ask for a range, but if you are building the alphabet yourself, audit the mapping.&lt;/p&gt;

&lt;p&gt;For the deepest treatment, the &lt;a href="https://csrc.nist.gov/publications/detail/sp/800-90a/rev-1/final" rel="noopener noreferrer"&gt;NIST SP 800-90A recommendation series&lt;/a&gt; describes the entropy-extraction patterns that standardized generators use. You do not need to implement it from scratch — the point is to recognize the failure mode when reviewing someone else's code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Logging and Side Channels
&lt;/h3&gt;

&lt;p&gt;The third constraint is the one that ships the bug. A password gets generated, then a stack trace gets printed, then someone greps logs six months later and finds the credential. The patterns I have seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An exception handler that logs its arguments, including a generated password that was passed in.&lt;/li&gt;
&lt;li&gt;A debug print of "request body" that serializes the whole payload.&lt;/li&gt;
&lt;li&gt;A test harness that captures stdout and stores it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mitigation is mechanical: generate the password as late as possible, hand it directly to the secret manager or the response object, and never let it touch a variable that has a debug representation. In Python this means returning from a function whose return value is wrapped immediately; in Go it means passing a &lt;code&gt;[]byte&lt;/code&gt; rather than a &lt;code&gt;string&lt;/code&gt; so that &lt;code&gt;%s&lt;/code&gt; formatting does not reveal it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Concrete Pattern That Survives Review
&lt;/h2&gt;

&lt;p&gt;Here is a minimal pattern in Python that hits the three constraints above and is short enough to read in one screen. It uses the &lt;a href="https://docs.python.org/3/library/secrets.html" rel="noopener noreferrer"&gt;standard &lt;code&gt;secrets&lt;/code&gt; module&lt;/a&gt;, applies rejection sampling implicitly through &lt;code&gt;secrets.choice&lt;/code&gt;, and returns the result without printing anything.&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;secrets&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;

&lt;span class="n"&gt;ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ascii_letters&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;digits&lt;/span&gt;
&lt;span class="n"&gt;LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&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="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ALPHABET&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;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Node, the equivalent is roughly:&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&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;ALPHABET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789&lt;/span&gt;&lt;span class="dl"&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;generatePassword&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;=&lt;/span&gt; &lt;span class="mi"&gt;24&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="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&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;*&lt;/span&gt; &lt;span class="mi"&gt;2&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;out&lt;/span&gt; &lt;span class="o"&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;for &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;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;ALPHABET&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;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;ALPHABET&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;length * 2&lt;/code&gt; on the randomBytes call — that is a cheap defense against output bias by oversampling. For high-volume key generation, switch to explicit rejection sampling:&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;unbiased&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;=&lt;/span&gt; &lt;span class="mi"&gt;24&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;max&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ALPHABET&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="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ALPHABET&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="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="k"&gt;while &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="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&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="nx"&gt;b&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&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;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;out&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="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="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;ALPHABET&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="p"&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="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="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;Both versions are short, but only the second one is appropriate for keys that get rotated through an API and could be observed by an attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Reach for a Library Instead
&lt;/h2&gt;

&lt;p&gt;Hand-rolling is fine for the four cases above. It is &lt;strong&gt;not&lt;/strong&gt; fine when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are generating passwords for end users at scale. The threat model changes from "credential is leaked through logs" to "credential is guessed online." You want a library that is audited for that case, plus rate limiting at the auth layer.&lt;/li&gt;
&lt;li&gt;You need passphrases instead of passwords. Four random dictionary words from a 7,776-word list is approximately 51 bits of entropy, which is excellent for human memorability but requires word-list hygiene you do not want to maintain yourself.&lt;/li&gt;
&lt;li&gt;You are signing or encrypting. A password generator is not a key derivation function. If your goal is to derive a key from a password, you want &lt;a href="https://en.wikipedia.org/wiki/PBKDF2" rel="noopener noreferrer"&gt;PBKDF2&lt;/a&gt;, &lt;a href="https://en.wikipedia.org/wiki/Scrypt" rel="noopener noreferrer"&gt;scrypt&lt;/a&gt;, or &lt;a href="https://en.wikipedia.org/wiki/Argon2" rel="noopener noreferrer"&gt;Argon2&lt;/a&gt; — three algorithms with very different performance and memory profiles, covered in their respective Wikipedia entries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dividing line I use in code review: if the generated string is going to be hashed with bcrypt or argon2 on the server side, the in-code generator is fine. If the generated string is going to be used as a key directly, or as input to encryption, stop and reach for a KDF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes in Review
&lt;/h2&gt;

&lt;p&gt;When I review a PR that contains a password generator, I look for exactly four things. They are a usable checklist.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Source of randomness.&lt;/strong&gt; Is it a CSPRNG? Grep for &lt;code&gt;Math.random&lt;/code&gt;, &lt;code&gt;random.randint&lt;/code&gt;, &lt;code&gt;rand(&lt;/code&gt;, and &lt;code&gt;mt_rand&lt;/code&gt;. Any hit is a reject.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alphabet size vs. length.&lt;/strong&gt; A 6-character password from a 62-character alphabet is 35 bits. That is brute-forceable on a GPU farm in hours. Length matters more than character variety past a certain threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging surface.&lt;/strong&gt; Does the variable holding the password ever appear in a format string, a JSON serializer, or an exception payload? If yes, rename it to something explicit and add a lint rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Post-generation handling.&lt;/strong&gt; Is the password written to a secret manager, or is it echoed? The former is correct. The latter is correct only for invitation flows where the consumer needs to see it once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If those four are clean, the PR is approvable. If any fails, the PR is bounced with a comment naming the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on "No Characters That Look Alike"
&lt;/h2&gt;

&lt;p&gt;You will be asked, at some point, to exclude characters that look alike — usually &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;O&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;l&lt;/code&gt;, &lt;code&gt;I&lt;/code&gt;. Resist this. It shrinks the alphabet, reduces entropy, and the actual usability benefit is small for a password that is copy-pasted once. The &lt;a href="https://pages.nist.gov/800-63-3/sp800-63b.html" rel="noopener noreferrer"&gt;NIST SP 800-63B Digital Identity Guidelines&lt;/a&gt; explicitly recommend against composition rules, including this one, in favor of length. If a stakeholder insists, document the entropy loss so the decision is made consciously rather than by reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long should a programmatically generated password be?
&lt;/h3&gt;

&lt;p&gt;For service accounts and CI tokens, 24 characters from a 62-character alphabet is a sensible default — roughly 143 bits of entropy, well past any brute-force horizon. For invitation passwords that humans will type once before rotating, 16 is acceptable. For end-user passwords, length matters less than your rate limiting and storage hashing strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;crypto.randomUUID()&lt;/code&gt; good enough?
&lt;/h3&gt;

&lt;p&gt;For tokens that get used as identifiers, yes. For passwords, no — UUIDs are drawn from a restricted bit pattern and produce strings with hyphens in fixed positions, which weakens them as passwords and makes them recognizable as UUIDs in logs. Use a CSPRNG directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I generate the password on the client?
&lt;/h3&gt;

&lt;p&gt;Almost never. If the client generates it, the client has the plaintext, and you have lost control of where it lives. Generate server-side, transmit over TLS, and never store the plaintext past the initial issuance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about using &lt;code&gt;openssl rand -base64&lt;/code&gt; from a shell script?
&lt;/h3&gt;

&lt;p&gt;It works for one-off provisioning, and the underlying CSPRNG is correct. The risks are the shell-script ones: the password lands in process listings, in shell history, and in any error output. Prefer a small language program that hands the value to your secret manager in the same process.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>encoding</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Auditing an Existing Subnet Plan Before a Cloud Migration</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Sun, 23 Aug 2026 20:03:03 +0000</pubDate>
      <link>https://dev.to/lizely/auditing-an-existing-subnet-plan-before-a-cloud-migration-2f6g</link>
      <guid>https://dev.to/lizely/auditing-an-existing-subnet-plan-before-a-cloud-migration-2f6g</guid>
      <description>&lt;p&gt;Most subnet articles start from a blank piece of paper and end with a clean diagram. That is not the situation most teams are actually in. The situation is a Confluence page from 2019, a router config someone exported last quarter, and a network engineer who swears "the 10.40.0.0/16 is in use somewhere" but cannot remember where. The migration deadline is in three weeks. Your job is to prove, on paper, that every existing subnet still fits its purpose, that nothing overlaps, and that the new VPC ranges will not collide with anything that survives the cutover.&lt;/p&gt;

&lt;p&gt;This is the audit mindset. It is mostly arithmetic, a little detective work, and a lot of writing things down so the next person does not have to redo it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the Audit Like a Code Review
&lt;/h2&gt;

&lt;p&gt;A subnet plan is a piece of infrastructure code. It deserves the same hygiene as a Terraform module: a source of truth, a diff against reality, and a sign-off. Before touching anything, gather the inputs into one place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An export of every router, switch, and firewall interface description. The description string on &lt;code&gt;GigabitEthernet0/0/0&lt;/code&gt; is often the only thing that says "links to warehouse printer VLAN."&lt;/li&gt;
&lt;li&gt;The DHCP scopes, including any helpers, reservations, and superscopes.&lt;/li&gt;
&lt;li&gt;The DNS zone file, or at least the forward and reverse delegations your team owns.&lt;/li&gt;
&lt;li&gt;Every cloud account's VPC, VNet, or project. Most teams have at least one "shadow VPC" that nobody admits to.&lt;/li&gt;
&lt;li&gt;The site-to-site VPN and peering configuration. Encapsulated ranges count.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put these into a single spreadsheet with one row per subnet: purpose, CIDR, VLAN ID, gateway, DHCP range, DNS suffix, owning team, and last-touched date. The last column is the most honest one. If a subnet has not been touched since 2021, ask whether it still needs to exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the Numbers, Not the Vibes
&lt;/h2&gt;

&lt;p&gt;Once the inventory exists, the next step is to reduce it to first principles. For every row, write down the network address, the broadcast address, the usable host range, and the prefix length. This sounds tedious, and it is, which is exactly why mistakes hide here.&lt;/p&gt;

&lt;p&gt;A reliable workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick the &lt;strong&gt;largest&lt;/strong&gt; block you actually control or peer with. For most enterprises this is a /16 from RFC 1918: &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt;, or &lt;code&gt;192.168.0.0/16&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Subtract the subnets you have already allocated from that block. If two subnets overlap, you have already found your first bug.&lt;/li&gt;
&lt;li&gt;Compute the remaining free space as a list of CIDR ranges. These are the candidates for the migration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The arithmetic itself is the same as the one covered in the &lt;a href="https://www.lizecheng.net/dev/guides/how-to-calculate-subnet-bits-from-an-ip-and-mask/" rel="noopener noreferrer"&gt;step-by-step guide to calculating subnet bits from an IP and mask&lt;/a&gt;. Re-do it by hand for the top five entries in your inventory. If you cannot, neither can the on-call engineer at 2 a.m.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failure Modes the Audit Has to Catch
&lt;/h2&gt;

&lt;p&gt;A few patterns show up in roughly every audit I have run. Build the review checklist around them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlapping allocations from acquisitions.&lt;/strong&gt; A merged company often keeps both legacy schemas alive. Two /20s that look unrelated on paper can share a /19 if someone carved them across the wrong boundary. The fix is to identify the &lt;em&gt;parent&lt;/em&gt; block each subnet belongs to and walk the tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "we only use a few hosts" myth.&lt;/strong&gt; A /24 has 254 usable addresses. Teams that ask for one "because we only need 30 hosts" almost always grow into it, then ask for another /24 instead of re-binning. Audits are a good moment to right-size: a /27 gives 30 hosts, leaves room to grow within the same /24, and is easier to summarize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation lag.&lt;/strong&gt; The spreadsheet says &lt;code&gt;10.20.5.0/24&lt;/code&gt; is "Finance printers." The router says &lt;code&gt;10.20.5.0/24&lt;/code&gt; is "Guest Wi-Fi pilot, temporary." One of those is wrong. Cross-check against DHCP leases and firewall logs. The truth is usually in the logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asymmetric VPN ranges.&lt;/strong&gt; Site A uses &lt;code&gt;10.10.0.0/16&lt;/code&gt;. Site B uses &lt;code&gt;10.20.0.0/16&lt;/code&gt;. The tunnel comes up, but a host at A trying to reach a service at B cannot, because the corporate hub has a route summary that includes &lt;code&gt;10.10.0.0/15&lt;/code&gt; and now covers B by accident. The mitigation is to keep site allocations inside distinct /16s and document the summary routes.&lt;/p&gt;

&lt;p&gt;A practical ordered checklist for the audit meeting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory sources exported and merged into one sheet.&lt;/li&gt;
&lt;li&gt;Every row has a verified purpose and owner.&lt;/li&gt;
&lt;li&gt;CIDR math re-derived for each row from first principles.&lt;/li&gt;
&lt;li&gt;Overlap test: no two rows share any address.&lt;/li&gt;
&lt;li&gt;Containment test: every row is inside the declared parent block.&lt;/li&gt;
&lt;li&gt;Right-size test: prefix length matches the documented host count plus headroom.&lt;/li&gt;
&lt;li&gt;Cloud test: every VPC range is unique against on-prem ranges.&lt;/li&gt;
&lt;li&gt;VPN test: every encrypted tunnel's local and remote ranges are documented and non-overlapping with any other range on either side.&lt;/li&gt;
&lt;li&gt;Sign-off recorded with date and reviewer name.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Designing the New Ranges to Avoid the Same Mistakes
&lt;/h2&gt;

&lt;p&gt;The migration is the right moment to introduce a &lt;strong&gt;prefix budget&lt;/strong&gt;. Treat your RFC 1918 space the way a sysadmin treats a disk: lay out a top-level partition scheme and stick to it.&lt;/p&gt;

&lt;p&gt;A workable pattern for a multi-VPC estate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reserve a contiguous /16 per region or per business unit.&lt;/li&gt;
&lt;li&gt;Inside each /16, reserve the first /20 for infrastructure (bastion, DNS, monitoring, jump hosts).&lt;/li&gt;
&lt;li&gt;Reserve the second /20 for shared services (CI runners, artifact caches, internal APIs).&lt;/li&gt;
&lt;li&gt;The remaining /11 is for application subnets, sliced per environment (dev, staging, prod) and per tier (web, app, data).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives you three properties for free. First, any summary route you advertise is clean and unlikely to cross-pollinate. Second, capacity planning is local: a team that needs more /24s asks inside its own /16, not against the whole /8. Third, audit answers become one-line: "Is this VPC inside its parent /16? Yes or no."&lt;/p&gt;

&lt;p&gt;The prefix budget also forces the conversation about IPv6. Most enterprises are still IPv4-only in production, but every modern cloud VPC supports dual-stack with essentially no extra cost. If your audit produces a single document that includes an IPv6 prefix per region (typically a &lt;code&gt;/56&lt;/code&gt; per VPC, carved from your ARIN or RIPE allocation), the next audit will not have to invent the scheme from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling and Verification
&lt;/h2&gt;

&lt;p&gt;Manual arithmetic is the right starting point because it exposes assumptions, but it does not scale to 400 subnets. Pick a calculator you trust, run every row through it, and then run the same rows through a second method. The two outputs should match exactly.&lt;/p&gt;

&lt;p&gt;A useful verification trick is to treat the inventory as a set of intervals on the number line and use simple set arithmetic: the union of all allocated subnets must be a subset of the parent block, and pairwise intersections must be empty. If you can express your spreadsheet as a list of &lt;code&gt;[start, end]&lt;/code&gt; pairs and pass it to a 30-line script that flags any pair whose intervals intersect, you will catch the overlap that the human eye misses at row 47.&lt;/p&gt;

&lt;p&gt;Two references to keep on hand during the audit. RFC 1918 is the canonical source for private IPv4 allocation and is short enough to read in one sitting; the &lt;a href="https://www.rfc-editor.org/rfc/rfc1918.html" rel="noopener noreferrer"&gt;RFC 1918 document&lt;/a&gt; is the right anchor. For IPv6 planning, the &lt;a href="https://en.wikipedia.org/wiki/IPv6" rel="noopener noreferrer"&gt;Wikipedia IPv6 article&lt;/a&gt; covers address types and the recommended allocation sizes for organizations. Neither is marketing material; both are stable and unlikely to rot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How often should a subnet audit be performed?
&lt;/h3&gt;

&lt;p&gt;At least once a year, and any time a new site, cloud account, or major acquisition enters the picture. The cost of a yearly review is far less than the cost of discovering a collision during a Friday night cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the smallest team that can do this responsibly?
&lt;/h3&gt;

&lt;p&gt;One engineer who owns the spreadsheet, plus one reviewer who challenges every row. The reviewer does not need to be a network specialist; they need to be willing to ask "why is this /24 and not /27?" and to wait for a real answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should overlapping subnets be merged or kept separate?
&lt;/h3&gt;

&lt;p&gt;Merge only when the owning teams agree on a common gateway, DHCP scope, and security policy. Otherwise, re-bin the parent block: pick a new prefix length that holds both, and document the carve-out as a single allocation rather than two overlapping ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the single highest-value artifact to produce?
&lt;/h3&gt;

&lt;p&gt;A signed diagram and spreadsheet that show, on one page, every subnet, its parent block, its purpose, and its owner. If a stranger can read that page and understand the network in five minutes, the audit has succeeded.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>dev</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
    <item>
      <title>Tipping on a Pre-Tax vs Post-Tax Base: A Reference Sheet for Engineers Who Build the Math</title>
      <dc:creator>Tea-sip</dc:creator>
      <pubDate>Sat, 22 Aug 2026 22:04:18 +0000</pubDate>
      <link>https://dev.to/lizely/tipping-on-a-pre-tax-vs-post-tax-base-a-reference-sheet-for-engineers-who-build-the-math-57ad</link>
      <guid>https://dev.to/lizely/tipping-on-a-pre-tax-vs-post-tax-base-a-reference-sheet-for-engineers-who-build-the-math-57ad</guid>
      <description>&lt;p&gt;If you have ever wired up a "calculator widget" in a checkout flow, expense module, or hospitality backend, you have probably discovered that the boring question — &lt;em&gt;do we tip on the pre-tax subtotal, the post-tax total, or something in between?&lt;/em&gt; — is not boring at all. It is a tiny rules engine wrapped in regional habits, rounding quirks, and disagreement between what a customer &lt;em&gt;expects&lt;/em&gt; and what a service worker &lt;em&gt;receives&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This article is for engineers and product builders who need to implement tipping cleanly. It treats the problem as a small data-mapping exercise: inputs, rules, edge cases, outputs. No vibes, no etiquette lectures — just the working surface area you need to ship a defensible feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the base amount matters more than the percentage
&lt;/h2&gt;

&lt;p&gt;Most developers assume tipping is a single multiplication: &lt;code&gt;tip = subtotal * 0.18&lt;/code&gt;. That works until your subtotal is the wrong one. In the United States, sales tax is appended at the register, after the tip prompt on most point-of-sale terminals — which means the tip is computed against the pre-tax base by default. In countries with a pre-tax model, such as much of Canada, the practice is similar. In jurisdictions where a service charge is already baked into the price, adding a "tip" on top of an already-inflated base double-counts the service component.&lt;/p&gt;

&lt;p&gt;For a builder, the practical consequence is that your calculation function should accept &lt;strong&gt;two numeric inputs&lt;/strong&gt; — the pre-tax base and the post-tax total — and an explicit rule selector, rather than a single opaque &lt;code&gt;subtotal&lt;/code&gt;. That makes the policy auditable later, because someone in QA &lt;em&gt;will&lt;/em&gt; ask why the figure on screen differs from the figure on the receipt.&lt;/p&gt;

&lt;p&gt;A concise table of common conventions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region / Venue Type&lt;/th&gt;
&lt;th&gt;Default Base&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US restaurants, cafes&lt;/td&gt;
&lt;td&gt;Pre-tax subtotal&lt;/td&gt;
&lt;td&gt;Tip line appears before tax on the receipt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US bars and table service&lt;/td&gt;
&lt;td&gt;Pre-tax subtotal&lt;/td&gt;
&lt;td&gt;Some terminals default to 20% on post-tax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canada (most provinces)&lt;/td&gt;
&lt;td&gt;Pre-tax subtotal&lt;/td&gt;
&lt;td&gt;GST/HST still added after the tip line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK pubs and restaurants&lt;/td&gt;
&lt;td&gt;Often discretionary&lt;/td&gt;
&lt;td&gt;Many receipts show service charge inclusive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU sit-down dining&lt;/td&gt;
&lt;td&gt;Service charge may already be included&lt;/td&gt;
&lt;td&gt;Tipping "on top" is unusual but tolerated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A minimal reference function
&lt;/h2&gt;

&lt;p&gt;Here is a small, copy-pasteable shape for the calculation, in plain pseudocode so the intent is obvious regardless of your stack:&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;computeTip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxAmount&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="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;baseAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PRE_TAX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;taxAmount&lt;/span&gt;
    &lt;span class="nx"&gt;rawTip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;baseAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;roundToCurrency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawTip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;policy&lt;/code&gt; is an enum, not a boolean. "Always post-tax" and "always pre-tax" are two of three or five modes your product will eventually support. Adding a string later is cheaper than refactoring a flag.&lt;/li&gt;
&lt;li&gt;The rounding rule belongs to the function, not the caller. If your terminal rounds up to the nearest dollar and your mobile app rounds to the nearest cent, your QA matrix will explode.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;taxAmount&lt;/code&gt; is a separate input. Trying to derive it inside the calculator from a magical &lt;code&gt;subtotal&lt;/code&gt; is how you end up with off-by-a-fraction bugs at the boundary between inclusive and exclusive tax regimes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the deeper walkthrough of how those policies behave when you stack discount lines, loyalty credits, and comped items, the &lt;a href="https://www.lizecheng.net/finance/guides/tip-before-or-after-tax-how-to-calculate-it-right/" rel="noopener noreferrer"&gt;Lizely guide on tipping before or after tax&lt;/a&gt; is the reference I send junior engineers to before they touch the checkout code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases that bite in production
&lt;/h2&gt;

&lt;p&gt;A few scenarios that look innocuous on a whiteboard but cause real tickets:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comp items and "voided" lines.&lt;/strong&gt; If a free dessert is zero-priced on the ticket, it usually stays in the tip denominator — the server still carried it. Decide that explicitly. Some systems exclude comps from the base; others include them at zero cost. Either is defensible, but the choice must be documented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tax on tax in inclusive regimes.&lt;/strong&gt; When VAT is folded into the displayed price, "post-tax" and "pre-tax" collapse to the same number, but the &lt;em&gt;suggested tip percentage&lt;/em&gt; may differ because the displayed price already includes the service mindset. &lt;a href="https://en.wikipedia.org/wiki/Value-added_tax" rel="noopener noreferrer"&gt;The OECD's value-added tax overview&lt;/a&gt; is a stable reference for which jurisdictions use inclusive pricing — bookmark the article rather than any single country's revenue page, since those URLs move frequently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currency and minor units.&lt;/strong&gt; JPY has zero decimal places; BHD has three. If your tip calculator returns &lt;code&gt;Math.round(value * 100) / 100&lt;/code&gt;, you have already shipped a bug to three markets. Store and compute in minor units (cents, fils, sen) and only format on the way out. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat" rel="noopener noreferrer"&gt;MDN guide to &lt;code&gt;Intl.NumberFormat&lt;/code&gt;&lt;/a&gt; covers the formatting half; pair it with a minor-units convention in your domain model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rounding direction and group fairness.&lt;/strong&gt; When a table of four splits a tip, the order in which you round each share matters. Round all shares down and the server loses a few cents; round all up and the last payer is subsidizing the others. The honest fix is to compute the unrounded total, distribute, then adjust only the largest share by the residual — which is why the "split the bill fairly" pattern is its own subsystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-entered percentages that ignore policy.&lt;/strong&gt; Many terminals let a customer tap "18%" without specifying the base. If your engine assumes pre-tax and the customer's mental model is post-tax (because the displayed total included tax), they will tip less than they intended. Surfacing the base on the tip screen — even as a small grey line — closes most of that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-launch checklist for the tipping component
&lt;/h2&gt;

&lt;p&gt;Before you ship, walk this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Confirm with finance or ops which base the business expects: pre-tax, post-tax, or configurable.&lt;/li&gt;
&lt;li&gt;[ ] Decide rounding direction per currency and document it next to the function.&lt;/li&gt;
&lt;li&gt;[ ] Treat tax as a separate input, not a derived value, so inclusive and exclusive regimes share the same code path.&lt;/li&gt;
&lt;li&gt;[ ] Validate that comp, void, and discount lines behave consistently with the chosen rule.&lt;/li&gt;
&lt;li&gt;[ ] Test split flows with uneven shares (3, 5, 7 payers) and confirm the residual adjustment lands on the largest share.&lt;/li&gt;
&lt;li&gt;[ ] Add a &lt;code&gt;policy&lt;/code&gt; enum field to any analytics event so you can later answer "what fraction of tips were computed on which base?"&lt;/li&gt;
&lt;li&gt;[ ] Localize the tip prompt wording; "gratuity" and "tip" carry different cultural weight.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to pick the base for your specific product
&lt;/h2&gt;

&lt;p&gt;There is no universal right answer. Three heuristics I lean on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match the physical receipt.&lt;/strong&gt; If your digital receipt mirrors the printed one, mirror the calculation. Customers who reconcile against a paper receipt will trust a number that matches their expectation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match local habit.&lt;/strong&gt; In the US, pre-tax is the default expectation; deviating without a label will read as a surcharge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match what your staff receives.&lt;/strong&gt; If a service charge is already in the price, your "tip" feature should probably be relabeled as a gratuity for exceptional service, and the policy should be clearly pre-tax-equivalent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is consistency between what the customer sees, what the worker takes home, and what your ledger records. When those three line up, the math is right, even if the chosen percentage is debatable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the tip default be pre-tax or post-tax in the United States?
&lt;/h3&gt;

&lt;p&gt;For most US sit-down venues, pre-tax is the expectation. Default your calculator to pre-tax and let advanced users override it. Showing the base explicitly on the tip screen ("Tipping on $42.18, before tax") builds trust and reduces the surprise at the bottom of the receipt.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle currencies without two decimal places?
&lt;/h3&gt;

&lt;p&gt;Compute in minor units throughout, and only format at the edge. For zero-decimal currencies like JPY, your rounding function should treat the integer as already-rounded. For three-decimal currencies like BHD, store thousandths of the main unit and round to the nearest thousandth at display time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the cleanest way to test split-bill fairness?
&lt;/h3&gt;

&lt;p&gt;Unit-test the unrounded total first, then assert that the sum of rounded shares equals the unrounded total to within one minor unit. If your test allows a two-unit drift, you are testing rounding, not distribution, and you will ship off-by-one bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where does the tax figure come from in my model?
&lt;/h3&gt;

&lt;p&gt;Treat tax as an explicit input supplied by the order service, not something the tip calculator recomputes. Recomputing it inside the tip module duplicates policy logic that belongs in a single pricing service, and it is the most common source of "the tip is off by a cent" tickets.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was drafted with AI assistance and reviewed for technical accuracy before publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>lizely</category>
      <category>finance</category>
      <category>webdev</category>
      <category>aibotwrotethis</category>
    </item>
  </channel>
</rss>
