I built a calculator for BaZi — Chinese "Four Pillars" birth charts.
Whatever you think of the interpretive tradition (and I'll get to that), the input math turned out to be a genuinely deep time-zone problem, and that's what this post is about.
If you've ever thought "time zones, how hard can it be" — this is a tour of exactly how hard, with working TypeScript.
The problem BaZi divides the day into twelve two-hour "branches", so your birth hour is one of the chart's four pillars.
Get the hour wrong and you get a different chart — not slightly different, categorically different.
Every calculator I could find feeds the system the wall-clock time from your birth certificate.
But the tradition predates time zones by about two thousand years; it obviously means solar time — where the sun actually was over your birthplace.
Clock time and solar time differ by more than most people think, and the difference decomposes into exactly three parts:
1.
Daylight saving time — and it's historical.
You need the DST rules in force on the birth date, not today's.
China ran a now-forgotten DST experiment from 1986–91; Harbin kept its own zone before
1949.
If you were born in Beijing in July 1988, your certificate is an hour ahead of standard time and no modern-day lookup will tell you that.
2.
Longitude.
Solar time shifts 4 minutes per degree from your zone's standard meridian.
China spans five geographic zones but uses one clock — born in Ürümqi, your clock runs about two hours ahead of the sun.
It's not just a China quirk: Vancouver sits at 123°W in a zone whose meridian is 120°W, so that's another 12 minutes, everywhere, always.
3.
The equation of time.
The sun itself runs up to ±16 minutes fast or slow over the year, thanks to orbital eccentricity and axial tilt.
NOAA publishes an approximation that's accurate to under a minute: Stack all three and a July birth in Vancouver needs ~78 minutes of correction.
That's easily a different hour branch — a different chart.
Getting historical offsets without shipping a tz database Here's the part that surprised me: you don't need to bundle tz data.
Node's is backed by ICU, which ships the full IANA tzdb — including the historical oddities.
The trick is that will happily format a UTC instant in any zone, and from the formatted parts you can recover the offset: Feed it and a 1988 timestamp and you get the 1986–91 DST hour back, for free.
No dependency, no data file, and it stays current with the runtime's ICU.
Going the other way — wall time to UTC — has the classic chicken-and-egg problem (you need the offset to compute the instant, but the offset depends on the instant).
Two fixed-point iterations settle it everywhere except inside the one-hour DST gap, where no exact answer exists anyway.
There's a subtler one hiding in "was DST active?".
JavaScript has no API, so I sample the zone's offset on Jan 1, Jul 1, and the birth instant, and take the minimum as the standard offset — DST always moves clocks forward, so the minimum is standard time in both hemispheres.
Sampling the birth instant too matters because of Morocco, which observes negative DST during Ramadan; without it, the heuristic reports a +60-minute DST that never happened.
The two edge cases I didn't see coming The date line.
The Chatham Islands sit at 176.5°W and use UTC+12:45.
Do the naive thing — longitude × 4 minutes from Greenwich — and the computed local mean solar time lands a full day off.
In a birth chart that silently corrupts the day pillar, which is the pillar the whole reading hangs on.
The fix is to normalize longitude to within ±180° of the zone's standard meridian, not Greenwich: Rounding that has to add up.
The UI shows the three components as an addition table: DST + longitude + equation of time = total.
Round each part independently and the table stops summing — off-by-one minutes that make the whole thing look broken.
So the rounded parts are forced to sum exactly to the rounded total, with the residual assigned to whichev