<?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: Fei Peng</title>
    <description>The latest articles on DEV Community by Fei Peng (@ztao0916).</description>
    <link>https://dev.to/ztao0916</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%2F4244205%2Fa3493b01-50b8-438a-9a9b-82625b5cac89.png</url>
      <title>DEV Community: Fei Peng</title>
      <link>https://dev.to/ztao0916</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ztao0916"/>
    <language>en</language>
    <item>
      <title>Designing 76 Zero-Backend Construction Calculators in Nuxt 4 &amp; TypeScript: Unit Normalization, Waste Math, and Static Scale</title>
      <dc:creator>Fei Peng</dc:creator>
      <pubDate>Sat, 14 Nov 2026 09:15:50 +0000</pubDate>
      <link>https://dev.to/ztao0916/designing-76-zero-backend-construction-calculators-in-nuxt-4-typescript-unit-normalization-51l5</link>
      <guid>https://dev.to/ztao0916/designing-76-zero-backend-construction-calculators-in-nuxt-4-typescript-unit-normalization-51l5</guid>
      <description>&lt;p&gt;Building one construction estimator is mostly basic geometry. Maintaining &lt;strong&gt;76 independent material calculators&lt;/strong&gt;—covering flooring, tile, roofing, concrete slabs, CMU block walls, stairs, and landscaping—without turning the codebase into copy-pasted spaghetti or paying for a backend API is a much more interesting TypeScript problem.&lt;/p&gt;

&lt;p&gt;While building &lt;a href="https://projectcalcs.com/" rel="noopener noreferrer"&gt;ProjectCalcs&lt;/a&gt; (Nuxt 4.2 + TypeScript + &lt;code&gt;@nuxt/ui&lt;/code&gt;, statically prerendered to 255 routes on Cloudflare Pages via Nitro's &lt;code&gt;cloudflare-pages-static&lt;/code&gt; preset), most of our real bugs weren't UI bugs—they were unit-boundary, rounding, and input-sanitization edge cases.&lt;/p&gt;

&lt;p&gt;Here are three patterns that kept the math predictable across all calculators.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Normalize Units at the Input Boundary (and Guard Against Cleared Inputs)
&lt;/h2&gt;

&lt;p&gt;Users measure rooms in feet, inches, meters, or centimeters—and frequently type partial decimals or clear an &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; back to &lt;code&gt;0&lt;/code&gt; or empty while editing.&lt;/p&gt;

&lt;p&gt;Instead of sprinkling conversion branches inside downstream cost and waste math, each calculator normalizes length inputs into a single canonical internal unit right at the top of a &lt;code&gt;computed()&lt;/code&gt; block, while clamping denominators and openings before dividing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;unitToFeet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LengthUnit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.28084&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0328084&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Always normalize before computing area&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;lengthFt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;roomLength&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unitToFeet&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;roomUnit&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="n"&gt;widthFt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;roomWidth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;unitToFeet&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;roomUnit&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="n"&gt;roomAreaSqFt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lengthFt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;widthFt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Guard user-entered pack sizes so clearing an input never yields Infinity&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;safeTilesPerBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tilesPerBox&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;tilesPerBox&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On wall calculators with window/door deductions, clamping deducted area prevents negative surface areas when someone tests an oversized opening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sanitizedOpenings&lt;/span&gt; &lt;span class="o"&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;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openingsSqFt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openingsSqFt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;openingsDeductedSqFt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grossWallAreaSqFt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sanitizedOpenings&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="n"&gt;wallAreaSqFt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grossWallAreaSqFt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;openingsDeductedSqFt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Model Nominal vs. Actual Dimensions Explicitly
&lt;/h2&gt;

&lt;p&gt;Material math breaks fastest when a nominal label looks like physical geometry. For example, in our &lt;a href="https://projectcalcs.com/calculators/concrete-block/" rel="noopener noreferrer"&gt;Concrete Block Calculator&lt;/a&gt;, a standard &lt;code&gt;8" × 8" × 16"&lt;/code&gt; CMU block physically measures &lt;code&gt;7⅝" × 7⅝" × 15⅝"&lt;/code&gt;. Because a standard &lt;code&gt;3/8"&lt;/code&gt; mortar joint restores the modular grid, the effective wall face area stays &lt;code&gt;128 sq in&lt;/code&gt; (&lt;code&gt;0.889 sq ft&lt;/code&gt;, or &lt;code&gt;1.125&lt;/code&gt; blocks/sq ft).&lt;/p&gt;

&lt;p&gt;Rather than scattering magic numbers across templates and formulas, each CMU size carries its own yield and core-fill coefficients in a typed catalog:&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;interface&lt;/span&gt; &lt;span class="nc"&gt;CMUSizeOption&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;nominalThickness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;faceAreaSqIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 128 sq in including 3/8" mortar joint&lt;/span&gt;
  &lt;span class="n"&gt;mortarYieldPerBag80&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// blocks laid per 80-lb mortar bag&lt;/span&gt;
  &lt;span class="n"&gt;fullGroutCuYdPer100SqFt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping mortar yields and hollow-cell grout fill factors bound to the block variant makes it trivial to keep related calculators consistent and testable with Vitest + &lt;code&gt;fast-check&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Order Ceiling-Rounding Matters More Than Waste Percentage
&lt;/h2&gt;

&lt;p&gt;One subtle UX bug shows up when combining waste allowance and discrete packaging (boxes of tile, bags of mortar, pallets, or bundles of shingles).&lt;/p&gt;

&lt;p&gt;In our &lt;a href="https://projectcalcs.com/calculators/tile/" rel="noopener noreferrer"&gt;Tile Calculator&lt;/a&gt;, continuous area (&lt;code&gt;per_sqft&lt;/code&gt;) scales with raw waste-adjusted square footage, whereas discrete quantities (&lt;code&gt;per_tile&lt;/code&gt;, &lt;code&gt;per_box&lt;/code&gt;) round &lt;strong&gt;up&lt;/strong&gt; at each purchasing step so users don't end up short by two tiles on installation day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;tilesNeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roomAreaSqFt&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;tileAreaSqFt&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="n"&gt;tilesWithWaste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tilesNeeded&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;wastePercentage&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;boxesNeeded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tilesWithWaste&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;safeTilesPerBox&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priceUnit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"per_sqft"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;estimatedCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;roomAreaSqFt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;wastePercentage&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tilePrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"per_tile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;estimatedCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tilesWithWaste&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tilePrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"per_box"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;estimatedCost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boxesNeeded&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;tilePrice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separating exact surface coverage from discrete packaged quantities avoids subtle under-ordering while still explaining every step transparently on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero-Backend Static Prerendering
&lt;/h2&gt;

&lt;p&gt;Because every calculation stays pure inside typed Vue composables, Nuxt prerenders all routes ahead of time (&lt;code&gt;routeRules: { "/**": { prerender: true } }&lt;/code&gt;) into static HTML + client JS chunks with zero runtime server cost or user data leaving the browser.&lt;/p&gt;

&lt;p&gt;If you've built engineering, finance, or CAD/takeoff calculators in TypeScript, I'd be curious how you handle imperial/metric fraction formatting and rounding edge cases in tests.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
