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:
/** Equation of time (minutes), NOAA approximation */
export function equationOfTimeMinutes(dayOfYear: number): number {
const b = (2 * Math.PI * (dayOfYear - 81)) / 364
return 9.87 * Math.sin(2 * b) - 7.53 * Math.cos(b) - 1.5 * Math.sin(b)
}
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 Intl is backed by ICU, which ships the full IANA tzdb — including the historical oddities. The trick is that Intl.DateTimeFormat will happily format a UTC instant in any zone, and from the formatted parts you can recover the offset:
The recipe, in words: format the UTC instant into the target zone with Intl.DateTimeFormat.formatToParts(), then re-read those wall-clock fields as if they were UTC. The gap between that and the real instant is the zone's offset at that moment — historical rules included, because ICU carries them.
What it gets you:
tzOffset('Asia/Shanghai', 1988-07-01) → +540 min (+9h — the forgotten DST)
tzOffset('Asia/Shanghai', 2001-11-03) → +480 min (+8h — normal)
That +9 is the whole point: a 1988 Shanghai birth certificate is an hour ahead of standard time, and ICU knows it without you shipping a byte of tz data.
Three things bit me getting there, and they're the difference between a snippet and something you run a few hundred thousand times a day: hourCycle: 'h23' is load-bearing (some runtimes hand you hour 24 for midnight, and Date.UTC cheerfully rolls that into the next day), a fresh DateTimeFormat per call is the most expensive thing in the whole pipeline, and zones ICU doesn't recognize need a fallback rather than a throw.
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 isdst 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 into the ±180° window centered on the zone's standard meridian, not the one centered on Greenwich — the Greenwich version is what you get for free, and it's what silently breaks:
Chatham Islands: longitude -176.5°, zone UTC+12:45 (meridian 183.75°)
naive, normalized against Greenwich → -176.5° → mean solar time off by ~24h
normalized against the meridian → +183.5° → correct
Same input, and the difference is a whole day in the day pillar.
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 whichever part had the largest rounding error. A tiny thing, but "the math visibly doesn't add up" is not a good look for a calculator.
Unknown birth hour: compute all twelve
Most calculators, when you don't know your birth hour, silently default to noon or midnight — producing a confident chart of a person who doesn't exist. But there are only twelve possible hour branches, and the chart function is pure. So: compute all twelve charts (~1ms), intersect the results, show blanks where they disagree. On a 184-sample test set, 52% of charts still have a unique strength verdict with no hour information at all — which means half the time we can give a real answer instead of a fabricated one.
And when the corrected time lands within 8 minutes of a two-hour boundary, we flag it and suggest comparing both charts, instead of pretending to a certainty the input data can't support.
"But isn't this astrology?"
The interpretive layer is a cultural system — take it or leave it. The computational layer is not: calendar conversion, historical time-zone resolution, solar position, and the sexagenary cycle all have objectively right and wrong answers, and most tools get them wrong. That's the part worth engineering carefully, and honestly it's the same rigor any birth-time-sensitive system (astronomy tooling, historical databases) deserves.
The stack: Next.js, lunar-typescript for the sexagenary calendar, Intl/ICU for time zones. No external API calls for the chart itself. We also publish our nayin translation table as open data (CC BY 4.0): github.com/Shann5/bazi-nayin.
The calculator is free, no signup, English and Chinese: auspiceoracle.com/en. The full write-up of the solar-time correction, with a city-by-city table, lives at auspiceoracle.com/en/content/true-solar-time.
Happy to go deeper on any of the time handling in the comments.
Top comments (0)