<?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: Zurox</title>
    <description>The latest articles on DEV Community by Zurox (@zbench).</description>
    <link>https://dev.to/zbench</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%2F4040251%2Fa49d3a6b-7935-4491-a4a6-6888195dfa66.png</url>
      <title>DEV Community: Zurox</title>
      <link>https://dev.to/zbench</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zbench"/>
    <language>en</language>
    <item>
      <title>The Hard Part of a Global Birth-Chart Calculator Was Time</title>
      <dc:creator>Zurox</dc:creator>
      <pubDate>Wed, 22 Jul 2026 03:33:51 +0000</pubDate>
      <link>https://dev.to/zbench/the-hard-part-of-a-global-birth-chart-calculator-was-time-3g6p</link>
      <guid>https://dev.to/zbench/the-hard-part-of-a-global-birth-chart-calculator-was-time-3g6p</guid>
      <description>&lt;p&gt;A birth-chart form looks simple: ask for a date, time, and place, then calculate.&lt;/p&gt;

&lt;p&gt;The interface may be simple. The input is not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1992-11-01 01:30&lt;/code&gt; does not identify one universal instant. It is a wall-clock reading that only becomes meaningful after you resolve the place, the historical time-zone rule, and any daylight-saving transition. If the time is unknown, inventing a convenient default can create chart features that were never supported by the user’s data.&lt;/p&gt;

&lt;p&gt;I ran into these problems while building &lt;a href="https://www.astrozen.co/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=global_birth_chart_pipeline" rel="noopener noreferrer"&gt;AstroZen&lt;/a&gt;, a Next.js application that calculates a BaZi Four Pillars chart and a Western natal chart before generating an optional interpretation.&lt;/p&gt;

&lt;p&gt;The most important architectural decision was this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Calculation is an evidence pipeline. Interpretation is a separate layer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post explains the calculation pipeline, the failure modes I had to remove, and why “unknown” must remain unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A city name is not a coordinate
&lt;/h2&gt;

&lt;p&gt;Early prototypes often use a short city list or a default coordinate. That works for a layout demo, but it is not acceptable once location affects the result.&lt;/p&gt;

&lt;p&gt;Names are ambiguous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paris can mean France or Texas.&lt;/li&gt;
&lt;li&gt;Springfield needs a state or region.&lt;/li&gt;
&lt;li&gt;Country abbreviations come in several forms.&lt;/li&gt;
&lt;li&gt;A valid city must resolve to both coordinates and a time-zone identifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AstroZen sends the submitted city to Open-Meteo’s geocoding service, then scores the returned candidates against the requested country and optional region hint. It keeps the following structured result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ResolvedPlace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;countryCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;timeZone&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="c1"&gt;// IANA, for example "Europe/Madrid"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Candidate selection considers exact city-name matches, country matches, an optional region hint, and population as a small tie-breaker. Population never replaces the country check.&lt;/p&gt;

&lt;p&gt;The more important rule is what happens when resolution fails:&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;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;selected&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;countryMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestedCountry&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;We could not match that city and country. Add a state or region.&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;There is no silent fallback to a famous city. A visible error is better than a polished chart calculated for the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A time zone is not a fixed UTC offset
&lt;/h2&gt;

&lt;p&gt;Storing &lt;code&gt;UTC+1&lt;/code&gt; is not enough. The same location may use different offsets across seasons and historical periods. Governments also change their rules.&lt;/p&gt;

&lt;p&gt;The geocoder therefore returns an IANA identifier such as &lt;code&gt;America/New_York&lt;/code&gt;, not just an offset. The server converts the submitted civil time to UTC using that zone and the runtime’s time-zone database.&lt;/p&gt;

&lt;p&gt;A simplified version of the conversion looks 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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;zonedTimeToUtc&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;CivilTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeZone&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="nb"&gt;Date&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;guess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&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;year&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;month&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;day&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;hour&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;minute&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;offsetMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOffsetFromIntl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;guess&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;guess&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="nx"&gt;offsetMinutes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is already safer than accepting a browser-supplied offset. It also reveals an edge case that every global date-time application should test explicitly: daylight-saving gaps and folds.&lt;/p&gt;

&lt;p&gt;During a spring transition, some local times never occur. During an autumn transition, one wall-clock time may occur twice. An IANA zone gives you the rules, but the product still needs a declared policy for ambiguous or nonexistent local times. A date picker cannot make that decision for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. UTC conversion and true solar time solve different problems
&lt;/h2&gt;

&lt;p&gt;The Western chart needs a UTC instant and geographic coordinates. The BaZi calculation in AstroZen also applies a declared true-solar-time strategy before establishing the Four Pillars.&lt;/p&gt;

&lt;p&gt;These are separate transformations.&lt;/p&gt;

&lt;p&gt;The longitude correction is based on the difference between the birthplace longitude and the standard meridian for the local offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;longitude correction (minutes)
  = (birthplace longitude - standard meridian) × 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline then adds an equation-of-time correction and applies the total minute shift with full date rollover. That rollover matters: a correction near midnight can move the effective local solar time into the previous or next 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="nx"&gt;standardMeridian&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offsetMinutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;solarTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveTrueSolarDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;civilBirthTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;standardMeridian&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a methodology choice, not an invisible truth. Different BaZi lineages can use different boundary conventions, especially around late-night births. The application therefore records the clock time, corrected solar time, longitude, correction in minutes, and any day rollover so the result can be reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Planet positions should not be browser approximations
&lt;/h2&gt;

&lt;p&gt;For the Western layer, I replaced lightweight orbital approximations with Swiss Ephemeris compiled to WebAssembly.&lt;/p&gt;

&lt;p&gt;The server calculates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;geocentric tropical planetary longitudes;&lt;/li&gt;
&lt;li&gt;retrograde state from longitude speed;&lt;/li&gt;
&lt;li&gt;Placidus house cusps;&lt;/li&gt;
&lt;li&gt;Ascendant and Midheaven;&lt;/li&gt;
&lt;li&gt;major aspects with declared orb limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The WASM package is loaded only on the server and cached after initialization:&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;let&lt;/span&gt; &lt;span class="nx"&gt;ephemeris&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SwissEph&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;initialization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SwissEph&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getEphemeris&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ephemeris&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;ephemeris&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;initialization&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;initialization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;initialization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SwissEph&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swisseph-wasm&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;instance&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;SwissEph&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initSwissEph&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;ephemeris&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;instance&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;instance&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;initialization&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;Lazy initialization helps with serverless cold starts, but WASM deployment adds its own failure mode: the binary must actually be present in the production bundle. A build that passes TypeScript can still fail at runtime if the &lt;code&gt;.wasm&lt;/code&gt; asset is missing. Production-like deployment tests are essential.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. “Unknown birth time” is not 12:00 PM
&lt;/h2&gt;

&lt;p&gt;Many chart applications ask for a birth time but quietly substitute noon when it is missing. Noon is useful as an internal date reference because it is far from a day boundary. It is not evidence that the person was born at noon.&lt;/p&gt;

&lt;p&gt;AstroZen uses local noon only as a neutral calculation reference, then removes every output that depends materially on the unknown time.&lt;/p&gt;

&lt;p&gt;For BaZi, unknown-time mode omits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Hour Pillar;&lt;/li&gt;
&lt;li&gt;Hour-dependent stem and branch relationships;&lt;/li&gt;
&lt;li&gt;Zi Wei Dou Shu;&lt;/li&gt;
&lt;li&gt;Dayun start timing where the boundary cannot be supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the Western chart, it omits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the Moon;&lt;/li&gt;
&lt;li&gt;Ascendant and Midheaven;&lt;/li&gt;
&lt;li&gt;all houses;&lt;/li&gt;
&lt;li&gt;time-sensitive aspects;&lt;/li&gt;
&lt;li&gt;the sidereal/Vedic validation snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remaining result is explicitly labeled as date-based. The internal noon reference is recorded as a limitation, not shown as the user’s birth time.&lt;/p&gt;

&lt;p&gt;This led to a general rule I now use beyond astrology software:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A fallback may keep a pipeline running, but it must not manufacture confidence in the output.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  6. The browser should not own the paid calculation
&lt;/h2&gt;

&lt;p&gt;Another tempting shortcut is to calculate in the browser and send the finished chart to checkout. That makes the interface responsive, but it also lets a modified client become the source of truth.&lt;/p&gt;

&lt;p&gt;AstroZen instead sends raw, validated birth input to a server endpoint. The endpoint:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;limits request size and rate;&lt;/li&gt;
&lt;li&gt;resolves the place and time zone;&lt;/li&gt;
&lt;li&gt;performs the Eastern and Western calculations;&lt;/li&gt;
&lt;li&gt;returns a display model plus a detailed calculation context;&lt;/li&gt;
&lt;li&gt;signs that context on the server.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw birth input
  → place + IANA zone
  → UTC and declared solar-time strategy
  → deterministic chart engines
  → signed calculation context
  → optional interpretation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checkout verifies the signature before accepting the context. A user can inspect the free result, but changing the browser payload does not silently change the evidence used for the paid report.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. AI interprets evidence; it does not calculate the chart
&lt;/h2&gt;

&lt;p&gt;The report-generation model receives the structured calculation context after the deterministic engines finish. It does not decide the coordinates, time zone, Four Pillars, planet positions, houses, or aspects.&lt;/p&gt;

&lt;p&gt;That separation gives the system two useful properties:&lt;/p&gt;

&lt;h3&gt;
  
  
  Repeatability
&lt;/h3&gt;

&lt;p&gt;The same supported input produces the same base chart. Generated prose may vary, but the underlying evidence does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auditability
&lt;/h3&gt;

&lt;p&gt;The report can show which calculated facts support an interpretation. When a factor is unavailable because the birth time is unknown, the model receives that limitation instead of a fabricated value.&lt;/p&gt;

&lt;p&gt;This does not eliminate model errors. It constrains where they can occur and makes them easier to detect.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Tests should target boundaries, not just happy paths
&lt;/h2&gt;

&lt;p&gt;The most valuable fixtures are not ordinary noon births in major cities. They are cases close to a boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;two cities with the same name in different countries;&lt;/li&gt;
&lt;li&gt;dates around daylight-saving transitions;&lt;/li&gt;
&lt;li&gt;births close to a solar-term boundary;&lt;/li&gt;
&lt;li&gt;longitude corrections that cross midnight;&lt;/li&gt;
&lt;li&gt;unknown-time inputs;&lt;/li&gt;
&lt;li&gt;repeated identical inputs;&lt;/li&gt;
&lt;li&gt;production builds that must load a WASM asset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also test invariants rather than only snapshots:&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unknownTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pillars&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unknownTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moon&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unknownTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ascendant&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unknownTime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;houses&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attractive result is not proof that the input pipeline is correct. Boundary tests are where hidden assumptions become visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep if I rebuilt it
&lt;/h2&gt;

&lt;p&gt;The reusable lessons are not specific to birth charts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Resolve human place names into structured, reviewable data.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Store an IANA zone, not a fixed offset.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep distinct time transformations separate and named.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never turn “unknown” into a confident feature.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the server the source of truth for paid calculations.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate deterministic evidence from generative explanation.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expose methodology choices and limitations in the product.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can inspect the public explanation of the calculation pipeline on &lt;a href="https://www.astrozen.co/how-it-works?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=global_birth_chart_pipeline" rel="noopener noreferrer"&gt;AstroZen’s methodology page&lt;/a&gt;, or try the &lt;a href="https://www.astrozen.co/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=global_birth_chart_pipeline#free-chart" rel="noopener noreferrer"&gt;free calculated chart&lt;/a&gt;. No email is required for the free calculation.&lt;/p&gt;

&lt;p&gt;I would be especially interested in how other developers handle DST folds, historical time-zone data, and WASM assets in serverless deployments.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Scope note:&lt;/strong&gt; BaZi and astrology are traditional symbolic systems used for reflection and entertainment. They are not scientifically validated diagnostic or predictive methods. Better engineering can make a calculation pipeline more consistent and transparent; it does not establish the scientific validity of the interpretation.&lt;/p&gt;

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