Open-sourcing Timeslottr: Create timeslots for scheduling

I recently open-sourced timeslottr, a small TypeScript library for generating time slots and detecting overlaps. When I started building it, the problem seemed straightforward: Partition a time range into appointment slots, account for some exclusions, done. But as I worked through the implementation, I discovered the subtle complexity hiding behind what looks like a simple scheduling problem.

If you've ever built a calendar feature, booking system, or appointment scheduler, you know the pain. Time zones, edge cases, overlapping exclusions, alignment preferences. It compounds quickly. I want to share some of the design decisions I made and what I learned along the way.

What Does timeslottr Actually Do?

At its core, the library solves two problems:

  1. Generate available time slots within a given range (like "9 AM to 5 PM on Tuesday")
  2. Check if two time slots overlap (to detect scheduling conflicts)

It has some other interesting features:

  • Supports flexible input formats (Date objects, ISO strings, time-only strings)
  • Handles time zones without external dependencies
  • Lets you exclude periods (lunch breaks, meetings, holidays)
  • Offers alignment strategies for how slots should be positioned
  • Adds configurable buffers before/after appointments

No dependencies. Works in Node, browsers, and edge runtimes. The entire thing is ~500 lines of TypeScript.

Here's what the API looks like in practice:

import { generateTimeslots } from 'timeslottr';

// Input: Generate 30-minute slots from 9 AM to 5 PM
const slots = generateTimeslots({
  start: '2025-10-27T09:00:00',
  end: '2025-10-27T17:00:00',
  slotDuration: 30,        // minutes
  slotInterval: 30,        // gap between slot starts
  excludeRanges: [
    // Exclude lunch hour
    {
      start: '2025-10-27T12:00:00',
      end: '2025-10-27T13:00:00'
    }
  ]
});

// Output: Array of available time slots
// [
//   { start: 2025-10-27T09:00:00.000Z, end: 2025-10-27T09:30:00.000Z, metadata: { index: 0, durationMinutes: 30 } },
//   { start: 2025-10-27T09:30:00.000Z, end: 2025-10-27T10:00:00.000Z, metadata: { index: 1, durationMinutes: 30 } },
//   { start: 2025-10-27T10:00:00.000Z, end: 2025-10-27T10:30:00.000Z, metadata: { index: 2, durationMinutes: 30 } },
//   ... (slots from 9 AM - 12 PM)
//   { start: 2025-10-27T13:00:00.000Z, end: 2025-10-27T13:30:00.000Z, metadata: { index: 6, durationMinutes: 30 } },
//   ... (slots from 1 PM - 5 PM)
// ]

The Overlap Detection Algorithm

Let's start with the simplest part: detecting if two time slots overlap.

Here's the entire function:

function overlaps(a: Timeslot, b: Timeslot): boolean {
  return a.start < b.end && b.start < a.end;
}

Wait, that's it?

Yeah. And it's kind of beautiful. This is the classic interval overlap check. If slot A's start comes before slot B's end, and slot B's start comes before slot A's end, they must overlap.

Think about it visually:

A:  |-------|
B:         |-------|
     └─ A.start < B.end 
     └─ B.start < A.end 

If either condition is false, they don't overlap. No complex logic, no case analysis for "A contains B" vs "B contains A" vs "partial overlap." Just two comparisons.

I've seen production code with 20-line overlap checks full of nested ifs. When I was designing this, I kept reminding myself that sometimes the simplest solution really is the right one.

Generating Slots: The Core Loop

The interesting part was designing generateSlotsForSegment(), which distributes time slots across a range based on alignment strategy.

Here's the general flow I settled on:

  1. Calculate how many slots fit in the segment
  2. Determine padding (unused time) based on alignment:
    • Start alignment: slots hug the beginning, padding at the end
    • End alignment: slots hug the end, padding at the start
    • Center alignment: distribute padding evenly on both sides
  3. Iterate and create slots at the correct intervals

The key insight here is that most naive implementations would just start at the beginning and iterate. But if you want slots centered or end-aligned, you need to calculate the padding first, then offset your starting point.

// Simplified version of center alignment
const totalSlotsTime = numSlots * slotMinutes;
const padding = segmentMinutes - totalSlotsTime;
const startPadding = Math.floor(padding / 2);

// Start generating slots after the padding
let currentTime = addMinutes(segment.start, startPadding);

This means if you have a 4-hour window and can fit three 1-hour slots, center alignment will give you 30 minutes on each side. Start alignment gives you 0 minutes at the start, 60 at the end. End alignment is the reverse.

Why does this matter? Because in real booking systems, users care about presentation. A doctor's office might prefer start-aligned slots (9 AM, 10 AM, 11 AM). A salon might prefer center-aligned slots for aesthetic reasons. I didn't want the library to make assumptions—better to give users the control.

The Trickiest Part: Handling Exclusions

Here's where things get interesting. You want to generate slots from 9 AM to 5 PM, but you need to exclude:

  • Lunch: 12 PM to 1 PM
  • An existing meeting: 2:30 PM to 3:00 PM
  • A holiday block: overlaps with part of your range

The naive approach is to generate all slots, then filter out the ones that overlap with exclusions. But that's wasteful and doesn't account for the fact that exclusions might split your range into multiple segments.

I decided to take a different approach:

Step 1: Normalize Exclusions

The library first processes each exclusion to:

  • Resolve it into actual Date boundaries (handling timezones)
  • Filter out exclusions completely outside your window
  • Clamp exclusions that partially overlap with your window
  • Merge overlapping exclusions into single ranges
// If you have:
// Window: 9 AM - 5 PM
// Exclusion 1: 11 AM - 12 PM
// Exclusion 2: 11:30 AM - 1 PM

// After normalization:
// Window: 9 AM - 5 PM
// Merged exclusion: 11 AM - 1 PM

Step 2: Subtract Exclusions

Now comes the clever bit. The library treats your time window as a single interval and iteratively "subtracts" each exclusion from the remaining segments.

function subtractExclusions(source: Timeslot, exclusions: Timeslot[]) {
  let segments = [source];

  for (const exclusion of exclusions) {
    const nextSegments = [];
    for (const segment of segments) {
      // If exclusion doesn't overlap, keep segment as-is
      // If exclusion completely covers segment, discard it
      // If exclusion partially overlaps, split into remaining parts
      nextSegments.push(...splitSegment(segment, exclusion));
    }
    segments = nextSegments;
  }

  return segments;
}

The result is an array of non-overlapping segments where slots can be generated. So if you start with 9 AM - 5 PM and subtract lunch (12 PM - 1 PM), you get:

  • Segment 1: 9 AM - 12 PM
  • Segment 2: 1 PM - 5 PM

Then the library generates slots independently for each segment.

Timezone Handling Without a Library

One design constraint I set early on was zero dependencies. Most developers reach for date-fns-tz or luxon when they need timezone-aware date math, but I wanted to see if the platform APIs were good enough.

Turns out they are. The library uses the built-in Intl.DateTimeFormat API:

function getDateTimeFormatter(timeZone: string) {
  return new Intl.DateTimeFormat('en-US', {
    timeZone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
  });
}

This converts between local calendar dates and UTC instants. It's surprisingly robust—handles DST transitions, works across all modern browsers, and keeps the bundle tiny.

I also added a caching layer to avoid recreating formatters unnecessarily:

const formatterCache = new Map<string, Intl.DateTimeFormat>();

function getDateTimeFormatter(timeZone: string) {
  if (!formatterCache.has(timeZone)) {
    formatterCache.set(timeZone, /* create formatter */);
  }
  return formatterCache.get(timeZone)!;
}

This was a good reminder that platform APIs have gotten pretty good. Sometimes you don't need the external dependency.

Flexibility in Input Formats

One thing I spent time on was accepting flexible input formats for time boundaries. You can pass:

  • A full Date object: new Date('2025-10-27T09:00:00')
  • An ISO string: '2025-10-27T09:00:00'
  • A date-only string: '2025-10-27' (implies midnight)
  • A time-only string: '09:00' (requires a defaultCalendarDate context)
  • An object: { date: '2025-10-27', time: '09:00' }

The idea was to meet developers where they are. Different parts of your app might have different formats—your date picker gives you a date-only string, your time picker gives you { hour: 9, minute: 0 }. You shouldn't need to pre-process everything into a single format.

The trade-off? The type definitions get a bit gnarly:

type TimeslotBoundaryInput =
  | DateValue
  | TimeOfDayInput
  | { date?: DateValue; time: TimeOfDayInput };

But that complexity is encapsulated in the library, not your application code.

What I Built This For

When designing the API, I had a few specific use cases in mind:

  • Booking systems: Generate available appointment slots for a service
  • Calendar UIs: Show open time blocks in a day/week view
  • Interview scheduling: Find mutual availability between candidates and interviewers
  • Resource allocation: Partition time for shared resources (meeting rooms, equipment)

It's small enough to include without much bundle cost (~2KB minified), and the API is intuitive enough that you're not fighting with it.

Design Trade-offs

No library is perfect, and I made some conscious trade-offs:

Time slots are half-open intervals. A slot from 9:00 to 10:00 includes 9:00 but excludes 10:00. This is consistent with how most date/time APIs work, but if you're not careful with boundary conditions, you might get surprising results.

Performance scales with exclusion complexity. If you have hundreds of small exclusions, the normalization and subtraction steps could become a bottleneck. The library doesn't optimize for that case—it assumes a reasonable number of exclusions.

No recurring patterns. If you want "9 AM - 5 PM every weekday for the next month," you'll need to call the function multiple times. It's designed to handle a single date range, not recurring schedules.

Final Thoughts

Building timeslottr taught me a lot about API design and the value of constraints. By limiting the scope to just time slot generation, I could focus on getting the core algorithm right.

The zero-dependency constraint forced me to learn the platform APIs better. The flexible input formats added complexity to the types, but made the library easier to integrate. The alignment strategies seemed like over-engineering at first, but they reflect real presentation needs.

What surprised me most was how many edge cases emerged from what seemed like a simple problem. Timezone handling, DST transitions, overlapping exclusions that need merging, partial slots at segment boundaries. Each one required careful thought.

If you're working on anything scheduling-related, feel free to check out the source code or give it a try. It's MIT licensed and available on npm. And if you have ideas for improvements or find edge cases I missed, I'd love to hear about them.