Building a Meeting Scheduler Across Timezones

javascript
// Find overlapping working hours between New York and Tokyo
const overlap = findWorkingHourOverlap(
	{ timezone: "America/New_York", workStart: 9, workEnd: 17 },
	{ timezone: "Asia/Tokyo", workStart: 9, workEnd: 17 },
);

console.log(overlap);
// { start: '22:00 UTC', end: '00:00 UTC', duration: 2 }
// 6pm-8pm New York = 8am-10am Tokyo

Scheduling meetings across timezones is harder than it looks. A 9am meeting in New York is 10pm in Tokyo. This guide covers building a scheduler that finds times that work for everyone, avoids holidays, and communicates clearly.

The Challenge#

Building a meeting scheduler that works across timezones involves several interconnected problems. You need to convert working hours between different timezone offsets, handle daylight saving time transitions that shift these offsets, and account for holidays that vary by country. The naive approach of picking a time that "looks good" leads to scheduling meetings at midnight for some participants or on national holidays they observe.

The World Data API provides the building blocks: timezone data for accurate conversions and holiday data to avoid conflicts. This guide shows you how to combine them into a working scheduler.

Prerequisites#

Before starting, you need:

  • A World Data API key (sign up for free)

  • Node.js 18+ (for JavaScript examples) or Python 3.8+ (for Python examples)

  • Basic understanding of timezone concepts (UTC, offsets, IANA timezone identifiers)

Install the dependencies:

bash
# JavaScript
npm install node-fetch

# Python
pip install requests

The Core Problem#

When scheduling across timezones, you need to:

  1. Find overlap — When are all participants in working hours?

  2. Avoid holidays — Don't schedule on days off for any participant

  3. Communicate clearly — Show times in each participant's local timezone

  4. Handle edge cases — DST transitions, half-day overlaps, no overlap scenarios

Finding Working Hour Overlap#

The basic algorithm: convert each participant's working hours to UTC, then find the intersection.

javascript
function getWorkingHoursInUTC(timezone, workStart, workEnd, date) {
	// Create timestamps for work start and end in the given timezone
	const dateStr = date.toISOString().split("T")[0];

	const startLocal = new Date(
		`${dateStr}T${String(workStart).padStart(2, "0")}:00:00`,
	);
	const endLocal = new Date(
		`${dateStr}T${String(workEnd).padStart(2, "0")}:00:00`,
	);

	// Convert to UTC by getting the timezone offset
	const formatter = new Intl.DateTimeFormat("en-US", {
		timeZone: timezone,
		year: "numeric",
		month: "2-digit",
		day: "2-digit",
		hour: "2-digit",
		minute: "2-digit",
		hour12: false,
	});

	// Get offset for this timezone on this date
	const parts = formatter.formatToParts(startLocal);
	// ... offset calculation

	return {
		startUTC: startLocal.toISOString(),
		endUTC: endLocal.toISOString(),
	};
}

function findOverlap(ranges) {
	if (ranges.length === 0) return null;

	let overlapStart = Math.max(
		...ranges.map((r) => new Date(r.start).getTime()),
	);
	let overlapEnd = Math.min(...ranges.map((r) => new Date(r.end).getTime()));

	if (overlapStart >= overlapEnd) {
		return null; // No overlap
	}

	return {
		start: new Date(overlapStart).toISOString(),
		end: new Date(overlapEnd).toISOString(),
		durationMinutes: (overlapEnd - overlapStart) / 60000,
	};
}

Complete Scheduler Implementation#

javascript
class MeetingScheduler {
	constructor(apiKey) {
		this.apiKey = apiKey;
		this.baseUrl = "https://worlddataapi.com/v1";
	}

	async findAvailableSlots(participants, dateRange, options = {}) {
		const {
			meetingDuration = 60, // minutes
			preferredHours = null, // optional preferred time range
			excludeHolidays = true,
		} = options;

		const slots = [];
		const currentDate = new Date(dateRange.start);
		const endDate = new Date(dateRange.end);

		while (currentDate <= endDate) {
			const dateStr = currentDate.toISOString().split("T")[0];

			// Check if this date works for everyone
			const daySlots = await this.findSlotsForDay(
				participants,
				dateStr,
				meetingDuration,
				excludeHolidays,
			);

			slots.push(...daySlots);
			currentDate.setDate(currentDate.getDate() + 1);
		}

		return this.rankSlots(slots, preferredHours, participants);
	}

	async findSlotsForDay(participants, date, duration, excludeHolidays) {
		// Check for holidays
		if (excludeHolidays) {
			const holidayChecks = await Promise.all(
				participants.map((p) => this.isHoliday(p.country, date)),
			);

			if (holidayChecks.some((isHoliday) => isHoliday)) {
				return []; // At least one participant has a holiday
			}
		}

		// Check for weekends in each timezone
		const isWeekendForAny = participants.some((p) => {
			const dayOfWeek = this.getDayOfWeekInTimezone(date, p.timezone);
			return dayOfWeek === 0 || dayOfWeek === 6;
		});

		if (isWeekendForAny) {
			return [];
		}

		// Find overlapping working hours
		const workingRanges = participants.map((p) => ({
			...this.getWorkingHoursUTC(
				date,
				p.timezone,
				p.workStart,
				p.workEnd,
			),
			participant: p,
		}));

		const overlap = findOverlap(
			workingRanges.map((r) => ({
				start: r.startUTC,
				end: r.endUTC,
			})),
		);

		if (!overlap || overlap.durationMinutes < duration) {
			return [];
		}

		// Generate possible slots within the overlap
		return this.generateSlots(overlap, duration, date, participants);
	}

	getWorkingHoursUTC(date, timezone, workStart, workEnd) {
		// Convert local working hours to UTC
		const startDate = new Date(
			`${date}T${String(workStart).padStart(2, "0")}:00:00`,
		);
		const endDate = new Date(
			`${date}T${String(workEnd).padStart(2, "0")}:00:00`,
		);

		// Get offset for this timezone
		const offset = this.getTimezoneOffset(date, timezone);

		return {
			startUTC: new Date(
				startDate.getTime() - offset * 60000,
			).toISOString(),
			endUTC: new Date(endDate.getTime() - offset * 60000).toISOString(),
		};
	}

	getTimezoneOffset(date, timezone) {
		const utcDate = new Date(`${date}T12:00:00Z`);
		const tzDate = new Date(
			utcDate.toLocaleString("en-US", { timeZone: timezone }),
		);
		return (tzDate - utcDate) / 60000;
	}

	getDayOfWeekInTimezone(date, timezone) {
		const d = new Date(`${date}T12:00:00Z`);
		const options = { timeZone: timezone, weekday: "short" };
		const dayName = d.toLocaleDateString("en-US", options);
		const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
		return days.indexOf(dayName);
	}

	generateSlots(overlap, duration, date, participants) {
		const slots = [];
		const slotStart = new Date(overlap.start);
		const slotEnd = new Date(overlap.end);

		while (slotStart.getTime() + duration * 60000 <= slotEnd.getTime()) {
			slots.push({
				date,
				startUTC: slotStart.toISOString(),
				endUTC: new Date(
					slotStart.getTime() + duration * 60000,
				).toISOString(),
				duration,
				localTimes: participants.map((p) => ({
					name: p.name,
					timezone: p.timezone,
					localStart: this.formatInTimezone(slotStart, p.timezone),
					localEnd: this.formatInTimezone(
						new Date(slotStart.getTime() + duration * 60000),
						p.timezone,
					),
				})),
			});

			slotStart.setMinutes(slotStart.getMinutes() + 30); // 30-min increments
		}

		return slots;
	}

	formatInTimezone(date, timezone) {
		return date.toLocaleString("en-US", {
			timeZone: timezone,
			hour: "numeric",
			minute: "2-digit",
			hour12: true,
		});
	}

	async isHoliday(country, date) {
		try {
			const response = await fetch(
				`${this.baseUrl}/business-days/${country}?date=${date}`,
				{ headers: { "X-API-Key": this.apiKey } },
			);
			const data = await response.json();
			return data.reason === "holiday";
		} catch {
			return false; // Assume not a holiday on error
		}
	}

	rankSlots(slots, preferredHours, participants) {
		return slots
			.map((slot) => {
				let score = 100;

				// Penalize early morning or late evening
				for (const lt of slot.localTimes) {
					const hour = parseInt(lt.localStart.split(":")[0]);
					if (hour < 9) score -= 20;
					if (hour > 17) score -= 15;
					if (hour < 8 || hour > 18) score -= 30;
				}

				// Boost if matches preferred hours
				if (preferredHours) {
					const slotHour = new Date(slot.startUTC).getUTCHours();
					if (
						slotHour >= preferredHours.start &&
						slotHour <= preferredHours.end
					) {
						score += 20;
					}
				}

				return { ...slot, score };
			})
			.sort((a, b) => b.score - a.score);
	}
}

Usage Example#

javascript
const scheduler = new MeetingScheduler(process.env.API_KEY);

const participants = [
	{
		name: "Alice",
		timezone: "America/New_York",
		country: "US",
		workStart: 9,
		workEnd: 17,
	},
	{
		name: "Bob",
		timezone: "Europe/London",
		country: "GB",
		workStart: 9,
		workEnd: 17,
	},
	{
		name: "Charlie",
		timezone: "Asia/Tokyo",
		country: "JP",
		workStart: 9,
		workEnd: 17,
	},
];

const slots = await scheduler.findAvailableSlots(
	participants,
	{ start: "2026-01-20", end: "2026-01-31" },
	{ meetingDuration: 60, excludeHolidays: true },
);

console.log(slots[0]);
// {
//   date: '2026-01-20',
//   startUTC: '2026-01-20T22:00:00.000Z',
//   endUTC: '2026-01-20T23:00:00.000Z',
//   duration: 60,
//   localTimes: [
//     { name: 'Alice', timezone: 'America/New_York', localStart: '5:00 PM', localEnd: '6:00 PM' },
//     { name: 'Bob', timezone: 'Europe/London', localStart: '10:00 PM', localEnd: '11:00 PM' },
//     { name: 'Charlie', timezone: 'Asia/Tokyo', localStart: '7:00 AM', localEnd: '8:00 AM' }
//   ],
//   score: 75
// }

Handling Edge Cases#

No Overlap Scenario#

When participants' working hours don't overlap at all:

javascript
async function handleNoOverlap(participants, date) {
	const overlap = findOverlap(/* ... */);

	if (!overlap) {
		// Suggest extended hours for some participants
		const suggestions = participants.map((p) => {
			const extendedStart = Math.max(7, p.workStart - 2);
			const extendedEnd = Math.min(21, p.workEnd + 2);

			return {
				participant: p.name,
				suggestion: `Extend hours to ${extendedStart}:00-${extendedEnd}:00`,
				timezone: p.timezone,
			};
		});

		// Find overlap with extended hours
		const extendedParticipants = participants.map((p) => ({
			...p,
			workStart: Math.max(7, p.workStart - 2),
			workEnd: Math.min(21, p.workEnd + 2),
		}));

		const extendedOverlap = findOverlap(/* with extended hours */);

		return {
			hasOverlap: false,
			suggestions,
			extendedOverlap,
		};
	}

	return { hasOverlap: true, overlap };
}

Different Workweeks#

Israel works Sunday-Thursday. UAE works Monday-Friday but used to work Sunday-Thursday. Handle non-standard workweeks:

javascript
const WORKWEEKS = {
	IL: [0, 1, 2, 3, 4], // Sunday-Thursday
	AE: [1, 2, 3, 4, 5], // Monday-Friday (since 2022)
	SA: [0, 1, 2, 3, 4], // Sunday-Thursday
	default: [1, 2, 3, 4, 5], // Monday-Friday
};

function isWorkday(date, timezone, country) {
	const dayOfWeek = getDayOfWeekInTimezone(date, timezone);
	const workweek = WORKWEEKS[country] || WORKWEEKS.default;
	return workweek.includes(dayOfWeek);
}

DST Transition Days#

DST transitions can shift overlap windows unexpectedly:

javascript
function checkDSTTransition(date, timezone) {
	const dayBefore = new Date(date);
	dayBefore.setDate(dayBefore.getDate() - 1);

	const offsetBefore = getTimezoneOffset(
		dayBefore.toISOString().split("T")[0],
		timezone,
	);
	const offsetAfter = getTimezoneOffset(date, timezone);

	if (offsetBefore !== offsetAfter) {
		return {
			isDSTTransition: true,
			change: offsetAfter - offsetBefore,
			message:
				offsetAfter > offsetBefore
					? "Clocks spring forward"
					: "Clocks fall back",
		};
	}

	return { isDSTTransition: false };
}

Building the UI#

Time Slot Picker#

jsx
function TimeSlotPicker({ slots, onSelect }) {
	const groupedByDate = slots.reduce((acc, slot) => {
		if (!acc[slot.date]) acc[slot.date] = [];
		acc[slot.date].push(slot);
		return acc;
	}, {});

	return (
		<div className="time-slot-picker">
			{Object.entries(groupedByDate).map(([date, daySlots]) => (
				<div key={date} className="day-column">
					<h3>{formatDate(date)}</h3>
					<div className="slots">
						{daySlots.map((slot, i) => (
							<button
								key={i}
								className={`slot ${slot.score > 80 ? "recommended" : ""}`}
								onClick={() => onSelect(slot)}
							>
								<span className="time-range">
									{formatTime(slot.startUTC)} -{" "}
									{formatTime(slot.endUTC)} UTC
								</span>
								<span className="score">
									Score: {slot.score}
								</span>
							</button>
						))}
					</div>
				</div>
			))}
		</div>
	);
}

Participant Time Display#

Show each participant what the meeting time means for them:

jsx
function MeetingConfirmation({ slot }) {
	return (
		<div className="meeting-confirmation">
			<h2>Meeting Scheduled</h2>
			<p className="utc-time">
				{formatDate(slot.date)} at {formatTime(slot.startUTC)} UTC
			</p>

			<h3>Local Times</h3>
			<ul className="local-times">
				{slot.localTimes.map((lt, i) => (
					<li key={i}>
						<strong>{lt.name}</strong>
						<span className="time">
							{lt.localStart} - {lt.localEnd}
						</span>
						<span className="timezone">({lt.timezone})</span>
					</li>
				))}
			</ul>
		</div>
	);
}

Calendar Invite Generation#

javascript
function generateICSContent(slot, participants) {
	const startDate = new Date(slot.startUTC);
	const endDate = new Date(slot.endUTC);

	const formatICSDate = (d) =>
		d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";

	const attendees = participants
		.map((p) => `ATTENDEE;CN=${p.name}:mailto:${p.email}`)
		.join("\r\n");

	return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Meeting Scheduler//EN
BEGIN:VEVENT
UID:${Date.now()}@meetingscheduler.com
DTSTART:${formatICSDate(startDate)}
DTEND:${formatICSDate(endDate)}
SUMMARY:Team Meeting
DESCRIPTION:Meeting times in local timezones:\\n${slot.localTimes
		.map((lt) => `${lt.name}: ${lt.localStart}`)
		.join("\\n")}
${attendees}
END:VEVENT
END:VCALENDAR`;
}

function downloadICS(slot, participants) {
	const content = generateICSContent(slot, participants);
	const blob = new Blob([content], { type: "text/calendar" });
	const url = URL.createObjectURL(blob);

	const a = document.createElement("a");
	a.href = url;
	a.download = "meeting.ics";
	a.click();
}

Holiday Integration#

Check holidays for all participants. Here are examples in JavaScript, Python, and curl:

JavaScript#

javascript
async function checkHolidaysForAll(participants, date) {
	const checks = await Promise.all(
		participants.map(async (p) => {
			const response = await fetch(
				`https://worlddataapi.com/v1/business-days/${p.country}?date=${date}`,
				{ headers: { "X-API-Key": API_KEY } },
			);
			const data = await response.json();

			return {
				participant: p.name,
				country: p.country,
				isHoliday: data.reason === "holiday",
				holidayName: data.holiday?.name,
			};
		}),
	);

	const conflicts = checks.filter((c) => c.isHoliday);

	return {
		hasConflicts: conflicts.length > 0,
		conflicts,
		message:
			conflicts.length > 0
				? `${conflicts.map((c) => `${c.participant} (${c.holidayName})`).join(", ")} unavailable`
				: "No holiday conflicts",
	};
}

Python#

python
import requests
from typing import List, Dict, Any

def check_holidays_for_all(participants: List[Dict], date: str, api_key: str) -> Dict[str, Any]:
    """Check if any participant has a holiday on the given date."""
    base_url = "https://worlddataapi.com/v1"
    headers = {"X-API-Key": api_key}

    conflicts = []

    for participant in participants:
        response = requests.get(
            f"{base_url}/business-days/{participant['country']}",
            params={"date": date},
            headers=headers
        )
        data = response.json()

        if data.get("reason") == "holiday":
            conflicts.append({
                "participant": participant["name"],
                "country": participant["country"],
                "holiday_name": data.get("holiday", {}).get("name")
            })

    return {
        "has_conflicts": len(conflicts) > 0,
        "conflicts": conflicts,
        "message": (
            ", ".join(f"{c['participant']} ({c['holiday_name']})" for c in conflicts) + " unavailable"
            if conflicts else "No holiday conflicts"
        )
    }

# Usage
participants = [
    {"name": "Alice", "country": "US"},
    {"name": "Bob", "country": "GB"},
    {"name": "Charlie", "country": "JP"}
]

result = check_holidays_for_all(participants, "2026-01-20", "your-api-key")
print(result)

curl#

bash
# Check if a date is a business day for a specific country
curl -X GET "https://worlddataapi.com/v1/business-days/US?date=2026-01-20" \
  -H "X-API-Key: YOUR_API_KEY"

# Response example:
# {
#   "date": "2026-01-20",
#   "country": "US",
#   "is_business_day": false,
#   "reason": "holiday",
#   "holiday": {
#     "name": "Martin Luther King Jr. Day",
#     "type": "public"
#   }
# }

# Get all holidays for a country in a year
curl -X GET "https://worlddataapi.com/v1/holidays/US?year=2026" \
  -H "X-API-Key: YOUR_API_KEY"

Performance Optimization#

For scheduling across many days, batch API calls:

javascript
async function prefetchHolidays(participants, dateRange) {
	const countries = [...new Set(participants.map((p) => p.country))];
	const startYear = new Date(dateRange.start).getFullYear();
	const endYear = new Date(dateRange.end).getFullYear();

	const holidaySets = {};

	for (const country of countries) {
		holidaySets[country] = new Set();

		for (let year = startYear; year <= endYear; year++) {
			const response = await fetch(
				`https://worlddataapi.com/v1/holidays/${country}?year=${year}`,
				{ headers: { "X-API-Key": API_KEY } },
			);
			const data = await response.json();

			for (const holiday of data.holidays) {
				holidaySets[country].add(holiday.date);
			}
		}
	}

	return holidaySets;
}

// Use cached data for instant lookups
function isHolidayCached(country, date, holidaySets) {
	return holidaySets[country]?.has(date) ?? false;
}

Common Pitfalls#

Assuming Monday-Friday Workweeks#

Not all countries work Monday-Friday. Israel and Saudi Arabia work Sunday-Thursday. UAE switched from Sunday-Thursday to Monday-Friday in 2022. Always check the workweek for each participant's country rather than hardcoding.

Ignoring Half-Day Overlaps#

A 30-minute overlap technically exists but is not practical for a 1-hour meeting. Always compare the overlap duration against your meeting length before suggesting a slot.

Forgetting DST Transitions#

Twice a year, DST transitions can shift your carefully calculated overlaps by an hour. A meeting scheduled for "9am local time" might suddenly become "8am local time" for one participant. Check for DST transitions on the meeting date and warn users.

Not Caching Holiday Data#

Making an API call for each date in a range is inefficient. Fetch the full year of holidays once and cache it locally. Holiday data changes infrequently, so a 24-hour cache is reasonable.

Displaying Only UTC Times#

Showing "14:00 UTC" forces participants to do mental math. Always convert and display times in each participant's local timezone. Include the timezone identifier (e.g., "2:00 PM EST") to avoid ambiguity.

Ignoring Regional Holidays#

National holidays are not the only days off. Some participants may observe state or provincial holidays. The World Data API supports regional holiday queries for more accurate scheduling.

Summary#

Building a cross-timezone meeting scheduler requires:

  1. Converting working hours to UTC for accurate overlap calculations

  2. Checking holidays for all participant countries before suggesting dates

  3. Handling edge cases like different workweeks, DST transitions, and no-overlap scenarios

  4. Communicating clearly by showing times in each participant's local timezone

The key insight is that timezone and holiday data must work together. A time slot that falls within working hours for everyone is useless if it lands on a national holiday for one participant.

Ready to build your scheduler? Sign up for a free World Data API key to access timezone and holiday data for 230+ countries.

Related guides: