<?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: Vera Yang</title>
    <description>The latest articles on DEV Community by Vera Yang (@vera_yang_tw).</description>
    <link>https://dev.to/vera_yang_tw</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%2F4050846%2Fc00ec23d-dfa1-4afd-8328-7d212480df15.png</url>
      <title>DEV Community: Vera Yang</title>
      <link>https://dev.to/vera_yang_tw</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vera_yang_tw"/>
    <language>en</language>
    <item>
      <title>I thought porting my margin-call calculator to short selling was a one-line sign flip. It wasn't even close.</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:00:04 +0000</pubDate>
      <link>https://dev.to/begoodtool/i-thought-porting-my-margin-call-calculator-to-short-selling-was-a-one-line-sign-flip-it-wasnt-3fgn</link>
      <guid>https://dev.to/begoodtool/i-thought-porting-my-margin-call-calculator-to-short-selling-was-a-one-line-sign-flip-it-wasnt-3fgn</guid>
      <description>&lt;p&gt;I already had a margin-call calculator for regular margin buying (the long side), so when someone asked for a short-selling version I figured it'd be a ten-minute job: take the existing ratio formula, flip a sign so the loss direction points up instead of down, done. It wasn't. Once I actually sat down to write the short-side math, I realized the "maintenance ratio" isn't a single universal equation at all — depending on which market's convention you're modeling, it's two structurally different formulas, and the code has to branch on which one applies before it can even ask "what price triggers a margin call."&lt;/p&gt;

&lt;h2&gt;
  
  
  Two regions, two different ratio formulas
&lt;/h2&gt;

&lt;p&gt;The tool splits behavior on a locale check:&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;isAsianMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one boolean decides which of two completely different maintenance-ratio formulas gets used:&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;now_maintenance_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;old_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;self_provided&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;maintenance_margin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isAsianMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Asia: total collateral / current market value (call below 130%)&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// West: (total collateral - current market value) / current market value&lt;/span&gt;
    &lt;span class="c1"&gt;// i.e. equity / liability&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;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;res&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;totalAsset&lt;/code&gt; is the same thing in both branches — proceeds from the short sale plus the initial margin you put up plus anything you've added since. What differs is the denominator relationship. The Asian-market version treats the ratio as "how much collateral do I have relative to what I owe," full stop, so it's always comfortably above 100% (the usual floor is 130%). The Western version subtracts the current market value first — it's computing account &lt;em&gt;equity&lt;/em&gt; (what's left over after covering your short liability) as a fraction of the liability, which is why 25–40% is a normal maintenance floor there instead of 130%. Same underlying accounting, but they're not the same formula with a constant swapped — one has a subtraction the other doesn't.&lt;/p&gt;

&lt;p&gt;That difference isn't cosmetic. It's why the component also switches default thresholds when you change language:&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="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;maintenance_ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;130&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;target_maintenance_ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;167&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;maintenance_ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Western-style equity %&lt;/span&gt;
      &lt;span class="nx"&gt;target_maintenance_ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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="na"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;If you plugged a Taiwan-style 130% floor into the Western formula (or vice versa), the numbers would be nonsense — you'd either never trigger a call or trigger one immediately. The defaults aren't just localization polish; they exist because the underlying math actually changes shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solving for the price, not just the ratio
&lt;/h2&gt;

&lt;p&gt;The more useful number isn't "what's my ratio right now," it's "at what price do I get called." That means solving the ratio equation for price instead of evaluating it. For the Asian branch that's simple algebra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Asset / Price = Ratio  =&amp;gt;  Price = Asset / Ratio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the Western branch, the subtraction means an extra step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(Asset - Price) / Price = Ratio
Asset / Price - 1 = Ratio
Price = Asset / (1 + Ratio)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both show up almost verbatim in the code, just divided by quantity to turn total asset value back into a per-share price:&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;gg_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;sell_quantity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;old_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;self_provided&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;maintenance_margin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;ratioDecimal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maintenance_ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;gg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isAsianMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;gg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ratioDecimal&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sell_quantity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;gg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ratioDecimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sell_quantity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gg&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the actual mirror-image part, and it's a genuine inversion, not just a sign flip: in a long margin-call calculator the danger direction is the stock price &lt;em&gt;falling&lt;/em&gt; (your collateral shrinks relative to your loan). Here &lt;code&gt;now_market_value&lt;/code&gt; — the thing working against you — is &lt;code&gt;now_price * sell_quantity&lt;/code&gt;, and it grows as price &lt;em&gt;rises&lt;/em&gt;, because rising price means it costs more to buy back the shares you borrowed and sold. Same "collateral vs. liability" skeleton as the long-side tool, opposite trigger direction, and (for the Western branch) an extra subtraction that the long-side formula never needed.&lt;/p&gt;

&lt;p&gt;Working out how much cash to inject to get back to a target ratio is the same algebra run in reverse — set the ratio equation equal to a target instead of the current value, then solve for the "add" term:&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="c1"&gt;// Asian: (Asset + Add) / Price = Target  =&amp;gt;  Add = Price * Target - Asset&lt;/span&gt;
&lt;span class="nx"&gt;needValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;targetDecimal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Western: ((Asset + Add) - Price) / Price = Target  =&amp;gt;  Add = Price * (1 + Target) - Asset&lt;/span&gt;
&lt;span class="nx"&gt;needValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now_market_value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;targetDecimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalAsset&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this doesn't account for
&lt;/h2&gt;

&lt;p&gt;The feature description says it outright: the calculation excludes fees and taxes. It also doesn't model the cost that's specific to shorting and doesn't exist on the long side at all — the daily stock-borrow fee you pay for as long as the position stays open. Every non-Asian language file mentions this in the intro copy as a caveat, but it's genuinely absent from the computation, not just undocumented; &lt;code&gt;totalAsset&lt;/code&gt; never touches a daily accrual.&lt;/p&gt;

&lt;p&gt;The other asymmetry is economic, not a code bug, but it's the reason the whole tool exists: a long position's maximum loss is capped (the stock can only fall to zero), while a short position's loss is theoretically unbounded, since price has no ceiling. The calculator will happily compute a "you get called at $X" number, but nothing in the math communicates that the position you're protecting has open-ended downside in a way a long position never does — that's on the person reading the result, not the formula.&lt;/p&gt;

&lt;p&gt;One more small thing worth knowing if you go digging in the source yourself: the maintenance-ratio input has a locale-dependent minimum (&lt;code&gt;:min="isAsianMode ? 100 : 0"&lt;/code&gt;), which quietly enforces that the Asian-mode floor can never be typed in below 100% — a sensible guard given that formula has no subtraction term to keep it in a sane range on its own.&lt;/p&gt;

&lt;p&gt;I turned this into a small free tool if you want to plug in real numbers instead of tracing the algebra yourself: &lt;a href="https://begoodtool.com/shortSelling/en" rel="noopener noreferrer"&gt;Short Selling Calculator&lt;/a&gt;. No sign-up, and if you're on the long side instead, there's a companion margin-call calculator linked from the same page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling" rel="noopener noreferrer"&gt;融券維持率計算機&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/cn" rel="noopener noreferrer"&gt;融券维持率计算器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/en" rel="noopener noreferrer"&gt;Short Selling Calculator&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/jp" rel="noopener noreferrer"&gt;空売り維持率計算機&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/kr" rel="noopener noreferrer"&gt;공매도 담보비율 계산기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/fr" rel="noopener noreferrer"&gt;Calculateur Short Selling&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/ru" rel="noopener noreferrer"&gt;Калькулятор Шорта&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/de" rel="noopener noreferrer"&gt;Leerverkauf (Short) Rechner&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/id" rel="noopener noreferrer"&gt;Kalkulator Short Selling&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/es" rel="noopener noreferrer"&gt;Calculadora de Venta en Corto&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/vi" rel="noopener noreferrer"&gt;Máy Tính Bán Khống&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/th" rel="noopener noreferrer"&gt;เครื่องคำนวณ Short Sell&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/pl" rel="noopener noreferrer"&gt;Kalkulator Short Selling&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/tr" rel="noopener noreferrer"&gt;Short Selling Hesaplayıcı&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/it" rel="noopener noreferrer"&gt;Calcolatore Short Selling&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/pt" rel="noopener noreferrer"&gt;Calculadora Short Selling&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/nl" rel="noopener noreferrer"&gt;Short Selling Calculator&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/shortSelling/uk" rel="noopener noreferrer"&gt;Калькулятор Шорт&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The shuffle in my memory-training game isn't fully random — and nobody would ever notice</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:00:02 +0000</pubDate>
      <link>https://dev.to/begoodtool/the-shuffle-in-my-memory-training-game-isnt-fully-random-and-nobody-would-ever-notice-33no</link>
      <guid>https://dev.to/begoodtool/the-shuffle-in-my-memory-training-game-isnt-fully-random-and-nobody-would-ever-notice-33no</guid>
      <description>&lt;p&gt;I built a small card-flip memory game a while back: nine tiles numbered 1–9 flash their numbers for ten seconds, then go blank, and you have to click them back in ascending order purely from memory of where each number was sitting. Recently I went back into the source to clean it up and actually read the shuffle function line by line instead of skimming it. Turns out it has a real bug — one that's completely invisible while playing, but is a textbook example of how easy it is to get a "just shuffle an array" loop subtly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not "repeat the sequence" — it's "reconstruct it from memory"
&lt;/h2&gt;

&lt;p&gt;The game doesn't show you an order to repeat, like Simon. It shows you nine tiles in scrambled positions, each briefly displaying its number, and your job during recall is to click the tiles in ascending numeric order (1, 2, 3…9) even though the numbers are now hidden. The click handler is bound once, on the container, using event delegation:&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="nx"&gt;box_num&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nx"&gt;ccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// wrong tile — game over&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ff4e50&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// last tile clicked correctly — win&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tile's &lt;code&gt;id&lt;/code&gt; was set to its real number when it was created (&lt;code&gt;divtag.id = nums[key]&lt;/code&gt;), it just isn't displayed anymore. &lt;code&gt;ccount&lt;/code&gt; counts how many correct clicks you've made in a row, and every click has to match &lt;code&gt;ccount&lt;/code&gt; exactly — so there's no partial credit and no way to click out of order and self-correct. The &lt;code&gt;tagName&lt;/code&gt; check exists because the listener sits on the parent &lt;code&gt;#box_num&lt;/code&gt;, not on individual tiles, so any click that bubbles up from outside an actual &lt;code&gt;.div_num&lt;/code&gt; (padding, gaps between tiles) has to be filtered out rather than miscounted as a wrong tile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shuffle that's almost, but not quite, Fisher-Yates
&lt;/h2&gt;

&lt;p&gt;Before the tiles are drawn, the numbers get shuffled with 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;let&lt;/span&gt; &lt;span class="nx"&gt;nums&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Array&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;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// [1, 2, ..., length]&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;j&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="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;j&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nums&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="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;nums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;nums&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks like the standard in-place swap shuffle, and for most of the array it behaves like one. But walk through the first two iterations by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;i = 0&lt;/code&gt;: &lt;code&gt;j = Math.floor(Math.random() * 0)&lt;/code&gt;, which is always &lt;code&gt;0&lt;/code&gt;. Swapping &lt;code&gt;nums[0]&lt;/code&gt; with itself — a no-op.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i = 1&lt;/code&gt;: &lt;code&gt;j = Math.floor(Math.random() * 1)&lt;/code&gt;. &lt;code&gt;Math.random()&lt;/code&gt; returns a value in &lt;code&gt;[0, 1)&lt;/code&gt;, so &lt;code&gt;Math.random() * 1&lt;/code&gt; is also in &lt;code&gt;[0, 1)&lt;/code&gt;, and &lt;code&gt;Math.floor()&lt;/code&gt; of anything in &lt;code&gt;[0, 1)&lt;/code&gt; is &lt;strong&gt;always &lt;code&gt;0&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;j&lt;/code&gt; isn't random at all here — it's a hardcoded 0 in disguise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the second tile always swaps with the first tile, every single game, regardless of the RNG. From &lt;code&gt;i = 2&lt;/code&gt; onward &lt;code&gt;j&lt;/code&gt; does range properly over &lt;code&gt;[0, i)&lt;/code&gt;, so the bias fades out fast and by the time you're looking at nine tiles it's not something a human could ever detect just by playing — but it means the first position isn't shuffled with the same freedom as the rest. A correct inside-out Fisher-Yates needs &lt;code&gt;j&lt;/code&gt; to range over &lt;code&gt;[0, i]&lt;/code&gt; &lt;em&gt;inclusive&lt;/em&gt;, not &lt;code&gt;[0, i)&lt;/code&gt;; missing that one boundary is exactly what turns "shuffle" into "shuffle, except the first step."&lt;/p&gt;

&lt;h2&gt;
  
  
  The on-screen timer only ever measures the recall phase
&lt;/h2&gt;

&lt;p&gt;The ten-second memorization window and the timer you see on screen are two different clocks, and they don't overlap:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;numTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;ccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&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;numTimeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;numTimeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;numTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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;&lt;code&gt;startTimer&lt;/code&gt; runs immediately when the tiles first appear, but &lt;code&gt;stopTimer&lt;/code&gt; — which resets the counter to zero — fires again right when the ten seconds are up, just before the tiles go blank and the click listener gets attached. So the number you see counting up during recall never includes the memorization window; it's purely "how long did clicking the right sequence take you," which is also the number baked into the win message. It's a small design choice, but it's the reason the "success" time can be genuinely fast (2–3 seconds) even though the whole round took thirteen.&lt;/p&gt;

&lt;p&gt;That reset function doing double duty (as both "pause between phases" and "reset on game over") is also why &lt;code&gt;addbox&lt;/code&gt; — the click handler — is captured in an outer-scoped variable instead of being an anonymous inline function. The &lt;code&gt;#box_num&lt;/code&gt; container itself is never destroyed between games, only its child tiles are cleared out, so on &lt;code&gt;closeMask()&lt;/code&gt; the code has to explicitly call &lt;code&gt;removeEventListener("click", addbox)&lt;/code&gt; using that exact same function reference. Without holding onto it, a second playthrough would stack a second click listener on top of the first, and every click would silently count twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that look configurable but aren't
&lt;/h2&gt;

&lt;p&gt;The shuffle function takes a &lt;code&gt;length&lt;/code&gt; parameter, and &lt;code&gt;num_click(length)&lt;/code&gt; reads like it's built to support different board sizes. In practice the template only ever calls &lt;code&gt;num_click(9)&lt;/code&gt; — there's no way in the current UI to play a smaller or larger board, and the ten-second memorization window (&lt;code&gt;RememberTime = 10&lt;/code&gt;) is a flat constant that doesn't scale with &lt;code&gt;length&lt;/code&gt; either. So despite the code being written in a reusable, parameterized way, there's currently exactly one difficulty level.&lt;/p&gt;

&lt;p&gt;The other honest gotcha: the tiles themselves are built with raw &lt;code&gt;document.createElement&lt;/code&gt; and &lt;code&gt;innerHTML&lt;/code&gt; calls straight into a &lt;code&gt;#box_num&lt;/code&gt; div, sitting inside an otherwise normal Vue component. That works, but it means Vue's reactivity system has no idea those tiles exist — which is exactly why cleanup (removing the old listener, clearing &lt;code&gt;innerHTML&lt;/code&gt; by hand) has to be done manually instead of just letting Vue's own re-render/unmount handle it.&lt;/p&gt;

&lt;p&gt;I turned the cleaned-up version into a small free tool if you want to see whether you can beat the "less than 2 seconds" tier without tripping the shuffle's one deterministic swap: &lt;a href="https://begoodtool.com/memory/en" rel="noopener noreferrer"&gt;Memory Test &amp;amp; Training&lt;/a&gt;. No sign-up, just nine cards and a clock.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory" rel="noopener noreferrer"&gt;記憶力測試訓練&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/cn" rel="noopener noreferrer"&gt;记忆力测试训练&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/en" rel="noopener noreferrer"&gt;Memory Test &amp;amp; Training&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/jp" rel="noopener noreferrer"&gt;記憶力テスト＆トレーニング&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/kr" rel="noopener noreferrer"&gt;기억력 테스트 훈련&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/fr" rel="noopener noreferrer"&gt;Entraînement de mémoire&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/ru" rel="noopener noreferrer"&gt;Тренировка памяти&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/de" rel="noopener noreferrer"&gt;Gedächtnistest &amp;amp; Training&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/id" rel="noopener noreferrer"&gt;Pelatihan Memori&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/es" rel="noopener noreferrer"&gt;Entrenamiento de Prueba de Memoria&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/vi" rel="noopener noreferrer"&gt;Luyện Tập Kiểm Tra Trí Nhớ&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/th" rel="noopener noreferrer"&gt;ฝึกและทดสอบความจำออนไลน์&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/pl" rel="noopener noreferrer"&gt;Trening i Test Pamięci Online&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/tr" rel="noopener noreferrer"&gt;Hafıza Testi Eğitimi&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/it" rel="noopener noreferrer"&gt;Allenamento Test di Memoria&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/pt" rel="noopener noreferrer"&gt;Teste e Treino de Memória&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/nl" rel="noopener noreferrer"&gt;Geheugentesttraining&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/memory/uk" rel="noopener noreferrer"&gt;Тренування Тесту Пам'яті&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Why every "calories burned" calculator boils down to one formula (I found this out because my own copy button was lying to me)</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:00:02 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-every-calories-burned-calculator-boils-down-to-one-formula-i-found-this-out-because-my-own-2g20</link>
      <guid>https://dev.to/begoodtool/why-every-calories-burned-calculator-boils-down-to-one-formula-i-found-this-out-because-my-own-2g20</guid>
      <description>&lt;p&gt;I built a little MET-based calorie table — type in your weight and how long you exercised, get calories burned for 178 different activities, from mountain biking to sitting on the couch. While testing it I clicked the calorie cell to copy the number to my clipboard, and the value that landed in my clipboard didn't match the number on screen. Not a rounding difference — a completely different number. Chasing that down taught me more about how these calculators actually work than building the formula did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole calculator is one line of math
&lt;/h2&gt;

&lt;p&gt;Every "calories burned" tool, no matter how many activities it lists, comes down to the same MET (Metabolic Equivalent of Task) formula: calories = MET × weight in kg × duration in hours. 1 MET is defined as the energy your body burns at rest — about 3.5 ml of oxygen per kg per minute — and every activity in the table just gets a multiplier of that baseline (walking is around 3, running 8 km/h is around 8.3, competitive rowing can be 12+).&lt;/p&gt;

&lt;p&gt;In the component, weight and duration are the only two reactive inputs, and they get collapsed into one computed value that every row in the table reuses:&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;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&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;weightUnit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Kilos&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;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;MET_computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;kg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;weightUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pounds&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;kg&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&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;&lt;code&gt;MET_computed&lt;/code&gt; is just "weight in kg, converted to a per-hour basis" (dividing minutes by 60 turns duration into hours). Then for each of the 178 rows, the displayed calorie figure is simply &lt;code&gt;MET_computed * item.MET&lt;/code&gt; — weight × time × that activity's multiplier. No per-activity logic, no special cases. One formula, reused 178 times with a different constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number you see isn't the number you copy
&lt;/h2&gt;

&lt;p&gt;Here's the bug that sent me digging. Each row has a click-to-copy cell for the calorie value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tableGroup__item__cal"&lt;/span&gt;
  &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"copy(item.man)"&lt;/span&gt;
  &lt;span class="na"&gt;:class=&lt;/span&gt;&lt;span class="s"&gt;"{ canCopy: item.man }"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {{
    ((MET_computed * (item.MET * 10)) / 10)
      .toFixed(1)
      .replace(/\.?0+$/, "")
  }}
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text rendered on screen is the live calculation — it changes the instant you change your weight or duration. But the click handler copies &lt;code&gt;item.man&lt;/code&gt; instead, which is a static field baked into the dataset for every single exercise, like this one for mountain biking:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;01009&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bicycle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;man&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;595&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;feman&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;425&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;desc_en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bicycling, mountain, general&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;MET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;8.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;595&lt;/code&gt; is a precomputed reference value — 8.5 MET × 70 kg × 1 hour = 595, the classic "average man" figure. &lt;code&gt;feman&lt;/code&gt; (425) is the same math at 50 kg, the "average woman" reference. Those numbers exist because the underlying data was originally built as a static lookup table, and the live per-user calculation got layered on top of it later. If you leave the defaults at 60 kg and 10 minutes, the screen shows a small number based on your actual inputs, but the clipboard silently gives you the "70kg person exercising for an hour" reference instead. I left it as-is for now — fixing it means deciding whether &lt;code&gt;man&lt;/code&gt;/&lt;code&gt;feman&lt;/code&gt; are worth keeping around at all, since nothing else in the UI uses them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;* 10 / 10&lt;/code&gt; that does nothing (and the regex that does)
&lt;/h2&gt;

&lt;p&gt;That calorie expression has another detail worth pointing at: &lt;code&gt;item.MET * 10 / 10&lt;/code&gt;. Multiplying by 10 and immediately dividing by 10 is mathematically a no-op — the only real effect is that &lt;code&gt;item.MET&lt;/code&gt; (stored as a string like &lt;code&gt;"8.5"&lt;/code&gt; in the dataset) gets forced into a number a step earlier than it strictly needs to be, since JavaScript's &lt;code&gt;*&lt;/code&gt; operator already coerces strings automatically. My best guess, looking at it now, is that it's a leftover from an earlier version that rounded to the nearest tenth before this logic got simplified, and only half of the original line got deleted. It's harmless, so it stayed.&lt;/p&gt;

&lt;p&gt;The part actually doing work is the tail end: &lt;code&gt;.toFixed(1).replace(/\.?0+$/, "")&lt;/code&gt;. &lt;code&gt;toFixed(1)&lt;/code&gt; always produces one decimal place, so a clean result like 85 comes out as &lt;code&gt;"85.0"&lt;/code&gt;. The regex strips trailing zeros (and the decimal point if nothing but zeros follows), turning &lt;code&gt;"85.0"&lt;/code&gt; back into &lt;code&gt;"85"&lt;/code&gt; while leaving a genuine decimal like &lt;code&gt;"92.1"&lt;/code&gt; untouched. Small detail, but it's the difference between a table that looks hand-typed and one that visibly reeks of floating-point math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this falls apart
&lt;/h2&gt;

&lt;p&gt;MET values aren't measured on you — they're population averages from a published reference table (the Compendium of Physical Activities), tested on a mix of people under lab conditions. Two people doing the exact same 30 minutes of "moderate cycling" can burn meaningfully different amounts of energy depending on fitness level, technique, terrain, and even how their body regulates heat that day. The calculator can only ever give you an estimate built from weight and duration — it has no way to know if you're a beginner grinding through this activity for the first time or someone who does it every day. It also converts pounds to kilograms with a flat &lt;code&gt;/2.2&lt;/code&gt;, not the more precise 2.20462, so heavier inputs in pounds drift by a small but nonzero amount. None of that makes the number useless, it just means "482 calories" should be read as "roughly this ballpark," not a lab-verified figure.&lt;/p&gt;

&lt;p&gt;I cleaned up the version I built for this into a small free tool if you want to look up MET values for something specific without hunting through a PDF: &lt;a href="https://begoodtool.com/exercise-heat/en" rel="noopener noreferrer"&gt;Exercise Calorie Burn Calculator&lt;/a&gt;. No sign-up, works for any weight or duration you throw at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat" rel="noopener noreferrer"&gt;各類運動熱量消耗表&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/cn" rel="noopener noreferrer"&gt;运动热量消耗计算器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/en" rel="noopener noreferrer"&gt;Exercise Calorie Burn Calculator&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/jp" rel="noopener noreferrer"&gt;運動消費カロリー計算機&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/kr" rel="noopener noreferrer"&gt;운동 칼로리 소모 계산기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/fr" rel="noopener noreferrer"&gt;Calculateur de Calories Brûlées&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/ru" rel="noopener noreferrer"&gt;Расход калорий для упражнений&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/de" rel="noopener noreferrer"&gt;Kalorienverbrauchsrechner&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/id" rel="noopener noreferrer"&gt;Tabel Konsumsi Kalori&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/es" rel="noopener noreferrer"&gt;Calculadora de Calorías Quemadas&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/vi" rel="noopener noreferrer"&gt;Bảng Tiêu Thụ Calo&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/th" rel="noopener noreferrer"&gt;เครื่องคำนวณการเผาผลาญแคลอรี่&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/pl" rel="noopener noreferrer"&gt;Kalkulator spalanych kalorii&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/tr" rel="noopener noreferrer"&gt;Kalori Harcama Tablosu&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/it" rel="noopener noreferrer"&gt;Tabella del consumo calorico&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/pt" rel="noopener noreferrer"&gt;Calculadora de Calorias de Exercício&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/nl" rel="noopener noreferrer"&gt;Tabel van Calorieverbruik&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/exercise-heat/uk" rel="noopener noreferrer"&gt;Таблиця витрати калорій&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Keep moving forward!</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:17:27 +0000</pubDate>
      <link>https://dev.to/vera_yang_tw/-14dl</link>
      <guid>https://dev.to/vera_yang_tw/-14dl</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/begoodtool/how-i-built-a-highly-profitable-web-platform-but-wrote-zero-backend-code-to-keep-my-server-costs-1n5l" class="crayons-story__hidden-navigation-link"&gt;How I built a highly profitable web platform, with zero backend code to keep my server costs at&amp;nbsp;$0.&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;
          &lt;a class="crayons-logo crayons-logo--l" href="/begoodtool"&gt;
            &lt;img alt="BeGoodTool.com logo" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14295%2Fd519278f-433a-4dab-9489-447ef51fc6d4.jpg" class="crayons-logo__image" width="300" height="301"&gt;
          &lt;/a&gt;

          &lt;a href="/yuntao_lin" class="crayons-avatar  crayons-avatar--s absolute -right-2 -bottom-2 border-solid border-2 border-base-inverted  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066889%2Fcbfd5bde-d893-4635-97d5-aea05722cb26.jpeg" alt="yuntao_lin profile" class="crayons-avatar__image" width="800" height="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yuntao_lin" class="crayons-story__secondary fw-medium m:hidden"&gt;
              YunTao Lin
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                YunTao Lin
                
              
              &lt;div id="story-author-preview-content-4338408" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yuntao_lin" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066889%2Fcbfd5bde-d893-4635-97d5-aea05722cb26.jpeg" class="crayons-avatar__image" alt="" width="800" height="800"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;YunTao Lin&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

            &lt;span&gt;
              &lt;span class="crayons-story__tertiary fw-normal"&gt; for &lt;/span&gt;&lt;a href="/begoodtool" class="crayons-story__secondary fw-medium"&gt;BeGoodTool.com&lt;/a&gt;
            &lt;/span&gt;
          &lt;/div&gt;
          &lt;a href="https://dev.to/begoodtool/how-i-built-a-highly-profitable-web-platform-but-wrote-zero-backend-code-to-keep-my-server-costs-1n5l" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/begoodtool/how-i-built-a-highly-profitable-web-platform-but-wrote-zero-backend-code-to-keep-my-server-costs-1n5l" id="article-link-4338408"&gt;
          How I built a highly profitable web platform, with zero backend code to keep my server costs at&amp;nbsp;$0.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/frontend"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;frontend&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/architecture"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;architecture&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/indiehackers"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;indiehackers&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/begoodtool/how-i-built-a-highly-profitable-web-platform-but-wrote-zero-backend-code-to-keep-my-server-costs-1n5l" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;6&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/begoodtool/how-i-built-a-highly-profitable-web-platform-but-wrote-zero-backend-code-to-keep-my-server-costs-1n5l#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            10 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Dead-pixel test sites are just colored divs — the actual engineering problem is the Fullscreen API, not the colors</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Fri, 07 Aug 2026 07:00:03 +0000</pubDate>
      <link>https://dev.to/begoodtool/dead-pixel-test-sites-are-just-colored-divs-the-actual-engineering-problem-is-the-fullscreen-api-2a22</link>
      <guid>https://dev.to/begoodtool/dead-pixel-test-sites-are-just-colored-divs-the-actual-engineering-problem-is-the-fullscreen-api-2a22</guid>
      <description>&lt;p&gt;I bought a used monitor a while back and did what everyone tells you to do first: pull up an online "dead pixel test" and cycle through some solid colors before deciding whether to keep it. It worked, but afterward I was curious why every single one of these tools looks identical — same handful of colors, same full-bleed look, no ads-covered corners, nothing fancy. So I built my own version to see what's actually involved, and it turns out almost none of the interesting work is in the colors. It's in getting a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; to actually, reliably, cover the entire physical screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The colors are chosen, not decorative
&lt;/h2&gt;

&lt;p&gt;The color grid isn't nine random swatches — each one is picked to isolate a specific kind of pixel failure:&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;testModes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;red&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FF0000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;green&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#00FF00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0000FF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;black&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;white&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FFFFFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;darkText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gray&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#808080&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yellow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FFFF00&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;darkText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cyan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#00FFFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;darkText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;magenta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FF00FF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pure red, green, and blue each light up exactly one of a pixel's three sub-pixels — that's how you catch a sub-pixel that's stuck &lt;em&gt;off&lt;/em&gt; (it stays dark against a color that should light it) or stuck permanently &lt;em&gt;on&lt;/em&gt; in one channel (it shows up as a stray colored dot against black). Black and white check the opposite extremes: a "dead" pixel is usually defined as one that never lights at all, so a pure black fill is actually the easiest way to spot one — any point of light against total black is a defect, not a judgment call. Gray is there because it's R, G, and B mixed in equal parts; if the panel has any color tint or backlight unevenness, gray is where a human eye notices it fastest, since a slight tint is invisible against a color but obvious against neutral gray. Yellow, cyan, and magenta are two channels at once — useful for catching a sub-pixel that's stuck in a way pure R/G/B alone wouldn't reveal.&lt;/p&gt;

&lt;p&gt;Then there are two patterns instead of flat colors:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gradient&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linear-gradient(to right, #000000, #FFFFFF)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;isPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkerboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backgroundImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linear-gradient(45deg, #000 25%, transparent 25%, transparent 75%, #000 75%, #000), linear-gradient(45deg, #000 25%, transparent 25%, transparent 75%, #000 75%, #000)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backgroundSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;20px 20px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backgroundPosition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 0, 10px 10px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;isPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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 gradient isn't for pixel defects at all — it's for banding. A cheap panel (or a panel driven at a reduced color depth) can't render a smooth 0–255 transition, so instead of a clean fade you see visible steps. The checkerboard does something similar for contrast and sharpness: alternating black/white squares make it obvious if edges look soft or if there's visible bleed between adjacent pixels, neither of which a flat color would show you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fullscreen isn't cosmetic — it's the whole point, and it's annoyingly stateful
&lt;/h2&gt;

&lt;p&gt;The naive version of this tool is "put a colored div on the page." The actual requirement is that the color has to cover &lt;em&gt;every pixel&lt;/em&gt;, including the space normally eaten by the browser's tab bar, address bar, and OS taskbar — otherwise you're not testing the whole panel. That means the Fullscreen API, not just big CSS:&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;enterFullscreen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;currentIndex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&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;elem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fullscreenContainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&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;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestFullscreen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestFullscreen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitRequestFullscreen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;webkitRequestFullscreen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msRequestFullscreen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;elem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;msRequestFullscreen&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;isFullscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nf"&gt;displayHint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;screenTest.fullscreenError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things about this bit me before I wired it up correctly. First, &lt;code&gt;requestFullscreen()&lt;/code&gt; returns a promise that rejects if it's not called as a direct result of a user gesture — you can't call it inside a &lt;code&gt;setTimeout&lt;/code&gt; or on page load and expect it to work, which is exactly why the tool requires a click on a color swatch rather than trying to auto-launch. Second, there's no unprefixed-only world yet: Safari still wants &lt;code&gt;webkitRequestFullscreen&lt;/code&gt;, and old Edge/IE wanted &lt;code&gt;msRequestFullscreen&lt;/code&gt;, so the fallback chain isn't legacy cruft, it's still load-bearing.&lt;/p&gt;

&lt;p&gt;The other half of the problem is knowing when you've &lt;em&gt;left&lt;/em&gt; fullscreen — the user can hit Escape at any point, and the app needs to notice and reset its own state, not just rely on the browser's native exit animation:&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;handleFullscreenChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&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="nx"&gt;fullscreenElement&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;webkitIsFullScreen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;mozFullScreen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;msFullscreenElement&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;isFullscreen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;stopAutoPlay&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fullscreenchange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleFullscreenChange&lt;/span&gt;&lt;span class="p"&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webkitfullscreenchange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleFullscreenChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that listener, pressing Escape exits the OS-level fullscreen but leaves the app thinking it's still showing the color tester, so the next click does something confusing. The fixed-position overlay underneath (&lt;code&gt;position: fixed; width: 100vw; height: 100vh; z-index: 99999&lt;/code&gt;) is really just a safety net for browsers where the fullscreen request silently fails — it covers the viewport even without real fullscreen, just not the parts of the screen the browser chrome still owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-play is a plain interval, not a rendering-timed loop
&lt;/h2&gt;

&lt;p&gt;The "auto play" button just steps through the color list on a timer:&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;startAutoPlay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;isAutoPlaying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nf"&gt;enterFullscreen&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;autoPlayTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;nextColor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I want to flag what this &lt;em&gt;isn't&lt;/em&gt;, because it's tempting to assume any color-cycling tool is secretly doing frame-rate or response-time measurement with &lt;code&gt;requestAnimationFrame&lt;/code&gt;. It's not — there's no timing measurement here at all, just "switch to the next color every 3 seconds" so you can stand back and watch the whole panel without touching anything. &lt;code&gt;setInterval&lt;/code&gt; is fine for that because nobody's timing precision matters at a 3-second cadence; it would be the wrong tool if you were trying to measure actual pixel response time or refresh-rate behavior, which needs frame-accurate deltas, not a coarse timer that browsers are free to throttle when a tab is backgrounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this can't actually tell you
&lt;/h2&gt;

&lt;p&gt;A couple of honest gaps, since a full-screen color page will always be a cheap proxy for real display measurement, not a replacement for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The colors you see aren't calibrated.&lt;/strong&gt; &lt;code&gt;#FF0000&lt;/code&gt; is just a CSS value — what actually hits your eyes depends on the OS color profile, the monitor's own color space, whether a blue-light filter or "night mode" is active, and how the GPU compositor happens to render it. Two perfectly healthy panels can render "pure red" slightly differently; this tool can't and doesn't try to correct for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only catches what a human eye can see in real time.&lt;/strong&gt; A pixel that's stuck a very &lt;em&gt;slightly&lt;/em&gt; wrong shade, viewing-angle color shift on cheaper IPS/VA panels, or backlight bleed that only shows up at specific brightness levels are all things a careful look under the right lighting conditions will catch far better than a quick scroll through nine colors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fullscreen support is inconsistent across mobile browsers.&lt;/strong&gt; Some mobile Safari versions restrict or ignore &lt;code&gt;requestFullscreen()&lt;/code&gt; on arbitrary elements entirely, so on iOS you may just get the fixed-overlay fallback rather than true fullscreen, meaning the OS status bar area doesn't actually get tested.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that makes it useless — it's still the fastest way to catch the obvious stuff (a fully dead pixel, an obviously stuck one, ugly gradient banding) before you decide whether a monitor is worth returning. I turned the version I built into a small free tool: &lt;a href="https://begoodtool.com/screenTest/en" rel="noopener noreferrer"&gt;Screen Test Tool&lt;/a&gt;. No install, no sign-up, just click a color and go fullscreen.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest" rel="noopener noreferrer"&gt;螢幕測試工具&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/cn" rel="noopener noreferrer"&gt;屏幕测试工具&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/en" rel="noopener noreferrer"&gt;Screen Test Tool&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/jp" rel="noopener noreferrer"&gt;画面テストツール&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/kr" rel="noopener noreferrer"&gt;화면 테스트 도구&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/fr" rel="noopener noreferrer"&gt;Outil de Test d'Écran&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/ru" rel="noopener noreferrer"&gt;Инструмент Тестирования Экрана&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/de" rel="noopener noreferrer"&gt;Bildschirmtest-Tool&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/id" rel="noopener noreferrer"&gt;Alat Tes Layar&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/es" rel="noopener noreferrer"&gt;Herramienta de Prueba de Pantalla&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/vi" rel="noopener noreferrer"&gt;Công Cụ Kiểm Tra Màn Hình&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/th" rel="noopener noreferrer"&gt;เครื่องมือทดสอบหน้าจอ&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/pl" rel="noopener noreferrer"&gt;Narzędzie do Testowania Ekranu&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/tr" rel="noopener noreferrer"&gt;Ekran Test Aracı&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/it" rel="noopener noreferrer"&gt;Strumento di Test dello Schermo&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/pt" rel="noopener noreferrer"&gt;Ferramenta de Teste de Tela&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/nl" rel="noopener noreferrer"&gt;Schermtesttool&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/screenTest/uk" rel="noopener noreferrer"&gt;Інструмент Тестування Екрану&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>css</category>
    </item>
    <item>
      <title>Generating unique random numbers is easy — swapping min and max broke my own validation</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Fri, 07 Aug 2026 05:44:32 +0000</pubDate>
      <link>https://dev.to/begoodtool/generating-unique-random-numbers-is-easy-swapping-min-and-max-broke-my-own-validation-4ae1</link>
      <guid>https://dev.to/begoodtool/generating-unique-random-numbers-is-easy-swapping-min-and-max-broke-my-own-validation-4ae1</guid>
      <description>&lt;p&gt;I put together a small number picker for an office raffle: type a minimum, type a maximum, hit generate, optionally pull several numbers with no repeats so you can draw multiple winners at once. It felt like a five-minute job. It mostly was — right up until I typed the range in backwards and the tool told me a perfectly valid request was impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting min and max be typed in either order
&lt;/h2&gt;

&lt;p&gt;The core random-pick function doesn't trust that the "minimum" field actually holds the smaller number:&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;real_min&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;real_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;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;real_res&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;real_max&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;real_min&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;real_min&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;real_res&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;&lt;code&gt;Math.min&lt;/code&gt;/&lt;code&gt;Math.max&lt;/code&gt; normalize the two inputs before doing anything else, so if you type 99 into "min" and 1 into "max," it just works — no error telling you to swap the fields. The actual random pick is the standard inclusive-integer-range formula: &lt;code&gt;Math.random()&lt;/code&gt; gives you &lt;code&gt;[0, 1)&lt;/code&gt;, multiplying by &lt;code&gt;(range + 1)&lt;/code&gt; and flooring spreads that across every integer in the range with equal probability, then you shift up by the minimum. At the range sizes this tool deals with (double-digit or triple-digit spans), there's no meaningful &lt;code&gt;Math.random()&lt;/code&gt; bias to worry about — that only becomes a real concern at ranges in the billions, where floating-point precision starts running out of distinct multiples.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validation check that forgot about the swap
&lt;/h2&gt;

&lt;p&gt;Here's where it got interesting. The "generate multiple, no duplicates" path validates the range &lt;em&gt;before&lt;/em&gt; generating anything:&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;getRandom&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;real_res&lt;/span&gt; &lt;span class="o"&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;max&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nf"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;random.error1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isMultiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;multiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;multiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;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;multiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;random&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;real_res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;real_res&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;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look closely at that first &lt;code&gt;if&lt;/code&gt;. It computes &lt;code&gt;max.value - min.value + 1&lt;/code&gt; — the raw field values, with no &lt;code&gt;Math.min&lt;/code&gt;/&lt;code&gt;Math.max&lt;/code&gt; normalization. &lt;code&gt;random()&lt;/code&gt; happily tolerates a swapped min/max, but this check doesn't. Type min=99, max=1, ask for 5 unique numbers, and the check computes &lt;code&gt;1 - 99 + 1 = -97&lt;/code&gt;, which is less than 5, so it rejects a request that's actually completely fine — there are 99 valid numbers to draw from. The tool is inconsistent with itself: it's forgiving about swapped input everywhere else, then strict about it in exactly the one place where being strict produces a wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry until it's unique, not shuffle-and-slice
&lt;/h2&gt;

&lt;p&gt;The no-duplicates mode itself is worth a second look, because there are two very different ways to implement "give me N unique numbers from a range," and this uses the simpler-sounding one:&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="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;multiple&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;random&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;real_res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;real_res&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;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is rejection sampling: draw a number, check if it's already in the result array, and if it is, decrement the loop counter so the same slot gets tried again. It's unbiased — every still-available number is equally likely on each draw — and it needs zero extra memory beyond the result array itself.&lt;/p&gt;

&lt;p&gt;The alternative would be shuffle-and-slice: build an array of the entire range, Fisher–Yates shuffle it, and take the first N. That's guaranteed to finish in time proportional to the range size no matter what, but it has to allocate an array covering the &lt;em&gt;whole&lt;/em&gt; range even if you only want 3 numbers out of it.&lt;/p&gt;

&lt;p&gt;Retry-until-unique flips that trade-off: cheap when N is small relative to the range, but it gets worse the closer N gets to the range size. Near the end, most of the range is already taken, so a growing fraction of draws come back as duplicates and get thrown away — the classic coupon-collector problem. On top of that, each &lt;code&gt;real_res.includes(now)&lt;/code&gt; check is a linear scan, so as the result array grows the checks themselves get slower too. This tool caps &lt;code&gt;multiple&lt;/code&gt; at 100, so in practice you'd have to set a very tight range (like 1–100 while asking for 100 numbers) to actually feel the slowdown — but it's the kind of thing that would matter a lot if someone reused this loop for a bigger range without reusing the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;A few things worth knowing if you actually use this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The min/max-swap validation bug above is real — if your "no duplicates" request gets rejected as impossible, try swapping which number you put in which field before assuming your range is actually too small.&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;multiple.value * 1 &amp;gt; 100&lt;/code&gt; clamp happens &lt;em&gt;after&lt;/em&gt; the range check already ran with whatever number you originally typed, and it silently caps your request to 100 with no message. Ask for 500 unique numbers from a range of 10,000 and you'll quietly get 100 back, not 500.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Math.random()&lt;/code&gt; is a pseudorandom generator, not a cryptographically secure one. That's completely fine for raffles, games, and sampling, but it's not the right tool for generating anything security-sensitive, like password seeds or real-money draws.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I cleaned up the version I built for the raffle into a small free tool: &lt;a href="https://begoodtool.com/random/en" rel="noopener noreferrer"&gt;Random Number Generator (RNG)&lt;/a&gt;. No sign-up, works for any range you throw at it — just watch which field you put the bigger number in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random" rel="noopener noreferrer"&gt;隨機亂數產生器&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/cn" rel="noopener noreferrer"&gt;在线随机数生成器 (RNG)&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/en" rel="noopener noreferrer"&gt;Random Number Generator (RNG)&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/jp" rel="noopener noreferrer"&gt;乱数ジェネレーター (RNG)&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/kr" rel="noopener noreferrer"&gt;랜덤 숫자 생성기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/fr" rel="noopener noreferrer"&gt;Générateur de nombres aléatoires&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/ru" rel="noopener noreferrer"&gt;Генератор случайных чисел&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/de" rel="noopener noreferrer"&gt;Zufallszahlengenerator (RNG)&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/id" rel="noopener noreferrer"&gt;Pembangkit Angka Acak&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/es" rel="noopener noreferrer"&gt;Generador de Números Aleatorios&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/vi" rel="noopener noreferrer"&gt;Trình Tạo Số Ngẫu Nhiên&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/th" rel="noopener noreferrer"&gt;เครื่องมือสร้างเลขสุ่ม&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/pl" rel="noopener noreferrer"&gt;Generator Liczb Losowych&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/tr" rel="noopener noreferrer"&gt;Rastgele Sayı Üretici&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/it" rel="noopener noreferrer"&gt;Generatore di Numeri Casuali&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/pt" rel="noopener noreferrer"&gt;Gerador de Números Aleatórios&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/nl" rel="noopener noreferrer"&gt;Willekeurige Nummergenerator&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/random/uk" rel="noopener noreferrer"&gt;Генератор Випадкових Чисел&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Why "must be subscribed to enter" is a checkbox I had to delete from a YouTube giveaway picker</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:00:03 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-must-be-subscribed-to-enter-is-a-checkbox-i-had-to-delete-from-a-youtube-giveaway-picker-1ib9</link>
      <guid>https://dev.to/begoodtool/why-must-be-subscribed-to-enter-is-a-checkbox-i-had-to-delete-from-a-youtube-giveaway-picker-1ib9</guid>
      <description>&lt;p&gt;A YouTuber I know asked me to help pick a winner from a giveaway video fairly, because doing it by scrolling through comments and eyeballing one invites exactly the kind of "the mod's friend won" accusations you'd expect. I figured: grab the comments, roll a die, done — how hard can that be? Turns out, once you actually build it against the real YouTube Data API v3 instead of imagining how it probably works, there's pagination to chase, replies to exclude, duplicate commenters to catch, and one feature I had to rip out because it's technically impossible to build honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning whatever URL someone pastes into a video ID
&lt;/h2&gt;

&lt;p&gt;People paste video links in three different shapes — the desktop &lt;code&gt;watch?v=&lt;/code&gt; link, the shortened &lt;code&gt;youtu.be/&lt;/code&gt; link, and the &lt;code&gt;shorts/&lt;/code&gt; link — and none of them go through a real URL parser, just string splitting on whichever marker is present:&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;let&lt;/span&gt; &lt;span class="nx"&gt;searchID&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;searchID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtu.be&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// https://youtu.be/KtkW3Hx0MvA&lt;/span&gt;
  &lt;span class="nx"&gt;searchID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;youtu.be/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shorts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// https://www.youtube.com/shorts/QWUTKpZmSX0&lt;/span&gt;
  &lt;span class="nx"&gt;searchID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shorts/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for a clean link, but it doesn't strip anything that comes after the ID. Paste a link with a timestamp (&lt;code&gt;&amp;amp;t=45s&lt;/code&gt;) or a share-tracking suffix (&lt;code&gt;?si=xyz&lt;/code&gt;) and that junk rides along as part of &lt;code&gt;searchID&lt;/code&gt;, gets sent straight to the video-lookup call, and comes back as a generic "couldn't find that video" error — which is technically true but tells the organizer nothing about why. I didn't catch this until someone pasted a link straight from the YouTube mobile app's share sheet, which appends a tracking param by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paginating through the comments API without losing your place
&lt;/h2&gt;

&lt;p&gt;YouTube's &lt;code&gt;commentThreads&lt;/code&gt; endpoint hands back comments in pages of up to 100, with a &lt;code&gt;nextPageToken&lt;/code&gt; when there's more. The fetch function just calls itself with that token until there isn't one:&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;getComment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;$API&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;youtubeData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;youtube_getComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageToken&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&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;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;comentData&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="na"&gt;authorChannelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topLevelComment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorChannelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;authorDisplayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topLevelComment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorDisplayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;likeCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topLevelComment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likeCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;textOriginal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topLevelComment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textOriginal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;totalReplyCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snippet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalReplyCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="c1"&gt;// ...a few more fields for the table&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextPageToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;getComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextPageToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice everything is pulled from &lt;code&gt;topLevelComment.snippet&lt;/code&gt; — never the replies themselves. &lt;code&gt;totalReplyCount&lt;/code&gt; is captured just so it can be displayed in the table, but the actual reply text is never fetched. So if a giveaway's rule is "reply to the pinned comment to enter" rather than "leave a top-level comment," this tool structurally can't see those entries — it only ever eats top-level comments, which is also why the UI explicitly labels its count as "comments (excluding replies)."&lt;/p&gt;

&lt;h2&gt;
  
  
  One entry per commenter, not per comment
&lt;/h2&gt;

&lt;p&gt;Before picking a winner, every comment runs through a filter chain, and the interesting one is duplicate removal:&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;MessageFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&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;filterList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;comentData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;textDisplay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;likeCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authorChannelId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publishedAt&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mustContain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;textDisplay&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mustContainValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;mustLength&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;textDisplay&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;mustLengthValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;mustLike&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;likeCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;mustLikeValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;mustNoRepeat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;MessageFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authorChannelId&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;MessageFrom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authorChannelId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...date-range check&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;Dedup is keyed on &lt;code&gt;authorChannelId&lt;/code&gt;, not the display name — a name is just cosmetic text a user can change at any moment, so two comments from the same account could show two different names. The channel ID is the one thing that's actually stable. Since &lt;code&gt;Array.filter&lt;/code&gt; walks the list in order, whichever comment from a given channel appears first is the one that survives; any later comment from that same account is silently dropped, keyword-spam or not.&lt;/p&gt;

&lt;p&gt;The date-range check on the next line is the one piece of this function I'd flag if I were reviewing someone else's PR:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mustDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mustDateStart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mustDateEnd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parentheses only wrap &lt;code&gt;mustDate.value &amp;amp;&amp;amp; startCheck&lt;/code&gt; — the end-date half of the &lt;code&gt;||&lt;/code&gt; has no such guard, so it's evaluated regardless of whether the "date range" checkbox is even ticked. In practice it's harmless today only because an empty &lt;code&gt;mustDateEnd&lt;/code&gt; produces &lt;code&gt;Invalid Date&lt;/code&gt;, and &lt;code&gt;NaN &amp;lt; anything&lt;/code&gt; is always &lt;code&gt;false&lt;/code&gt;. But it's one stray value away from silently filtering out legitimate entries the moment someone fills in an end date without checking the box first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking winners without replacement, the brute-force way
&lt;/h2&gt;

&lt;p&gt;Once the pool is filtered, picking N winners isn't a shuffle — it's 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;let&lt;/span&gt; &lt;span class="nx"&gt;real_index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="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;AwardNumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterList&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="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;real_index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;real_index&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;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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="c1"&gt;// collision — roll again&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;real_max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;real_max&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roll a random index; if it's already been picked, back the loop counter up and roll again. It's not a proper Fisher–Yates partial shuffle, and it doesn't need to be — the code already checked earlier that &lt;code&gt;filterList.length &amp;gt;= AwardNumber.value&lt;/code&gt;, so the loop is guaranteed to eventually land on N distinct indexes, and for a typical giveaway (a handful of winners out of hundreds of eligible comments) collisions are rare enough that a plain retry loop is simpler to read than a real shuffle and costs nothing anyone would notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this quietly falls short
&lt;/h2&gt;

&lt;p&gt;There's a checkbox still sitting in the template, commented out, for "must be subscribed to the channel" — with a note next to it admitting that anyone who hasn't made their subscriptions public gets treated as unsubscribed. I left it disabled on purpose: YouTube's API has no endpoint that tells you "does channel A have viewer B subscribed," because a user's subscription list is private by default and there's no way to check it from public comment data without that specific user handing over their own OAuth token. Any tool claiming to enforce a subscribe-to-enter rule automatically is either lying or quietly excluding most of your actual subscribers.&lt;/p&gt;

&lt;p&gt;A couple other honest gotchas: the comments API has a daily quota, so a video with an unusually large comment count can exhaust it mid-fetch and just stop with a generic error. And dedup by channel ID stops someone from commenting five times on one account, but does nothing to stop someone running the exact same giveaway comment from two separate Google accounts — there's no way to detect that from a comment thread alone.&lt;/p&gt;

&lt;p&gt;I cleaned this up into a small free tool if you run YouTube giveaways and want to skip building your own version: &lt;a href="https://begoodtool.com/youtube-picker/en" rel="noopener noreferrer"&gt;YouTube Comment Picker Tool&lt;/a&gt;. No sign-up, just paste a video link.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker" rel="noopener noreferrer"&gt;Youtube留言抽獎工具&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/cn" rel="noopener noreferrer"&gt;YouTube评论抽奖工具&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/en" rel="noopener noreferrer"&gt;YouTube Comment Picker Tool&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/jp" rel="noopener noreferrer"&gt;YouTubeコメント抽選ツール&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/kr" rel="noopener noreferrer"&gt;Youtube 댓글 추첨 도구&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/fr" rel="noopener noreferrer"&gt;Outil de tirage au sort de commentaires Youtube&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/ru" rel="noopener noreferrer"&gt;Инструмент розыгрыша комментариев на Youtube&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/de" rel="noopener noreferrer"&gt;YouTube-Kommentar-Picker-Tool&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/id" rel="noopener noreferrer"&gt;Alat Undian Komentar YouTube&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/es" rel="noopener noreferrer"&gt;Selector de Comentarios de YouTube&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/vi" rel="noopener noreferrer"&gt;Công cụ chọn bình luận YouTube&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/th" rel="noopener noreferrer"&gt;เครื่องมือจับรางวัลคอมเม้นต์ยูทูบ&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/pl" rel="noopener noreferrer"&gt;Losowanie Komentarzy YouTube&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/tr" rel="noopener noreferrer"&gt;YouTube Yorum Çekiliş Aracı&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/it" rel="noopener noreferrer"&gt;Estrattore Commenti YouTube&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/pt" rel="noopener noreferrer"&gt;Sorteador de Comentários do YouTube&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/nl" rel="noopener noreferrer"&gt;YouTube Reactie Verloting Tool&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/youtube-picker/uk" rel="noopener noreferrer"&gt;Інструмент для витягування коментарів на YouTube&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>api</category>
    </item>
    <item>
      <title>Why my auto-scrolling teleprompter froze at low speed (and the hidden float that fixed it)</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Thu, 06 Aug 2026 07:00:02 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-my-auto-scrolling-teleprompter-froze-at-low-speed-and-the-hidden-float-that-fixed-it-3e45</link>
      <guid>https://dev.to/begoodtool/why-my-auto-scrolling-teleprompter-froze-at-low-speed-and-the-hidden-float-that-fixed-it-3e45</guid>
      <description>&lt;p&gt;I built a browser-based teleprompter for a friend prepping a conference talk — paste your script, hit play, text scrolls up the screen while you read it into the camera. Simple enough. Except when she set the scroll speed slider all the way down (she's a slow, deliberate speaker), the text just... didn't move. Not slowly. Not at all. For a solid few seconds, nothing happened, then it would suddenly jump a pixel.&lt;/p&gt;

&lt;p&gt;Turned out the bug wasn't in my speed math. It was in how &lt;code&gt;scrollTop&lt;/code&gt; handles numbers smaller than 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  requestAnimationFrame, not setInterval
&lt;/h2&gt;

&lt;p&gt;The scroll loop is driven by &lt;code&gt;requestAnimationFrame&lt;/code&gt;, not &lt;code&gt;setInterval&lt;/code&gt;:&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;scrollStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;isPlaying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;teleprompterViewRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;decimalScrollTop&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;teleprompterViewRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decimalScrollTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// ...end-of-script check omitted here...&lt;/span&gt;

  &lt;span class="nx"&gt;animationFrameId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollStep&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters more than it looks. &lt;code&gt;setInterval(fn, 16)&lt;/code&gt; fires on a wall-clock timer regardless of whether the browser is actually ready to paint — if the tab is busy or the display can't keep up, intervals pile up and scrolling gets jerky. &lt;code&gt;requestAnimationFrame&lt;/code&gt; instead asks the browser "call me right before your next repaint," so the scroll step is always synced to the actual frame rate. For something people are reading off in real time while talking to a camera, that smoothness is the whole point of the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decimal accumulator that makes slow speeds actually scroll
&lt;/h2&gt;

&lt;p&gt;Here's the bug I mentioned. The speed slider is 1–100, and it's turned into pixels-per-frame with a flat multiplier: &lt;code&gt;speed.value * 0.05&lt;/code&gt;. At speed 1, that's 0.05 pixels per frame. At ~60fps, that's 3 pixels per second — deliberately slow, for someone reading carefully.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;element.scrollTop = 0.05&lt;/code&gt; doesn't accumulate the way you'd hope. Browsers round or coerce &lt;code&gt;scrollTop&lt;/code&gt; toward whole pixels, so if you add 0.05 to it every frame and read it back before adding again, you can lose the fractional remainder on every single frame — the number never crosses the 1px threshold, and the text never visibly moves.&lt;/p&gt;

&lt;p&gt;The fix is a variable that lives outside the DOM entirely:&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;let&lt;/span&gt; &lt;span class="nx"&gt;decimalScrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scrollStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;decimalScrollTop&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;teleprompterViewRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decimalScrollTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;decimalScrollTop&lt;/code&gt; is a plain JS float, so it keeps every fractional pixel across frames — 0.05, 0.10, 0.15... — and only the DOM write gets truncated. The DOM forgets the remainder each frame; the JS variable never does. That's the entire fix: stop trusting the browser to remember fractions you handed it, and remember them yourself.&lt;/p&gt;

&lt;p&gt;There's a second wrinkle tied to this. When you press play, the code resyncs the accumulator to wherever the element actually is first:&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;startScrolling&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;teleprompterViewRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;decimalScrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;teleprompterViewRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;isPlaying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nx"&gt;animationFrameId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollStep&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;Without that line, if someone manually scrolled the view while paused (or after seeking around), hitting play would snap back to wherever the stale accumulator last was, undoing their manual scroll. It only reads from the DOM at the moment playback starts, though — while it's actively playing, &lt;code&gt;scrollStep&lt;/code&gt; overwrites &lt;code&gt;scrollTop&lt;/code&gt; every frame, which means a mouse wheel nudge mid-playback gets silently stomped on the very next frame. You can only manually reposition while paused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mirror mode is one CSS property, applied twice
&lt;/h2&gt;

&lt;p&gt;The feature that actually makes this a "teleprompter" and not just an autoscrolling text box is mirror mode — for people using real teleprompter rigs with a beam-splitter glass in front of the camera lens, where the display underneath has to show mirrored text for it to look correct once reflected. The whole implementation is a single transform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.teleprompter-content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nc"&gt;.mirrored&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scaleX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scaleX(-1)&lt;/code&gt; flips the element horizontally around its own vertical axis, which is exactly what a reflection off glass needs. The only non-obvious part is that it has to be applied to &lt;em&gt;two&lt;/em&gt; elements independently — the script text and the countdown overlay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"countdown-overlay"&lt;/span&gt; &lt;span class="na"&gt;:class=&lt;/span&gt;&lt;span class="s"&gt;"{ mirrored: isMirrored }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"teleprompter-content"&lt;/span&gt; &lt;span class="na"&gt;:class=&lt;/span&gt;&lt;span class="s"&gt;"{ mirrored: isMirrored }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only flip the content and forget the countdown, you get an unmirrored "3, 2, 1, Get Ready..." for a couple of seconds before the mirrored script kicks in — a small but very obvious continuity break if you're actually staring through a beam splitter at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;A few things worth knowing if you're relying on this for a real shoot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backgrounding the tab pauses scrolling.&lt;/strong&gt; Because the loop runs on &lt;code&gt;requestAnimationFrame&lt;/code&gt;, browsers throttle or fully suspend it when the tab isn't visible/focused. If your recording setup involves switching windows (e.g. controlling OBS on another monitor while this runs unfocused), the scroll can stall completely until you click back into the tab. &lt;code&gt;setInterval&lt;/code&gt; wouldn't have this problem — but it also wouldn't scroll as smoothly, which is the tradeoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fullscreen isn't guaranteed.&lt;/strong&gt; &lt;code&gt;startTeleprompter&lt;/code&gt; calls &lt;code&gt;requestFullscreen()&lt;/code&gt; and just logs a warning to the console if it's denied — it doesn't tell the user or fall back to anything. iOS Safari in particular doesn't support the Fullscreen API on arbitrary elements at all, so on an iPhone you'll never get a true fullscreen call to succeed. It still mostly works visually, because the teleprompter view is &lt;code&gt;position: fixed&lt;/code&gt; at &lt;code&gt;100vw&lt;/code&gt;/&lt;code&gt;100vh&lt;/code&gt; regardless of fullscreen state — but the browser's own UI chrome stays on screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stop-at-the-end check is nested oddly.&lt;/strong&gt; It first checks if you're within 20px of the bottom, and only inside that branch checks if you're within 1px of the true max scroll before actually stopping. In practice it works fine, but it means the "are we done" logic runs two slightly different distance checks back to back rather than one clean comparison — a good reminder that shipped code doesn't have to be elegant to work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I turned this into a small free tool if you want to use it without wiring up your own &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop: &lt;a href="https://begoodtool.com/online-teleprompter/en" rel="noopener noreferrer"&gt;Online Teleprompter&lt;/a&gt;. No sign-up, and your script never leaves your browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter" rel="noopener noreferrer"&gt;線上提詞機&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/cn" rel="noopener noreferrer"&gt;在线提词器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/en" rel="noopener noreferrer"&gt;Online Teleprompter&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/jp" rel="noopener noreferrer"&gt;オンラインプロンプター&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/kr" rel="noopener noreferrer"&gt;온라인 텔레프롬프터&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/fr" rel="noopener noreferrer"&gt;Téléprompteur En Ligne&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/ru" rel="noopener noreferrer"&gt;Онлайн-телесуфлер&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/de" rel="noopener noreferrer"&gt;Online Teleprompter&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/id" rel="noopener noreferrer"&gt;Teleprompter Online&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/es" rel="noopener noreferrer"&gt;Teleprompter Online&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/vi" rel="noopener noreferrer"&gt;Máy Nhắc Chữ Online&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/th" rel="noopener noreferrer"&gt;Teleprompter ออนไลน์&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/pl" rel="noopener noreferrer"&gt;Teleprompter Online&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/tr" rel="noopener noreferrer"&gt;Online Teleprompter&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/it" rel="noopener noreferrer"&gt;Teleprompter Online&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/pt" rel="noopener noreferrer"&gt;Teleprompter Online&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/nl" rel="noopener noreferrer"&gt;Online Teleprompter&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/online-teleprompter/uk" rel="noopener noreferrer"&gt;Онлайн-телесуфлер&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why splitting text into sentences by punctuation breaks on "Dr. Smith" (I found out building a case converter)</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:00:03 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-splitting-text-into-sentences-by-punctuation-breaks-on-dr-smith-i-found-out-building-a-case-5g25</link>
      <guid>https://dev.to/begoodtool/why-splitting-text-into-sentences-by-punctuation-breaks-on-dr-smith-i-found-out-building-a-case-5g25</guid>
      <description>&lt;p&gt;I added a "Sentence case" mode to a text case converter I was building — the kind of tool that turns pasted text into UPPERCASE, lowercase, Title Case, or "just capitalize the start of each sentence." The first three are one-liners. The fourth one, sentence case, is the one that quietly ate an afternoon, because "find the start of a sentence" turns out to be a much fuzzier problem than "find a period."&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;.&lt;/code&gt;split() trap I started with
&lt;/h2&gt;

&lt;p&gt;The file I was working in still has my first attempt sitting there, commented out, as a reminder of how I got it wrong:&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="c1"&gt;// const capitalizeSentences = () =&amp;gt; {&lt;/span&gt;
&lt;span class="c1"&gt;//   outputArea.value = inputArea.value.replace(&lt;/span&gt;
&lt;span class="c1"&gt;//     /.+?[\.\?\!](\s|$)/g,&lt;/span&gt;
&lt;span class="c1"&gt;//     function (txt) {&lt;/span&gt;
&lt;span class="c1"&gt;//       return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();&lt;/span&gt;
&lt;span class="c1"&gt;//     }&lt;/span&gt;
&lt;span class="c1"&gt;//   );&lt;/span&gt;
&lt;span class="c1"&gt;// };&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks reasonable: lazily match everything up to a &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, or &lt;code&gt;!&lt;/code&gt; followed by whitespace (or end of string), then capitalize the first letter and lowercase everything else. The bug is in that last part — &lt;code&gt;.toLowerCase()&lt;/code&gt; on the rest of the "sentence." Paste in "I love NASA missions. The ISS orbits every 90 minutes." and this version happily lowercases NASA and ISS along with everything else, because it doesn't know the difference between "the rest of a sentence" and "a proper noun or acronym that happens to not be first." It also had no concept of line breaks, so multi-paragraph text got flattened into a single blob-replace pass.&lt;/p&gt;

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

&lt;p&gt;The version that replaced it splits the problem into two passes instead of one regex:&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;capitalizeSentences&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;inputArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;trimmedText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmedText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\n)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sentences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;.?!&lt;/span&gt;&lt;span class="se"&gt;])\s&lt;/span&gt;&lt;span class="sr"&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;capitalizedSentences&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sentences&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trimmedSentence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sentence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;firstChar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmedSentence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charAt&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="nf"&gt;toUpperCase&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;restOfSentence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trimmedSentence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;firstChar&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;restOfSentence&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;capitalizedSentences&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="s2"&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;span class="nx"&gt;outputArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things changed on purpose here. First, it splits on lines before splitting on sentences, using a lookbehind &lt;code&gt;(?&amp;lt;=\n)&lt;/code&gt; so the newline stays attached to the chunk before it — that keeps paragraph structure intact instead of merging everything into one line. Second, and more importantly, sentence-splitting uses &lt;code&gt;(?&amp;lt;=[.?!])\s+&lt;/code&gt; instead of a consuming match. A lookbehind checks that a period/question mark/exclamation point came right before the split point without actually consuming it, so the punctuation stays glued to the end of the sentence it belongs to instead of getting lost or duplicated.&lt;/p&gt;

&lt;p&gt;The other deliberate choice: it only touches &lt;code&gt;firstChar&lt;/code&gt;. &lt;code&gt;restOfSentence&lt;/code&gt; is used exactly as typed, no &lt;code&gt;.toLowerCase()&lt;/code&gt; anywhere. That's what fixes the NASA/ISS problem from the naive version — if a word was already capitalized correctly, sentence case just leaves it alone instead of "helpfully" re-lowercasing it. It's a much dumber piece of code than the first attempt, and that's exactly why it behaves better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Title Case has a completely different bug
&lt;/h2&gt;

&lt;p&gt;Title Case (capitalize every word) doesn't need sentence boundaries at all — it just needs "word" boundaries, so it uses a much simpler regex:&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;capitalizeWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;outputArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inputArea&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\w\S&lt;/span&gt;&lt;span class="sr"&gt;*/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txt&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;txt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charAt&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="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\w\S*&lt;/code&gt; means "a word character, followed by any run of non-whitespace." That last part is the trap: &lt;code&gt;\S*&lt;/code&gt; doesn't stop at hyphens, apostrophes, or slashes, so "well-known" is matched as a single token, not two words. Capitalizing the first letter and lowercasing the rest turns it into "Well-known" instead of "Well-Known." Same story for anything with an internal capital that isn't at a word boundary a human would recognize — "NASA," for instance, becomes "Nasa," because the regex only sees one long non-whitespace run and blindly lowercases everything after its first character.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this still gets it wrong
&lt;/h2&gt;

&lt;p&gt;Even the shipped sentence-case version has real gaps, because it has no idea what an abbreviation is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Abbreviations fool it completely.&lt;/strong&gt; "He is 6 ft. tall and strong." splits into "He is 6 ft." and "tall and strong." because a period followed by a space is treated as a sentence end every time — there's no list of "Dr.", "Mr.", "etc.", "e.g." that get skipped. The output capitalizes "Tall" as if it started a new sentence, because as far as the regex is concerned, it did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual line wraps get treated as sentence boundaries too.&lt;/strong&gt; Since each line is processed independently before being rejoined with &lt;code&gt;\n&lt;/code&gt;, a sentence that was soft-wrapped across two lines without ending punctuation still gets its second line's first letter capitalized — even though grammatically it's the middle of a sentence, not the start of one.&lt;/li&gt;
&lt;li&gt;One detail I noticed while writing localized copy for this tool: a couple of the translated marketing blurbs claim the sentence-case mode "recognizes common abbreviations (Dr., Mr., etc.) that don't end sentences." That line isn't true of the actual code above — there's no abbreviation list anywhere in it. It's a good reminder to go check the source before believing your own product description.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes it useless — it still saves you from manually re-capitalizing a pasted paragraph — but it's pattern-matching on punctuation, not parsing grammar, and it's worth knowing exactly where that line is.&lt;/p&gt;

&lt;p&gt;I cleaned this up into a small free tool if you want to see the four modes side by side: &lt;a href="https://begoodtool.com/uppercase/en" rel="noopener noreferrer"&gt;Online Case Converter&lt;/a&gt;. No sign-up, just paste and click.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase" rel="noopener noreferrer"&gt;英文大小寫轉換器&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/cn" rel="noopener noreferrer"&gt;在线英文大小写转换器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/en" rel="noopener noreferrer"&gt;Online Case Converter&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/jp" rel="noopener noreferrer"&gt;英語大文字小文字変換ツール&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/kr" rel="noopener noreferrer"&gt;영문 대소문자 변환기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/fr" rel="noopener noreferrer"&gt;Convertisseur de casse en anglais&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/ru" rel="noopener noreferrer"&gt;Конвертер регистра английского текста&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/de" rel="noopener noreferrer"&gt;Online-Konverter für Groß- und Kleinschreibung&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/id" rel="noopener noreferrer"&gt;Konverter Kasus Bahasa Inggris&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/es" rel="noopener noreferrer"&gt;Convertidor de mayúsculas y minúsculas en inglés&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/vi" rel="noopener noreferrer"&gt;Bộ chuyển đổi kiểu chữ tiếng Anh&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/th" rel="noopener noreferrer"&gt;เครื่องมือแปลงตัวพิมพ์ภาษาอังกฤษ&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/pl" rel="noopener noreferrer"&gt;Konwerter wielkości liter angielskich&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/tr" rel="noopener noreferrer"&gt;İngilizce Büyük Küçük Harf Dönüştürücüsü&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/it" rel="noopener noreferrer"&gt;Convertitore di maiuscole e minuscole inglesi&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/pt" rel="noopener noreferrer"&gt;Conversor de Maiúsculas e Minúsculas em Inglês&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/nl" rel="noopener noreferrer"&gt;Engelse hoofdletteromzetter&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/uppercase/uk" rel="noopener noreferrer"&gt;Конвертер регістру англійського тексту&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>vue</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why "the date exactly halfway between these two dates" doesn't always have one answer</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Wed, 05 Aug 2026 02:00:02 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-the-date-exactly-halfway-between-these-two-dates-doesnt-always-have-one-answer-22e8</link>
      <guid>https://dev.to/begoodtool/why-the-date-exactly-halfway-between-these-two-dates-doesnt-always-have-one-answer-22e8</guid>
      <description>&lt;p&gt;A friend asked me to help her find the exact midpoint between her and her partner's birthdays — apparently it's a whole thing people do, so they get a "shared" anniversary to celebrate every year. I figured it'd be a five-minute &lt;code&gt;(date1 + date2) / 2&lt;/code&gt; kind of problem. It's not. Building the date calculator that eventually answered her question, I ran into a genuinely annoying edge case: sometimes there just isn't a single day exactly in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting days between two dates without native &lt;code&gt;Date&lt;/code&gt; math
&lt;/h2&gt;

&lt;p&gt;The tool doesn't do &lt;code&gt;new Date(a) - new Date(b)&lt;/code&gt; and divide by 86400000. It uses &lt;a href="https://moment.github.io/luxon/" rel="noopener noreferrer"&gt;Luxon&lt;/a&gt;, parses both inputs as calendar dates, and asks Luxon for a calendar-aware diff:&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;let&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;twoDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;twoDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date2&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;diffInMonths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;days&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;betweenRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;diffInMonths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toObject&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That variable name is not a typo I'm inventing for the article — it's sitting in the actual source, and I only noticed it while re-reading the file for this post. It's clearly a leftover from an earlier version of the function that computed something else, and it's now permanently doing a &lt;code&gt;days&lt;/code&gt; diff while being named &lt;code&gt;diffInMonths&lt;/code&gt;. Harmless, since &lt;code&gt;.diff(start, "days")&lt;/code&gt; is unambiguous regardless of variable naming, but it's a good reminder that variable names lie the moment you stop actively maintaining them.&lt;/p&gt;

&lt;p&gt;The more deliberate design choice is letting Luxon own the calendar math instead of hand-rolling it. Raw millisecond subtraction on native &lt;code&gt;Date&lt;/code&gt; objects gets you into trouble the moment DST or timezone offsets are involved; a calendar-aware &lt;code&gt;diff("days")&lt;/code&gt; over ISO date strings just counts calendar days, which is what a "how many days between these two dates" tool actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fencepost question nobody asks out loud: is today "day 0" or "day 1"?
&lt;/h2&gt;

&lt;p&gt;Say two dates are 3 days apart. Is the second date "the 3rd day after" the first, or "the 4th day" if you count the first date itself as day 1? This is the classic fencepost ambiguity, and the tool resolves it by spelling out its own convention in the result text instead of leaving it implicit:&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="nx"&gt;between_res_detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;以隔天為第1天來說，{date1}過後的第{betweenRes}天，就是{data2}。&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;// "Counting the day after {date1} as day 1, the {betweenRes}th day after that is {data2}."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a 3-day gap is explicitly defined as: the day after date1 is day 1, and date2 is day 3. It's a plain diff under the hood, but the UI text exists specifically so nobody has to guess whether the tool is counting inclusively or exclusively — which is exactly the kind of ambiguity that makes people distrust a date calculator's output even when the math is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the midpoint: what happens when the gap is an odd number of days
&lt;/h2&gt;

&lt;p&gt;This is the part that actually stopped me. If two dates are, say, 6 days apart, the midpoint is clean: 3 days from either end. But if they're 7 days apart, dividing by 2 gives 3.5 — there is no calendar day at "3.5 days from the start." The tool handles this by branching on parity and returning a range instead of a single date when the gap is odd:&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;let&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;diffInMonths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toObject&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;days&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;days&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// even gap: one exact midpoint&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;middle&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;middleRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// odd gap: midpoint falls between two candidate days&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;middle1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;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;middle2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;middle1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;middleRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle1&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;~&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle2&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle1&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;~&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;middle2&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd (ccc)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even gap → one exact answer. Odd gap → two adjacent dates shown as a range, because both are equally "the middle" and picking one over the other would just be arbitrary. The &lt;code&gt;days &amp;lt; 0&lt;/code&gt; branch also handles a detail that's easy to overlook: the user isn't required to type the earlier date into the first field, so the function figures out which of &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; is actually earlier before it adds the offset, rather than assuming input order. The same input-order tolerance shows up in the days-between result too — before display, the component swaps which date is shown first based on the sign of the raw diff, so &lt;code&gt;date1&lt;/code&gt; and &lt;code&gt;date2&lt;/code&gt; in the output text always resolve to the chronologically earlier and later date, regardless of which one the user typed first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this falls short
&lt;/h2&gt;

&lt;p&gt;A few honest limitations, since none of this is magic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The single-date calculator only adds or subtracts a plain count of &lt;strong&gt;days&lt;/strong&gt; — there's no "add 1 month" or "add 1 year" option. If you want a month from now, you have to already know how many days that is (28–31, depending), the tool won't do that translation for you.&lt;/li&gt;
&lt;li&gt;The date inputs allow anything up to year 9999 with no realistic lower bound. Luxon will happily compute a diff against a date in year 1, but that's applying the proleptic Gregorian calendar backwards past 1582, when the Gregorian calendar didn't actually exist yet — the math is internally consistent, it just isn't "historically real" that far back.&lt;/li&gt;
&lt;li&gt;The "at least 1" rule on the day-count input for the single-date calculator is enforced by a UI hint and a truthy check (&lt;code&gt;!inferNumber.value&lt;/code&gt;), not a strict numeric range check — it stops empty/zero input but isn't the same as validating that the value is a genuine positive integer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs exactly, just the edges you hit once you stop treating "days between two dates" as a solved problem.&lt;/p&gt;

&lt;p&gt;I cleaned up the version I built for my friend's shared-birthday math into a small free tool: &lt;a href="https://begoodtool.com/dateCalculator/en" rel="noopener noreferrer"&gt;Online Date Calculator&lt;/a&gt;. No sign-up, and yes, it shows the range when the midpoint lands between two days instead of quietly rounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator" rel="noopener noreferrer"&gt;日期計算機&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/cn" rel="noopener noreferrer"&gt;在线日期计算器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/en" rel="noopener noreferrer"&gt;Online Date Calculator&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/jp" rel="noopener noreferrer"&gt;オンライン日付計算ツール&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/kr" rel="noopener noreferrer"&gt;온라인 날짜 계산기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/fr" rel="noopener noreferrer"&gt;Calculateur de Date en Ligne&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/ru" rel="noopener noreferrer"&gt;Онлайн Калькулятор Дат&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/de" rel="noopener noreferrer"&gt;Online Datumsrechner&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/id" rel="noopener noreferrer"&gt;Kalkulator Tanggal Online&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/es" rel="noopener noreferrer"&gt;Calculadora de Fechas Online&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/vi" rel="noopener noreferrer"&gt;Máy Tính Ngày Trực Tuyến&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/th" rel="noopener noreferrer"&gt;เครื่องคิดเลขวันที่ออนไลน์&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/pl" rel="noopener noreferrer"&gt;Internetowy Kalkulator Dat&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/tr" rel="noopener noreferrer"&gt;Çevrimiçi Tarih Hesaplayıcı&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/it" rel="noopener noreferrer"&gt;Calcolatore di Date Online&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/pt" rel="noopener noreferrer"&gt;Calculadora de Datas Online&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/nl" rel="noopener noreferrer"&gt;Online Datumcalculator&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/dateCalculator/uk" rel="noopener noreferrer"&gt;Онлайн Калькулятор Дат&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why `new Date(garbage) === "Invalid Date"` is always false (a timestamp converter taught me this the hard way)</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Tue, 04 Aug 2026 07:00:01 +0000</pubDate>
      <link>https://dev.to/begoodtool/why-new-dategarbage-invalid-date-is-always-false-a-timestamp-converter-taught-me-this-362</link>
      <guid>https://dev.to/begoodtool/why-new-dategarbage-invalid-date-is-always-false-a-timestamp-converter-taught-me-this-362</guid>
      <description>&lt;p&gt;I built a small Unix timestamp converter — paste in seconds or milliseconds, get a date back, or go the other way. Simple enough that I figured the only real work would be the date math. Then I went to add "tell the user when they typed garbage" and ran straight into a JavaScript gotcha that's been quietly living in more codebases than anyone wants to admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit dropdown is secretly a multiplier, not a branch
&lt;/h2&gt;

&lt;p&gt;The converter has a unit selector next to the input — "Seconds" or "Milliseconds." The tempting way to wire that up is an if/else: if seconds, do this math; if milliseconds, do that math. That's not what's in the actual component. The &lt;code&gt;&amp;lt;a-select&amp;gt;&lt;/code&gt; options are bound directly to the numbers &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;1000&lt;/code&gt;, and those numbers get used as literal arithmetic operands everywhere downstream:&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;unixToNoraml_getOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;unix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unixToNoraml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unix&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;unit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unixToNoraml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 or 1000&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&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;unix&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unixToNoraml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;output&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;If &lt;code&gt;unit&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; (seconds), that's &lt;code&gt;unix * 1000 / 1&lt;/code&gt; — converts seconds to the milliseconds &lt;code&gt;Date&lt;/code&gt; wants. If &lt;code&gt;unit&lt;/code&gt; is &lt;code&gt;1000&lt;/code&gt; (milliseconds), it's &lt;code&gt;unix * 1000 / 1000&lt;/code&gt;, which cancels out and leaves the value untouched, because it was already in milliseconds. Same one-liner handles both cases with no conditional at all.&lt;/p&gt;

&lt;p&gt;The reverse direction does the same trick in reverse — compute the answer in seconds, then multiply by the unit to get either seconds or milliseconds back out:&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;normalToUnixOutputFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&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;It's a small thing, but reusing the dropdown's raw value as a multiplier instead of introducing a &lt;code&gt;"seconds" | "milliseconds"&lt;/code&gt; enum saved a branch in four separate places in the component.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validation check that doesn't actually validate anything
&lt;/h2&gt;

&lt;p&gt;Here's the one that got me. When you type a date string into the "normal time" field and hit convert, the code tries to reject bad input before doing math on it:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid Date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Swal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fire&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timestamp.error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;confirmButtonColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#1890ff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;new Date("not a real date")&lt;/code&gt; doesn't throw and doesn't return the string &lt;code&gt;"Invalid Date"&lt;/code&gt; — it returns an actual &lt;code&gt;Date&lt;/code&gt; object whose internal time value is &lt;code&gt;NaN&lt;/code&gt;. Calling &lt;code&gt;.toString()&lt;/code&gt; on that object &lt;em&gt;prints&lt;/em&gt; &lt;code&gt;"Invalid Date"&lt;/code&gt;, but the object itself is never, ever &lt;code&gt;===&lt;/code&gt; to a string, no matter what garbage you feed it. So that guard clause is dead code. It will never fire, for any input, ever.&lt;/p&gt;

&lt;p&gt;What actually keeps the tool from displaying literal "NaN" to the user is something else entirely, three lines away in a computed property:&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;normalToUnixOutputFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;normalToUnix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&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;&lt;code&gt;NaN / 1000&lt;/code&gt; is still &lt;code&gt;NaN&lt;/code&gt;, and &lt;code&gt;!NaN&lt;/code&gt; evaluates to &lt;code&gt;true&lt;/code&gt; in JavaScript — so the falsy check blanks the output field instead of the intended validation ever running. The tool behaves correctly, but by accident: the real safety net is a coincidental side effect of how &lt;code&gt;NaN&lt;/code&gt; interacts with &lt;code&gt;!&lt;/code&gt;, not the &lt;code&gt;Swal.fire()&lt;/code&gt; warning dialog that was written to handle exactly this case. The correct check would be &lt;code&gt;isNaN(new Date(normal).getTime())&lt;/code&gt;, but that's not what's in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The live clock ticks in your local time, not UTC
&lt;/h2&gt;

&lt;p&gt;The page also has a running "current timestamp" display that updates once a second, with pause/continue buttons:&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;init_clock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentUnix&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentLocalString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentUnix&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy/MM/dd HH:mm:ss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&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;pause_clock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out. First, &lt;code&gt;setInterval(..., 1000)&lt;/code&gt; doesn't guarantee a tick every exact second — it just re-reads &lt;code&gt;Date.now()&lt;/code&gt; and rounds each time it fires, so under heavy main-thread load a tick can land late; the displayed number just catches up rather than drifting permanently. Second, &lt;code&gt;DateTime.fromSeconds()&lt;/code&gt; from Luxon defaults to the &lt;em&gt;local system time zone&lt;/em&gt; when you don't pass one — so the human-readable clock next to the raw Unix number is your machine's local time, not UTC, even though a Unix timestamp is by definition timezone-independent. There's no UTC toggle for it. If you're in Tokyo and I'm in Berlin, we see the same integer but a different clock face next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No digit-count auto-detection.&lt;/strong&gt; I assumed a paste-in converter like this would sniff "10 digits, must be seconds" vs "13 digits, must be milliseconds." It doesn't — the unit is a manual dropdown. Paste a 13-digit millisecond value while "Seconds" is still selected and you silently get a date several thousand years in the future. No warning, no clamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything renders in local time.&lt;/strong&gt; The seconds-since-epoch value is timezone-agnostic by definition, but every human-readable output on the page — the live clock, the converted date — is displayed in whatever timezone the browser is set to, with no UTC option. If you're debugging a server log recorded in UTC, you have to do the offset math yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "type a date manually" field parses whatever &lt;code&gt;new Date(string)&lt;/code&gt; accepts&lt;/strong&gt;, which is a browser-implementation detail, not a fully spec'd format. It works fine for the &lt;code&gt;YYYY/MM/DD HH:MM:SS&lt;/code&gt; shape the placeholder suggests, but it's not the same guarantee you'd get from an explicit parser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Invalid Date check discussed above never runs.&lt;/strong&gt; It's harmless here because &lt;code&gt;NaN&lt;/code&gt;'s falsiness happens to save it, but it means the warning dialog telling you "please enter a valid time" is unreachable code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I turned the cleaned-up version into a small free tool: &lt;a href="https://begoodtool.com/timestamp/en" rel="noopener noreferrer"&gt;Unix Timestamp Converter&lt;/a&gt;. No sign-up, works entirely in the browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp" rel="noopener noreferrer"&gt;Unix時間戳轉換工具&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/cn" rel="noopener noreferrer"&gt;Unix时间戳转换器&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/en" rel="noopener noreferrer"&gt;Unix Timestamp Converter&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/jp" rel="noopener noreferrer"&gt;Unixタイムスタンプ変換ツール&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/kr" rel="noopener noreferrer"&gt;Unix 타임스탬프 변환기&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/fr" rel="noopener noreferrer"&gt;Outil de Conversion de Timestamp Unix&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/ru" rel="noopener noreferrer"&gt;Конвертер Unix Timestamp&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/de" rel="noopener noreferrer"&gt;Unix-Zeitstempel-Konverter&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/id" rel="noopener noreferrer"&gt;Alat Konversi Unix Timestamp&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/es" rel="noopener noreferrer"&gt;Conversor de Unix Timestamp&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/vi" rel="noopener noreferrer"&gt;Công cụ chuyển đổi Unix Timestamp&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/th" rel="noopener noreferrer"&gt;เครื่องมือแปลง Unix Timestamp&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/pl" rel="noopener noreferrer"&gt;Konwerter znacznika czasu Unix&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/tr" rel="noopener noreferrer"&gt;Unix Timestamp Dönüştürme Aracı&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/it" rel="noopener noreferrer"&gt;Strumento di conversione Unix Timestamp&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/pt" rel="noopener noreferrer"&gt;Conversor de Unix Timestamp&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/nl" rel="noopener noreferrer"&gt;Unix Timestamp Conversie Tool&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/timestamp/uk" rel="noopener noreferrer"&gt;Інструмент конвертації Unix Timestamp&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a calorie lookup table and realized the search box was already a category filter</title>
      <dc:creator>Vera Yang</dc:creator>
      <pubDate>Tue, 04 Aug 2026 02:00:04 +0000</pubDate>
      <link>https://dev.to/begoodtool/i-built-a-calorie-lookup-table-and-realized-the-search-box-was-already-a-category-filter-4n9p</link>
      <guid>https://dev.to/begoodtool/i-built-a-calorie-lookup-table-and-realized-the-search-box-was-already-a-category-filter-4n9p</guid>
      <description>&lt;p&gt;A while back I wanted a fast way to answer "how many calories are in this" while eating instead of after — no app to install, no account, just type the food and see a number. I figured I'd build a small searchable table over a list of common foods. There's no clever algorithm hiding in something like this. It's a filtered array rendered as rows. What actually took the time was the boring stuff: how do you store "1 bowl (200g)" as data, what happens when someone searches for a category instead of a food, and whether the number you show is even the number you meant to show.&lt;/p&gt;

&lt;h2&gt;
  
  
  The search box already does more than the category dropdown
&lt;/h2&gt;

&lt;p&gt;The table has both a text search and a category dropdown (Staple Food, Fruit, Drinks, Vegetable, Seafood, Meat, Egg). I built them as two separate filters, applied one after the other:&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;let&lt;/span&gt; &lt;span class="nx"&gt;showTable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;searchResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;foodCalories&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupName_en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name_en&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;searchResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;showGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;groupName_en&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;showGroup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The text filter checks four fields per item, not one: the Chinese name, the English name, the Chinese category, and the English category. I added the category fields mostly so a &lt;code&gt;groupName_en&lt;/code&gt; typo somewhere wouldn't make an item unfindable. What I didn't plan for is that typing "fruit" into the search box now returns every fruit in the table, with zero interaction with the dropdown next to it — the dropdown and the search box quietly do the same job through two different UI elements. It's not a bug exactly, it's just a filter that's wider than the label on the box suggests. Nobody's complained, but if I rebuilt this I'd probably narrow the text search to just the name fields and let the dropdown own category filtering exclusively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Calories are stored as a string, and that decision has a shelf life
&lt;/h2&gt;

&lt;p&gt;Each row 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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;白米飯&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;name_en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;White rice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1碗&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;unit_en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 bowl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;200g&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;calories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;225&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;主食&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;groupName_en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Staple Food&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;&lt;code&gt;calories&lt;/code&gt; is a string, &lt;code&gt;"225"&lt;/code&gt;, not the number &lt;code&gt;225&lt;/code&gt;. That was fine as long as all I ever did with it was print it next to the name. The table has no sort feature today, and that's exactly why it's gotten away with this so far — &lt;code&gt;"9"&lt;/code&gt; sorting after &lt;code&gt;"80"&lt;/code&gt; because &lt;code&gt;"9" &amp;gt; "8"&lt;/code&gt; lexicographically only matters the moment you try to sort. The day I add "sort by calories," every row with a triple-digit value will need &lt;code&gt;parseInt&lt;/code&gt; first, or the ordering will look randomly wrong to anyone who doesn't know why. I left it as a string because the data came from a spreadsheet of copy-pasted nutrition labels, and normalizing every value to a real number felt like busywork for a feature that didn't exist yet. That's a fair trade until it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  A &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; your browser never actually shows you
&lt;/h2&gt;

&lt;p&gt;The template renders the food data twice. Once as a real HTML &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;tbody&amp;gt;&lt;/code&gt;, and once as a grid of styled &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s that's the thing you actually see and interact with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="nc"&gt;.foodCalories__table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&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 semantic table exists purely for crawlers and screen readers — search engines and assistive tech get proper table markup with real headers, while the visible UI is a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; grid because that's what let me do per-cell click-to-copy, a font-size toggle, and a mobile layout that reflows without fighting &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; layout rules. Two representations of the same 300+ rows, kept in sync only because they're both generated from the same &lt;code&gt;v-for&lt;/code&gt; over the same array. If I ever changed one loop without the other, they'd silently drift apart with no error to catch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the numbers don't quite line up
&lt;/h2&gt;

&lt;p&gt;The honest gotchas, since none of this is as tidy as it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Click-to-copy doesn't always match what's on screen.&lt;/strong&gt; The visible unit column shows the Chinese unit for both &lt;code&gt;tw&lt;/code&gt; and &lt;code&gt;cn&lt;/code&gt; locales (&lt;code&gt;locale == "tw" || locale == "cn" ? item.unit : item.unit_en&lt;/code&gt;), but the click-to-copy handler for that same cell only checks &lt;code&gt;locale == "tw"&lt;/code&gt;. A &lt;code&gt;cn&lt;/code&gt;-locale user sees "1碗" on screen and copies "1 bowl" to their clipboard. Small, easy to miss, and exactly the kind of thing that survives for years in a component nobody re-reads line by line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calories are per labeled serving, not per 100g&lt;/strong&gt;, and the serving unit and its gram weight are just two adjacent strings (&lt;code&gt;unit&lt;/code&gt;, &lt;code&gt;weight&lt;/code&gt;) glued together for display — &lt;code&gt;"1碗（200g）"&lt;/code&gt; — with no actual unit conversion behind them. Comparing two foods with different serving sizes means doing the per-gram math yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The category dropdown is a hand-written list&lt;/strong&gt;, separate from the actual &lt;code&gt;groupName_en&lt;/code&gt; values in the data file. If a future data update adds a new category, it's searchable by text immediately but invisible in the dropdown until someone remembers to add it there too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that makes the table wrong, exactly — it makes it a spreadsheet with a search box, and it's worth knowing that's what you're looking at before you trust it for anything more precise than "roughly how much is this."&lt;/p&gt;

&lt;p&gt;I cleaned this up into a small free tool if you want to poke at the real thing: &lt;a href="https://begoodtool.com/food-calories/en" rel="noopener noreferrer"&gt;Common Food Calorie Table&lt;/a&gt;. No sign-up, just search and filter over 300+ foods.&lt;/p&gt;




&lt;h2&gt;
  
  
  Available in other languages
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories" rel="noopener noreferrer"&gt;常見食物熱量表&lt;/a&gt; — 繁體中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/cn" rel="noopener noreferrer"&gt;常见食物热量表&lt;/a&gt; — 简体中文&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/en" rel="noopener noreferrer"&gt;Common Food Calorie Table&lt;/a&gt; — English&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/jp" rel="noopener noreferrer"&gt;食品カロリー表（300種類以上）&lt;/a&gt; — 日本語&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/kr" rel="noopener noreferrer"&gt;식품 칼로리 표&lt;/a&gt; — 한국어&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/fr" rel="noopener noreferrer"&gt;Tableau des Calories des Aliments Courants&lt;/a&gt; — Français&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/ru" rel="noopener noreferrer"&gt;Таблица калорийности продуктов&lt;/a&gt; — Русский&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/de" rel="noopener noreferrer"&gt;Kalorientabelle für gängige Lebensmittel&lt;/a&gt; — Deutsch&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/id" rel="noopener noreferrer"&gt;Tabel Kalori Makanan Umum&lt;/a&gt; — Bahasa Indonesia&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/es" rel="noopener noreferrer"&gt;Tabla de calorías de alimentos comunes&lt;/a&gt; — Español&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/vi" rel="noopener noreferrer"&gt;Bảng calo thực phẩm thông thường&lt;/a&gt; — Tiếng Việt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/th" rel="noopener noreferrer"&gt;ตารางแคลอรี่อาหารทั่วไป&lt;/a&gt; — ไทย&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/pl" rel="noopener noreferrer"&gt;Tabela kalorii popularnych produktów spożywczych&lt;/a&gt; — Polski&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/tr" rel="noopener noreferrer"&gt;Yaygın Yiyecek Kalori Tablosu&lt;/a&gt; — Türkçe&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/it" rel="noopener noreferrer"&gt;Tabella delle calorie dei cibi comuni&lt;/a&gt; — Italiano&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/pt" rel="noopener noreferrer"&gt;Tabela de calorias de alimentos comuns&lt;/a&gt; — Português&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/nl" rel="noopener noreferrer"&gt;Tabel met calorieën van veelvoorkomende voedingsmiddelen&lt;/a&gt; — Nederlands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://begoodtool.com/food-calories/uk" rel="noopener noreferrer"&gt;Таблиця калорійності популярних продуктів&lt;/a&gt; — Українська&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
