Oh Dear is the all-in-one monitoring tool for your entire website. We monitor uptime, SSL certificates, broken links, scheduled tasks and more. You'll get a notifications for us when something's wrong. All that paired with a developer friendly API and kick-ass documentation. O, and you'll also be able to create a public status page under a minute. Start monitoring using our free trial now.

Determining the start of the next business day in Oh Dear

Original – by Freek Van der Herten – 9 minute read

When a site is down, Oh Dear sends a notification every hour. Since last year, our notifications can be snoozed for a fixed amount of time (5 minutes, 1 hour, 4 hours, one day).

In the evenings and weekends, our users might not want to receive repeated notifications. That's why we've added a nice human touch: all notifications can now be snoozed until the start of the next workday.

In this blog post, I'd like to share some of the code that powers this feature. We'll focus on how the start of the next workday is calculated.

Snoozable Slack notifications

Whenever an uptime check fails, we will send the user a notification. On of the available channels in Slack. Using the spatie/interactive-slack-notification-channel we can send Slack notifications with interactive elements like buttons and menus.

Inside the Oh Dear code base, we've added small helpers classes like SlackAttachmentDropdown that can be used to create a menu quickly. Here's the code that builds up the "Snooze notifications" menu.

public function addSnoozeDropdown(): self
{
    $snoozeDropdown = (new SlackAttachmentDropdown('snooze', 'Snooze notifications'))
        ->addOption(5, 'Five minutes')
        ->addOption(30, '30 minutes')
        ->addOption(CarbonInterval::hour(1)->totalMinutes, 'An hour')
        ->addOption(CarbonInterval::hour(4)->totalMinutes, 'Four hours')
        ->addOption(CarbonInterval::day(1)->totalMinutes, 'A day')
        ->addOption('nextWorkday', ucfirst($this->check->site->team->businessHours()->humanReadableNextStartDateTime()))
        ->toArray();

    return $this->addElement($snoozeDropdown);
}

Here's how that drop down looks like.

dropdown

Let's focus on the last option that displays the next workday. In the Oh Dear database, a "check" is something that is checked on a given site (uptime, mixed content). A check belongs to a site, a site belongs to a team, and a team has business hours. You can see those relations being used in the code.

$this->check->site->team->businessHours()->humanReadableNextStartDateTime());

In the teams table, there is a business_hours JSON attribute. Here's the content that's there by default.

Users can customise the business hours on the "Team settings" screen. Any changes will be written in the business_hours column.

dropdown

When we look at the Team model, we can see that a BusinessHours class that wraps a team provides some handy methods to work with those business hours.

// inside the `Team` model

public function businessHours(): BusinessHours
{
    return new BusinessHours($this);
}

Exploring the BusinessHours class

Let's take a look at the constructor of the BusinessHours class.

namespace App\Domain\Team\Support;

// imports omitted for brevity

protected Team $team;

protected array $businessHoursSchedule;

protected Carbon $now;

public function __construct(Team $team)
{
    $this->team = $team;

    $this->businessHoursSchedule = $team->business_hours ?? [];

    $this->now = Carbon::now()->toTeamTimezone($team);
}

Oh Dear is being used worldwide. In the preferences of a team, our users can specify in which timezone their team operates in. We are going to keep an instance Carbon with the current time in the timezone of the team. By doing so, calling dayName on it will return the expected day name for the given time (you'll see it being used later in the code).

In Laravel apps, the Illuminate\Support\Carbon class is macroable. That toTeamTimezone function is being added via this macro that lives in our AppServiceProvider.

Carbon::macro('toTeamTimezone', function (Team $team = null) {
    $team = $team ?? currentTeam();

    $timezone = $team ? $team->timezone : 'UTC';

    return $this->setTimezone($timezone);
});

Let's now look at the implementation of humanReadableNextStartDateTime on BusinessHours, which is used in the addSnoozeDropdown snippet earlier in this post. This function is responsible for determining a human-readable string that shows how long a check will be snoozed (it will return something like, "Today at 9:00", "Tomorrow at 10:00" or "Wednesday at 11:00").

 public function humanReadableNextStartDateTime(): string
{
    /** @var \Illuminate\Support\Carbon $nextStartInTeamTimeZone */
    $nextStartInTeamTimeZone = $this->getNextStartDateTimeInAppTimeZone()->toTeamTimezone($this->team);

    $dayName = $this->now->isSameDay($nextStartInTeamTimeZone)
        ? 'today'
        : $nextStartInTeamTimeZone->dayName;

    if ($nextStartInTeamTimeZone->isTomorrow()) {
        $dayName = 'tomorrow';
    }

    return "{$dayName} at {$this->getNextStartDateTimeInAppTimeZone()->toTeamTimezone($this->team)->format("h:s")}";
}

The actual magic of determining when the next business days starts, happens in the getNextStartDateTimeInAppTimeZone function. Here's how that function looks like:

public function getNextStartDateTimeInAppTimeZone(): Carbon
{
    if (count($this->businessHoursSchedule) === 0) {
        return $this->getNextStartDateTimeUsingDefaultSchedule();
    }

    if ($this->willOpenToday()) {
        return $this->getStartDateTimeForToday();
    }

    return $this->getOpenDateTimeForNextOpenDay();
}

There are three cases. First, it could be that a team has no schedule defined at all. In that case, the getNextStartDateTimeUsingDefaultSchedule() will assume that the team has a 9 to 5 schedule for all weekdays. Next, it could be that next business open is today (for example when it's now 1 am, and the business opens at 9 am). In the case will execute getStartDateTimeForToday(). Finally, if the business doesn't open today, we will find the next open date in getOpenDateTimeForNextOpenDay.

Let's take a look at the implementations of all those functions. I'm not going to explain them all. You can see copy being used on $this->now() a couple of times. This is needed because the Carbon class is mutable and we don't want our modifications to affect the instance we have in $this->now(). If this confuses you, read this post by Jeff Madsen that explains the mutability problem.

protected function willOpenToday(): bool
{
    $dayName = $this->now->dayName;
    if (!isset($this->businessHoursSchedule[$dayName])) {
        return false;
    }

    $startTime = $this->now->copy()->setTimeFromTimeString($this->businessHoursSchedule[$dayName]['start']);

    return $startTime->isFuture();
}

protected function getNextStartDateTimeUsingDefaultSchedule(): Carbon
{
    $baseCarbon = $this->now->copy();

    if ($this->now->hour >= 9) {
        $baseCarbon->nextWeekday();
    }

    return $baseCarbon
        ->setTimeFromTimeString('09:00')
        ->setTimeZone(config('app.timezone'));
}

protected function getStartDateTimeForToday(): Carbon
{
    $dayName = $this->now->dayName;

    return $this->now->copy()
        ->setTimeFromTimeString($this->businessHoursSchedule[$dayName]['start'])
        ->setTimeZone(config('app.timezone'));
}

protected function getOpenDateTimeForNextOpenDay(): Carbon
{
    return collect($this->businessHoursSchedule)
        ->map(function (array $schedule, string $dayName) {
            return $this->now->copy()
                ->next($dayName)
                ->setTimeFromTimeString($schedule['start']);
        })
        ->sortBy(fn(Carbon $carbon) => $carbon->timestamp)
        ->first()
        ->setTimeZone(config('app.timezone'));
}

Most of the code above should be straightforward, but I'll explain getOpenDateTimeForNextOpenDay a bit as that one can seem a bit weird on first sight. In this function, we will convert all day names in our schedule to Carbon instances for the next day with that given day name. Those instances will be sorted by their timestamp value, so the one that happens first will also be the first one in the collection.

Testing the business schedule

Let's also take a look at the tests for the BusinessHours class. In these tests, we will set and modify the date-time that Laravel app considered to be the current date-time. We do that using the TestTime class, which is provided by the spatie/test-time package.

namespace Tests\Unit\Services\ValueObjects;

use App\Domain\Team\Models\Team;
use Carbon\Carbon;
use Spatie\TestTime\TestTime;
use Tests\TestCase;

class BusinessHoursTest extends TestCase
{
    protected Team $team;

    public function setUp(): void
    {
        parent::setUp();

        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 00:00:00');

        $this->team = Team::factory()->create([
            'timezone' => 'UTC',
            'business_hours' =>
                [
                    'Monday' => ['start' => '09:00', 'end' => '17:00'],
                    'Tuesday' => ['start' => '09:00', 'end' => '17:00'],
                    'Wednesday' => ['start' => '09:00', 'end' => '17:00'],
                    'Thursday' => ['start' => '09:00', 'end' => '17:00'],
                    'Friday' => ['start' => '09:00', 'end' => '17:00'],
                ],
        ]);
    }

    /** @test */
    public function it_can_determine_the_start_of_the_next_business_day()
    {
        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 00:00:00');
        $this->assertCarbon('2021-02-01 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('today at 09:00', $this->team->businessHours()->humanReadableNextStartDateTime());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 09:00:00');
        $this->assertCarbon('2021-02-02 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('tomorrow at 09:00', $this->team->businessHours()->humanReadableNextStartDateTime());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-05 08:00:00');
        $this->assertCarbon('2021-02-05 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('today at 09:00', $this->team->businessHours()->humanReadableNextStartDateTime());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-05 18:00:00');
        $this->assertCarbon('2021-02-08 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('Monday at 09:00', $this->team->businessHours()->humanReadableNextStartDateTime());
    }

    /** @test */
    public function it_respects_the_time_zone_of_the_team()
    {
        $team = Team::factory()->create([
            'timezone' => 'CST',
            'business_hours' =>
                [
                    'Monday' => ['start' => '09:00', 'end' => '17:00'],
                ],
        ]);

        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 14:00:00');
        $this->assertCarbon('2021-02-01 15:00', $team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('today at 09:00', $team->businessHours()->humanReadableNextStartDateTime());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 19:00:00');
        $this->assertCarbon('2021-02-08 15:00', $team->businessHours()->getNextStartDateTimeInAppTimeZone());
        $this->assertEquals('Monday at 09:00', $team->businessHours()->humanReadableNextStartDateTime());
    }

    /** @test */
    public function when_no_schedule_is_set_it_will_default_to_nine_to_five_week_days()
    {
        $this->team->update(['business_hours' => []]);

        TestTime::freeze('Y-m-d H:i:s', '2021-02-01 00:00:00');
        $this->assertCarbon('2021-02-01 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-02 00:00:00');
        $this->assertCarbon('2021-02-02 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-02 13:00:00');
        $this->assertCarbon('2021-02-03 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());

        TestTime::freeze('Y-m-d H:i:s', '2021-02-05 13:00:00');
        $this->assertCarbon('2021-02-08 09:00', $this->team->businessHours()->getNextStartDateTimeInAppTimeZone());
    }

    /** @test */
    public function it_can_determine_that_is_open_on_a_given_day()
    {
        $this->assertTrue($this->team->businessHours()->openOnDay('Monday'));
        $this->assertFalse($this->team->businessHours()->openOnDay('Sunday'));
    }

    /** @test */
    public function it_can_get_the_start_hour_for_a_given_day()
    {
        $this->assertEquals('09:00', $this->team->businessHours()->startHour('Monday'));
        $this->assertNull($this->team->businessHours()->startHour('Sunday'));
    }

    /** @test */
    public function it_can_get_the_end_hour_for_a_given_day()
    {
        $this->assertEquals('17:00', $this->team->businessHours()->endHour('Monday'));
        $this->assertNull($this->team->businessHours()->endHour('Sunday'));
    }

    protected function assertCarbon(string $expectedDateTime, Carbon $actualCarbon): void
    {
        $this->assertEquals($expectedDateTime, $actualCarbon->format('Y-m-d H:i'));
    }
}

In closing

I hope you've enjoyed this detailed look at how the next business day's start is being calculated. I'm not ashamed to say that it took me a while to get it right.

If you want to see this all in actions, consider subscribing to Oh Dear. You can start a free 10-day trial, no credit card needed. All our uses have a shot at winning a MacBook Air M1.

Stay up to date with all things Laravel, PHP, and JavaScript.

You can follow me on these platforms:

On all these platforms, regularly share programming tips, and what I myself have learned in ongoing projects.

Every month I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

What are your thoughts on "Determining the start of the next business day in Oh Dear"?

Comments powered by Laravel Comments
Want to join the conversation? Log in or create an account to post a comment.