Find shared working hours across time zones in JavaScript

Choose two time zones and a date to find the overlap between their local working hours.

JavaScript (browser)Last tested Get the complete example ↓
On this page

Working example

HTML + JavaScript

Start with two time zones

Choose a date and two time zones. The example compares a 09:00–17:00 working day in each place and shows the hours they share, in both local clocks.

Create a Public key on the keys page, allow localhost and your site's hostname, and replace YOUR_PUBLIC_API_KEY in the downloaded HTML. Keep secret keys on your server. Serve the file's folder:

Terminal
python3 -m http.server 8000

Open the local example.

1. Compare the same local date

The selected date applies to each person's own local day. September 15 in New York and September 15 in Tokyo cover different instants.

HTML
<form id="lookup-form" aria-busy="false">
<label>Date in each person's own time zone<input id="date" type="date" min="2000-01-01" max="2099-12-31" value="2026-09-15" required></label>
<div class="fields"><label>Person 1<select id="zone-1"><option value="America/New_York" selected>New York</option><option value="America/Los_Angeles">Los Angeles</option><option value="Europe/London">London</option><option value="Europe/Paris">Paris</option><option value="Asia/Tokyo">Tokyo</option><option value="Australia/Sydney">Sydney</option><option value="UTC">UTC</option></select></label>
<label>Person 2<select id="zone-2"><option value="America/New_York">New York</option><option value="America/Los_Angeles">Los Angeles</option><option value="Europe/London" selected>London</option><option value="Europe/Paris">Paris</option><option value="Asia/Tokyo">Tokyo</option><option value="Australia/Sydney">Sydney</option><option value="UTC">UTC</option></select></label></div>
<p id="hours" class="hint"></p>
<button id="submit" type="submit">Find shared hours</button><p id="status" role="status" aria-live="polite" aria-atomic="true"></p></form>

Use IANA time-zone IDs such as America/New_York; a fixed offset can be wrong on another date. The working hours are defined once in the download's WORKING_HOURS constant. Change that constant to adapt the example; each window must end after it starts on the chosen day.

2. Resolve the dated offsets

Load each distinct zone once for an ordinary date. If next_dst places an offset transition inside the checked window, load it again at that instant.

JavaScript
async function loadTimeline(zone, date, signal) {
  if (!ZONES.has(zone)) throw new Error('Choose one of the listed time zones.');
  const day = dateMs(date), begin = day - 14 * 60 * MINUTE, end = day + DAY + 14 * 60 * MINUTE;
  const load = async at => readZone(await getJson(`/timezone/${encodeURIComponent(zone)}`, { at: new Date(at).toISOString() }, signal), zone, at);
  const first = await load(begin);
  if (!first.next || first.next.at >= end) return [{ begin, end, offset: first.offset }];
  const second = await load(first.next.at);
  if (second.offset !== first.next.offset || second.next && second.next.at < end) {
    throw new Error('This date has clock changes the example cannot resolve. Choose another date.');
  }
  return [{ begin, end: first.next.at, offset: first.offset }, { begin: first.next.at, end, offset: second.offset }];
}

The 52-hour UTC window covers the local date at offsets up to 14 hours either way. The complete source validates each Timezone response and checks that the follow-up offset agrees with the announced transition. It uses at most two requests per distinct zone.

To resolve a local boundary, subtract each possible offset and keep only instants that belong to that offset's segment. One candidate is usable. No candidates means the clock skipped that time; two means it occurred twice. The source rejects both instead of guessing when you adapt the working hours.

3. Show the shared interval

Take the later starting instant and earlier ending instant. If the end is later than the start, the working days overlap.

JavaScript
function findOverlap(date, people, timelines) {
  const windows = people.map(person => {
    if (clockMinutes(person.end) <= clockMinutes(person.start)) throw new Error('Each working window must end after it starts on the selected date.');
    const timeline = timelines.get(person.zone);
    return { ...person, begin: resolveBoundary(date, person.start, timeline), finish: resolveBoundary(date, person.end, timeline), timeline };
  });
  const begin = Math.max(...windows.map(row => row.begin)), end = Math.min(...windows.map(row => row.finish));
  const available = Math.max(0, (end - begin) / MINUTE);
  return { windows, begin, end, available };
}

Display that interval with a date and UTC offset for each person. Duration uses elapsed minutes, so it remains correct across a clock change. No overlap is a normal answer. Editing the date or zone clears the previous result and cancels pending requests; late responses cannot replace a newer calculation.

Try September 15, 2026 with New York and London. They share three hours: New York 09:00–12:00, London 14:00–17:00. Switch London to Tokyo: the same local working days have no overlap.

This compares stated hours. Calendar appointments are separate. For recurring meetings, retain the local recurrence rule and zone, then refresh future conversions as time-zone rules change.

Complete example

One HTML file with the markup, styles, and JavaScript. Replace YOUR_PUBLIC_API_KEY with your public key, then run it on a domain you have added to that key.

Download HTML
View and copy the complete source
meeting-time-overlap.html
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Find meeting times across time zones</title><style>
    :root { color-scheme: light dark; --bg: #fff; --ink: #152029; --muted: #5e6b75; --line: #d9e2e7; --field: #f6f9fa; --accent: #007c91; --button: #c5f6ff; --error: #b42335; }
    @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; } }
    :root[data-theme="dark"] { color-scheme: dark; --bg: #0b1015; --ink: #eff7fa; --muted: #94a6b2; --line: #2c3942; --field: #121b23; --accent: #67e8f9; --button: #67e8f9; --error: #fda4af; }
    :root[data-theme="light"] { color-scheme: light; }
    * { box-sizing: border-box; }
    [hidden] { display: none !important; }
    body { margin: 0; padding: 24px; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
    main { max-width: 560px; margin: 0 auto; }
    p { margin: 0; }
    .hint, .note { color: var(--muted); }
    form { margin-top: 0; }
    .fields { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
    label { display: block; font-size: 13px; font-weight: 550; }
    input, select, button { font: inherit; }
    input, select { width: 100%; min-width: 0; margin-top: 7px; min-height: 46px; padding: 11px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--field); color: var(--ink); }
    .hint { margin: 8px 0 16px; font-size: 12px; }
    fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
    legend { padding: 0; margin-bottom: 8px; font-size: 13px; font-weight: 550; }
    /* Keep native selection; size the closed control consistently in Safari. */
    select:not([multiple]):where(:not([size]), [size="0"], [size="1"]) {
      -webkit-appearance: none;
      appearance: none;
      padding-right: 40px;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m4 6 4 4 4-4' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      background-position: right 12px center;
      background-size: 16px;
    }
    :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
    button { min-height: 46px; margin-top: 16px; padding: 11px 18px; border: 0; border-radius: 7px; background: var(--button); color: #07313c; cursor: pointer; font-size: 14px; font-weight: 650; }
    button:disabled { opacity: .55; cursor: wait; }
    #status { min-height: 20px; margin-top: 14px; color: var(--muted); font-size: 13px; }
    #status:empty { display: none; }
    #status[data-error="true"] { color: var(--error); }
    h2 { margin: 0; font-size: 16px; font-weight: 600; }
    .note { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); font-size: 12px; }
    @media (max-width: 380px) { body { padding: 18px; } .fields { grid-template-columns: minmax(0, 1fr); } button { width: 100%; } }

    main { max-width: 620px; } .fields { margin: 14px 0; } .wide { grid-column: 1 / -1; }
    textarea { width:100%; min-width:0; margin-top:7px; padding:11px 10px; border:1px solid var(--line); border-radius:7px; background:var(--field); color:var(--ink); font:inherit; resize:vertical; }
    .result { border-top:1px solid var(--line); margin-top:10px; padding-top:18px; }
    .answer { font-size:clamp(27px,6vw,38px); font-weight:600; color:var(--accent); letter-spacing:-.04em; }
    .detail { font-size:13px; color:var(--muted); overflow-wrap:anywhere; margin-top:8px; }
    dl { margin:12px 0 0; } dl div { display:grid; grid-template-columns: minmax(0,1fr) minmax(0,1.6fr); gap:12px; padding:11px 0; border-bottom:1px solid var(--line); }
    dt { color:var(--muted); font-size:13px; } dd { margin:0; font-size:14px; overflow-wrap:anywhere; }
    fieldset { border-top:1px solid var(--line); padding-top:12px; margin:16px 0; } legend { padding-right:10px; }
    #windows { padding:0; list-style:none; } #windows li { border-top:1px solid var(--line); margin-top:12px; padding-top:12px; }
</style></head>
<body><main>
<!-- example:form -->
<form id="lookup-form" aria-busy="false">
<label>Date in each person's own time zone<input id="date" type="date" min="2000-01-01" max="2099-12-31" value="2026-09-15" required></label>
<div class="fields"><label>Person 1<select id="zone-1"><option value="America/New_York" selected>New York</option><option value="America/Los_Angeles">Los Angeles</option><option value="Europe/London">London</option><option value="Europe/Paris">Paris</option><option value="Asia/Tokyo">Tokyo</option><option value="Australia/Sydney">Sydney</option><option value="UTC">UTC</option></select></label>
<label>Person 2<select id="zone-2"><option value="America/New_York">New York</option><option value="America/Los_Angeles">Los Angeles</option><option value="Europe/London" selected>London</option><option value="Europe/Paris">Paris</option><option value="Asia/Tokyo">Tokyo</option><option value="Australia/Sydney">Sydney</option><option value="UTC">UTC</option></select></label></div>
<p id="hours" class="hint"></p>
<button id="submit" type="submit">Find shared hours</button><p id="status" role="status" aria-live="polite" aria-atomic="true"></p></form>
<!-- /example:form -->
<section id="result" class="result" role="status" aria-live="polite" aria-atomic="true" aria-label="Meeting result" hidden><p id="answer" class="answer"></p><p id="summary" class="detail"></p><ul id="windows"></ul></section></main><script>
    const API_KEY = 'YOUR_PUBLIC_API_KEY';

    const $ = selector => document.querySelector(selector);
    const form = $('#lookup-form'), button = $('#submit'), status = $('#status'), result = $('#result');
    let revision = 0, controller;
    function setStatus(message, error = false) { status.textContent = message; status.dataset.error = String(error); }
    function cancel() {
      revision++; controller?.abort(); controller = undefined; result.hidden = true;
      button.disabled = false; form.setAttribute('aria-busy', 'false');
    }
    function edit() { cancel(); setStatus('Inputs changed. Check again for a new result.'); }
    async function getJson(path, params, signal) {
      if (!API_KEY.startsWith('parse_public_')) throw new Error('Add your public API key and allow this hostname.');
      const url = new URL(path, 'https://api.parseapi.com');
      url.search = new URLSearchParams({ ...params, key: API_KEY });
      const response = await fetch(url, { signal });
      if (!response.ok) {
        const message = response.status === 401 || response.status === 403 ? 'Check your public key and allowed hostnames.'
          : response.status === 429 ? 'The request limit was reached. Try again later.'
          : response.status === 404 ? 'This lookup is unavailable for the selected place or date.'
          : 'The lookup failed. Try again later.';
        throw new Error(message);
      }
      return response.json();
    }
    async function run(task) {
      cancel(); if (!form.reportValidity()) return;
      const current = revision, request = new AbortController(); controller = request;
      let timedOut = false;
      const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 12000);
      button.disabled = true; form.setAttribute('aria-busy', 'true'); setStatus('Checking...');
      try {
        const answer = await task(request.signal);
        if (current !== revision) return;
        if (request.signal.aborted) throw new Error('The request was cancelled.');
        showResult(answer); result.hidden = false;
      } catch (error) {
        if (current !== revision) return;
        request.abort();
        setStatus(timedOut ? 'The request timed out. Try again.'
          : error instanceof TypeError ? 'Could not reach ParseAPI. Check your connection.'
          : error instanceof SyntaxError ? 'The response could not be read. Try again.'
          : error instanceof Error ? error.message : 'Could not complete the lookup. Try again.', true);
      } finally {
        clearTimeout(timeout);
        if (current === revision) { button.disabled = false; form.setAttribute('aria-busy', 'false'); controller = undefined; }
      }
    }
    form.addEventListener('input', edit);

    const DAY = 86400000, MINUTE = 60000;
    function dateMs(date) {
      if (typeof date !== 'string' || !/^20\d{2}-\d{2}-\d{2}$/.test(date)) throw new Error('Choose a date between 2000 and 2099.');
      const ms = Date.parse(`${date}T00:00:00Z`);
      if (!Number.isFinite(ms) || new Date(ms).toISOString().slice(0, 10) !== date) throw new Error('Choose a valid calendar date.');
      return ms;
    }
    function clockMinutes(value) {
      if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error('Use times in HH:MM format.');
      return Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
    }
    function offsetMinutes(value) {
      const match = typeof value === 'string' && value.match(/^([+-])(\d{2}):([0-5]\d)$/);
      if (!match) throw new Error('The time-zone offset could not be verified.');
      const minutes = (Number(match[2]) * 60 + Number(match[3])) * (match[1] === '-' ? -1 : 1);
      if (Math.abs(minutes) > 840) throw new Error('The time-zone offset is out of range.');
      return minutes;
    }
    function offsetText(minutes) {
      return `${minutes < 0 ? '-' : '+'}${String(Math.floor(Math.abs(minutes) / 60)).padStart(2, '0')}:${String(Math.abs(minutes) % 60).padStart(2, '0')}`;
    }

    const WORKING_HOURS = { start: '09:00', end: '17:00' };
    $('#hours').textContent = `Both work ${WORKING_HOURS.start}–${WORKING_HOURS.end} in their own time zone.`;
    const ZONES = new Set(['America/New_York', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo', 'Australia/Sydney', 'UTC']);
    function readZone(data, zone, at) {
      if (!data || data.timezone !== zone || !Number.isInteger(data.offset_minutes)
        || offsetMinutes(data.offset) !== data.offset_minutes) throw new Error('The time-zone response could not be verified.');
      let next = null;
      if (data.next_dst !== null) {
        const row = data.next_dst;
        const when = typeof row?.at === 'string' && /Z$/.test(row.at) ? Date.parse(row.at) : NaN;
        if (!Number.isFinite(when) || when <= at) throw new Error('The clock-change information could not be verified.');
        next = { at: when, offset: offsetMinutes(row.offset) };
        if (next.offset === data.offset_minutes) throw new Error('The clock-change offset could not be verified.');
      }
      return { offset: data.offset_minutes, next };
    }
    // example:request
    async function loadTimeline(zone, date, signal) {
      if (!ZONES.has(zone)) throw new Error('Choose one of the listed time zones.');
      const day = dateMs(date), begin = day - 14 * 60 * MINUTE, end = day + DAY + 14 * 60 * MINUTE;
      const load = async at => readZone(await getJson(`/timezone/${encodeURIComponent(zone)}`, { at: new Date(at).toISOString() }, signal), zone, at);
      const first = await load(begin);
      if (!first.next || first.next.at >= end) return [{ begin, end, offset: first.offset }];
      const second = await load(first.next.at);
      if (second.offset !== first.next.offset || second.next && second.next.at < end) {
        throw new Error('This date has clock changes the example cannot resolve. Choose another date.');
      }
      return [{ begin, end: first.next.at, offset: first.offset }, { begin: first.next.at, end, offset: second.offset }];
    }
    // /example:request

    function resolveBoundary(date, time, timeline) {
      const wall = dateMs(date) + clockMinutes(time) * MINUTE;
      const candidates = [...new Set(timeline.map(segment => wall - segment.offset * MINUTE)
        .filter(instant => timeline.some(segment => instant >= segment.begin && instant < segment.end
          && instant + segment.offset * MINUTE === wall)))];
      if (!candidates.length) throw new Error(`${date} ${time} is skipped by a clock change. Choose another boundary time.`);
      if (candidates.length > 1) throw new Error(`${date} ${time} occurs twice. Choose an unambiguous boundary time.`);
      return candidates[0];
    }
    function localLabel(instant, timeline) {
      const segment = timeline.find(row => instant >= row.begin && instant < row.end);
      if (!segment) throw new Error('The meeting falls outside the checked date window.');
      return `${new Date(instant + segment.offset * MINUTE).toISOString().slice(0, 16).replace('T', ' ')} UTC${offsetText(segment.offset)}`;
    }
    // example:calculate
    function findOverlap(date, people, timelines) {
      const windows = people.map(person => {
        if (clockMinutes(person.end) <= clockMinutes(person.start)) throw new Error('Each working window must end after it starts on the selected date.');
        const timeline = timelines.get(person.zone);
        return { ...person, begin: resolveBoundary(date, person.start, timeline), finish: resolveBoundary(date, person.end, timeline), timeline };
      });
      const begin = Math.max(...windows.map(row => row.begin)), end = Math.min(...windows.map(row => row.finish));
      const available = Math.max(0, (end - begin) / MINUTE);
      return { windows, begin, end, available };
    }
    // /example:calculate
    async function calculate(date, people, signal) {
      dateMs(date);
      for (const person of people) {
        if (!ZONES.has(person.zone)) throw new Error('Choose one of the listed time zones.');
        if (clockMinutes(person.end) <= clockMinutes(person.start)) throw new Error('Each working window must end after it starts on the selected date.');
      }
      const zones = [...new Set(people.map(person => person.zone))];
      const timelines = new Map(await Promise.all(zones.map(async zone => [zone, await loadTimeline(zone, date, signal)])));
      return findOverlap(date, people, timelines);
    }

    // example:result
    function showResult(answer) {
      $('#answer').textContent = answer.available > 0 ? 'Shared working hours' : 'No overlap';
      $('#summary').textContent = answer.available > 0
        ? `${answer.available} minutes together.`
        : 'These working hours do not overlap on this date.';
      $('#windows').replaceChildren(...(answer.available > 0 ? answer.windows : []).map((row, index) => {
        const item = document.createElement('li');
        const heading = document.createElement('h2'); heading.textContent = `Person ${index + 1} · ${row.zone}`;
        item.append(heading);
        const slot = document.createElement('p'); slot.className = 'detail';
        slot.textContent = `${localLabel(answer.begin, row.timeline)} → ${localLabel(answer.end, row.timeline)}.`;
        item.append(slot);
        return item;
      }));
      setStatus('');
    }
    // /example:result
    form.addEventListener('submit', event => {
      event.preventDefault();
      return run(signal => calculate($('#date').value, [1, 2].map(index => ({ zone: $(`#zone-${index}`).value,
        ...WORKING_HOURS })), signal));
    });

</script></body></html>

Need a key? Create a public API key. For every field and parameter, see Timezone API reference.

Build something else

All tutorials →