<?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: farcrak</title>
    <description>The latest articles on DEV Community by farcrak (@farcrak).</description>
    <link>https://dev.to/farcrak</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%2F4016757%2Ffdb0d602-e6f5-466d-b816-f1f0f8bd79a9.jpg</url>
      <title>DEV Community: farcrak</title>
      <link>https://dev.to/farcrak</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/farcrak"/>
    <language>en</language>
    <item>
      <title>Google indexed 428 of my 1,432 generated pages and none of my 2,290 handwritten ones</title>
      <dc:creator>farcrak</dc:creator>
      <pubDate>Sun, 23 Aug 2026 19:50:35 +0000</pubDate>
      <link>https://dev.to/farcrak/google-indexed-428-of-my-1432-generated-pages-and-none-of-my-2290-handwritten-ones-8m8</link>
      <guid>https://dev.to/farcrak/google-indexed-428-of-my-1432-generated-pages-and-none-of-my-2290-handwritten-ones-8m8</guid>
      <description>&lt;p&gt;I spent yesterday planning to delete the best part of my site.&lt;/p&gt;

&lt;p&gt;I run a calculators-and-tools site, solo, five months old. About 5,800 pages across four language sections. Google has 642 of them in its index. Not great. The rest sit in two buckets: 1,153 "crawled, currently not indexed" and 4,161 "discovered, currently not indexed."&lt;/p&gt;

&lt;p&gt;Like everyone in that situation, I had a theory. Somewhere in those 5,800 pages I had generated 1,432 timezone pages — every URL a pair of cities, &lt;code&gt;/date-time/time-difference/london-and-buenos-aires&lt;/code&gt;, that sort of thing. One calculation, different arguments, spread across a URL space. It looks exactly like what Google's spam policy calls scaled content abuse, and I assumed it was dragging the whole host down. The plan was to collapse them into one hub page and 301 the rest away.&lt;/p&gt;

&lt;p&gt;Before doing it, I exported the list of what Google actually has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The export
&lt;/h2&gt;

&lt;p&gt;This is the part I had never bothered with, and it takes about four minutes.&lt;/p&gt;

&lt;p&gt;In Search Console: &lt;strong&gt;Indexing → Pages&lt;/strong&gt;, then the link &lt;strong&gt;"View data about indexed pages."&lt;/strong&gt; At the bottom of the examples table, change rows-per-page to 500. The examples table caps around 1,000 URLs, so if your indexed count is under that, you get the complete list, not a sample. Mine was 642, and I pulled 638 unique URLs out of it.&lt;/p&gt;

&lt;p&gt;Then classify them. I did it with a throwaway script that buckets by URL prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;family&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://example\.com/[a-z]{2}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^/date-time/(convert|time-difference|meeting-time|world-clock)/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;generated timezone pairs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/recipes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;handwritten recipes&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/cooking&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cooking guides&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^/finance/[^/]+/.+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;generated finance scenarios&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tools and hubs&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;indexed.txt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Counter&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="n"&gt;family&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;most_common&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same for the "crawled, not indexed" and "discovered, not indexed" drilldowns and you get the whole picture in three exports.&lt;/p&gt;

&lt;p&gt;One trap if you automate the reading: the console reuses its DOM between drilldowns. My first pass at the "crawled, not indexed" list came back looking almost identical to the indexed list, and it took a set intersection to notice that 638 of those 660 rows &lt;em&gt;were&lt;/em&gt; the indexed list, still sitting in the page. Reload the drilldown URL directly before reading it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came back
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Indexed&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Crawled, rejected&lt;/th&gt;
&lt;th&gt;Never crawled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generated timezone pairs&lt;/td&gt;
&lt;td&gt;1,432&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;428&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;647&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated finance scenarios&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;74&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cooking guides&lt;/td&gt;
&lt;td&gt;530&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;11.5%&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools and hubs&lt;/td&gt;
&lt;td&gt;1,377&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Market-specific pages&lt;/td&gt;
&lt;td&gt;593&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Handwritten recipes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,290&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;195&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two generated families I was about to delete are 502 of the 638 indexed pages. Seventy-nine percent of my index. They also have the two highest acceptance rates on the site.&lt;/p&gt;

&lt;p&gt;The 2,290 recipes — the ones with photographs, per-serving nutrition, ingredient scaling rules, written natively in two languages rather than machine-translated — have zero pages in the index. Not rejected. Zero of them appear in the "crawled, not indexed" bucket either. Google has never fetched a single one.&lt;/p&gt;

&lt;p&gt;(The counts in the last two columns are from samples — those drilldowns cap out around a thousand example URLs, and my buckets are larger than that. The indexed column is complete.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not the story I expected
&lt;/h2&gt;

&lt;p&gt;The obvious reading is "Google likes generated pages better," which is nonsense. Look at the last two columns instead.&lt;/p&gt;

&lt;p&gt;For the timezone family, Google is &lt;strong&gt;finished&lt;/strong&gt;. It crawled essentially all 1,432, kept 428, threw out 647, and has almost nothing left in the queue. That is a completed judgement, and 30% is the verdict.&lt;/p&gt;

&lt;p&gt;For the recipes, Google hasn't &lt;strong&gt;started&lt;/strong&gt;. They are all sitting in "discovered, currently not indexed," which does not mean rejected. It means Google knows the URLs exist and has not spent a request on them.&lt;/p&gt;

&lt;p&gt;Those are two completely different failure modes and the summary number blends them into one scary total. "Crawled, not indexed" is a quality verdict. "Discovered, not indexed" is a scheduling decision — Google deciding your host isn't worth more requests right now. My Links report says external links: 0. That's the whole story: crawl allowance got spent on whatever was linked from the hubs first, and the rest of the site never got its turn.&lt;/p&gt;

&lt;p&gt;So the site does not have a content-quality problem in the family I suspected. It has a "nobody links to this domain" problem, which no amount of deleting pages will fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would have lost
&lt;/h2&gt;

&lt;p&gt;Had I shipped the collapse-and-redirect plan, here is the sequence.&lt;/p&gt;

&lt;p&gt;Day one, the old URLs are still in the index. Someone searches for a city pair, finds the old URL, clicks, gets a 301 to the hub with the pair pre-filled. Fine — the reader gets their answer.&lt;/p&gt;

&lt;p&gt;Week four, Google re-crawls, sees the redirects, drops the old URLs and shows the hub. Now one generic hub page has to rank for every city-pair query on its own. A hub with two dropdowns competes badly against a page whose title is literally the query.&lt;/p&gt;

&lt;p&gt;Net effect: hand back 67% of the index in exchange for rankings the hub would have to earn from scratch. To fix a quality problem that the data says is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;If you are about to prune pages because they feel thin, export the indexed list first. Four minutes. The report you need is one click below the chart most people stop at, and the answer may be the opposite of your theory. Mine was.&lt;/p&gt;

&lt;p&gt;And split those two "not indexed" reasons in your head permanently. If your pages are &lt;strong&gt;crawled and rejected&lt;/strong&gt;, work on the pages. If they are &lt;strong&gt;discovered and never crawled&lt;/strong&gt;, the pages are irrelevant — go get links, because you are asking Google for more of its budget than your domain currently earns.&lt;/p&gt;

&lt;p&gt;I am doing the second one now. If it's useful to anybody, the recipe data those uncrawled pages are built on is published as an open dataset — 501 dishes from 127 countries, JSON and CSV, CC BY-SA 4.0, with per-serving nutrition and sourced cooking times: &lt;a href="https://theunitools.com/en/data" rel="noopener noreferrer"&gt;theunitools.com/en/data&lt;/a&gt;. Google hasn't read it either, but you might.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>My number input turned 3,635,758 into 758 and said nothing</title>
      <dc:creator>farcrak</dc:creator>
      <pubDate>Tue, 18 Aug 2026 14:18:46 +0000</pubDate>
      <link>https://dev.to/farcrak/my-number-input-turned-3635758-into-758-and-said-nothing-5ebc</link>
      <guid>https://dev.to/farcrak/my-number-input-turned-3635758-into-758-and-said-nothing-5ebc</guid>
      <description>&lt;p&gt;I run a small site with a few hundred calculators. One of them writes an amount out in words — the thing you have to do on a cheque or a contract in Spanish. It is by a wide margin my most successful page: it brings 43 of the 44 search clicks the whole site gets in a month.&lt;/p&gt;

&lt;p&gt;Yesterday I finally looked at &lt;em&gt;which&lt;/em&gt; searches those were. Almost all of them were shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;como se escribe con letra 1,370,801,919.12
3.635.758 escribir letras
10,377.84 se escribe en letra
como se escribe 77107,10 enletra
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;People do not arrive wondering how the tool works. They arrive with a number already in their hand, copied out of an invoice, and they paste it.&lt;/p&gt;

&lt;p&gt;So I pasted one myself.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;3.635.758&lt;/code&gt; went into the field. The field showed &lt;code&gt;758&lt;/code&gt;. And the page answered, in complete confidence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;setecientos cincuenta y ocho euros&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Seven hundred fifty-eight euros. The number was three million six hundred thirty-five thousand seven hundred fifty-eight. Off by a factor of about 4,800, with no error, no warning, nothing red. On a page whose entire job is to produce the amount you then sign your name under.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;The field was &lt;code&gt;&amp;lt;input type="number"&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That input does not accept "a number as people write it". Per the HTML spec it accepts a &lt;em&gt;valid floating-point number&lt;/em&gt;: optional sign, digits, optional &lt;code&gt;.&lt;/code&gt; and more digits, optional exponent. That's it. No thousands separators. No locale. &lt;code&gt;1,234.56&lt;/code&gt; is not a valid value. Neither is &lt;code&gt;1.234.567,89&lt;/code&gt;. Neither is &lt;code&gt;1 234,56&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What the browser does with an invalid value is the actual problem. It doesn't reject the input in a way you notice. Typing character by character, each keystroke is sanitized against that grammar, and you are left with whatever survived — in my case, &lt;code&gt;758&lt;/code&gt;. Pasting usually leaves you with an empty string instead, and &lt;code&gt;element.value&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. &lt;code&gt;valueAsNumber&lt;/code&gt; hands you &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every one of those outcomes is silent. The field looks fine. And that is the part worth internalising: &lt;strong&gt;&lt;code&gt;type="number"&lt;/code&gt; doesn't protect you from bad input, it converts bad input into plausible input.&lt;/strong&gt; A blank field is a bug you notice. &lt;code&gt;758&lt;/code&gt; is a bug that ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same bug from the other end
&lt;/h2&gt;

&lt;p&gt;While I was in there I found the server-side half:&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;normalized&lt;/span&gt; &lt;span class="o"&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="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="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="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;numeric&lt;/span&gt; &lt;span class="o"&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;normalized&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;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;numeric&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallback&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;String.prototype.replace&lt;/code&gt; with a string pattern replaces the &lt;strong&gt;first&lt;/strong&gt; match only. I wrote that line myself and had read past it many times.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"1234,56"&lt;/code&gt; becomes &lt;code&gt;"1234.56"&lt;/code&gt; becomes 1234.56. Fine — which is why it survived so long.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"1 234,56"&lt;/code&gt; becomes &lt;code&gt;"1 234.56"&lt;/code&gt; becomes &lt;code&gt;NaN&lt;/code&gt;, and falls back to &lt;strong&gt;0&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"1,234.56"&lt;/code&gt; becomes &lt;code&gt;"1.234.56"&lt;/code&gt; becomes &lt;code&gt;NaN&lt;/code&gt;, and falls back to &lt;strong&gt;0&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the two halves of the same system failed in opposite directions — one invented a small number, the other invented zero — and both did it quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a number the way a person reads it
&lt;/h2&gt;

&lt;p&gt;You cannot fix this by picking one locale. My own traffic proves it: &lt;code&gt;10,377.84&lt;/code&gt; and &lt;code&gt;77107,10&lt;/code&gt; are both real inputs from real people on the same page, one written the American way and one the Spanish way. Force either convention and you are wrong for half your users.&lt;/p&gt;

&lt;p&gt;But you don't need to guess, because a human reading &lt;code&gt;1.370.801.919,12&lt;/code&gt; has no trouble at all. Write down what they're doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Both &lt;code&gt;.&lt;/code&gt; and &lt;code&gt;,&lt;/code&gt; appear&lt;/strong&gt; — the one that appears &lt;strong&gt;last&lt;/strong&gt; is the decimal separator, the other groups thousands. &lt;code&gt;1,370,801,919.12&lt;/code&gt; decides on &lt;code&gt;.&lt;/code&gt;; &lt;code&gt;208.333.333,33&lt;/code&gt; decides on &lt;code&gt;,&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One kind of separator, and it repeats&lt;/strong&gt; — it groups thousands. &lt;code&gt;3.635.758&lt;/code&gt; has two dots, so the dots are grouping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One separator, exactly three digits after it&lt;/strong&gt; — genuinely ambiguous. &lt;code&gt;1.234&lt;/code&gt; is "one thousand two hundred thirty-four" to a Spaniard and "one point two three four" to an American, and nothing in the string tells you which. This is the only case where the page's own locale decides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything else&lt;/strong&gt; — decimal separator. &lt;code&gt;77107,10&lt;/code&gt;, &lt;code&gt;1.5&lt;/code&gt;, &lt;code&gt;0,5&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here it is, minus the currency-symbol stripping:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseHumanNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decimalSeparator&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;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&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;[\s&lt;/span&gt;&lt;span class="sr"&gt;&amp;nbsp; &lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="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;[&lt;/span&gt;&lt;span class="sr"&gt;€$£¥&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;cleaned&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;null&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;negative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cleaned&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;[&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="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[\d&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&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;null&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;lastDot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastComma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;decimalAt&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastDot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lastComma&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nx"&gt;decimalAt&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;lastDot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastComma&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// rule 1&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;lastDot&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;lastComma&lt;/span&gt; &lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&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;lastDot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastComma&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;mark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;at&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;howMany&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&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="nx"&gt;mark&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digitsAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&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="nx"&gt;at&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;howMany&lt;/span&gt; &lt;span class="o"&gt;&amp;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;decimalAt&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="c1"&gt;// rule 2&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;digitsAfter&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;// rule 3&lt;/span&gt;
      &lt;span class="nx"&gt;decimalAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mark&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;decimalSeparator&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;at&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="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;decimalAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                               &lt;span class="c1"&gt;// rule 4&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;wholeRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decimalAt&lt;/span&gt; &lt;span class="o"&gt;&amp;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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decimalAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&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;fraction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;decimalAt&lt;/span&gt; &lt;span class="o"&gt;&amp;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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decimalAt&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="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="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;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fraction&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Separators left in the integer part have to be real grouping:&lt;/span&gt;
  &lt;span class="c1"&gt;// one kind of mark, groups of exactly three.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&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;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wholeRaw&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;wholeRaw&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;wholeRaw&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="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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;mark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wholeRaw&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="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="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wholeRaw&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="nx"&gt;mark&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;groups&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;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;groups&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;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&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;null&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;groups&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="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;g&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;3&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;null&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;whole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wholeRaw&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;[&lt;/span&gt;&lt;span class="sr"&gt;.,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;whole&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="nx"&gt;fraction&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;null&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&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;whole&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="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fraction&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="s2"&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;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&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;span class="nx"&gt;negative&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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;That last block is the one I nearly skipped, and it matters most. Without it, &lt;code&gt;1,23,4.5&lt;/code&gt; parses to &lt;code&gt;1234.5&lt;/code&gt; — a confident answer to a string that means nothing. &lt;strong&gt;Returning &lt;code&gt;null&lt;/code&gt; on garbage is the whole point.&lt;/strong&gt; If you are replacing a silent wrong number, do not replace it with a different silent wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The element itself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
  &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"decimal"&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;typed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="nf"&gt;setTyped&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;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseHumanNumber&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;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;NaN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inputMode="decimal"&lt;/code&gt; still brings up the numeric keypad on phones, which is the one thing &lt;code&gt;type="number"&lt;/code&gt; was genuinely providing here. You also lose the spinner arrows, and nobody has ever wanted to increment an invoice total by one.&lt;/p&gt;

&lt;p&gt;There is a React trap in those few lines. The &lt;strong&gt;typed text&lt;/strong&gt; and the &lt;strong&gt;parsed number&lt;/strong&gt; have to be separate state. If the input's &lt;code&gt;value&lt;/code&gt; is the parsed number, then the moment someone types &lt;code&gt;1,&lt;/code&gt; the value is &lt;code&gt;1&lt;/code&gt;, React re-renders, and the comma disappears from under their fingers. They cannot type a decimal at all. Show what they typed; compute from what you understood.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more change, and it costs nothing
&lt;/h2&gt;

&lt;p&gt;The page now prints the amount it understood, in digits, right beside the words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.370.801.919,12 €  -&amp;gt;  mil trescientos setenta millones ochocientos
                        un mil novecientos diecinueve euros con doce céntimos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any parser like this has a genuinely ambiguous case — rule 3 above. I can pick a sensible default, but I cannot be right every time, and a tool that writes the sum on a contract has no business being quietly wrong. Echoing the parsed number back turns an invisible failure into one the reader catches in half a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests are just the search log
&lt;/h2&gt;

&lt;p&gt;I didn't invent test cases. I pasted in the actual queries people had used to reach the page:&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&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,370,801,919.12&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;1370801919.12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3.635.758&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3635758&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;10,377.84&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;10377.84&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;77107,10&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;77107.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;208.333.333,33&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;208333333.33&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$14.688.000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;14688000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&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 234,56&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// ambiguity resolved by the page's language&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&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.234&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// Spanish page&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&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.234&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;1.234&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// English page&lt;/span&gt;

&lt;span class="c1"&gt;// and the ones that must not produce an answer&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;junk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abc&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;12abc&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;1.2.3,4,5&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;1,23,4.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;junk&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your search console already knows the exact strings your users bring you. It is the best fixture list you will ever get, and it is free.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;type="number"&lt;/code&gt; is fine for a quantity picker. It is the wrong control for any figure a person copies from somewhere else — an invoice total, a salary, a price, an account balance — because those arrive &lt;em&gt;already formatted&lt;/em&gt;, and formatting is precisely what that element cannot survive.&lt;/p&gt;

&lt;p&gt;Go and paste &lt;code&gt;1,234.56&lt;/code&gt; into your own form right now. Then paste &lt;code&gt;1.234,56&lt;/code&gt;. Then look at what your backend stored.&lt;/p&gt;




&lt;p&gt;The tool in question, if you want to see the fix in place: &lt;a href="https://theunitools.com/es/calculadoras/calculadora-numero-a-letras" rel="noopener noreferrer"&gt;Número a letras&lt;/a&gt; — it writes amounts out in Spanish for contracts and cheques, in euros, pesos or dollars.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>ux</category>
    </item>
    <item>
      <title>How long to cook 200 foods in the oven — as an open dataset with official sources (CC BY-SA 4.0)</title>
      <dc:creator>farcrak</dc:creator>
      <pubDate>Thu, 06 Aug 2026 01:08:09 +0000</pubDate>
      <link>https://dev.to/farcrak/how-long-to-cook-200-foods-in-the-oven-as-an-open-dataset-with-official-sources-cc-by-sa-40-1ljk</link>
      <guid>https://dev.to/farcrak/how-long-to-cook-200-foods-in-the-oven-as-an-open-dataset-with-official-sources-cc-by-sa-40-1ljk</guid>
      <description>&lt;p&gt;Yesterday I released a &lt;a href="https://dev.to/farcrak/501-world-recipes-as-an-open-dataset-per-serving-nutrition-step-timings-ingredient-scaling-rules-4a95"&gt;recipe dataset&lt;/a&gt;. Today its companion: &lt;strong&gt;oven cooking times and temperatures for 200 products&lt;/strong&gt;, from chicken breast to cheesecake — every number backed by a primary source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON&lt;/strong&gt; (~216 KB): &lt;a href="https://theunitools.com/data/unitools-cooking-times-v1.json" rel="noopener noreferrer"&gt;https://theunitools.com/data/unitools-cooking-times-v1.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt; (one product per row): &lt;a href="https://theunitools.com/data/unitools-cooking-times-v1.csv" rel="noopener noreferrer"&gt;https://theunitools.com/data/unitools-cooking-times-v1.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://github.com/farcrak/unitools-recipes" rel="noopener noreferrer"&gt;https://github.com/farcrak/unitools-recipes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset page: &lt;a href="https://theunitools.com/en/data" rel="noopener noreferrer"&gt;https://theunitools.com/en/data&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What each record holds
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Oven temperature (°C and °F) and a &lt;strong&gt;practical time range&lt;/strong&gt; in minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe minimum internal temperature&lt;/strong&gt; where an official one exists (FoodSafety.gov / USDA FSIS charts)&lt;/li&gt;
&lt;li&gt;Rest time, reference weight/thickness, doneness levels, and the key geometry driver (thickness vs weight vs batch size)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sources&lt;/code&gt;&lt;/strong&gt; — the actual URLs each number came from: government charts, King Arthur Baking, manufacturer package directions (Ore-Ida, Gorton's, Stouffer's, Totino's…)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;confidence&lt;/code&gt;&lt;/strong&gt; — an honest &lt;code&gt;high&lt;/code&gt; / &lt;code&gt;medium&lt;/code&gt; flag: chart-backed numbers vs converging culinary sources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why sources per row matter
&lt;/h2&gt;

&lt;p&gt;Most cooking-time tables on the internet are copies of copies with no provenance. Here you can trace "boneless chicken thighs: 18–25 min at 425°F" back to the page it came from, and decide for yourself whether to trust it. The &lt;code&gt;medium&lt;/code&gt; confidence flag marks numbers where no official chart exists — that honesty is baked into the data rather than hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CC BY-SA 4.0&lt;/strong&gt; — commercial use allowed. Credit "UniTools — theunitools.com", share derivatives alike.&lt;/p&gt;

&lt;p&gt;If you build a kitchen timer, an Alexa skill, or a meal-prep planner on top of it — tell me in the comments, I'd genuinely love to see it.&lt;/p&gt;

</description>
      <category>data</category>
      <category>github</category>
      <category>json</category>
      <category>opensource</category>
    </item>
    <item>
      <title>501 world recipes as an open dataset: per-serving nutrition, step timings, ingredient scaling rules (CC BY-SA 4.0)</title>
      <dc:creator>farcrak</dc:creator>
      <pubDate>Thu, 06 Aug 2026 00:05:32 +0000</pubDate>
      <link>https://dev.to/farcrak/501-world-recipes-as-an-open-dataset-per-serving-nutrition-step-timings-ingredient-scaling-rules-4a95</link>
      <guid>https://dev.to/farcrak/501-world-recipes-as-an-open-dataset-per-serving-nutrition-step-timings-ingredient-scaling-rules-4a95</guid>
      <description>&lt;p&gt;Last month I wrote about building a 1,800-page calculator site solo. Since then the recipe hub on that site grew to 501 dishes from 127 countries — and today I'm releasing all of it as an open dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON&lt;/strong&gt; (full dataset, ~2.6 MB): &lt;a href="https://theunitools.com/data/unitools-recipes-v1.json" rel="noopener noreferrer"&gt;https://theunitools.com/data/unitools-recipes-v1.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV&lt;/strong&gt; (one dish per row): &lt;a href="https://theunitools.com/data/unitools-recipes-v1.csv" rel="noopener noreferrer"&gt;https://theunitools.com/data/unitools-recipes-v1.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs + sample record&lt;/strong&gt;: &lt;a href="https://github.com/farcrak/unitools-recipes" rel="noopener noreferrer"&gt;https://github.com/farcrak/unitools-recipes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset page: &lt;a href="https://theunitools.com/en/data" rel="noopener noreferrer"&gt;https://theunitools.com/en/data&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's inside
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;501 home-cooking recipes, 127 countries, bilingual (English + Russian, both written by hand — no machine translation)&lt;/li&gt;
&lt;li&gt;Per-serving nutrition (calories, protein, fat, carbs) on every single dish&lt;/li&gt;
&lt;li&gt;3,200+ steps, each annotated with minutes&lt;/li&gt;
&lt;li&gt;Ingredients with stable ids and &lt;strong&gt;scaling rules&lt;/strong&gt;: meat scales linearly with servings, salt and spices are damped — the way an actual kitchen scales a recipe, not naive multiplication&lt;/li&gt;
&lt;li&gt;Human-reviewed Wikimedia Commons photos with author + licence per photo&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the scaling rules matter
&lt;/h2&gt;

&lt;p&gt;Most recipe datasets store "2 tbsp salt for 4 servings" and leave scaling to you. Multiply salt linearly to 16 servings and the dish is inedible. Each ingredient in this dataset carries a &lt;code&gt;scaling&lt;/code&gt; field (&lt;code&gt;linear&lt;/code&gt; | &lt;code&gt;damped&lt;/code&gt; | &lt;code&gt;fixed&lt;/code&gt;), so a portion calculator can be built directly on top of the data. That's exactly how the recipe pages on the site work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Licence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CC BY-SA 4.0&lt;/strong&gt; — free for commercial use. Credit "UniTools — theunitools.com" and share derivatives under the same licence. Photos carry their own Commons licences (in the data).&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats
&lt;/h2&gt;

&lt;p&gt;Nutrition is computed from ingredients, not lab-measured — a planning reference, not medical data. The dataset is maintained by one person; if you spot an error, open an issue on the repo and the fix lands in the next version.&lt;/p&gt;

&lt;p&gt;If you build something with it — a meal planner, a viz, a model fine-tune — I'd genuinely love to hear about it in the comments.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>dataset</category>
      <category>food</category>
      <category>json</category>
    </item>
    <item>
      <title>How I built ~1,800 "honest" calculator pages with Next.js + SQLite</title>
      <dc:creator>farcrak</dc:creator>
      <pubDate>Tue, 07 Jul 2026 00:57:08 +0000</pubDate>
      <link>https://dev.to/farcrak/how-i-built-1800-honest-calculator-pages-with-nextjs-sqlite-4pop</link>
      <guid>https://dev.to/farcrak/how-i-built-1800-honest-calculator-pages-with-nextjs-sqlite-4pop</guid>
      <description>&lt;p&gt;It started with a dumb frustration. Every "how long to cook chicken thighs" page gives you a different number, and none of them tell you where the number comes from. Some say 25 minutes, some say 40, and you're standing there with a thermometer wondering who to trust.&lt;/p&gt;

&lt;p&gt;So I did the developer thing and over-engineered a solution. What began as one cooking-time calculator turned into ~1,800 generated pages. Here's how it's built and a couple of footguns that cost me real traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model: start from a source, not from blog numbers
&lt;/h2&gt;

&lt;p&gt;The core idea: don't hardcode "chicken = 30 min". Start from the &lt;strong&gt;official safe internal temperature&lt;/strong&gt; (USDA/FSIS charts — poultry 74°C, whole cuts 63°C + rest, fish 63°C) and treat time as a function of geometry.&lt;/p&gt;

&lt;p&gt;Each food is a row with a reference point and a driver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CookingEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;safeMinInternalTempC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referenceTempC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referenceTimeMin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referenceTimeMax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referenceThicknessCm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;referenceWeightG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;keyGeometryDriver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;thickness&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;total_weight&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;Then the estimate scales the reference time by how far your piece is from the reference, using a power law instead of a linear guess (a steak twice as thick doesn't take twice as long — heat diffusion is closer to thickness²):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thicknessCm&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referenceThicknessCm&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;factor&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;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// clamped to sane bounds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;referenceCenter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tempFactor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is it perfect? No — it's a household estimate, and the page says so and shows the safe temperature so you can verify with a thermometer. But it's &lt;em&gt;honest about where it comes from&lt;/em&gt;, which is more than most results give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  1 entity = 1 indexable page
&lt;/h2&gt;

&lt;p&gt;The whole thing is programmatic SEO: every food, every city, every scenario is its own route under the Next.js App Router.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/[locale]/cooking/how-long-to-cook/[slug]/page.tsx
app/[locale]/date-time/world-clock/[slug]/page.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Metadata is generated per-entity in &lt;code&gt;generateMetadata&lt;/code&gt;, and there's a dynamic &lt;code&gt;app/sitemap.ts&lt;/code&gt; that enumerates every dataset entry. The trap here is &lt;code&gt;lastmod&lt;/code&gt;: I first set it to &lt;code&gt;new Date()&lt;/code&gt;, which re-stamped all ~1,800 URLs as "changed" on every deploy. Crawlers learn to distrust that fast. Fix: a single &lt;code&gt;datasetLastReviewed&lt;/code&gt; constant I bump only when the data actually changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite is fine (really)
&lt;/h2&gt;

&lt;p&gt;The datastore is SQLite via Prisma. For a read-heavy site with curated datasets it's genuinely fine — no connection pool, no separate DB server, the whole thing is a file you can &lt;code&gt;.backup&lt;/code&gt; in a cron job. The dataset itself lives in versioned TSV/JSON, so the DB is more of a cache than a source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time zones will humble you
&lt;/h2&gt;

&lt;p&gt;The world-clock pages use the &lt;code&gt;Temporal&lt;/code&gt; API (via the polyfill) instead of raw &lt;code&gt;Date&lt;/code&gt;, because DST math is where naive code dies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zoned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toZonedDateTimeISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Prague&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;offsetMin&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;zoned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offsetNanoseconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000_000_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each page also computes the &lt;em&gt;next&lt;/em&gt; clock change by binary-searching for the offset flip, so a "12:00 UTC to Kyiv" page can tell you when the answer will shift. Real IANA zones, date-aware offsets, no static UTC+2 lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two footguns that cost me traffic
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;www&lt;/code&gt; returning 200.&lt;/strong&gt; Behind a proxy, &lt;code&gt;request.nextUrl.hostname&lt;/code&gt; isn't reliable — I had to check &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-Host&lt;/code&gt; in middleware to 301 &lt;code&gt;www&lt;/code&gt; → apex. Until I did, Google had two copies of every page and split the signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hreflang&lt;/code&gt; &lt;code&gt;x-default&lt;/code&gt; pointing at the wrong locale.&lt;/strong&gt; It decides what Google serves everyone outside your declared locales. Mine defaulted to the non-English version while the inventory targeted English queries. One-line change, big difference.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Stack, for the curious
&lt;/h2&gt;

&lt;p&gt;Next.js (App Router) + TypeScript + Tailwind + Prisma/SQLite, ~1,800 pages generated from curated datasets, deployed with a boring &lt;code&gt;git pull&lt;/code&gt; + &lt;code&gt;pm2 restart&lt;/code&gt; script. Solo project.&lt;/p&gt;

&lt;p&gt;If you want to poke at the actual output, it's live and free (no signups, no paywall): &lt;a href="https://theunitools.com/en" rel="noopener noreferrer"&gt;https://theunitools.com/en&lt;/a&gt; — I'd genuinely like to hear if a cooking time looks off to you, since that's the part I care about most.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>sqlite</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
