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:
- Raw time slots are fragmented and messy.
- Saving new slots can leave fragmented rows in your database.
- 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:
Raw time slots
-> merged day ranges
-> safe persistence plan
-> shared availability across a groupThe 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:
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:00becomes54013:30becomes81017:00becomes1020
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:
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:
- Sort by
startMinascending. - Break ties by
endMindescending. - Seed the result with the first range.
- Walk forward and either merge into the last range or append a new one.
The deliberate detail is this comparison:
sorted[i].startMin <= last.endMinUsing <= means adjacent slots merge too.
So these all collapse into one:
09:00-11:00
10:30-12:00
12:00-13:00Result:
09:00-13:00But clearly separated ranges stay separate:
14:00-15:00
16:00-17:00Result:
14:00-15:00
16:00-17:00Why 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:
Mon 09:00-11:00
Mon 13:00-14:00Then the user adds:
Mon 10:30-13:00Now 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.
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:
- Identify the days touched by the incoming ranges.
- Pull existing rows for each affected day.
- Combine existing and new ranges.
- Merge them into normalized blocks.
- Delete the old rows for that day.
- Insert the normalized replacements.
For the earlier Monday example:
Existing:
- Mon 09:00-11:00 (id: a1)
- Mon 13:00-14:00 (id: a2)
New:
- Mon 10:30-13:00Merged result:
Mon 09:00-14:00Write plan:
{
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:
a.start < b.end AND b.start < a.endThat rule excludes edge-touching ranges.
So:
09:00-11:00and10:00-12:00overlap09:00-11:00and11:00-12:00do 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:
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:
start = max(a.start, b.start)
end = min(a.end, b.end)Example:
A: Mon 09:00-12:00
B: Mon 10:30-11:30Result:
Mon 10:30-11:30Touching-but-not-overlapping example:
A: Mon 09:00-11:00
B: Mon 11:00-13:00Result:
[]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.
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:
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:
[
{ dayOfWeek: 1, startMin: 600, endMin: 630 },
{ dayOfWeek: 1, startMin: 930, endMin: 960 },
]Which means:
Mon 10:00-10:30
Mon 15:30-16:00That 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:
Mon 09:00-10:00
Mon 10:00-11:00
Mon 10:30-12:00And participant B has:
Mon 10:45-11:15If 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:
Mon 09:00-12:00And now the shared slot is unambiguous:
Mon 10:45-11:15That is why the flow should be:
messy slots
-> merged time ranges
-> persistence plan
-> pairwise intersection
-> group overlapNot 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:
O(n log n)intersectTwo() uses nested loops:
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
dayOfWeekfirst - 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:
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:
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:
- merge messy or adjacent time slots
- persist normalized day ranges safely
- 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.
