Determining the start of the next business day in Oh Dear
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.
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.
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.
What are your thoughts on "Determining the start of the next business day in Oh Dear"?