The first time I tried to build a "find a meeting slot across time zones" tool, my plan was the obvious one: convert every team's working hours to UTC, then intersect the ranges like a classic interval-overlap problem. It fell apart almost immediately, for two very unglamorous reasons — overnight shifts, and daylight saving time. Neither of those care about your clean UTC ranges.
What I ended up shipping doesn't convert anything to UTC at all. It picks one team's time zone as the reference, walks through that team's day in fixed steps, and for every step re-projects the same instant into every other team's local wall clock using Luxon. That single design choice is what actually made DST and midnight-crossing "just work" instead of needing special-cased math.
The reference axis is a team's time zone, not UTC
Instead of normalizing everyone to UTC, the tool lets you pick any added team as the "viewing" perspective, and that team's zone becomes the axis the 24-hour timeline is drawn against:
const axisTz = computed(() => viewTeam.value?.tz || "UTC");
function toAxisDate(hour = 0, minute = 0) {
return DateTime.fromISO(selectedDate.value, { zone: axisTz.value })
.startOf("day")
.set({ hour, minute });
}
toAxisDate(hour, minute) builds a real Luxon DateTime — an absolute instant, not just a clock string — anchored to midnight of the selected date in the axis zone, then advanced to a specific hour/minute. Because it's a real instant, every other team's local time for that same moment is just .setZone(tm.tz) away:
function isHourInWork(hour, tm) {
const local = toAxisDate(hour, 0).setZone(tm.tz);
const mins = local.hour * 60 + local.minute;
return withinWork(mins, hmToMin(tm.workStart), hmToMin(tm.workEnd));
}
This is the part that quietly buys you correct DST handling for free. .setZone() doesn't apply a fixed offset you calculated earlier in the day — it re-derives the correct wall-clock time for that zone at that instant, using whatever offset actually applies then. If a UTC offset shifts partway through the day (a DST transition), each hour on the timeline still resolves correctly, because each hour is its own independent instant-to-local conversion, not one offset applied across 24 hours.
Overnight shifts break the assumption that "end > start"
The other thing that killed my original UTC-interval plan: a team whose working hours span midnight in their own local time (say 22:00–06:00) doesn't fit a normal start <= x < end interval check. The actual working-hours test flips to an OR when the end time is earlier than the start time:
function withinWork(mins, ws, we) {
return we <= ws ? mins >= ws || mins < we : mins >= ws && mins < we;
}
If workEnd <= workStart, the window wraps around midnight, so a minute-of-day value counts as "in work hours" if it's either after the start or before the end — not between them. Miss this one-line branch and any night-shift team just silently never matches anything, because 22:00 <= mins < 06:00 is never true for any mins when tested as a normal AND.
Finding the overlap is a brute-force scan across discrete steps, not an interval merge
Given how the working-hours check works, the actual "find the best meeting time" logic isn't a textbook sorted-interval-merge algorithm — it's a straightforward scan across the day at whatever granularity you picked (15/30/60 minutes), checking every team at every step:
const overlaps = computed(() => {
if (!teams.value.length) return [];
const slots = Math.floor(1440 / stepMin.value);
const need = Math.ceil(Number(duration.value || 30) / stepMin.value);
const res = [];
for (let i = 0; i <= slots - need; i++) {
const sAxis = toAxisDate(0, 0).plus({ minutes: i * stepMin.value });
let ok = true;
for (let j = 0; j < need && ok; j++) {
const cur = sAxis.plus({ minutes: j * stepMin.value });
ok = teams.value.every((tm) => {
const local = cur.setZone(tm.tz);
const mins = local.hour * 60 + local.minute;
return withinWork(mins, hmToMin(tm.workStart), hmToMin(tm.workEnd));
});
}
if (ok) { /* record this candidate slot */ }
}
return res.slice(0, 100);
});
For every candidate start slot, it checks every sub-slot the meeting would occupy, and every team has to be inside their working hours for every one of those sub-slots. There's no clever sorted-pointer intersection here — it's O(slots × need × teams), which sounds worse than it is, since at a 15-minute granularity that's at most 96 outer iterations. It's not elegant, but it's correct by construction: every check is a fresh, DST-aware, per-instant zone conversion, so there's no accumulated offset error to worry about.
There's a related nicety in how results get displayed: since a slot that's, say, 9 AM in Taipei might already be "tomorrow" or "yesterday" for a team on the other side of the date line, the share text explicitly flags the day offset instead of just printing a bare time:
const diff = ss.startOf("day").diff(sAxis.startOf("day"), "days").days;
const offset = diff > 0.5 ? " (+1)" : diff < -0.5 ? " (-1)" : "";
Without that (+1)/(-1) marker, a copy-pasted meeting time that's correct in absolute terms could still get someone showing up on the wrong calendar day.
Honest limitations
-
The axis team's own DST transition day is the one case per-instant conversion doesn't save you from.
toAxisDatebuilds the axis day by calling.set({ hour, minute })on a localDateTime— and on a spring-forward day, an hour like 2 AM might not exist in that zone at all. Luxon normalizes it rather than erroring, but that means the visual 24-hour timeline for the team you're currently viewing from can show a skipped or duplicated hour on their own transition day, even though the cross-zone overlap math for every other team stays correct. - No overlap means no overlap — there's no "closest partial match" fallback. If two teams' working hours genuinely never intersect at the chosen date and step size, the tool just says so. It won't offer you the best 10-minute sliver it could find; it's a binary pass/fail per discretized step.
- Granularity is bounded by the step size you pick. At a 30-minute step, a real 20-minute common window simply won't be found. You have to drop to a finer interval to see it, and the smallest available is 15 minutes.
-
Time zone data comes straight from the browser's
Intl.supportedValuesOf('timeZone'). If a government changes its DST rules and a user's browser hasn't picked up the updated tz database yet, the computed offsets will be wrong until the browser updates — there's no independent, pinned tz database bundled with the tool.
I turned this into a small free tool if you want to try it without wiring up Luxon yourself: Time Zone Meeting Scheduler. No sign-up, and it'll happily take any number of teams.
Available in other languages
- 跨國會議交集時間 — 繁體中文
- 跨时区会议时间规划器 — 简体中文
- Time Zone Meeting Scheduler — English
- タイムゾーン会議スケジューラー — 日本語
- 국경 간 회의 겹침 시간 — 한국어
- Chevauchements de réunions transfrontalières — Français
- Пересечения для международных встреч — Русский
- Zeitzonen-Meeting-Planer — Deutsch
- Penjadwal Rapat Zona Waktu — Bahasa Indonesia
- Planificador de Reuniones con Zonas Horarias — Español
- Thời gian họp chung cho nhóm quốc tế — Tiếng Việt
- เวลาประชุมร่วมกันข้ามพรมแดน — ไทย
- Wspólne godziny spotkań międzynarodowych — Polski
- Zaman Dilimi Toplantı Planlayıcısı — Türkçe
- Orari comuni per riunioni internazionali — Italiano
- Agendador de Reuniões de Fuso Horário — Português
- Gemeenschappelijke tijden voor internationale meetings — Nederlands
- Планувальник нарад за часовими поясами — Українська
Top comments (0)