<?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: Mustafa Tarabya</title>
    <description>The latest articles on DEV Community by Mustafa Tarabya (@thedolceway).</description>
    <link>https://dev.to/thedolceway</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%2F3733349%2F379a6b33-09b6-4045-beca-0860eddea1d3.jpg</url>
      <title>DEV Community: Mustafa Tarabya</title>
      <link>https://dev.to/thedolceway</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thedolceway"/>
    <language>en</language>
    <item>
      <title>new Date('2026-03-01') is 28 February if your user is in California</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:53:22 +0000</pubDate>
      <link>https://dev.to/thedolceway/new-date2026-03-01-is-28-february-if-your-user-is-in-california-5h6b</link>
      <guid>https://dev.to/thedolceway/new-date2026-03-01-is-28-february-if-your-user-is-in-california-5h6b</guid>
      <description>&lt;p&gt;We build a tool that turns a few form fields into a finished resignation or notice letter.&lt;br&gt;
The single most important string in that output is a date. A resignation letter states your&lt;br&gt;
last working day, and that date is the thing your employer acts on.&lt;/p&gt;

&lt;p&gt;So the date formatting code is four lines and it is the most dangerous four lines in the&lt;br&gt;
feature.&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;formatDate&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="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;trim&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;d&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;value&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="k"&gt;return&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&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;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&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;Reasonable looking. Here is what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;An HTML &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt; gives you &lt;code&gt;"2026-03-01"&lt;/code&gt;. Feed that to &lt;code&gt;new Date&lt;/code&gt; and run it&lt;br&gt;
in Los Angeles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;America/Los_Angeles node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(new Date('2026-03-01').toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'}))"&lt;/span&gt;
&lt;span class="go"&gt;28 February 2026
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code in Helsinki:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 March 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user picked 1 March. The letter says 28 February. Nothing threw, nothing logged, and&lt;br&gt;
the developer in a positive UTC offset never sees it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;ECMAScript specifies that a &lt;strong&gt;date-only ISO string is parsed as UTC&lt;/strong&gt;. &lt;code&gt;"2026-03-01"&lt;/code&gt;&lt;br&gt;
becomes midnight UTC. &lt;code&gt;toLocaleDateString&lt;/code&gt; then renders it in the runtime's local zone. In&lt;br&gt;
Los Angeles, midnight UTC on 1 March is 4pm on 28 February.&lt;/p&gt;

&lt;p&gt;Any format that is not date-only ISO is parsed as &lt;strong&gt;local time&lt;/strong&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;America/Los_Angeles node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(new Date('2026/03/01').toDateString())"&lt;/span&gt;
&lt;span class="go"&gt;Sun Mar 01 2026
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slashes give the right answer here and dashes give the wrong one, but only half of that is&lt;br&gt;
specified. The specification pins down exactly one thing: a date-only ISO string is UTC.&lt;br&gt;
Everything else, &lt;code&gt;2026/03/01&lt;/code&gt; included, falls through to an implementation-defined fallback&lt;br&gt;
parser, so it happens to work in V8 and you cannot lean on it. Do not read this as "use&lt;br&gt;
slashes". Read it as "do not hand a string to &lt;code&gt;Date&lt;/code&gt; at all". The UTC half is guaranteed by&lt;br&gt;
the spec, and it hits every negative UTC offset, which is the whole of the Americas.&lt;/p&gt;

&lt;p&gt;The consequence for the product is specific and bad. Someone resigning on the first of the&lt;br&gt;
month sends a letter naming the last day of the previous month. There is no crash to&lt;br&gt;
report, so the first time anyone finds out is a conversation with HR.&lt;/p&gt;
&lt;h2&gt;
  
  
  The second failure: invalid dates that are not invalid
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Number.isNaN&lt;/code&gt; guard implies dates either parse or fail. Try 30 February:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;America/Los_Angeles node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(new Date('2026-02-30').toDateString())"&lt;/span&gt;
&lt;span class="go"&gt;Sun Mar 01 2026
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;Invalid Date&lt;/code&gt;. &lt;code&gt;Date&lt;/code&gt; rolls the overflow forward, so an impossible day silently&lt;br&gt;
becomes a real day in the following month. The guard never fires, because there is nothing&lt;br&gt;
to catch. A typed-in date of &lt;code&gt;2026-02-31&lt;/code&gt; produces a confident, wrong, plausible answer.&lt;/p&gt;

&lt;p&gt;The other side of the same coin is that real invalid input does need handling, but not by&lt;br&gt;
rejecting it. Somebody typing &lt;code&gt;end of March&lt;/code&gt; into a free text field is being reasonable.&lt;br&gt;
The fallback in that function returns their string untouched, and I would keep that. Do not&lt;br&gt;
punish a human for not using the date picker. Just do not confuse "I could not parse this"&lt;br&gt;
with "this parsed to something sensible".&lt;/p&gt;
&lt;h2&gt;
  
  
  The third failure: two weeks is not 1,209,600,000 milliseconds
&lt;/h2&gt;

&lt;p&gt;A notice-period feature wants to offer "two weeks from today". The tempting arithmetic:&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;end&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;start&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;14&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it across a daylight-saving boundary:&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;$&lt;/span&gt; &lt;span class="nx"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nx"&gt;America&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;Los_Angeles&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;
  const s = new Date(2026, 9, 25);
  console.log(new Date(s.getTime() + 14*86400000).toDateString());
  console.log(new Date(2026, 9, 25 + 14).toDateString());
&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="nx"&gt;Sat&lt;/span&gt; &lt;span class="nx"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;07&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;span class="nx"&gt;Sun&lt;/span&gt; &lt;span class="nx"&gt;Nov&lt;/span&gt; &lt;span class="mi"&gt;08&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two weeks after 25 October 2026 is either 7 or 8 November depending on which arithmetic you&lt;br&gt;
used. The millisecond version loses a day because the clocks go back on 1 November and one&lt;br&gt;
of those "days" was 25 hours long.&lt;/p&gt;

&lt;p&gt;Adding days is a calendar operation. Adding milliseconds is a physics operation. They agree&lt;br&gt;
most of the year, which is what makes this survive code review.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Stop letting &lt;code&gt;Date&lt;/code&gt; parse strings, and stop letting UTC into a calendar calculation.&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;formatDate&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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="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;v&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;m&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{4})&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;(\d{2})&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;(\d{2})&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;m&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;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// "end of March" passes through untouched&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dt&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;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mo&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;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// local construction, no UTC shift&lt;/span&gt;

  &lt;span class="c1"&gt;// catch the silent rollover: 2026-02-30 comes back as March&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;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMonth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;mo&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;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;d&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;v&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;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&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;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;numeric&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;Three changes, each fixing one of the failures above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match the format explicitly.&lt;/strong&gt; If it is not &lt;code&gt;YYYY-MM-DD&lt;/code&gt;, it is not a date we are
willing to interpret, so it goes through as typed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Construct from components.&lt;/strong&gt; &lt;code&gt;new Date(y, mo - 1, d)&lt;/code&gt; builds a local date. No parsing,
no UTC, no offset arithmetic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Round-trip the result.&lt;/strong&gt; Read the fields back off the constructed date and compare. If
they disagree with the input, an overflow happened and the input was never a real date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For date arithmetic, use component maths for the same reason:&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;end&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;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&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;getMonth&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;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime handles month and year boundaries and, critically, handles DST, because you&lt;br&gt;
asked for a calendar day rather than a fixed duration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; conflates two things that behave differently: an instant on a timeline, and a day on&lt;br&gt;
a calendar. Timestamps, expiry, ordering, "how long ago" are instants, and UTC is correct&lt;br&gt;
for all of them. A birthday, a deadline, a last working day is a calendar day, has no time&lt;br&gt;
component, and does not exist on a timeline until you attach a zone to it.&lt;/p&gt;

&lt;p&gt;Almost every date bug we have put live came from storing a calendar day as an instant and&lt;br&gt;
then rendering it somewhere else.&lt;/p&gt;

&lt;p&gt;Two habits that catch it cheaply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run your test suite under at least one negative UTC offset.&lt;/strong&gt; &lt;code&gt;TZ=America/Los_Angeles
npm test&lt;/code&gt; in CI takes no extra time and turns this entire class of bug into a red build.
If your only tests run in UTC, or in a European zone, this bug is invisible by
construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test a DST boundary date on purpose.&lt;/strong&gt; Pick the specific weekend your target zone
changes and put it in a fixture. It is the only way that arithmetic gets exercised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We found all three of these in one small function whose whole job was to print one line. The&lt;br&gt;
&lt;a href="https://cvbooster.ai/resignation-letter-generator" rel="noopener noreferrer"&gt;resignation letter template&lt;/a&gt; tool it&lt;br&gt;
belongs to is free and needs no account, and the date it prints is now the date the person&lt;br&gt;
actually picked, which was always the only requirement.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Our regex found 199 records in a 1,723-record corpus and reported no errors</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Mon, 07 Sep 2026 20:41:43 +0000</pubDate>
      <link>https://dev.to/thedolceway/our-regex-found-199-records-in-a-1723-record-corpus-and-reported-no-errors-31eh</link>
      <guid>https://dev.to/thedolceway/our-regex-found-199-records-in-a-1723-record-corpus-and-reported-no-errors-31eh</guid>
      <description>&lt;p&gt;We maintain a corpus of 456 role-specific resume examples in TypeScript. Someone asked me&lt;br&gt;
what a good bullet point actually looks like, and rather than answer from taste I decided&lt;br&gt;
to measure the thing I already had.&lt;/p&gt;

&lt;p&gt;Fifteen minutes later we had a script, a set of numbers, and a conclusion. The conclusion&lt;br&gt;
was wrong, because the script had silently read about twelve percent of the data.&lt;/p&gt;

&lt;p&gt;This is a post about that failure mode, and then about the numbers I got once the script&lt;br&gt;
worked.&lt;/p&gt;
&lt;h2&gt;
  
  
  The corpus
&lt;/h2&gt;

&lt;p&gt;Thirty-one TypeScript files, each exporting an array of role objects. One role looks roughly&lt;br&gt;
like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cloud-architect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cloud Architect Resume&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Information Technology&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sampleData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;experiences&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;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Amazon Web Services&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Senior Cloud Architect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Designed multi-region architecture...&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;- Led migration of...&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="nx"&gt;skills&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;tips&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 interesting field is &lt;code&gt;description&lt;/code&gt;. It holds a newline-delimited list of bullets as a&lt;br&gt;
single string, so the whole corpus of bullets is sitting there in source, greppable,&lt;br&gt;
without a database or an export step.&lt;/p&gt;
&lt;h2&gt;
  
  
  Version one
&lt;/h2&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;descs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/description: '&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="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="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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Nothing exotic. Match &lt;code&gt;description:&lt;/code&gt;, then a single-quoted string, allowing escapes so an&lt;br&gt;
apostrophe inside the text does not terminate the match early.&lt;/p&gt;

&lt;p&gt;It found 199 description strings. I did not question that, because I had no prior for what&lt;br&gt;
the number should be. 199 sounded like a lot of text. We computed medians off it, looked at&lt;br&gt;
the opener distribution, and started writing.&lt;/p&gt;

&lt;p&gt;The number that saved me was on a different line of the same output: &lt;code&gt;roles 456&lt;/code&gt;. The slug&lt;br&gt;
count was fine. So 456 roles between them had 199 job descriptions, which would mean the&lt;br&gt;
overwhelming majority of roles had no work history at all. I knew that was false, because&lt;br&gt;
I had rendered these pages.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it read twelve percent
&lt;/h2&gt;

&lt;p&gt;Prettier. That is the whole answer.&lt;/p&gt;

&lt;p&gt;Some of these files were formatted at a width that keeps the value on the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Led enterprise sales team of 30+...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Others were formatted with the value pushed onto the next line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;- Led enterprise sales team of 30+...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern had a literal space after the colon. Every record in the second shape failed to&lt;br&gt;
match. Not with an error. Not with a warning. The regex simply found fewer things, and a&lt;br&gt;
regex that finds fewer things looks exactly like a regex running over a smaller corpus.&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;// before&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;
&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&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;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;\s*&lt;/code&gt; took the count from 199 to 1,723.&lt;/p&gt;

&lt;p&gt;The general shape of this bug is worth naming, because it is not really about regexes.&lt;br&gt;
&lt;strong&gt;A parser that silently skips malformed input converts a crash into a wrong answer.&lt;/strong&gt; A&lt;br&gt;
crash costs you ten minutes. A wrong answer costs you however long you spend believing it,&lt;br&gt;
which in my case included a first draft of this post.&lt;/p&gt;

&lt;p&gt;Two habits that catch it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Have an expected order of magnitude before you look at the output.&lt;/strong&gt; 456 roles times
three jobs each is well over a thousand. 199 should have been alarming on sight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-check one extracted count against a different one from the same run.&lt;/strong&gt; Slugs and
descriptions come from the same files through different patterns. When one is plausible
and the other is not, the pattern is the suspect, not the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the corpus actually looks like
&lt;/h2&gt;

&lt;p&gt;With the parser fixed: 1,723 description blocks, of which 1,267 use the bullet format,&lt;br&gt;
yielding &lt;strong&gt;3,815 individual bullets&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Length.&lt;/strong&gt; Median 14 words. The 10th percentile is 11, the 90th is 17, the full range is&lt;br&gt;
7 to 25. That is a tighter distribution than I expected, and it is not the product of a&lt;br&gt;
style rule, because there was no style rule. It is what a claim plus its evidence happens&lt;br&gt;
to cost in English. Under about ten words you have stated a duty. Over about twenty you&lt;br&gt;
have written a paragraph that gets skimmed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers.&lt;/strong&gt; 89.0 percent of bullets contain at least one digit. 1,081 contain a percentage,&lt;br&gt;
555 contain a currency amount. That leaves 418 bullets with no number in them at&lt;br&gt;
all, and reading a sample of those is instructive: they are the ones describing scope&lt;br&gt;
rather than outcome, which is a legitimate thing to describe when you cannot quantify it.&lt;br&gt;
The failure is not "has no number", it is "has neither a number nor a specific".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verbs.&lt;/strong&gt; 332 distinct opening words across 3,815 bullets. The top ten (&lt;code&gt;managed&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;developed&lt;/code&gt;, &lt;code&gt;led&lt;/code&gt;, &lt;code&gt;built&lt;/code&gt;, &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;designed&lt;/code&gt;, &lt;code&gt;implemented&lt;/code&gt;, &lt;code&gt;conducted&lt;/code&gt;, &lt;code&gt;reduced&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;manage&lt;/code&gt;) account for 40.9 percent of all openers. 143 verbs appear exactly once.&lt;/p&gt;

&lt;p&gt;That distribution is the interesting part. It is not flat and it should not be flat. A long&lt;br&gt;
tail of one-off verbs (&lt;code&gt;architected&lt;/code&gt;, &lt;code&gt;migrated&lt;/code&gt;, &lt;code&gt;authored&lt;/code&gt;) carries the specificity, while&lt;br&gt;
a small head of common verbs carries the ordinary work. A resume where every bullet opens&lt;br&gt;
with an unusual verb reads like a thesaurus exercise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weak openers.&lt;/strong&gt; 149 bullets, under five percent, begin with &lt;code&gt;supported&lt;/code&gt;, &lt;code&gt;assisted&lt;/code&gt; or&lt;br&gt;
similar. Zero begin with &lt;code&gt;responsible for&lt;/code&gt;. That one is a genuine consensus in the corpus&lt;br&gt;
rather than something I enforced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Density.&lt;/strong&gt; The median role shows three bullets per position, never more than four. The&lt;br&gt;
456 summaries run a median of 37 words, with the 10th to 90th percentile band sitting&lt;br&gt;
between 29 and 45.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicates: zero.&lt;/strong&gt; Not one bullet text appears twice across 3,815. I checked because I&lt;br&gt;
assumed generated-looking content would have collisions, and finding none told me the&lt;br&gt;
corpus was written per role rather than stamped from a shared list.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that transfers
&lt;/h2&gt;

&lt;p&gt;The measurement I would suggest to anyone writing bullets for their own history, since it&lt;br&gt;
takes a minute and needs no tooling:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Count words. If a bullet is under ten or over twenty, it is probably a duty or a
paragraph.&lt;/li&gt;
&lt;li&gt;Look for the digit. If there is none, check there is at least a proper noun, a system
name, or a scale.&lt;/li&gt;
&lt;li&gt;Read only the first word of every bullet, top to bottom. If the same verb appears three
times in one job, two of those bullets are describing the same thing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Number three is the one that finds real problems fastest, and it is the same trick as the&lt;br&gt;
script: look at one narrow projection of the data instead of reading all of it.&lt;/p&gt;

&lt;p&gt;The corpus these numbers came from is public. It is the set of&lt;br&gt;
&lt;a href="https://cvbooster.ai/resume-examples" rel="noopener noreferrer"&gt;resume examples&lt;/a&gt; on the builder I run, organised by&lt;br&gt;
role, free to open and free to preview without an account. It was not written in order to&lt;br&gt;
be measured, which is exactly why the measurements were worth taking.&lt;/p&gt;

&lt;p&gt;And the script stayed in the repo, with the &lt;code&gt;\s*&lt;/code&gt; and a hard assertion that the description&lt;br&gt;
count is within an order of magnitude of the role count. A checker that can quietly read&lt;br&gt;
twelve percent of its input will do it again.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Our site served every URL the same 3,780 bytes, and Google believed it</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Mon, 07 Sep 2026 20:40:49 +0000</pubDate>
      <link>https://dev.to/thedolceway/our-site-served-every-url-the-same-3780-bytes-and-google-believed-it-1d9m</link>
      <guid>https://dev.to/thedolceway/our-site-served-every-url-the-same-3780-bytes-and-google-believed-it-1d9m</guid>
      <description>&lt;p&gt;Checked with a Googlebot user agent one morning: every single URL on our site returned the&lt;br&gt;
same 3,780-byte shell. Same &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, zero &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, zero body text. The homepage, a blog&lt;br&gt;
post and a product page were byte-identical before JavaScript ran.&lt;/p&gt;

&lt;p&gt;Search Console agreed with the crawler rather than with us. Of 741 URLs, 116 had earned a&lt;br&gt;
single impression in 28 days, and a landing page that had been live for five months was&lt;br&gt;
still reported as "URL is unknown to Google".&lt;/p&gt;

&lt;p&gt;Here is what I actually learned fixing it, including the two things that cost us the most&lt;br&gt;
time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Google does render JavaScript. That is not the point.
&lt;/h2&gt;

&lt;p&gt;The standard reply to this problem is "Googlebot executes JS now, you are fine." It does.&lt;br&gt;
Several of our pages were indexed, so rendering clearly happened.&lt;/p&gt;

&lt;p&gt;But rendering is a &lt;strong&gt;separate, budgeted queue&lt;/strong&gt;. A domain with little authority does not&lt;br&gt;
get much of that budget. So the practical question is not "can Google render our page", it&lt;br&gt;
is "will Google spend its budget rendering &lt;em&gt;this&lt;/em&gt; page, today, before it decides what the&lt;br&gt;
page is about".&lt;/p&gt;

&lt;p&gt;There is a second problem that has nothing to do with rendering: 741 URLs that are&lt;br&gt;
byte-identical before render look like duplicates. You are handing a duplicate-content&lt;br&gt;
signal to the crawler and hoping the render queue fixes your first impression.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we built, and what we deliberately did not
&lt;/h2&gt;

&lt;p&gt;We wrote a post-build script that injects a real &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; into each generated HTML file:&lt;br&gt;
title, description, canonical, robots, Open Graph, Twitter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Head only.&lt;/strong&gt; The body stayed exactly as the SPA served it. That was deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No hydration flash.&lt;/li&gt;
&lt;li&gt;No risk of a static copy drifting out of sync with what users see.&lt;/li&gt;
&lt;li&gt;Nothing that could be read as cloaking, because the static markup is a subset of the
rendered markup, not a different page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every value is read from the same source the React page reads. Where a title is a literal&lt;br&gt;
inside a component, the script extracts it from that component's source rather than having&lt;br&gt;
anyone retype it. A number retyped in two places is a number that will disagree with itself&lt;br&gt;
eventually.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 1: react-helmet-async deletes tags you did not mark
&lt;/h2&gt;

&lt;p&gt;Our shell had static &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; in &lt;code&gt;index.html&lt;/code&gt;. Helmet sets its own on&lt;br&gt;
mount. The assumption was that the later one wins.&lt;/p&gt;

&lt;p&gt;What actually happens: on its first commit, Helmet removes every tag carrying &lt;code&gt;data-rh&lt;/code&gt;&lt;br&gt;
and re-inserts its own. Tags &lt;strong&gt;without&lt;/strong&gt; that attribute survive. So the static tag stayed,&lt;br&gt;
Helmet's tag was added, and the page went out with two descriptions, with the static one&lt;br&gt;
first in the head, which is the one scrapers and crawlers read.&lt;/p&gt;

&lt;p&gt;Every blog post on the site was advertising the generic homepage copy.&lt;/p&gt;

&lt;p&gt;The fix is to mark the tags Helmet actually emits:&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;meta&lt;/span&gt; &lt;span class="na"&gt;data-rh=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"..."&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;With one sharp edge: only mark tags Helmet will re-add. A &lt;code&gt;data-rh&lt;/code&gt; tag that Helmet does&lt;br&gt;
not re-insert gets deleted on mount and never comes back. We marked &lt;code&gt;og:image&lt;/code&gt; and lost our&lt;br&gt;
share image everywhere until we worked out why.&lt;/p&gt;

&lt;p&gt;The matching bug in our own script: the replace was written as a regex keyed on attribute&lt;br&gt;
order, &lt;code&gt;&amp;lt;meta name="description" ...&amp;gt;&lt;/code&gt;. The shell writes &lt;code&gt;&amp;lt;meta data-rh="true"&lt;br&gt;
name="description" ...&amp;gt;&lt;/code&gt;. It silently matched nothing. Match on &lt;em&gt;a tag containing the&lt;br&gt;
key&lt;/em&gt;, never on attribute order.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 2: the script read the sitemap, and nothing regenerated the sitemap
&lt;/h2&gt;

&lt;p&gt;This one cost the most time and had the least to do with SEO.&lt;/p&gt;

&lt;p&gt;The prerender script takes its URL list from &lt;code&gt;sitemap.xml&lt;/code&gt;. Reasonable: one source of truth&lt;br&gt;
for what exists.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;npm run build&lt;/code&gt; did not include the sitemap generator. It ran vite, then redirects,&lt;br&gt;
then the crawl index, then prerender. The sitemap was generated by a separate command&lt;br&gt;
somebody had to remember to run.&lt;/p&gt;

&lt;p&gt;So we added a new route, built, deployed, and the page was live and completely absent from&lt;br&gt;
the prerendered set. No error. No warning. The build exited 0. The route simply was not in&lt;br&gt;
the list, so it was never processed, and it went out with the generic shell head.&lt;/p&gt;

&lt;p&gt;We burned two builds assuming the code was wrong before checking whether the input was&lt;br&gt;
stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a build step consumes a generated artifact, that artifact's generator belongs in the&lt;br&gt;
same build command.&lt;/strong&gt; Otherwise you have an ordering dependency that lives only in&lt;br&gt;
somebody's memory, and it fails silently rather than loudly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 3: a dead ternary that read as intentional
&lt;/h2&gt;

&lt;p&gt;We found this while adding static content for a specific route:&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;m&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="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/some-route&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;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;Both branches return &lt;code&gt;null&lt;/code&gt;. It was presumably mid-refactor when someone got interrupted.&lt;br&gt;
It looks purposeful enough to skim past in review: there is a route name in it, so it&lt;br&gt;
reads like a handled special case.&lt;/p&gt;

&lt;p&gt;The effect was that the route fell through to a generic fallback body, which contained&lt;br&gt;
none of the terms the page existed to rank for. The page had been quietly pointless for&lt;br&gt;
weeks.&lt;/p&gt;

&lt;p&gt;Grep your codebase for &lt;code&gt;? null : null&lt;/code&gt;. I will wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verify with &lt;code&gt;curl&lt;/code&gt; and a crawler user agent, not with your browser. Your browser runs
the JavaScript. That is exactly the thing you are trying to see past.&lt;/li&gt;
&lt;li&gt;Static head, dynamic body, always from one source. Two hand-maintained copies of the
same string are a future contradiction.&lt;/li&gt;
&lt;li&gt;When something silently does nothing, check the input before you debug the logic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data-rh&lt;/code&gt; is not decoration. It decides which tags survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the output, it is running on &lt;a href="https://cvbooster.ai" rel="noopener noreferrer"&gt;CVBooster&lt;/a&gt;, a&lt;br&gt;
resume builder I run. Every page announces what it is before a line of JavaScript&lt;br&gt;
executes, which is all this work was ever about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postscript, September 2026: head only was not enough
&lt;/h2&gt;

&lt;p&gt;The approach above held for the pages the prerender step knew about. It did nothing for&lt;br&gt;
the ones it did not. Every URL without a prerendered file fell through to the SPA fallback,&lt;br&gt;
and on Cloudflare Pages that fallback was a copy of the homepage, complete with a canonical&lt;br&gt;
tag pointing at &lt;code&gt;/&lt;/code&gt;. Google indexed that fallback for 309 URLs and filed them under&lt;br&gt;
"alternate page with proper canonical", which is the polite way of saying it believed the&lt;br&gt;
tag and threw the pages away.&lt;/p&gt;

&lt;p&gt;Three changes made it hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render the body too.&lt;/strong&gt; A server-side render step in the build writes the real page
content into each file, so the static markup is no longer a subset of the page but the
page itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send the fallback to a &lt;code&gt;noindex&lt;/code&gt; shell&lt;/strong&gt;, never to a homepage clone. An unknown URL
should look like nothing, not like the front door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate the build.&lt;/strong&gt; A last step reads &lt;code&gt;dist&lt;/code&gt; the way a crawler does and fails the build if
any sitemap URL lacks a static file, a self-referencing canonical or 150 words of text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then measure from outside, every deploy: fetch every sitemap URL with a Googlebot user agent&lt;br&gt;
and count the failures. The only acceptable number is zero.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>react</category>
      <category>astro</category>
    </item>
    <item>
      <title>We removed the LLM call and replaced it with 200 lines of template code</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Tue, 25 Aug 2026 12:11:32 +0000</pubDate>
      <link>https://dev.to/thedolceway/i-removed-the-llm-call-and-replaced-it-with-200-lines-of-template-code-2lh0</link>
      <guid>https://dev.to/thedolceway/i-removed-the-llm-call-and-replaced-it-with-200-lines-of-template-code-2lh0</guid>
      <description>&lt;p&gt;The feature was a letter generator. Somebody fills in a few fields and gets a finished&lt;br&gt;
letter of recommendation, resignation letter or notice letter, in plain text, ready to&lt;br&gt;
paste into an email.&lt;/p&gt;

&lt;p&gt;The obvious build is a prompt and a model call. We built the deterministic version instead:&lt;br&gt;
a pure function, about two hundred lines, no network, no key, no tokens. I want to lay out&lt;br&gt;
the reasoning, because "just call a model" is the default now and the default is not always&lt;br&gt;
right.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three reasons, in order of weight
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The output is short and the shape is fixed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A recommendation letter is a date block, a greeting, three or four paragraphs, a sign off&lt;br&gt;
and a name. There is no structural variation to discover. Generation is valuable when the&lt;br&gt;
space of good outputs is large and you cannot enumerate it. Here the space is small enough&lt;br&gt;
to write down, and once you have written it down the model is doing an expensive&lt;br&gt;
approximation of a &lt;code&gt;switch&lt;/code&gt; statement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It is a legal-adjacent document.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not legal advice, but it goes into an employment record. A resignation letter that invents&lt;br&gt;
a notice period, or a reference that invents a fact about a person, is a real problem for&lt;br&gt;
the person who sent it. Templates cannot hallucinate. Everything specific in the output&lt;br&gt;
either came from a form field or is a sentence we wrote and can be held to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Zero marginal cost changes what the product can be.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the one that actually decided it. A model call costs money per use, and anything&lt;br&gt;
that costs money per use needs an account, a rate limit and eventually a card. A pure&lt;br&gt;
function costs nothing, so the tool can stay open with no signup, forever, without a&lt;br&gt;
business case. That is a product decision expressed as an architecture decision, and it&lt;br&gt;
only works if the code path is free.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the code looks like
&lt;/h2&gt;

&lt;p&gt;The whole engine is one exported function over one input type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LetterKind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resignation&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="s1"&gt;notice&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="s1"&gt;recommendation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LetterTone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;formal&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="s1"&gt;warm&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="s1"&gt;brief&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateLetter&lt;/span&gt;&lt;span class="p"&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;LetterInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tone is not a prompt instruction, it is a dimension of the data. Two tiny functions carry&lt;br&gt;
most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;greeting&lt;/span&gt;&lt;span class="p"&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;LetterInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LetterTone&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&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;recipientName&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="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;name&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;tone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm&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="s1"&gt;Hello,&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="s1"&gt;Dear Sir or Madam,&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;tone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm&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="s2"&gt;`Hi &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="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="s2"&gt;`Dear &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="s2"&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;function&lt;/span&gt; &lt;span class="nf"&gt;signOff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LetterTone&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;With thanks,&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;tone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brief&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Regards,&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sincerely,&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;The bodies are arrays of paragraphs, assembled conditionally. A recommendation body opens&lt;br&gt;
differently depending on whether the writer told us how they know the subject:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;paras&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;rel&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`I am pleased to recommend &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for the role of &lt;/span&gt;&lt;span class="p"&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;role&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;rel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, which gave me a direct view of how they work.`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`I am pleased to recommend &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; for the role of &lt;/span&gt;&lt;span class="p"&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;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, based on my direct experience of working with them at &lt;/span&gt;&lt;span class="p"&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;company&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;That ternary is the whole trick, repeated maybe fifteen times. It is not clever. Clever was&lt;br&gt;
never the requirement.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you get for free that a model call does not give you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Determinism, which means testability.&lt;/strong&gt; Same input, same bytes out. A snapshot test over&lt;br&gt;
the full cross product of three kinds and three tones is nine assertions and runs in&lt;br&gt;
milliseconds. Testing a model call means either mocking it, in which case you are testing&lt;br&gt;
your mock, or asserting fuzzy properties of real output and paying for the privilege on&lt;br&gt;
every CI run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline and instant.&lt;/strong&gt; No spinner, no failure state, no retry logic, no timeout, no&lt;br&gt;
"the service is busy" copy to write and translate. The letter updates as the user types&lt;br&gt;
because rendering it is a function call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real validator instead of an implicit one.&lt;/strong&gt; With a model you tend to send whatever you&lt;br&gt;
have and hope. With a template you have to decide what is required, which forces the&lt;br&gt;
product question into the open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;missingFields&lt;/span&gt;&lt;span class="p"&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;LetterInput&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;senderName&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your name&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Company&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recommendation&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="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;subjectName&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Who you are recommending&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Their role&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="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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your role&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastDay&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="nx"&gt;missing&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Last working day&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;missing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the required set differs by kind. A recommendation has no last working day. A&lt;br&gt;
resignation has no subject. A single prompt would have blurred those together and produced&lt;br&gt;
something plausible for a missing field, which is worse than refusing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Localisation is mechanical.&lt;/strong&gt; Nine strings per tone, translated once, correct forever. The&lt;br&gt;
same feature backed by a model needs the prompt tuned per language and the output checked&lt;br&gt;
per language by someone who reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the approach genuinely loses
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this scales to everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot say the specific thing.&lt;/strong&gt; The generated paragraphs are competent and generic,&lt;br&gt;
and generic is exactly the part of a reference letter that carries no weight. A hiring&lt;br&gt;
manager skims "demonstrated consistent judgement" and stops at "she rewrote our billing&lt;br&gt;
reconciliation and the month-end close went from four days to one".&lt;/p&gt;

&lt;p&gt;So the engine has a &lt;code&gt;highlight&lt;/code&gt; field, free text, dropped verbatim into the middle of the&lt;br&gt;
letter. That is not a limitation we worked around, it is the correct division of labour. The&lt;br&gt;
tool writes the scaffolding nobody reads. The human writes the one sentence that does the&lt;br&gt;
work. A model would have written a fluent guess at that sentence, and a fluent guess about&lt;br&gt;
a real person is precisely the thing you do not want in a reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot rewrite arbitrary prose.&lt;/strong&gt; Paste in three rambling paragraphs and ask for them&lt;br&gt;
tightened, and templates have nothing to offer. That is a genuine generation task and I&lt;br&gt;
would use a model for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding a kind costs a function.&lt;/strong&gt; A new letter type means new code, not a new prompt&lt;br&gt;
string. For three kinds that is fine. For thirty I would be rethinking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heuristic I took away
&lt;/h2&gt;

&lt;p&gt;Reach for generation when the output space is large, variable, and you cannot enumerate the&lt;br&gt;
good answers. Reach for templates when the output space is small, the shape is fixed, and&lt;br&gt;
being wrong is expensive.&lt;/p&gt;

&lt;p&gt;Short formal documents sit squarely in the second category, and the industry keeps building&lt;br&gt;
them with the first tool because the first tool is what everyone is holding.&lt;/p&gt;

&lt;p&gt;The engine described here runs the&lt;br&gt;
&lt;a href="https://cvbooster.ai/recommendation-letter-generator" rel="noopener noreferrer"&gt;letter of recommendation template&lt;/a&gt;&lt;br&gt;
tool on the resume builder I run. No account, no card, no model call, and the plain&lt;br&gt;
text output is free to copy. It renders in whatever time a string concatenation takes,&lt;br&gt;
which is the entire point.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We audited 99 resume templates for contrast. Nine rendered the candidate's name invisible.</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Tue, 25 Aug 2026 11:16:23 +0000</pubDate>
      <link>https://dev.to/thedolceway/i-audited-99-resume-templates-for-contrast-nine-rendered-the-candidates-name-invisible-hom</link>
      <guid>https://dev.to/thedolceway/i-audited-99-resume-templates-for-contrast-nine-rendered-the-candidates-name-invisible-hom</guid>
      <description>&lt;p&gt;I run a resume builder with 99 templates. A user said the designs looked "basic", so we&lt;br&gt;
wrote a script to measure them instead of arguing about taste. The script did not find a&lt;br&gt;
taste problem. It found that nine templates rendered the candidate's name at a contrast&lt;br&gt;
ratio of about 1.0 against its background.&lt;/p&gt;

&lt;p&gt;Contrast 1.0 means the text and the background are the same colour. Two of them were&lt;br&gt;
literally white on white. The name, the single most important string on the document,&lt;br&gt;
was invisible.&lt;/p&gt;

&lt;p&gt;Nobody had reported it, because nobody could see it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why nobody noticed
&lt;/h2&gt;

&lt;p&gt;The templates are generated. A base design defines a palette, and variants override parts&lt;br&gt;
of it. That is a completely normal pattern and it is where the bug lives.&lt;/p&gt;

&lt;p&gt;The override was a shallow merge of a &lt;code&gt;colors&lt;/code&gt; object. So a variant could set a pale&lt;br&gt;
&lt;code&gt;headerBackground&lt;/code&gt; while silently inheriting &lt;code&gt;headerText&lt;/code&gt; from a dark base design. Both&lt;br&gt;
values are individually reasonable. The combination is unreadable, and nothing in the type&lt;br&gt;
system objects, because both are valid colour strings.&lt;/p&gt;

&lt;p&gt;The failure only exists in the &lt;em&gt;pair&lt;/em&gt;, and nothing was checking pairs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Measuring instead of eyeballing
&lt;/h2&gt;

&lt;p&gt;Contrast ratio is defined in WCAG and is fully mechanical. Relative luminance per channel:&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;chan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&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;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03928&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.92&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;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&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;luminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;r&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="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="mf"&gt;0.2126&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;chan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;chan&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;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;chan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contrast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&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="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&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;hi&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="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The range is 1 to 21. AA wants 4.5 for body text, 3.0 for large text. Anything reporting&lt;br&gt;
close to 1.0 is not "low contrast", it is &lt;em&gt;the same colour&lt;/em&gt;, and it should be treated as a&lt;br&gt;
crash rather than a style nit.&lt;/p&gt;

&lt;p&gt;Rendering each template with sample data and walking the DOM took an afternoon. The result:&lt;br&gt;
nine invisible names, and, the part I did not expect, &lt;strong&gt;92 failing nodes in sidebars&lt;/strong&gt;,&lt;br&gt;
including 14 templates rendering certification names at exactly 1.00.&lt;/p&gt;

&lt;p&gt;Sidebars were worse than main content because dark-sidebar designs are the ones most likely&lt;br&gt;
to inherit a light-background text colour. The pattern that looks best is the pattern most&lt;br&gt;
exposed to the bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two things the audit taught me that I did not expect
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The inherited claims were mostly wrong.&lt;/strong&gt; We started from a previous audit's notes that&lt;br&gt;
said 69 templates had five or more failing nodes and that one template rendered every&lt;br&gt;
heading at 1.00. Measured: the worst had four, and that template's headings came in at&lt;br&gt;
11.83, comfortably passing. The described root cause was a function that no longer existed&lt;br&gt;
in the codebase, only a comment saying it used to.&lt;/p&gt;

&lt;p&gt;If we had trusted the notes we would have "fixed" three things that were not broken and&lt;br&gt;
missed the 92 that were. Re-measure before you refactor, especially when the notes sound&lt;br&gt;
confident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The type checker was checking nothing.&lt;/strong&gt; While wiring the audit into CI we ran&lt;br&gt;
&lt;code&gt;tsc --noEmit&lt;/code&gt; and it passed instantly on a codebase we knew had errors. The root&lt;br&gt;
&lt;code&gt;tsconfig.json&lt;/code&gt; had &lt;code&gt;"files": []&lt;/code&gt;. It was type checking an empty set, and had been for&lt;br&gt;
a long time, exiting 0 the whole way. The real command was&lt;br&gt;
&lt;code&gt;tsc -p tsconfig.app.json --noEmit&lt;/code&gt;, which reported a 65-error backlog nobody knew about.&lt;/p&gt;

&lt;p&gt;A green check on an empty input looks exactly like a green check on a clean codebase.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix that did not break the designs
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to force every text colour to something safe. That flattens every&lt;br&gt;
design at once, and the designs were mostly fine.&lt;/p&gt;

&lt;p&gt;Instead we resolved colours at render time and substituted &lt;strong&gt;only when a pair actually&lt;br&gt;
fails&lt;/strong&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;function&lt;/span&gt; &lt;span class="nf"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallbackDark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallbackLight&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="nf"&gt;contrast&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;fg&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;bg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.5&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;fg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// untouched&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;luminance&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;bg&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;fallbackDark&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallbackLight&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;Every passing node stays byte-identical. Only the broken pairs change, and they change to&lt;br&gt;
the correct end of the scale for their background. Final audit: &lt;strong&gt;0 failing nodes across&lt;br&gt;
99 templates&lt;/strong&gt;, with no design regressions, because nothing that worked was touched.&lt;/p&gt;

&lt;p&gt;The audit script stayed in the repo. A rule that is not enforced is a rule that comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take to any generated-design system
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shallow-merging a palette is a correctness bug waiting to happen.&lt;/strong&gt; Validate the
resulting pairs, not the individual values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contrast is measurable, so measure it.&lt;/strong&gt; "Looks fine to me" is not a test, and the
people who cannot read it are not in the room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust audit notes, including your own.&lt;/strong&gt; Re-measure. Confident prose about a
function that no longer exists is a real thing that happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify your verifier.&lt;/strong&gt; Feed it a known-bad input and confirm it fails. An empty
&lt;code&gt;files&lt;/code&gt; array will pass every check you write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The templates this came from are at &lt;a href="https://cvbooster.ai/templates" rel="noopener noreferrer"&gt;CVBooster&lt;/a&gt;: free to&lt;br&gt;
browse, no account, and now with every one of them actually legible.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>css</category>
    </item>
    <item>
      <title>Building a Minimalist Habit Tracker — Why I Stopped Using Streaks and Forest</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Sun, 05 Apr 2026 20:05:56 +0000</pubDate>
      <link>https://dev.to/thedolceway/building-a-minimalist-habit-tracker-why-i-stopped-using-streaks-and-forest-272k</link>
      <guid>https://dev.to/thedolceway/building-a-minimalist-habit-tracker-why-i-stopped-using-streaks-and-forest-272k</guid>
      <description>&lt;p&gt;I have tried, without exaggeration, every popular habit tracker on the App Store. Streaks. Forest. Habitica. Fabulous. Productive. Way of Life. Done. I used each of them for between a week and three months. Then I stopped. Then I tried the next one. Then I built my own.&lt;/p&gt;

&lt;p&gt;This is a post about why all of them eventually annoyed me, what I wanted instead, and the iOS app I ended up writing over about four evenings. It is called SimpleStreaks and it is about as boring as the name suggests, which is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I kept hitting
&lt;/h2&gt;

&lt;p&gt;Every habit app I tried started the same way. Clean. Promising. I would set up three habits — meditate, read, stretch — and feel vaguely virtuous about my new system. Then, reliably, one of these things would happen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The app asked me to subscribe.&lt;/strong&gt; Usually on day three, sometimes on day one, occasionally hidden inside the "advanced" features I actually wanted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gamification got in the way.&lt;/strong&gt; A tree dies. A pet is sad. A little warrior loses XP. I am a grown adult and I do not need a guilt trip from a pixel owl because I skipped yoga on a Tuesday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UI got too clever.&lt;/strong&gt; Charts inside charts. Swipe gestures for everything. Settings nested four screens deep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The data model broke my brain.&lt;/strong&gt; Some apps let you check a habit off multiple times per day. Some don't. Some treat skip and fail differently. Some have "rest days". By the time I understood the rules I had already lost the streak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I do not think these are bad apps. Several of them are great. They are just not what I wanted, which is a thing that shows me seven circles, lets me tap each one once a day, and leaves me alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually wanted
&lt;/h2&gt;

&lt;p&gt;I wrote the requirements on a napkin before I opened Xcode. I still have the napkin. It says:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Home screen shows all my habits at once.&lt;/li&gt;
&lt;li&gt;One tap marks a habit done for today.&lt;/li&gt;
&lt;li&gt;A streak counter next to each habit. That is the only number.&lt;/li&gt;
&lt;li&gt;No accounts. No cloud. No subscription. No ads.&lt;/li&gt;
&lt;li&gt;Can I open it, check three things, and close it in under five seconds? That is the only UX test.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything that did not serve those five lines got cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;SimpleStreaks is a SwiftUI app with a Core Data backend. That is almost the entire architecture. A habit is a row with a name, an emoji, a colour, and an ordered list of completion dates. The streak is computed, not stored — walk backwards from today until you hit a gap.&lt;/p&gt;

&lt;p&gt;The bits that were harder than expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reordering.&lt;/strong&gt; SwiftUI's &lt;code&gt;List&lt;/code&gt; reorder works, but persisting the order to Core Data in a way that survives undo, redo, and iCloud sync took longer than the rest of the app combined. I dropped iCloud sync for v1 because of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Today" logic.&lt;/strong&gt; What counts as "today" in a timezone-changing world? I went with device-local midnight. It is not perfect but it is predictable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptics.&lt;/strong&gt; The difference between a habit tracker that feels good and one that feels sterile is one line of &lt;code&gt;UIImpactFeedbackGenerator&lt;/code&gt;. Do not skip haptics on the check tap. It is the entire reward loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widget.&lt;/strong&gt; I wanted the widget to be the app. You should be able to tap habits on your home screen without ever opening SimpleStreaks. AppIntents made this possible in an afternoon, which felt like cheating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total code: about 1,800 lines of Swift. No external dependencies. No analytics SDK. No SDK anything, actually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things I deliberately did not build
&lt;/h2&gt;

&lt;p&gt;This list was longer than the feature list.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streak recovery.&lt;/strong&gt; Every app has this. Pay us and your broken streak comes back. No. If the streak broke, the streak broke. Start again on Monday. It is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Social feeds.&lt;/strong&gt; The worst thing you can do to a habit tracker is turn it into a feed. The moment I have to perform my habits for strangers, they stop being habits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XP, levels, achievements.&lt;/strong&gt; If the streak number itself is not enough reward, no amount of confetti will save the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI coaching.&lt;/strong&gt; Several people have asked me for this. I am not ruling it out but I am ruling it out for now. The app is small on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categories, tags, folders.&lt;/strong&gt; If you have so many habits that you need folders, the problem is not your habit tracker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cutting things is the actual work of a minimalist app. Writing code is easy. Saying no to features that sound reasonable is the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it is for
&lt;/h2&gt;

&lt;p&gt;If you used Streaks and found it a bit over-engineered, you will probably like SimpleStreaks. If you used Forest for studying and wanted something that stuck around for non-study habits, same answer. If you loved Fabulous but fell off when the subscription kicked in, this is the free version of that itch.&lt;/p&gt;

&lt;p&gt;If you are the kind of person who likes charts, analytics, mood journals, and deep customisation — this is honestly not your app. Use Productive. Use Way of Life. Those are good tools for that job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to find it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Landing page with screenshots and context: &lt;a href="https://thedolceway.com/apps/simplestreaks" rel="noopener noreferrer"&gt;https://thedolceway.com/apps/simplestreaks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free, no ads, no subscription: &lt;a href="https://apps.apple.com/us/app/id6760590721?pt=126940293&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/id6760590721?pt=126940293&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would love to hear what you think, especially if you bounce off it, because "why did this not stick" is more useful feedback than "nice work". The contact is on the landing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons for the next small app
&lt;/h2&gt;

&lt;p&gt;A few things I am taking into the next build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the napkin first.&lt;/strong&gt; Five lines. If a feature does not serve a line on the napkin, it does not go in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the ugly version.&lt;/strong&gt; I spent a full evening on the settings screen. Nobody has used the settings screen. I should have shipped without one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Haptics and animation are 80% of "feel".&lt;/strong&gt; The actual logic of a habit tracker is trivial. The reason people keep opening it is how the check button feels under your thumb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimalism is a promise, not an aesthetic.&lt;/strong&gt; It is fine to have lots of colours and big emojis if the app still opens, does one thing, and closes. Minimal means "no wasted steps", not "black and white".&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>showdev</category>
      <category>ios</category>
      <category>productivity</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I Built a Hydration Reminder App in a Weekend (and What I Learned)</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Sun, 05 Apr 2026 19:57:11 +0000</pubDate>
      <link>https://dev.to/thedolceway/i-built-a-hydration-reminder-app-in-a-weekend-and-what-i-learned-28m4</link>
      <guid>https://dev.to/thedolceway/i-built-a-hydration-reminder-app-in-a-weekend-and-what-i-learned-28m4</guid>
      <description>&lt;p&gt;I spent a lot of last year telling myself I drank enough water. I did not. I knew this because every afternoon around 3pm my head would start to feel like someone had parked a small van on it, and the only cure was a glass of water and the quiet humiliation of realising I had drunk exactly one coffee and half a Coke Zero since breakfast.&lt;/p&gt;

&lt;p&gt;So one Saturday I decided to build a water tracking app. Not a productivity opus. Not a subscription empire. Just something that would nag me gently, let me tap a button, and get out of the way. I called it WaterDrop and shipped it on the App Store two days later. This post is about how the weekend actually went — the parts that worked, the parts I threw away, and the stuff I wish someone had told me before I opened Xcode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every water app I tried annoyed me
&lt;/h2&gt;

&lt;p&gt;Before I wrote a single line of code I downloaded the top ten hydration trackers on the App Store. I wanted to see what the category looked like. Here is what I found, in roughly descending order of how much it bothered me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Onboarding flows that asked for my height, weight, activity level, climate zone, and astrological sign before I could log a single glass.&lt;/li&gt;
&lt;li&gt;A paywall on day three. Sometimes day one.&lt;/li&gt;
&lt;li&gt;Gamification so aggressive that logging water felt like being yelled at by a cartoon fish.&lt;/li&gt;
&lt;li&gt;Reminders that fired at 11pm, because apparently the app thought I should chug a litre before bed.&lt;/li&gt;
&lt;li&gt;Widgets that were just ads for the premium tier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wanted the opposite of all of this. Open app. Tap once. Done. No login. No fish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture, such as it is
&lt;/h2&gt;

&lt;p&gt;WaterDrop is embarrassingly simple and I am not apologising for it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SwiftUI for everything. No UIKit bridges.&lt;/li&gt;
&lt;li&gt;A single &lt;code&gt;@AppStorage&lt;/code&gt; key for today's count. A small Core Data store for history, because I wanted streaks.&lt;/li&gt;
&lt;li&gt;Local notifications scheduled via &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;, no server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WidgetKit&lt;/code&gt; for the home screen widget. &lt;code&gt;AppIntents&lt;/code&gt; so you can log a glass from Siri or a shortcut.&lt;/li&gt;
&lt;li&gt;Zero network calls. Zero analytics SDKs. Zero accounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is maybe 1,400 lines of Swift. The hardest part was not the code. The hardest part was resisting the urge to add things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The features I cut
&lt;/h2&gt;

&lt;p&gt;Here is a partial list of things I designed, half-built, or sketched and then deleted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "hydration score" that factored in caffeine and alcohol. Fun, but it needed a whole second data model and nobody asked for it.&lt;/li&gt;
&lt;li&gt;Social sharing of streaks. Why would you share this with anyone. Cut.&lt;/li&gt;
&lt;li&gt;Apple Health two-way sync. I still want this but doing it properly takes more than a weekend.&lt;/li&gt;
&lt;li&gt;A plant that grows as you drink. Cute. Not me. Cut.&lt;/li&gt;
&lt;li&gt;Custom drink types (tea, sparkling, etc). I may add this later. For launch it was friction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time I cut something the app felt lighter. That is the actual lesson of the weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reminder logic is harder than it looks
&lt;/h2&gt;

&lt;p&gt;The part I underestimated was reminders. You would think "ping the user every 90 minutes during waking hours" is a three-line function. It is not. You have to think about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quiet hours. Nobody wants a 2am water ping.&lt;/li&gt;
&lt;li&gt;The user's actual sleep schedule, which you do not know.&lt;/li&gt;
&lt;li&gt;Not reminding them if they just logged a glass (otherwise the app feels robotic).&lt;/li&gt;
&lt;li&gt;Weekends vs weekdays.&lt;/li&gt;
&lt;li&gt;What happens after the device restarts and your pending notifications were reset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up with a simple rule: the user picks a start hour and end hour. Inside that window, reminders fire every N hours, and any reminder within 30 minutes of a logged glass gets cancelled. That was version one. Turns out version one was also version final, because it just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping on a Sunday night
&lt;/h2&gt;

&lt;p&gt;I hit upload in Xcode around 9pm Sunday. Apple approved it in about 18 hours, which is faster than I expected for a brand new developer account doing anything health-adjacent. The only feedback from review was a request to clarify that the app does not replace medical advice, which is fair.&lt;/p&gt;

&lt;p&gt;If you want to see what I ended up with, it is here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Landing page with screenshots: &lt;a href="https://thedolceway.com/apps/waterdrop" rel="noopener noreferrer"&gt;https://thedolceway.com/apps/waterdrop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Free on the App Store: &lt;a href="https://apps.apple.com/us/app/id6760830381?pt=126940293&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/us/app/id6760830381?pt=126940293&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is free, there is no subscription, and there is no account. If you try it and something feels wrong I would genuinely like to hear about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;A few honest regrets.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots.&lt;/strong&gt; I rushed them. App Store screenshots are the single biggest conversion lever for a free app and I spent maybe 40 minutes on them. I am redoing them this week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword research.&lt;/strong&gt; I picked "hydration tracker" as my main keyword on vibes. I should have used an actual ASO tool. I am fixing this in the next metadata update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Icon iteration.&lt;/strong&gt; I made one icon and shipped it. It is fine. It is not memorable. Icons deserve their own afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not timing the build.&lt;/strong&gt; I wish I had logged how long each part took. Would have been great data for the next one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The point of the weekend
&lt;/h2&gt;

&lt;p&gt;I have shipped maybe a dozen things this year, most of which nobody will ever use, and a few I genuinely like. WaterDrop is in the second category because it solved an actual problem for me personally. I stopped getting the afternoon headache about a week after I started using it, which is either the app working or a placebo, and honestly I do not care which.&lt;/p&gt;

&lt;p&gt;If you are sitting on a small idea and telling yourself it is too small to ship — it is not. Two days. One screen. One problem you personally have. That is the whole formula. Go build the thing.&lt;/p&gt;

&lt;p&gt;And drink some water while you are at it.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>indiehackers</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How AI Resume Builders Are Changing Job Applications in 2026</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 26 Mar 2026 11:53:37 +0000</pubDate>
      <link>https://dev.to/thedolceway/how-ai-resume-builders-are-changing-job-applications-in-2026-3145</link>
      <guid>https://dev.to/thedolceway/how-ai-resume-builders-are-changing-job-applications-in-2026-3145</guid>
      <description>&lt;p&gt;The job market has always rewarded people who know how to present themselves. In 2026, that presentation is increasingly mediated by AI — on both sides of the hiring table.&lt;/p&gt;

&lt;p&gt;Recruiters are using AI to screen applications. So job seekers are using AI to write them. What started as a niche productivity hack has become table stakes for anyone serious about their search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers Are Hard to Ignore
&lt;/h2&gt;

&lt;p&gt;Recent hiring data shows that over 75% of resumes submitted to large employers are filtered by applicant tracking systems before a human ever sees them. These systems scan for keywords, formatting compliance, and relevance signals. A beautifully designed resume that fails ATS parsing is invisible.&lt;/p&gt;

&lt;p&gt;AI-powered resume tools have responded to this shift directly. The best ones don't just help you write — they help you write in a way that survives automated screening and reads well to a human afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Resume Tools Are Actually Good At
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rewriting weak bullet points.&lt;/strong&gt; This is where AI genuinely earns its keep. "Managed a team" becomes "Led a cross-functional team of 8 engineers to deliver a platform migration 3 weeks ahead of schedule." The AI pulls on pattern recognition trained across millions of successful resumes to elevate vague descriptions into quantified accomplishments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyword optimization.&lt;/strong&gt; A good &lt;a href="https://cvbooster.ai/ai-resume-builder" rel="noopener noreferrer"&gt;ai resume builder&lt;/a&gt; will analyze a job posting and suggest which terms belong in your resume. This isn't gaming the system — it's speaking the language that both ATS and recruiters are scanning for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reducing blank-page paralysis.&lt;/strong&gt; Many people know what they've done but struggle to articulate it in resume format. AI suggestions act as a starting draft that you refine, rather than a blank document that stares back at you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistent formatting at speed.&lt;/strong&gt; Tailoring a resume to each application used to mean an hour of reformatting per job. AI-powered tools can restructure and reorder sections in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What They're Not Good At
&lt;/h2&gt;

&lt;p&gt;AI resume tools are pattern-matching engines, not career coaches. They don't know your actual impact, the real context of your achievements, or what makes you different. Output always needs a human editorial pass.&lt;/p&gt;

&lt;p&gt;They're also not magic for career changers. If you're pivoting industries, the AI will default to your existing experience framing. You'll need to guide it more deliberately toward how your transferable skills map to the new domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Democratization Angle
&lt;/h2&gt;

&lt;p&gt;Perhaps the most important shift is accessibility. Professional resume writing services charge $200-600 per resume. That's a real barrier for recent graduates, career returners, or anyone in financial transition — precisely the people who most need help.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cvbooster.ai/ai-resume-builder" rel="noopener noreferrer"&gt;AI-powered resume tools&lt;/a&gt; like CVBooster have changed that equation. The same quality of structured, keyword-optimized, professionally formatted resume that used to require either expertise or a budget is now available for free, instantly, without a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hiring Side Is Adapting Too
&lt;/h2&gt;

&lt;p&gt;It's worth acknowledging the counter-movement: some hiring managers are now flagging AI-generated resumes as a negative signal, particularly for roles requiring strong written communication. The lesson here is balance — use AI to structure and optimize, but make sure your voice and specific context come through in the final version.&lt;/p&gt;

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

&lt;p&gt;The trajectory is clear. Resume screening will become more AI-to-AI in nature — automated systems on both sides, with humans reviewing only shortlisted candidates. The job seekers who understand this dynamic and use AI tools intelligently will have a structural advantage over those who don't.&lt;/p&gt;

&lt;p&gt;The tools are good now. They'll be better in six months. The question isn't whether to use them — it's whether you're using them well.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
    </item>
    <item>
      <title>The Best Free Resume Builders That Don't Charge You After Download</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 26 Mar 2026 09:17:45 +0000</pubDate>
      <link>https://dev.to/thedolceway/the-best-free-resume-builders-that-dont-charge-you-after-download-130o</link>
      <guid>https://dev.to/thedolceway/the-best-free-resume-builders-that-dont-charge-you-after-download-130o</guid>
      <description>&lt;p&gt;There's a frustrating pattern in the resume builder industry. You spend 45 minutes filling in your work history, choosing a template, tweaking the layout — and then you hit download. Suddenly a paywall appears. Your resume is being held hostage.&lt;/p&gt;

&lt;p&gt;This is not a coincidence. It's a business model. Get users invested before revealing the price. After 45 minutes of work, many people just pay rather than start over.&lt;/p&gt;

&lt;p&gt;Let's talk about how to avoid this — and which tools are actually free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Freemium Trap" in Resume Builders
&lt;/h2&gt;

&lt;p&gt;Most resume builders operate on a freemium model where the core feature — exporting your resume — is locked behind a subscription. The free tier exists to create friction and investment, not to deliver value.&lt;/p&gt;

&lt;p&gt;Here's what "free" usually means in this industry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resume.io&lt;/strong&gt;: Free to build, $2.95/week to download in a usable format&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zety&lt;/strong&gt;: Free to build, watermark on PDF unless you pay ~$5.99/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Novoresume&lt;/strong&gt;: Free plan limited to one resume with restricted templates; download requires premium&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VisualCV&lt;/strong&gt;: Free plan creates a web resume, but PDF export is paid&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canva&lt;/strong&gt;: Genuinely free for many templates, but resume-specific features are limited and formatting can be inconsistent for ATS systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key test: can you download a clean, watermark-free PDF without entering payment details? Most tools fail this test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Are Genuinely Free
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CVBooster&lt;/strong&gt; is the standout here. The &lt;a href="https://cvbooster.ai/free-resume-builder" rel="noopener noreferrer"&gt;free resume builder&lt;/a&gt; gives you access to professional templates, an AI-assisted editor, and clean PDF download — no signup wall, no credit card, no watermark. You can build and export a complete resume in under 20 minutes at zero cost.&lt;/p&gt;

&lt;p&gt;This matters more than it might seem. Job hunting is already expensive — LinkedIn Premium, Indeed sponsored applications, professional wardrobe for interviews. Paying $20/month for a resume builder adds up fast, especially when you might be between jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Docs&lt;/strong&gt; (with a resume template) is another genuinely free option if you know how to format documents. The trade-off is manual labor — no AI suggestions, no ATS optimization hints, just you and a word processor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LibreOffice&lt;/strong&gt; templates fall in the same category: free but manual.&lt;/p&gt;

&lt;p&gt;For most job seekers who want professional output without the manual work, the &lt;a href="https://cvbooster.ai/free-resume-builder" rel="noopener noreferrer"&gt;free resume builder&lt;/a&gt; at CVBooster is the strongest option I've found.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Look For in a Free Resume Builder
&lt;/h2&gt;

&lt;p&gt;When evaluating any tool, check these before investing your time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download test first&lt;/strong&gt;: Before filling in your details, check whether PDF download requires payment. Many tools advertise this in their pricing page — read it before you start.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;ATS compatibility&lt;/strong&gt;: Does the template use single-column, clean formatting? Multi-column layouts often fail ATS parsing, which can disqualify your application before a human reads it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No watermarks&lt;/strong&gt;: A resume with a service watermark looks unprofessional. Confirm the export is clean.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No forced account creation&lt;/strong&gt;: Some tools won't let you access your resume later without creating an account, which becomes a vector for upsell emails and locked features.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Template variety&lt;/strong&gt;: You may want to tweak templates for different industries. A tool with only 2-3 free templates limits your options.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Broader Point
&lt;/h2&gt;

&lt;p&gt;The resume builder market is built on a pain point — people genuinely struggle to present themselves on paper — and many companies exploit that vulnerability with bait-and-switch pricing. Knowing this going in puts you in a much stronger position.&lt;/p&gt;

&lt;p&gt;You don't need to pay to get a great resume. You need the right tool and 20 focused minutes. Start with something genuinely free, invest your energy in the content, and save your money for the parts of your job search that actually require spending it.&lt;/p&gt;

</description>
      <category>career</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Tested 5 AI Resume Builders — Here's What Actually Works</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 26 Mar 2026 09:16:09 +0000</pubDate>
      <link>https://dev.to/thedolceway/i-tested-5-ai-resume-builders-heres-what-actually-works-4okn</link>
      <guid>https://dev.to/thedolceway/i-tested-5-ai-resume-builders-heres-what-actually-works-4okn</guid>
      <description>&lt;p&gt;I spent a week testing every major AI resume builder on the market. Not quick demos — actual resumes, submitted to real jobs, run through ATS scanners. Here is what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Most AI Resume Builders
&lt;/h2&gt;

&lt;p&gt;Most tools in this space do the same thing: you paste your info, the AI rewrites your bullets to sound fancier, and you download a PDF. The problem is that "fancier" does not mean "optimized." A resume that sounds impressive to a human can still score poorly in an Applicant Tracking System if it lacks the right keywords.&lt;/p&gt;

&lt;p&gt;The best &lt;a href="https://cvbooster.ai/ai-resume-builder" rel="noopener noreferrer"&gt;AI resume builder&lt;/a&gt; tools do more than rewrite — they analyze the job description, match keywords, and ensure formatting survives ATS parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tested
&lt;/h2&gt;

&lt;p&gt;I evaluated five tools on these criteria: ATS compatibility, template quality, keyword matching, and whether the "free" tier is actually usable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Zety&lt;/strong&gt; — Slick interface, good templates. But the free tier is bait — you cannot download without paying. ATS optimization is minimal. The AI suggestions are generic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Resume.io&lt;/strong&gt; — Clean templates, fast editor. Same paywall problem. No keyword matching against job descriptions. The AI just rephrases your existing text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Teal&lt;/strong&gt; — Better approach. Matches your resume against a job description and highlights missing keywords. Free tier is limited to basic features. Good for keyword gaps but the template selection is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Rezi&lt;/strong&gt; — Focused on ATS optimization. Decent keyword analysis. Interface feels dated. The AI rewriting is solid but template variety is lacking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. CVBooster&lt;/strong&gt; — This one surprised me. 160+ templates including 17 completely free ones. No account required. The &lt;a href="https://cvbooster.ai/ai-resume-builder" rel="noopener noreferrer"&gt;AI resume builder&lt;/a&gt; matches your resume against job descriptions, suggests missing keywords, rewrites bullets with measurable impact, and generates cover letters. You can download a polished PDF without creating an account or hitting a paywall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;Most AI resume builders are fancy text editors with a paywall at the end. The ones that actually help you get hired do three things: keyword matching against the specific job, ATS-friendly formatting, and honest pricing.&lt;/p&gt;

&lt;p&gt;If you want to try the one that scored highest in my testing, check out CVBooster's &lt;a href="https://cvbooster.ai/ai-resume-builder" rel="noopener noreferrer"&gt;AI resume builder&lt;/a&gt; — it is genuinely free to start and does not trap you at the download step.&lt;/p&gt;

&lt;p&gt;The resume builder market is crowded but most of the crowd is selling the same thing. Find the tool that actually optimizes for ATS, not just for looking good on screen.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
    </item>
    <item>
      <title>5 Forest App Alternatives That Actually Help You Focus in 2026</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 26 Mar 2026 07:27:56 +0000</pubDate>
      <link>https://dev.to/thedolceway/5-forest-app-alternatives-that-actually-help-you-focus-in-2026-2c62</link>
      <guid>https://dev.to/thedolceway/5-forest-app-alternatives-that-actually-help-you-focus-in-2026-2c62</guid>
      <description>&lt;p&gt;If you've ever tried to cut down on phone distractions, you've probably come across Forest — the app that grows a virtual tree while you stay off your phone. It's charming, and it works for a lot of people. But it's not for everyone.&lt;/p&gt;

&lt;p&gt;The good news is that there are solid forest app alternatives out there. Here are five worth trying in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Flora
&lt;/h2&gt;

&lt;p&gt;Flora is the closest spiritual sibling to Forest. It uses the same tree-growing mechanic but adds a social layer — you can plant trees with friends and keep each other accountable. If you break focus, the tree dies for everyone. Flora also lets you donate to real tree-planting initiatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; People who want accountability with friends or coworkers.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Freedom
&lt;/h2&gt;

&lt;p&gt;Freedom takes a more aggressive approach. Rather than gamifying focus, it blocks distracting websites and apps across all your devices simultaneously. One session locks you out on your Mac, iPhone, and iPad at the same time. It's less cute than Forest, but far more effective for people who need hard barriers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Remote workers who need serious distraction blocking across multiple devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Be Focused (Pomodoro Timer)
&lt;/h2&gt;

&lt;p&gt;Be Focused is a clean Pomodoro timer for Apple devices. No gamification — just structured 25-minute work blocks with short breaks. It tracks how many Pomodoros you complete each day. Sometimes the most effective tool is the simplest one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Minimalists who prefer the Pomodoro technique.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Focusmate
&lt;/h2&gt;

&lt;p&gt;Focusmate flips the model entirely. Instead of a virtual tree, you get a real human. You book a video session with a stranger, state your goal, work silently, and check in at the end. The social presence effect is one of the strongest focus hacks known to productivity researchers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; People who struggle with isolation while working from home.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Session
&lt;/h2&gt;

&lt;p&gt;Session is a premium focus timer for Mac that integrates with your calendar and task manager. It shows your tasks alongside your timer, lets you block apps, and gives detailed analytics on your work patterns. If you want a forest app alternative that feels polished on macOS, Session is the one to beat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Mac users who want deep workflow integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which One Should You Pick?
&lt;/h2&gt;

&lt;p&gt;The right app depends on what kind of friction you need. Social accountability? Flora or Focusmate. Hard blocks? Freedom. Simplicity? Be Focused or Session.&lt;/p&gt;

&lt;p&gt;For a deeper comparison with pros, cons, and pricing for each forest app alternative, check out the full breakdown at &lt;a href="https://thedolceway.com/blog/forest-app-alternative" rel="noopener noreferrer"&gt;The Dolce Way&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Finding the right alternative is about matching the tool to how your brain actually works — not just downloading whatever has the best App Store rating.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>5 Ways AI is Changing Small Business Operations in 2026</title>
      <dc:creator>Mustafa Tarabya</dc:creator>
      <pubDate>Thu, 26 Mar 2026 07:26:35 +0000</pubDate>
      <link>https://dev.to/thedolceway/5-ways-ai-is-changing-small-business-operations-in-2026-k9l</link>
      <guid>https://dev.to/thedolceway/5-ways-ai-is-changing-small-business-operations-in-2026-k9l</guid>
      <description>&lt;p&gt;AI is no longer a luxury reserved for enterprise companies. In 2026, small businesses are using AI tools to automate repetitive tasks, make smarter decisions, and compete with organizations ten times their size.&lt;/p&gt;

&lt;p&gt;Here are five ways this is actually playing out.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Customer Support That Never Sleeps
&lt;/h2&gt;

&lt;p&gt;AI chatbots have moved past the "press 1 for billing" era. Modern AI support agents can understand context, pull from knowledge bases, and resolve issues without human intervention. For a small business owner who cannot afford a 24/7 support team, this is transformative.&lt;/p&gt;

&lt;p&gt;The key shift is that these tools now integrate with existing workflows. They connect to your CRM, read your documentation, and handle the first line of contact — escalating to humans only when necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Automated Bookkeeping and Financial Forecasting
&lt;/h2&gt;

&lt;p&gt;AI-powered accounting tools can categorize expenses, flag anomalies, and generate cash flow projections based on historical patterns. Small business owners spend an average of five hours per week on bookkeeping. AI can cut that to thirty minutes.&lt;/p&gt;

&lt;p&gt;The real value is not in data entry — it is in the forecasting. Knowing next month's likely revenue before it arrives changes how you plan inventory, hiring, and marketing spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Content Creation and Marketing Automation
&lt;/h2&gt;

&lt;p&gt;Small businesses need content but rarely have dedicated marketing teams. AI writing assistants can draft social media posts, email campaigns, and blog outlines in seconds. The output still needs a human eye, but the first draft is no longer the bottleneck.&lt;/p&gt;

&lt;p&gt;Marketing automation platforms now use AI to determine optimal send times, segment audiences, and personalize messaging at a level that was previously only possible for companies with data science teams.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Smart Inventory and Supply Chain Management
&lt;/h2&gt;

&lt;p&gt;For product-based businesses, AI demand forecasting prevents both overstock and stockouts. These systems analyze seasonal trends, competitor pricing, and historical sales data to recommend order quantities.&lt;/p&gt;

&lt;p&gt;The difference from traditional inventory management is precision. Instead of ordering based on gut feeling and last year's numbers, you are ordering based on a model that accounts for dozens of variables simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. AI-Powered Business Intelligence
&lt;/h2&gt;

&lt;p&gt;Dashboards that just show numbers are table stakes. AI business intelligence tools explain why the numbers changed and what to do about it. They surface patterns that a human reviewing spreadsheets would miss.&lt;/p&gt;

&lt;p&gt;Platforms like &lt;a href="https://vectaai.com" rel="noopener noreferrer"&gt;Vecta AI&lt;/a&gt; are building exactly this kind of intelligence layer for small businesses — turning raw data into actionable recommendations without requiring a data analyst on staff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;The businesses adopting AI in 2026 are not doing it because it is trendy. They are doing it because the tools are finally good enough, cheap enough, and simple enough to deliver real ROI at small scale.&lt;/p&gt;

&lt;p&gt;The gap between AI-enabled small businesses and those still running on spreadsheets and manual processes is widening every quarter. The time to start is not next year.&lt;/p&gt;

&lt;p&gt;Learn more about AI solutions for business at &lt;a href="https://vectaai.com" rel="noopener noreferrer"&gt;vectaai.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
