DEV Community

Shan Liu
Shan Liu

Posted on Originally published at auspiceoracle.com

Your birth time is lying to you: a time-zone rabbit hole in a Chinese astrology calculator

Clever TypeScript tricks for historical solar time

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (7)

Collapse
 
ofri-peretz profile image
Ofri Peretz

The Intl trick is the standout here — using formatToParts() to recover the offset by treating the formatted wall-clock fields as UTC is exactly the platform doing its job, and yet it looks like a hack the first time you see it. Every LLM I've thrown time-zone problems at reaches for getTimezoneOffset() or date-fns-tz immediately; none have spontaneously noticed that ICU already carries the historical rules without extra bundling. The deeper issue your post surfaces is that this class of correctness is essentially invisible to static analysis — no linter can tell you a 1988 Shanghai timestamp is an hour off, and most test suites use new Date() so the historical DST branches never fire. The three-part decomposition (DST, longitude, equation of time) is the most useful thing here as a checklist: I've seen mature fintech codebases that handle the first, occasionally the second, and never the third.

Collapse
 
shanni profile image
Shan Liu

Thanks — and yes, "the platform doing its job while looking like a hack" is exactly the feeling. I rewrote that helper three times before I trusted it.

Your point about static analysis is the one I'd underline. The only reason I caught the 1988 Shanghai case is that I had a fixture with a hardcoded expected offset — nothing flagged it, and nothing could have. new Date() in tests is the real culprit: every historical branch is dead code under a clock that only ever reads now. My whole tz test set is frozen instants for that reason.

On the three-part checklist, the equation of time is the one nobody implements because it's the only part that isn't a policy lookup — it's astronomy, and there's no library call that hands it to you. It's also the smallest term, so it's easy to talk yourself into ±16 minutes not mattering. It matters when you're binning into two-hour buckets and the input is already near a boundary.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The minimum-of-three-samples trick for standard offset holds while the zone's base offset stays put across that calendar year, and it breaks where the base itself moved. Europe/Istanbul in 2016 is the case I would add to the test set: +02 on 1 January, +03 on 1 July, and +03 with no DST in force from September onward, so a November birth takes its minimum from ten months earlier and gets reported as 60 minutes of DST while sitting on standard time. Taking the minimum over a window centred on the birth instant instead — monthly samples six months either side — keeps the Morocco case working and stops a permanent shift leaking in from the far end of the year.

Collapse
 
shanni profile image
Shan Liu

You're right, and thank you for the precise repro — I ran it and then fixed it.

Europe/Istanbul, birth 2016-11-15: my three samples were Jan 1 = +120, Jul 1 = +180, birth instant = +180, so the minimum came out +120 and I reported 60 minutes of DST on a date when Turkey had been permanently on +03 since September. Exactly the failure you described, down to the mechanism.

Swapped in your centred window — monthly samples six months either side of the birth instant — and Istanbul now gives std = +180, DST = 0. Casablanca 2020 during Ramadan still gives 0 with dstMinutes ≤ 0, so the negative-DST case survives, and 1988 Shanghai, Sydney (southern-hemisphere January) and Vancouver are all unchanged. It also fixes Moscow 2011, which I'd written into a code comment as a known limitation and then quietly left alone — turns out it's the same bug, and your framing is what made that obvious. Both are regression tests now.

One note on blast radius, since it's the kind of thing I'd want to know as a reader: this only corrupted the attribution row in the UI — the "DST + longitude + equation of time" breakdown — not the chart. The total correction is always computed from the actual offset at the birth instant, never summed from the decomposition, so the pillars were right the whole time. But a table telling someone they were born under a DST that didn't exist is its own kind of wrong.

The residual limitation is now much narrower: a permanent base-offset change within the ±6 month window still gets attributed to the smaller of the two standards. I don't think that's fixable with sampling alone — you'd need the actual transition list, which ICU won't hand you.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

ICU will hand you the transitions, just not as a list. The offset function is piecewise constant, so daily probes plus a bisection wherever two adjacent probes disagree recover the exact instant - for Europe/Istanbul around a 2016-08-15 birth that came to 385 offset calls across a six-month window either side, landing on 2016-03-27T01:00:00Z.

That still does not settle the DST label, and I think that is the real residual. The window has two segments, +120 then +180, and nothing reverts, which is indistinguishable from a permanent base change in March - but 15 August 2016 in Turkey genuinely was summer time. A "current segment is higher than both neighbours" rule gets 1988 Shanghai, 2016 Vancouver and Ramadan Casablanca right, and still reports 0 for that birth.

What did work on Node 25.9 is asking ICU for the name rather than the offset: timeZoneName: 'long' returns Eastern European Summer Time on 2016-08-15 and the standard-time name on 2016-11-15, and China Daylight Time for 1988 Shanghai. It is not universal - Casablanca during Ramadan comes back as GMT+00:00, so the negative-DST case still needs your offset path.

Thread Thread
 
shanni profile image
Shan Liu

You sent me down exactly the right hole, so here's a precise report back.

Your 2016-08-15 case actually comes out right on my end (Node 22): the ±6-month centred window reaches February's +02, so std=+120, dst=60 — genuine summer time, correctly labeled. The failure window turns out to be exactly one month wide: 2016-09-15 gives off=180, std=120, and reports 60 minutes of DST on a date when Turkey was already permanently on +03. By October the window can no longer see the old standard and it self-corrects.

And the mechanism is nastier than a base-offset change. tzdata:

Sep  6 20:59:59 2016 UT = EEST isdst=1 gmtoff=10800
Sep  6 21:00:00 2016 UT = +03  isdst=0 gmtoff=10800
Enter fullscreen mode Exit fullscreen mode

DST ended without the clock moving — isdst flips, gmtoff doesn't. Which means bisection can't find this one either: there's no offset discontinuity to bisect on. Your daily-probe-plus-bisection recovers real transitions fine, but this transition is invisible to any offset-based probe by construction.

Your timeZoneName: 'long' suggestion is what fixed it, with one adjustment: the name is a veto, not a verdict. The GMT± fallback turns out to be broader than Casablanca — Tokyo's genuine 1950 DST also comes back as "GMT+10:00", so a name-first rule silently erases real summer time. So the offset window still computes the magnitude; if it claims positive DST while ICU names the instant standard time, the name wins and DST zeroes; no localized name = no veto; negative DST never enters the path, so Ramadan Casablanca is untouched.

One correction to my earlier reply while I'm here: Moscow 2011 wasn't actually this bug. tzdata has 2011-03-27 as isdst=0, +03 → +04 — a permanent shift, no DST that July at all — and ICU agrees ("Moscow Standard Time"). Both methods were already right there; my old "known limitation" comment was describing a bug that didn't exist.

35 cases pass under the veto now (Istanbul Mar/Aug/Sep/Oct/Nov 2016, Shanghai 1988/1990, Casablanca both directions, Tokyo 1950, Seoul 1988, Taipei/HK 1979, São Paulo, Auckland, Sydney…). Sep-2016 Istanbul and Tokyo 1950 are regression tests. The residual is now: a permanent base change inside the window where ICU only offers a GMT± fallback name — attribution display only, the chart itself was never affected.

Two real bugs out of one comment thread is a very good ratio. Thank you.

Thread Thread
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The veto holds, but it only behaves like a veto in one locale. On Node v25.9.0 that Sep-2016 Istanbul instant comes back as Türkiye Standard Time in en-US, heure normale de Turquie in fr-FR and トルコ標準時 in ja-JP, so any "contains Standard / contains Daylight" classifier quietly stops vetoing as soon as the formatter runs in the user's display locale instead of a pinned one. The no-name branch fails in the other direction: your Tokyo 1950 fallback is GMT+10:00 in en-US but UTC+10:00 in fr-FR, so a /^GMT/ test reads the French fallback as a real localized name and lets the veto fire on genuine DST. The detector that came out locale-independent for me was comparing long against longOffset for the same instant — equal means ICU had nothing but an offset to offer, and it was false for both the named-standard and named-daylight cases in all three locales I tried.