Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

A birth time is a wall clock, not an instant: how one timezone bug moves a rising sign

If you turn "14:00 on 15 July 1990" into a Date on whatever device runs your code, you get a different instant on every device. For an astrology calculator that is not a rounding problem: the same birth data produced fou

If you turn "14:00 on 15 July 1990" into a Date on whatever device runs your code, you get a different instant on every device. For an astrology calculator that is not a rounding problem: the same birth data produced four different rising signs. The fix is to store the wall-clock reading together with the IANA zone of the birth place, and to convert on the server, never from the runtime's own zone.

I build Burcapp, a free set of Turkish astrology calculators, and this bug is why birth time now travels through the whole app as a wall clock plus a zone. Here is the whole thing with the numbers.

A wall clock is not an instant

A birth certificate says "14:00". That is a reading on a clock at the birth place. It becomes an instant only once you know which zone that clock was in on that date.

JavaScript's Date parser treats a date-time string without an offset as local time, meaning the local time of the device that runs it:

const wall = new Date("1990-07-15T14:00:00");
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone, wall.toISOString());

Running that with TZ set to four different zones:

Device zone Instant (UTC)
Europe/Istanbul 1990-07-15T11:00:00.000Z
UTC 1990-07-15T14:00:00.000Z
America/New_York 1990-07-15T18:00:00.000Z
Asia/Tokyo 1990-07-15T05:00:00.000Z

A spread of 13 hours for one birth. If that code runs in the browser, the answer depends on where the visitor lives. If it runs on your server, it depends on the server's zone, which says nothing about the birth place.

What it does to a chart

I sent the same birth, 14:00 on 15 July 1990 in Istanbul (41.0082 N, 28.9784 E), to our calculation service four times. Only the zone used to read "14:00" changed:

"14:00" read as Rising sign (Ascendant)
Europe/Istanbul Libra 29Β°10'
UTC Sagittarius 4Β°33'
America/New_York Aquarius 1Β°51'
Asia/Tokyo Leo 18Β°10'

Four zones, four signs. Only the first is correct, and it sits 50 arcminutes before the Scorpio boundary. The Ascendant sweeps the whole zodiac in a day, about one degree every four minutes on average, so a clock error of a few minutes can flip even the correct zone's answer. That is why a rising-sign calculator has to be exact about the instant in a way a horoscope-by-month page never was.

The offset is not a property of the city

The usual shortcut is "Istanbul is UTC+3". It is now, and the rows below show it was not always. The service resolves the offset from IANA tzdata for the exact date. Same city, same clock reading at noon:

Date Europe/Istanbul offset
1970-07-01 +02:00
1985-01-15 +02:00
1985-07-15 +03:00
2016-12-15 +03:00

A fixed offset per city would be wrong in half of these rows. You need the zone name and the date, and a library that knows the history. For births before 1970, tzdata is best-effort, so the zone field has to stay user-editable.

The fix

Never build the instant from the runtime's zone. Carry { date, time, zone } as data and convert with the zone you were given. With plain Intl, no dependency:

// Offset of `zone` at a given instant, in minutes.
function offsetMinutes(zone, instantMs) {
  const parts = Object.fromEntries(
    new Intl.DateTimeFormat("en-US", {
      timeZone: zone,
      hourCycle: "h23",
      year: "numeric",
      month: "2-digit",
      day: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
      second: "2-digit",
    })
      .formatToParts(new Date(instantMs))
      .map((p) => [p.type, p.value]),
  );
  const asUtc = Date.UTC(
    +parts.year,
    +parts.month - 1,
    +parts.day,
    +parts.hour,
    +parts.minute,
    +parts.second,
  );
  return (asUtc - Math.floor(instantMs / 1000) * 1000) / 60000;
}

export function wallClockToInstant(date, time, zone) {
  const [y, mo, d] = date.split("-").map(Number);
  const [h, mi] = time.split(":").map(Number);
  const guess = Date.UTC(y, mo - 1, d, h, mi);
  // Pretend the wall clock is UTC, then correct by the zone's offset.
  let instant = guess - offsetMinutes(zone, guess) * 60000;
  // The offset can differ at the corrected instant (a DST edge): take it once more.
  instant = guess - offsetMinutes(zone, instant) * 60000;
  return new Date(instant);
}

Call it for 14:00 on 15 July 1990 with each zone and run it under any TZ: the Europe/Istanbul call returns 11:00Z, UTC 14:00Z, America/New_York 18:00Z and Asia/Tokyo 05:00Z every time, because the runtime's zone is no longer an input.

Two honest limits. On the night a clock jumps forward, a wall-clock time can not exist; on the night it falls back, it happens twice. This function picks one answer silently. A product that cares should ask the user which one. And if a ready-made Temporal.ZonedDateTime, Luxon or date-fns-tz is available to you, use it rather than this sketch.

Better: do not convert in the browser at all

Our forms send the wall clock and the zone as they are, and the calculation service turns them into an instant:

{
  "birthDate": "1990-07-15",
  "birthTime": "14:00",
  "latitude": 41.0082,
  "longitude": 28.9784,
  "timeZone": "Europe/Istanbul"
}

The zone is filled in from the chosen city, and it stays editable. If no zone can be found, the service says so in the response and the page shows a warning, instead of quietly using the visitor's device zone.

Coordinates and the zone do two different jobs. The coordinates say where the observer stands, which sets the Ascendant and the house cusps. The zone says what the clock reading means. Getting one right does not get the other right, so every request carries both.

Test it the boring way

The check I rely on: open the same result link in a browser set to Europe/Istanbul, then UTC, then America/New_York. The page must render identically. In Node, run the test suite three times with TZ=Europe/Istanbul, TZ=UTC and TZ=America/New_York and compare the output.

I wrote up how the service computes positions, and what it checks them against, on the Burcapp calculation methodology page. The birth chart calculator that uses this flow is Doğum Haritası Hesaplama (Turkish).

Disclosure: I build Burcapp. It is free and needs no sign-up.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.