Posts

Calculating Hours of Operation in JS and Python

Published on 9/17/2020

I've been tasked with calculating hours of operation for a service twice in the last year, both for different use cases.

The first use case was straight-forward and completely implemented on the client-side with JavaScript and moment.js.

The second use case was a bit more complex, and ended up being implemented server-side in Python.

I want to look at the two approaches and talk about pros and cons for each.

Before I start, I want to preface this with a note that the code below is NOT production code, but should get you an idea of how to implement this on your own.

Straight-forward client-side approach

My first use-case was very simple.

Requirements:

  • Hours of operation are weekdays between 8am and 8pm US Eastern Time
  • Not closed on holidays

This is a fairly simple check. I broke down the task like so:

  • Get current time in Eastern Time
  • Get user's current time and convert to Eastern Time.
  • Check if user's current time's day falls within a weekday
  • If, user's current time's day is not a weekday, return false
  • If user's current time's day is a weekday, continue and check the time of day
  • If time of day is within 8am and 8pm US ET, return true
  • Else return false

I convert the user's current time to Eastern Time so we can make time comparisons more accurately, since in this use-case, we only care about if it is between 8am and 8pm US Eastern.

I check for the user's current day and check if it is within an available day before doing anything else. No use in making further calculations if it is a Saturday, and hours of operation are Monday - Friday.

If that last check passes, then we check if the current user's time is within the hours of 8am and 8pm US Eastern.

Sample code:

const moment = require('moment-timezone');

const MONDAY = 1;
const FRIDAY = 5;
const OPEN_HOUR = 8;
const CLOSE_HOUR = 20;

const isWeekday = (day) => {
  return day >= MONDAY && day <= FRIDAY;
}

const isWithinHours = (hour) => {
  return hour >= OPEN_HOUR && hour < CLOSE_HOUR;
}

const isAvailable = (currentTime) => {
  const day = currentTime.day();
  const hour = currentTime.hours();

  if (isWeekday(day)) {
    return isWithinHours(hour);
  }

  return false;
}

Here's a repl.it link where you can look at a version of this code.

More complex client-side approach

The next time I was given the task of calculating hours of operation, the data model was a bit more complex than before.

The requirements were:

  • Different products have different hours of operation
  • Hours of operation can vary by day (i.e. Monday - Wednesday 9am - 5pm, Thursday - Friday 9am - 8pm)
  • Closed for US holidays

When I was given the task, we already had a starting model, which I thought was neat.

We had a model, we'll call it Hours, and Hours had open and close props (moment.js objects), as well as a list of holidays.

Something like:

new Hours(moment('10:00', 'HH:mm'), moment('20:00', 'HH:mm'), HOLIDAYS)

The gotcha with this approach, is that if you are going to make calculations with a library like moment (or with the regular Date library for that matter), is that you need a full time stamp.

You cannot make a calculation like "is it between this hour and this hour" in code without telling the code which day it is.

Code will be like "is it between this hour and this hour, but WHAT DAY?" Could be between hour X today and hour Y yesterday.

If we're making time comparisons, we need a full timestamp to work with.

This became tricky because we would have to coerce the day into our open and closing hours in our model.

So our calculations were something like:

new Hours(moment('10:00', 'HH:mm'), moment('20:00', 'HH:mm'), HOLIDAYS)
const open = Hours.open.set({
  // set date based on current date
})
const close = Hours.close.set({
  // set date based on current date
})

This gave moment a full date to work with. But can you spot the issue that would eventually arise?

If you guessed timezones, you would be right.

There's only two three hard things in Computer Science: cache invalidation, naming things, and dealing with timezones.

The issue with coercing the date into the open and close times was that we were not taking into consideration timezones where the closing time would go into the next day.

I ended up finding a bug for a user in British Standard Time, where the hours of operation were being coerced into open 7/17/2020 4pm and close 7/17/2020 3am. In that case, the close time was in the past, so the service was showing as closed, when it really wasn't.

In order to fix this, I ended up adding an offset to our Hours model, which we could then use to calculate the closing time by adding an offset to the open time.

So new model became something like:

Hours {
  open: moment.Moment,
  close: moment.Moment,
  offset: number,
  ...
}

and we calculated closing time for comparison purposes as such:

const hours = new Hours(moment('10:00', 'HH:mm'), moment('20:00', 'HH:mm'), 12)

const open = Hours.open
const close = Hours.open.clone().add(Hours.offset, 'hours')

That approach handled roll overs.

One great thing you can do in this instance is add lots of testing where you specifically test use cases in timezones where you know your hours of operation will cause a roll over into the next day. For a US-based hours of operation, I added test cases for British Standard Time, Japan Standard Time, and Indian Standard Time.

One neat thing about Indian Standard Time is that the offset is UTC + 5:30, meaning that you also have to account for an extra half hour, which makes it a great test edge case.

When things started breaking

With this client-side approach, we ran into yet another bug, which was only affecting a small percentage of users, and was quite puzzling.

Some users were reporting that the product was not available during hours of operation.

In order to try to reproduce the bug, since we're getting the time on the client-side, you can very easily change your system clock to the user's reported timezone, and see if you can reproduce.

My teammates, my PM, and myself all tried to replicate, and we were not able to.

We realized perhaps there could be something preventing us from getting the user's time, maybe a browser extension, but we could not quite find a root cause.

We decided as a team to move this logic over to the server-side, since dealing with server time would be much more stable and reliable. So I got to work on a server-side Python implementation.

A Python server-side approach

My Python approach ended up being quite similar to my initial Javascript approach, where I calculate if the user's current day is within available days, and if so, we then calculate if the user's current time is within available hours.

Most everything you need is provided by Python's built-in datetime, but I used pytz as well to check time against a specific timezone, and I also used the Holidays package to check for US holidays.

A quick example of what this could look like:

class HoursOfOperation():
  def __init__(self, current_time, tz, availability):
    self.current_time = current_time or datetime.now(tz)
    self.availability = availability

  def is_available(self):
    today = self.current_time.isoweekday()
    hour = self.current_time.time().hour

    for a in self.availability:
      if today in a.get('days'):
        return hour >= a.get('start') and hour < a.get('end')
      else:
        return False

In this approach, availability is an Array of Objects, which has

  • days available (days are integer representations of a day of week based on Python's datetime)
  • start is an integer representation of the opening time
  • end is an integer representation of the closing time

We iterate over the availability array until we find that the current day is in a days set. If the day is in available days, then we check if the hour is within the start and end.

Here's a link to a repl to take a closer look.

Another neat thing we can do here is use the Python Holidays package to generate a list of holidays to check against your hours of operation.

Python Holidays can generate a list of holidays given a country. If your hours of operation exclude US holidays (i.e. you're closed during US Holidays), you can generate a list of Holidays for the year, and then check your current day against that.

This can look something like:

us_holidays = holidays.US(years=self.year)

And if you have certain US holidays you do not observe,you can remove them easily:

us_holidays.pop('Labor Day')

If you have company specific holidays to add to your hours of operation, you can also add them!

us_holidays.append(date(20XX, XX, XX))

I took the approach of writing a helper wrapper around Python Holidays that generates a list of holidays based on the current year (which we get from the server time), and then removing the holidays we don't observe.

Pros and cons of each

You may have noticed I use moment.js in my example. Moment recently announced they are moving to legacy mode: "We now generally consider Moment to be a legacy project in maintenance mode. It is not dead, but it is indeed done." You can read more in their docs.

A library like moment.js definitely adds bloat to your app and adds significant load time (Chrome is now even recommending removing it to improve load times).

Moving checks like these to be an async call to a server-side endpoint can help remove some of that bloat from your client-side app.

There's also what I mentioned earlier, which is that server time is going to be much more reliable and stable, and less prone to client-side issues (i.e. if a user changes their system clock, they could bypass client-side checks).

In the end, I think if your use-case is very, very simple, an approach such as the client-side approach I shared earlier might be a good place to start. As your availability gets more complex, that's when you'll want to consider moving that logic to the server side.