DEV Community

Furiosa Studio
Furiosa Studio

Posted on • Originally published at timezonematcher.com

Building a timezone meeting planner that doesn't lie to you

Timezone math is one of those problems where the bug is invisible until it ships: the demo works because everyone testing it sits in the same offset, and then someone in Kathmandu joins a "10am" call at the wrong hour. The root cause is almost always the same — treating a clock reading as if it were an absolute instant.

Why you should never do timezone math by hand

The naive approach is to grab a UTC offset and add hours. It breaks on contact with reality:

  • Offsets aren't whole hours. India is UTC+5:30, Nepal is UTC+5:45, parts of Australia run +8:45.
  • Offsets aren't fixed. A zone's offset changes across the year because of DST, and the transition dates differ by country (and have changed historically).
  • "Working hours" aren't universal. 9–5 in one place overlaps with a lunch break or a pre-dawn hour in another.

A stored "+2" is a fact about one instant, not about a place. The moment you persist an offset and reuse it, you've baked in a bug.

Get correct local hours from the IANA database

The fix is to stop storing offsets and start storing IANA zone identifiers (Europe/Madrid, Asia/Kolkata). The runtime owns the offset rules and applies whichever one is correct for a given instant. Intl.DateTimeFormat does this today, no dependencies:

function localHour(instant, timeZone) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone, hour: 'numeric', hour12: false, minute: 'numeric',
  }).formatToParts(instant);
  const h = +parts.find(p => p.type === 'hour').value;
  const m = +parts.find(p => p.type === 'minute').value;
  return h + m / 60; // 5.75 for Kathmandu when it's midnight UTC + offset
}
Enter fullscreen mode Exit fullscreen mode

The fractional return matters: a person in Nepal whose day starts at 9:00 local is at a different grid position than someone on a whole-hour offset. The emerging Temporal API makes this cleaner — Temporal.ZonedDateTime carries the zone with the instant, so arithmetic stays correct:

const zdt = Temporal.Instant.from('2026-03-29T09:00Z')
  .toZonedDateTimeISO('Europe/Madrid');
zdt.add({ hours: 6 }).hour; // DST-aware, not naive +6
Enter fullscreen mode Exit fullscreen mode

The DST trap

Here's the part that catches people twice a year. Say Madrid and New York agree on a recurring 3pm Madrid call. Both observe DST, but they switch on different weekends. For about two weeks each spring and fall, the gap between them is 5 hours instead of the usual 6. A meeting that was comfortable for New York silently moves an hour earlier in their day — without anyone touching the invite.

This is why you must compute overlap for the specific date the user is planning, never from a cached offset. Resolve each zone's local hours at that instant and the DST handling falls out for free.

Intersecting working hours across N people

Model each person as a local working range, normalize everything to a common axis (UTC works), then intersect. With each range expressed as [startUtc, endUtc) in minutes-from-midnight-UTC, the N-way intersection is a fold:

function intersect(ranges) {
  return ranges.reduce((acc, r) => {
    const start = Math.max(acc.start, r.start);
    const end = Math.min(acc.end, r.end);
    return { start, end: Math.max(start, end) };
  });
}
// empty result (start === end) means no shared hour
Enter fullscreen mode Exit fullscreen mode

Because each range was derived from the IANA rules at the planned date, half-hour offsets and DST are already encoded in the UTC bounds — the intersection logic itself stays trivial and correct.

Render overlap as a gradient, not a yes/no

The interesting UX decision: a binary "works / doesn't work" grid throws away information. With five people across enough zones, a perfect slot often doesn't exist — but "4 of 5 are in core hours and the 5th is in their early evening" is a real answer.

So color each hour cell by how many participants are inside their preferred range, with a lighter band for "awake but outside ideal." Count overlaps per hour:

function overlapScore(hourUtc, people) {
  return people.filter(p => hourUtc >= p.start && hourUtc < p.end).length;
}
Enter fullscreen mode Exit fullscreen mode

Mapping the score to opacity turns the grid into a heatmap. The eye finds the darkest column in a second — no arithmetic required from the user.

Hand back a shareable invite

Once a slot is chosen, the planner should emit a real calendar event, not a screenshot. iCalendar is plain text, and the trick is to write the times in UTC (Z suffix) so every client re-renders them in its own zone correctly:

function toICS({ startUtc, endUtc, title }) {
  const z = d => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
  return [
    'BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT',
    `DTSTART:${z(startUtc)}`, `DTEND:${z(endUtc)}`,
    `SUMMARY:${title}`, 'END:VEVENT', 'END:VCALENDAR',
  ].join('\r\n');
}
Enter fullscreen mode Exit fullscreen mode

Serve that as a .ics download or a data: URL and the recipient gets a one-click add that lands at the correct local time on their machine — the same instant, rendered honestly everywhere.

Closing

None of this is exotic; it's just the discipline of storing instants plus zones instead of offsets, and recomputing at the planned date every time. That discipline is exactly what TimezoneMatcher implements — drop in everyone's cities, see the overlap as a heatmap, and export the invite, free and with no login.

Built by Furiosa Studio.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

This is great! I'm curious if you found