Skip to main content

The Right Way to Implement Business Schedules

· 14 min read
Iago Lastra
Cofounder | TimeTime.in

For the vast majority of businesses, it's necessary to define when they are available to the public, and when we talk about availability, most of us tend to imagine a very simple example: "we're open Monday to Friday from 9 AM to 12 PM". It seems sufficient. But in real life, clients present much more varied scenarios: night shifts, specific exceptions, summer and winter seasons, schedules that depend on rules like "the first Monday of each month"... In this article, we'll reflect on the different options we have for defining schedules and on the correct way to do it.

When we talk about platforms for managing online bookings, it's normal for Cal.com or Calendly to come to mind. They are undisputed leaders in the sector and have built models that cover the most frequent cases well: weekly slots, specific exceptions, time zones. However, is their model correct? What happens when you start getting into more complex cases?

Let's walk through this story as a series of concrete client needs. We'll see how each one is solved in code and how all of this finally leads us to a standard solution.

Basic schedules

If we have to model the availability of a restaurant, it starts with the simplest request: "I want my business to appear open Monday to Friday, from 9 AM to 12 PM".

We could think of some code - an object with the days of the week and their time window is enough to represent it:

type Time = `${string}:${string}`; // “HH:MM”
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";

interface DayWindow {
start: Time;
end: Time;
}

interface WeeklySchedule {
[d in Day]?: DayWindow;
}

const schedule1: WeeklySchedule = {
mon: { start: "09:00", end: "12:00" },
tue: { start: "09:00", end: "12:00" },
wed: { start: "09:00", end: "12:00" },
thu: { start: "09:00", end: "12:00" },
fri: { start: "09:00", end: "12:00" },
};

However, here comes the first problem with time zones. Let's imagine that our restaurant is in Madrid and someone from the Canary Islands tries to make a reservation. What schedule is shown to them? 9:00 to 12:00 in Madrid time? Or is it automatically converted to Canary time (8:00 to 11:00)? And what happens if the client is traveling and their browser reports a different time zone than their actual location?

This problem appears immediately as soon as our business has clients outside their local time zone. We need to expand our model to include temporal information:

interface WeeklySchedule {
timezone: string; // "Europe/Madrid"
days: {
[d in Day]?: DayWindow;
};
}

const schedule1: WeeklySchedule = {
timezone: "Europe/Madrid",
days: {
mon: { start: "09:00", end: "12:00" },
tue: { start: "09:00", end: "12:00" },
wed: { start: "09:00", end: "12:00" },
thu: { start: "09:00", end: "12:00" },
fri: { start: "09:00", end: "12:00" },
},
};

Now the model is clearer and can correctly answer questions like "are we open Thursday at 10:30 AM?" regardless of where the client is located. Most booking platforms handle this correctly. Up to this point, everything works well.

The second client: the night pub

A pub poses another problem: "we open Friday from 10:00 PM and close Saturday at 5:00 AM". This is the first time the simple model doesn't work, because the schedule doesn't start and end on the same day.

There are several ways to approach this problem in code. The first is to add an overnight flag that indicates when a schedule crosses midnight:

interface DayWindow {
start: Time;
end: Time;
overnight?: boolean;
}

type WeeklySchedule2 = {
timezone: string;
days: {
[d in Day]?: DayWindow[];
};
};

const schedule2: WeeklySchedule2 = {
timezone: "Europe/Madrid",
days: {
fri: [{ start: "22:00", end: "05:00", overnight: true }],
},
};

The logic must interpret that overnight and divide it into two conceptual segments: Friday from 10:00 PM to midnight, and Saturday from 00:00 to 05:00 AM.

Another popular alternative is to allow multiple time slots per day from the beginning:

interface SimpleWindow {
start: Time;
end: Time;
}

type WeeklySchedule3 = {
timezone: string;
days: {
[d in Day]?: SimpleWindow[];
};
};

// The night pub would be modeled as two separate slots
const schedule3: WeeklySchedule3 = {
timezone: "Europe/Madrid",
days: {
fri: [{ start: "22:00", end: "23:59" }],
sat: [{ start: "00:00", end: "05:00" }],
},
};

Although both solutions work for simple cases, they present important limitations:

The overnight flag approach requires special logic in all functions that query availability. Every time we ask "are we open?", the code must detect if there are active overnight flags and expand them correctly.

The multiple arrays approach forces the user to think like a programmer: they must understand that a continuous schedule is represented as two separate slots. Furthermore, if a client wants to modify the closing time from 05:00 to 06:00, they have to remember to edit the Saturday slot, not the Friday one.

As we'll see later, neither of these approaches scales well when cases become more complex.

In most current platforms, the solution involves manually creating two slots: one Friday night and another Saturday dawn. It works, but it starts to force the user to think like an engineer instead of a business manager.

The third client: the Sunday to Monday disco

Another client, a disco, asks for something even more convoluted: "we open Sunday at 10:00 PM and close Monday at 04:00 AM". It's an overnight schedule that crosses the week boundary.

This case presents an additional challenge. Not only do we have to handle midnight, but we must also consider that Sunday is the last day of the week according to some conventions (or the first according to others). The algorithm needs to:

  1. Detect that Sunday + 1 day = Monday
  2. Handle the circular arithmetic of weekdays
  3. Consider different cultural conventions (is Sunday day 0 or day 7?)

The code becomes significantly more complex:

function getNextDay(day: Day): Day {
const dayOrder = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
const currentIndex = dayOrder.indexOf(day);
const nextIndex = (currentIndex + 1) % dayOrder.length;
return dayOrder[nextIndex] as Day;
}

// Expand overnight that crosses week boundary
function expandOvernightSchedule(schedule: WeeklySchedule2): SimpleSchedule {
// ... complex logic to handle weekly crossings
}

Here it's no longer enough to duplicate slots manually. The system needs to understand the cyclical nature of time. Traditional solutions remain the same: two manual slots, one for Sunday until midnight and another for Monday dawn. The system doesn't understand the concept of natural continuity in schedules.

Holidays and exceptions

A restaurant comes with a very common request: "our weekly schedule is regular, but on December 25th and May 1st we close. Additionally, on Sunday June 15th we open from 9 to 13 for a special event".

This is the moment where weekly models show their most evident limitations. The reality is that not all days follow the general pattern: there are special dates, national holidays, unique events, maintenance closures, extended hours for high seasons...

Real schedules are not perfect mathematical patterns. They are living systems that need to adapt to specific circumstances. A small café might have:

  • Regular closures: All Sundays and national holidays
  • Special events: Night opening for the local festival
  • Scheduled maintenance: Closure on the first Tuesday of each month
  • Seasonal hours: Different in summer vs winter
  • Unforeseen situations: Closure due to illness or emergencies

Traditional platforms address this problem with "date overrides": for specific dates, you can define a custom schedule that overrules the weekly pattern. It's a practical solution that works well for specific exceptions.

In code, we need to add a map of overrides by specific date:

type ISODate = string; // "YYYY-MM-DD"

interface DayOverride {
closed?: boolean;
windows?: SimpleWindow[];
}

interface FullSchedule {
timezone: string;
weekly: WeeklySchedule3;
overrides: Record<ISODate, DayOverride>;
}

const restaurantSchedule: FullSchedule = {
timezone: "Europe/Madrid",
weekly: {
timezone: "Europe/Madrid",
days: {
mon: [
{ start: "09:00", end: "12:00" },
{ start: "19:00", end: "22:00" },
],
tue: [
{ start: "09:00", end: "12:00" },
{ start: "19:00", end: "22:00" },
],
// ... rest of days
},
},
overrides: {
"2025-12-25": { closed: true }, // Christmas closed
"2025-05-01": { closed: true }, // Labor Day closed
"2025-06-15": {
// Special Sunday event
windows: [{ start: "09:00", end: "13:00" }],
},
},
};

Although effective for specific cases, this approach has important limitations:

  1. Scalability: For a business with multiple locations, each exception must be duplicated in every calendar.

  2. Manual maintenance: National holidays change dates every year. Who is in charge of updating all the exceptions?

  3. Complex patterns: How to express "the first Monday of each month" or "during Easter Week" without creating dozens of individual exceptions?

For many small businesses with few exceptions, this solution is sufficient and practical. But when patterns become complex, this entire patch system starts to show cracks.

Complex cases: when patches are not enough

Finally, a more demanding client appears, combining several problems at once: "in summer, during July, August and September, we only open from 8 to 15. The rest of the year we follow split hours, morning and afternoon. Additionally, we want to open the first Monday of each month from 10 to 12, and the last Thursday of November. And, of course, there are holidays when we must close".

This is where all the previous patches break down. Neither the overrides model, nor the multiple weekly schedules, nor even manual exceptions allow handling this in a practical way.

In current platforms, the only option would be to create multiple availability configurations: one for summer hours, another for winter, yet another for special events... and then manually activate or deactivate them on the corresponding dates.

These solutions work for small cases, but they don't scale. As soon as you manage multiple years, multiple centers or multiple countries, the number of configurations skyrockets and the system becomes unmanageable.

And this example isn't even the most complex. In reality, cases appear that require:

Advanced recurring rules:

  • "The first Monday of each month": How to calculate what exact date it is? What if the first Monday falls on a holiday?
  • "During Easter Week": Dates change every year according to complex liturgical calculations
  • "Weekdays in December, except Christmas week": Combines weekly, monthly patterns and specific exceptions
  • "Extended hours during Black Friday and January sales": Dates that depend on commercial calendars

Multiple locations and time zones:

  • A café with branches in Madrid, Barcelona and the Canary Islands
  • Each location with different hours
  • Different regional holidays (Sant Jordi in Catalonia, Canary Islands Day...)
  • Schedule changes that affect differently according to the time zone

Complex seasonal dependencies:

  • Winter vs summer hours
  • Specific tourist seasons by region
  • Recurring events (fairs, festivals) that affect availability
  • Schedules that depend on solar day length

For all these cases, traditional models of weekly schedules + exceptions become a maintenance nightmare.

The solution to all our problems

At TimeTime we have dedicated many hours to finding a model that is capable of representing availability and schedules in a simple way. We tried weekly approaches, exception systems, multiple configurations... In the end, after so much searching, we realized that the solution had always been in front of us.

All these rules, all these exceptions, all these complex patterns... They are exactly what a calendar solves!

Think about it: a calendar is not just a grid of days. It's a mathematical system for:

  • Express recurring patterns ("every Monday", "the first Tuesday of each month")
  • Handle exceptions ("except December 25th")
  • Combine complex rules ("Monday to Friday, but only in July")
  • Manage time zones and time changes
  • Calculate complex dates (Easter, Black Friday...)

The difference is that we were reinventing square wheels while the round wheel had already existed for decades.

The iCalendar standard, defined in RFC 5545, was created precisely for this. It's the universal language of calendars and solves all the problems we have been accumulating.

In iCalendar, each schedule is expressed as an event (VEVENT) with a start time (DTSTART) and an end time (DTEND). These events can repeat through rules (RRULE) that allow expressing complex patterns:

  • "Every Monday": RRULE:FREQ=WEEKLY;BYDAY=MO
  • "The first Monday of each month": RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1
  • "Only in July, August and September": RRULE:FREQ=DAILY;BYMONTH=7,8,9
  • "Monday to Friday, except holidays": RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR + EXDATE for each holiday

Exceptions are expressed with EXDATE (removes specific occurrences) and special inclusions with RDATE (adds specific dates). All of this linked to a time zone defined in VTIMEZONE, which correctly handles official time changes.

The syntax may seem intimidating at first, but its expressive power is extraordinary:

Summer hours (every day from July to September, 8:00 AM to 3:00 PM):

RRULE:FREQ=DAILY;BYMONTH=7,8,9
DTSTART;TZID=Europe/Madrid:20250701T080000
DTEND;TZID=Europe/Madrid:20250701T150000

Special opening first Monday of each month:

RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1
DTSTART;TZID=Europe/Madrid:20250203T100000
DTEND;TZID=Europe/Madrid:20250203T120000

Regular schedule with holiday exceptions:

RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
EXDATE;TZID=Europe/Madrid:20251225T090000,20250501T090000

But here a problem arises: although iCalendar is extremely powerful, its syntax is horrible for humans. Exchanging .ics files between applications is fine, but asking a restaurant manager to write RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;EXDATE=20251225T090000 is unrealistic.

At TimeTime we believe that the solution involves preserving the expressive power of iCalendar, but presenting it in a human way. Instead of users writing RRULE rules, they can say:

  • "All weekdays" → becomes FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
  • "The first Monday of each month" → becomes FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1
  • "During summer" → becomes FREQ=DAILY;BYMONTH=7,8,9
  • "Except Christmas" → becomes EXDATE

The interface hides the complexity, but underneath we use the full power of iCalendar. This allows us to:

  1. Express any temporal pattern without artificial limitations
  2. Total interoperability with other applications that support iCalendar
  3. Scalability without limits on configurations or manual exceptions
  4. Automatic maintenance of holidays, time changes, etc.

A complete iCalendar example that combines weekly schedules, exceptions and special rules would be:

BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:-//Timetime//Availability//ES

BEGIN:VTIMEZONE
TZID:Europe/Madrid
X-LIC-LOCATION:Europe/Madrid
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE

BEGIN:VEVENT
UID:weekday-morning@timetime
SUMMARY:Morning opening (Mon-Fri 09:00-12:00)
DTSTART;TZID=Europe/Madrid:20250106T090000
DTEND;TZID=Europe/Madrid:20250106T120000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR
EXDATE;TZID=Europe/Madrid:20250501T090000,20251225T090000
END:VEVENT

BEGIN:VEVENT
UID:summer-hours@timetime
SUMMARY:Summer hours (08:00-15:00)
DTSTART;TZID=Europe/Madrid:20250701T080000
DTEND;TZID=Europe/Madrid:20250701T150000
RRULE:FREQ=DAILY;BYMONTH=7,8,9
END:VEVENT

BEGIN:VEVENT
UID:first-monday-month@timetime
SUMMARY:Opening first Monday of each month (10:00-12:00)
DTSTART;TZID=Europe/Madrid:20250203T100000
DTEND;TZID=Europe/Madrid:20250203T120000
RRULE:FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1
END:VEVENT

END:VCALENDAR

Summary

Throughout this article we have seen how simple schemes for defining schedules quickly fall short:

  • Basic weekly schedules work for simple cases, but fail with time zones and overnight schedules
  • Flags and multiple arrays solve some night cases, but add complexity without scalability
  • Exception systems allow specific holidays, but become unmanageable with recurring patterns
  • Multiple configurations attempt to address seasons, but explode in complexity with multiple locations and years

The fundamental problem is that all of these are patches. They try to solve specific cases without addressing the underlying mathematical nature of time and recurrences.

iCalendar is the only standard that contemplates all these cases from its design:

  • Complex recurring patterns with RRULE
  • Specific exceptions with EXDATE
  • Precise time zones with VTIMEZONE
  • Rules that span years and cross temporal boundaries

The iCalendar challenge is that, although extremely powerful, it is difficult to serialize and work with directly. Its syntax is not friendly for developers, let alone for end users.

At TimeTime we are investigating how to "API-fy" iCalendar: maintain all its expressive power under a modern interface that is as easy to use as JSON, but automatically generates the necessary complexity underneath.

Because defining schedules shouldn't require choosing between simplicity and completeness. It should be as simple as saying "all weekdays except holidays" and as powerful as handling the most complex rules in the real world.