Skip to content

How to Find Shared Availability in TypeScript by Merging Time Slots

Introduction

Messy calendars create a deceptively hard scheduling problem.

One person has 09:00-11:00 and 13:00-15:00. Another has 10:30-12:00 and 14:00-16:00. A resource is blocked for part of the afternoon. Now you need to find a usable overlap fast, not just compare a couple of clean intervals on paper.

The product question is simple:

"When is everyone free?"

The implementation usually is not.

Once you start building a scheduler, booking flow, meeting planner, or availability feature, you quickly run into three separate problems:

  1. Raw time slots are fragmented and messy.
  2. Saving new slots can leave fragmented rows in your database.
  3. Shared availability only works if you can reliably intersect multiple schedules, calendars, or resources.

I ran into this while working on a React Native scheduling flow, but the underlying logic has nothing to do with React Native. The same algorithm applies to booking apps, shift coordination tools, marketplace availability, appointment pickers, and team calendar features.

In this article, we'll build a small TypeScript utility set that handles the full path:

text
Raw time slots
    -> merged day ranges
    -> safe persistence plan
    -> shared availability across a group

The main payoff is groupOverlap(), which answers the user-facing question. But to make that answer trustworthy, we first need to normalize overlapping time slots with mergeTimeRanges() and compute a clean write plan with computeMergePlan().

Represent Time Slots Explicitly

For recurring weekly availability, a compact model works well:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface TimeRange {
  startMin: number;
  endMin: number;
}

The key decision here is using minutes from midnight instead of strings like "09:30" or full Date objects.

That gives you a few practical benefits:

  • comparisons are numeric
  • sorting is trivial
  • overlap checks become one-line inequalities
  • UI formatting stays separate from scheduling logic

So:

  • 09:00 becomes 540
  • 13:30 becomes 810
  • 17:00 becomes 1020

This article assumes:

  • timezone conversion already happened upstream
  • each slot belongs to one day
  • cross-midnight availability is split before it reaches this utility
  • ranges behave as half-open intervals for intersection logic

That last point matters. A slot ending at 11:00 and another starting at 11:00 will be considered adjacent, not overlapping. We'll merge those for storage, but we will not count them as shared availability when intersecting schedules.

Step 1: Merge Overlapping and Adjacent Time Slots

Before you compute shared availability, you need clean day ranges.

Real input is often fragmented:

  • a user taps 09:00-11:00
  • then adds 10:30-12:00
  • then adds 12:00-13:00

Semantically, that is one block: 09:00-13:00.

If you leave those as separate rows, everything downstream gets harder:

  • overlap results are noisier
  • persistence rules become harder to reason about
  • duplicate or edge-touching ranges keep showing up

Here's the merge function:

ts
interface TimeRange {
  startMin: number;
  endMin: number;
}

export function mergeTimeRanges(ranges: TimeRange[]): TimeRange[] {
  if (ranges.length <= 1) return ranges.map((r) => ({ ...r }));

  const sorted = [...ranges].sort(
    (a, b) => a.startMin - b.startMin || b.endMin - a.endMin,
  );

  const merged: TimeRange[] = [{ ...sorted[0] }];

  for (let i = 1; i < sorted.length; i++) {
    const last = merged[merged.length - 1];

    if (sorted[i].startMin <= last.endMin) {
      last.endMin = Math.max(last.endMin, sorted[i].endMin);
    } else {
      merged.push({ ...sorted[i] });
    }
  }

  return merged;
}

The algorithm is straightforward:

  1. Sort by startMin ascending.
  2. Break ties by endMin descending.
  3. Seed the result with the first range.
  4. Walk forward and either merge into the last range or append a new one.

The deliberate detail is this comparison:

ts
sorted[i].startMin <= last.endMin

Using <= means adjacent slots merge too.

So these all collapse into one:

text
09:00-11:00
10:30-12:00
12:00-13:00

Result:

text
09:00-13:00

But clearly separated ranges stay separate:

text
14:00-15:00
16:00-17:00

Result:

text
14:00-15:00
16:00-17:00

Why sort with a.startMin - b.startMin || b.endMin - a.endMin instead of only sorting by start?

Because when two ranges start at the same minute, putting the longer one first makes the merge pass more predictable. For example, 09:00-13:00 should come before 09:00-10:00.

TIP

If your UI lets users select time in small chunks, adjacent-slot merging usually matches user intent better than preserving artificial boundaries.

Step 2: Turn Normalized Slots into a Safe Persistence Plan

Merging ranges in memory is only part of the problem.

In production, you often already have saved rows like this:

text
Mon 09:00-11:00
Mon 13:00-14:00

Then the user adds:

text
Mon 10:30-13:00

Now you do not just need the final merged availability. You need a deterministic write plan:

  • which existing rows should be deleted
  • which normalized rows should be inserted

That is what computeMergePlan() does.

ts
interface ExistingSlot {
  id: string;
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface NewRange {
  day_of_week: number;
  start_min: number;
  end_min: number;
}

export function computeMergePlan(
  existingSlots: ExistingSlot[],
  newRanges: NewRange[],
): { toDelete: string[]; toInsert: NewRange[] } {
  const affectedDays = new Set(newRanges.map((r) => r.day_of_week));
  const toDelete: string[] = [];
  const toInsert: NewRange[] = [];

  for (const day of affectedDays) {
    const existingOnDay = existingSlots.filter((s) => s.day_of_week === day);
    const newOnDay = newRanges.filter((r) => r.day_of_week === day);

    const allRanges: TimeRange[] = [
      ...existingOnDay.map((s) => ({
        startMin: s.start_min,
        endMin: s.end_min,
      })),
      ...newOnDay.map((r) => ({
        startMin: r.start_min,
        endMin: r.end_min,
      })),
    ];

    const merged = mergeTimeRanges(allRanges);

    toDelete.push(...existingOnDay.map((s) => s.id));
    toInsert.push(
      ...merged.map((m) => ({
        day_of_week: day,
        start_min: m.startMin,
        end_min: m.endMin,
      })),
    );
  }

  return { toDelete, toInsert };
}

The flow is:

  1. Identify the days touched by the incoming ranges.
  2. Pull existing rows for each affected day.
  3. Combine existing and new ranges.
  4. Merge them into normalized blocks.
  5. Delete the old rows for that day.
  6. Insert the normalized replacements.

For the earlier Monday example:

text
Existing:
- Mon 09:00-11:00   (id: a1)
- Mon 13:00-14:00   (id: a2)

New:
- Mon 10:30-13:00

Merged result:

text
Mon 09:00-14:00

Write plan:

ts
{
  toDelete: ['a1', 'a2'],
  toInsert: [
    { day_of_week: 1, start_min: 540, end_min: 840 }
  ]
}

This is usually safer than trying to patch rows in place with a growing pile of conditional updates.

The tradeoff is deliberate:

  • you may perform more writes than strictly necessary
  • but you get deterministic, normalized storage

That is almost always worth it for scheduling data.

WARNING

computeMergePlan() only computes the delete/insert operations. The actual transaction belongs in your application layer.

Step 3: Define Pairwise Interval Intersection

Now we can move to the actual shared-availability problem.

Before handling a group, define the overlap rule for two schedules.

Two time ranges overlap when:

text
a.start < b.end AND b.start < a.end

That rule excludes edge-touching ranges.

So:

  • 09:00-11:00 and 10:00-12:00 overlap
  • 09:00-11:00 and 11:00-12:00 do not overlap

That distinction is important. A person who becomes unavailable at 11:00 is not available for a shared slot that starts exactly at 11:00.

Here is the pairwise implementation:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface OverlapResult {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

export function intersectTwo(a: Slot[], b: Slot[]): OverlapResult[] {
  const result: OverlapResult[] = [];

  for (const sa of a) {
    for (const sb of b) {
      if (
        sa.dayOfWeek === sb.dayOfWeek &&
        sa.startMin < sb.endMin &&
        sb.startMin < sa.endMin
      ) {
        result.push({
          dayOfWeek: sa.dayOfWeek,
          startMin: Math.max(sa.startMin, sb.startMin),
          endMin: Math.min(sa.endMin, sb.endMin),
        });
      }
    }
  }

  return result;
}

Once the overlap condition is true, the shared interval is:

text
start = max(a.start, b.start)
end   = min(a.end, b.end)

Example:

text
A: Mon 09:00-12:00
B: Mon 10:30-11:30

Result:

text
Mon 10:30-11:30

Touching-but-not-overlapping example:

text
A: Mon 09:00-11:00
B: Mon 11:00-13:00

Result:

text
[]

This pairwise primitive is the foundation for everything else.

Step 4: Make groupOverlap() the Hero

Once you can intersect two schedules, finding shared availability across a group becomes a reduction problem.

Start with the first participant's slots, intersect with the second, intersect that result with the third, and keep going until everyone has been included.

ts
export function groupOverlap(allSlots: Slot[][]): OverlapResult[] {
  if (allSlots.length === 0) return [];
  return allSlots.reduce((acc, slots) => intersectTwo(acc, slots));
}

That tiny function carries the main product value.

If you feed it several participants' weekly availability, it returns the time ranges where all of them are available.

Let's walk through an example:

ts
const personA: Slot[] = [
  { dayOfWeek: 1, startMin: 540, endMin: 720 },   // Mon 09:00-12:00
  { dayOfWeek: 1, startMin: 840, endMin: 1020 },  // Mon 14:00-17:00
];

const personB: Slot[] = [
  { dayOfWeek: 1, startMin: 600, endMin: 690 },   // Mon 10:00-11:30
  { dayOfWeek: 1, startMin: 900, endMin: 1080 },  // Mon 15:00-18:00
];

const personC: Slot[] = [
  { dayOfWeek: 1, startMin: 480, endMin: 630 },   // Mon 08:00-10:30
  { dayOfWeek: 1, startMin: 930, endMin: 960 },   // Mon 15:30-16:00
];

const overlap = groupOverlap([personA, personB, personC]);

Result:

ts
[
  { dayOfWeek: 1, startMin: 600, endMin: 630 },
  { dayOfWeek: 1, startMin: 930, endMin: 960 },
]

Which means:

text
Mon 10:00-10:30
Mon 15:30-16:00

That is the shared-availability answer your UI can render directly.

A few edge cases fall out naturally:

  • no participants -> []
  • one participant -> their slots come back as-is
  • one person with no compatible window -> the result collapses to []

The important thing is that groupOverlap() stays simple because normalization already happened earlier in the flow.

Why Normalization Comes Before Intersection

It can be tempting to jump straight to the interval-intersection algorithm. But scheduling bugs often start earlier, with messy source data.

Imagine participant A has this stored availability:

text
Mon 09:00-10:00
Mon 10:00-11:00
Mon 10:30-12:00

And participant B has:

text
Mon 10:45-11:15

If you intersect first and normalize later, you risk returning multiple fragments for what is conceptually one continuous answer.

If you normalize first, participant A becomes:

text
Mon 09:00-12:00

And now the shared slot is unambiguous:

text
Mon 10:45-11:15

That is why the flow should be:

text
messy slots
-> merged time ranges
-> persistence plan
-> pairwise intersection
-> group overlap

Not the other way around.

Complexity and Scaling Notes

For UI-driven scheduling, this approach is usually more than fast enough.

mergeTimeRanges() is dominated by sorting:

text
O(n log n)

intersectTwo() uses nested loops:

text
O(a * b)

And groupOverlap() applies that pairwise across the participant list.

For typical booking and availability screens, slot counts are small:

  • a handful of windows per day
  • maybe 7 days
  • maybe a few participants

In that world, the code above is easy to understand and fast enough.

If your dataset grows, you may want to optimize:

  • group slots by dayOfWeek first
  • keep each day's ranges sorted
  • replace nested loops with a two-pointer scan
  • expand recurring rules before intersection, not during it

But start with the simpler version unless profiling tells you otherwise.

What This Utility Does Not Solve

This shared-availability algorithm is intentionally narrow.

It does not handle:

  • timezone conversion
  • recurrence expansion from RRULE-style calendar data
  • cross-midnight slots like 23:00-02:00
  • ranking candidate windows by preference
  • travel buffers or minimum-gap requirements
  • calendar conflicts pulled from external providers

That is a feature, not a weakness.

Good scheduling systems usually separate concerns:

  • convert and normalize time data first
  • intersect availability second
  • rank or present candidate slots third

Trying to collapse everything into one "smart" function usually produces code that is harder to test and easier to break.

Testing the Scheduling Flow

Interval logic is exactly the kind of code that deserves focused tests.

You do not need huge fixtures. A handful of edge-oriented test cases gives high confidence.

Here are some good examples using Vitest-style syntax:

ts
import { describe, expect, it } from 'vitest';
import {
  computeMergePlan,
  groupOverlap,
  intersectTwo,
  mergeTimeRanges,
} from './overlap';

describe('mergeTimeRanges', () => {
  it('merges overlapping and adjacent ranges', () => {
    expect(
      mergeTimeRanges([
        { startMin: 540, endMin: 660 },
        { startMin: 630, endMin: 720 },
        { startMin: 720, endMin: 780 },
      ]),
    ).toEqual([{ startMin: 540, endMin: 780 }]);
  });

  it('keeps separated ranges apart', () => {
    expect(
      mergeTimeRanges([
        { startMin: 540, endMin: 600 },
        { startMin: 660, endMin: 720 },
      ]),
    ).toEqual([
      { startMin: 540, endMin: 600 },
      { startMin: 660, endMin: 720 },
    ]);
  });
});

describe('computeMergePlan', () => {
  it('replaces affected-day rows with normalized inserts', () => {
    expect(
      computeMergePlan(
        [
          { id: 'a1', day_of_week: 1, start_min: 540, end_min: 660 },
          { id: 'a2', day_of_week: 1, start_min: 780, end_min: 840 },
        ],
        [{ day_of_week: 1, start_min: 630, end_min: 780 }],
      ),
    ).toEqual({
      toDelete: ['a1', 'a2'],
      toInsert: [{ day_of_week: 1, start_min: 540, end_min: 840 }],
    });
  });
});

describe('intersectTwo', () => {
  it('does not count edge-touching ranges as overlap', () => {
    expect(
      intersectTwo(
        [{ dayOfWeek: 1, startMin: 540, endMin: 660 }],
        [{ dayOfWeek: 1, startMin: 660, endMin: 720 }],
      ),
    ).toEqual([]);
  });
});

describe('groupOverlap', () => {
  it('finds shared availability across multiple participants', () => {
    expect(
      groupOverlap([
        [{ dayOfWeek: 1, startMin: 540, endMin: 720 }],
        [{ dayOfWeek: 1, startMin: 600, endMin: 690 }],
        [{ dayOfWeek: 1, startMin: 630, endMin: 660 }],
      ]),
    ).toEqual([{ dayOfWeek: 1, startMin: 630, endMin: 660 }]);
  });
});

These tests cover the main scheduling risks:

  • overlapping time slots merge correctly
  • adjacent time slots merge correctly
  • separated ranges stay separate
  • affected-day persistence is deterministic
  • edge-touching slots do not produce false overlap
  • shared availability across a group collapses as expected

If you are building a booking system, these are not optional niceties. They protect the exact logic your users will trust when they try to schedule real time with other people.

Final Implementation

Putting it all together:

ts
interface Slot {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

interface TimeRange {
  startMin: number;
  endMin: number;
}

interface ExistingSlot {
  id: string;
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface NewRange {
  day_of_week: number;
  start_min: number;
  end_min: number;
}

interface OverlapResult {
  dayOfWeek: number;
  startMin: number;
  endMin: number;
}

export function mergeTimeRanges(ranges: TimeRange[]): TimeRange[] {
  if (ranges.length <= 1) return ranges.map((r) => ({ ...r }));

  const sorted = [...ranges].sort(
    (a, b) => a.startMin - b.startMin || b.endMin - a.endMin,
  );

  const merged: TimeRange[] = [{ ...sorted[0] }];

  for (let i = 1; i < sorted.length; i++) {
    const last = merged[merged.length - 1];

    if (sorted[i].startMin <= last.endMin) {
      last.endMin = Math.max(last.endMin, sorted[i].endMin);
    } else {
      merged.push({ ...sorted[i] });
    }
  }

  return merged;
}

export function computeMergePlan(
  existingSlots: ExistingSlot[],
  newRanges: NewRange[],
): { toDelete: string[]; toInsert: NewRange[] } {
  const affectedDays = new Set(newRanges.map((r) => r.day_of_week));
  const toDelete: string[] = [];
  const toInsert: NewRange[] = [];

  for (const day of affectedDays) {
    const existingOnDay = existingSlots.filter((s) => s.day_of_week === day);
    const newOnDay = newRanges.filter((r) => r.day_of_week === day);

    const allRanges: TimeRange[] = [
      ...existingOnDay.map((s) => ({
        startMin: s.start_min,
        endMin: s.end_min,
      })),
      ...newOnDay.map((r) => ({
        startMin: r.start_min,
        endMin: r.end_min,
      })),
    ];

    const merged = mergeTimeRanges(allRanges);

    toDelete.push(...existingOnDay.map((s) => s.id));
    toInsert.push(
      ...merged.map((m) => ({
        day_of_week: day,
        start_min: m.startMin,
        end_min: m.endMin,
      })),
    );
  }

  return { toDelete, toInsert };
}

export function intersectTwo(a: Slot[], b: Slot[]): OverlapResult[] {
  const result: OverlapResult[] = [];

  for (const sa of a) {
    for (const sb of b) {
      if (
        sa.dayOfWeek === sb.dayOfWeek &&
        sa.startMin < sb.endMin &&
        sb.startMin < sa.endMin
      ) {
        result.push({
          dayOfWeek: sa.dayOfWeek,
          startMin: Math.max(sa.startMin, sb.startMin),
          endMin: Math.min(sa.endMin, sb.endMin),
        });
      }
    }
  }

  return result;
}

export function groupOverlap(allSlots: Slot[][]): OverlapResult[] {
  if (allSlots.length === 0) return [];
  return allSlots.reduce((acc, slots) => intersectTwo(acc, slots));
}

Conclusion

If you want reliable shared availability, the real problem is bigger than interval intersection alone.

You need to:

  1. merge messy or adjacent time slots
  2. persist normalized day ranges safely
  3. intersect schedules until only common free time remains

That is why groupOverlap() works best as the final layer, not the first one.

With these four small functions, you can support a surprising range of scheduling features:

  • appointment booking
  • meeting coordination
  • friend hangout planning
  • shift matching
  • marketplace availability

From here, the next useful extensions are usually:

  • ranking shared windows by duration
  • adding buffers between appointments
  • supporting recurring availability rules

But the core stays the same:

clean time slots in, trustworthy shared availability out.