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-notifications 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.
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">addSnoozeDropdown</span><span class="hljs-params">()</span>: <span class="hljs-title">self</span>
</span>{
$snoozeDropdown = (<span class="hljs-keyword">new</span> SlackAttachmentDropdown(<span class="hljs-string">'snooze'</span>, <span class="hljs-string">'Snooze notifications'</span>))
->addOption(<span class="hljs-number">5</span>, <span class="hljs-string">'Five minutes'</span>)
->addOption(<span class="hljs-number">30</span>, <span class="hljs-string">'30 minutes'</span>)
->addOption(CarbonInterval::hour(<span class="hljs-number">1</span>)->totalMinutes, <span class="hljs-string">'An hour'</span>)
->addOption(CarbonInterval::hour(<span class="hljs-number">4</span>)->totalMinutes, <span class="hljs-string">'Four hours'</span>)
->addOption(CarbonInterval::day(<span class="hljs-number">1</span>)->totalMinutes, <span class="hljs-string">'A day'</span>)
->addOption(<span class="hljs-string">'nextWorkday'</span>, ucfirst(<span class="hljs-keyword">$this</span>->check->site->team->businessHours()->humanReadableNextStartDateTime()))
->toArray();
<pre><code><span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->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.
<span class="hljs-keyword">$this</span>->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.
{
"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"
}
}
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.
<span class="hljs-comment">// inside the <code>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.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Team<span class="hljs-title">Support;
<p><span class="hljs-comment">// imports omitted for brevity</span></p>
<p><span class="hljs-keyword">protected</span> Team $team;</p>
<p><span class="hljs-keyword">protected</span> <span class="hljs-keyword">array</span> $businessHoursSchedule;</p>
<p><span class="hljs-keyword">protected</span> Carbon $now;</p>
<p><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">(Team $team)</span>
</span>{
<span class="hljs-keyword">$this</span>->team = $team;</p>
<pre><code><span class="hljs-keyword">$this</span>->businessHoursSchedule = $team->business_hours ?? [];
<span class="hljs-keyword">$this</span>->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 IlluminateSupportCarbon
class is macroable. That toTeamTimezone
function is being added via this macro that lives in our AppServiceProvider
.
Carbon::macro(<span class="hljs-string">'toTeamTimezone'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(Team $team = null)</span> </span>{
$team = $team ?? currentTeam();
<pre><code>$timezone = $team ? $team->timezone : <span class="hljs-string">'UTC'</span>;
<span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->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”).
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">humanReadableNextStartDateTime</span><span class="hljs-params">()</span>: <span class="hljs-title">string</span>
</span>{
<span class="hljs-comment">/** <span class="hljs-doctag">@var</span> IlluminateSupportCarbon $nextStartInTeamTimeZone */</span>
$nextStartInTeamTimeZone = <span class="hljs-keyword">$this</span>->getNextStartDateTimeInAppTimeZone()->toTeamTimezone(<span class="hljs-keyword">$this</span>->team);
<pre><code>$dayName = <span class="hljs-keyword">$this</span>->now->isSameDay($nextStartInTeamTimeZone)
? <span class="hljs-string">'today'</span>
: $nextStartInTeamTimeZone->dayName;
<span class="hljs-keyword">if</span> ($nextStartInTeamTimeZone->isTomorrow()) {
$dayName = <span class="hljs-string">'tomorrow'</span>;
}
<span class="hljs-keyword">return</span> <span class="hljs-string">"{$dayName} at {$this->getNextStartDateTimeInAppTimeZone()->toTeamTimezone($this->team)->format("</span>h:s<span class="hljs-string">")}"</span>;
}
The actual magic of determining when the next business days starts, happens in the getNextStartDateTimeInAppTimeZone
function. Here’s how that function looks like:
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getNextStartDateTimeInAppTimeZone</span><span class="hljs-params">()</span>: <span class="hljs-title">Carbon</span>
</span>{
<span class="hljs-keyword">if</span> (count(<span class="hljs-keyword">$this</span>->businessHoursSchedule) === <span class="hljs-number">0</span>) {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->getNextStartDateTimeUsingDefaultSchedule();
}
<pre><code><span class="hljs-keyword">if</span> (<span class="hljs-keyword">$this</span>->willOpenToday()) {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->getStartDateTimeForToday();
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->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.
<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">willOpenToday</span><span class="hljs-params">()</span>: <span class="hljs-title">bool</span>
</span>{
$dayName = <span class="hljs-keyword">$this</span>->now->dayName;
<span class="hljs-keyword">if</span> (!<span class="hljs-keyword">isset</span>(<span class="hljs-keyword">$this</span>->businessHoursSchedule[$dayName])) {
<span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
}
<pre><code>$startTime = <span class="hljs-keyword">$this</span>->now->copy()->setTimeFromTimeString(<span class="hljs-keyword">$this</span>->businessHoursSchedule[$dayName][<span class="hljs-string">'start'</span>]);
<span class="hljs-keyword">return</span> $startTime->isFuture();
}
protected function getNextStartDateTimeUsingDefaultSchedule(): Carbon
{
$baseCarbon = $this->now->copy();
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">$this</span>->now->hour >= <span class="hljs-number">9</span>) {
$baseCarbon->nextWeekday();
}
<span class="hljs-keyword">return</span> $baseCarbon
->setTimeFromTimeString(<span class="hljs-string">'09:00'</span>)
->setTimeZone(config(<span class="hljs-string">'app.timezone'</span>));
}
protected function getStartDateTimeForToday(): Carbon
{
$dayName = $this->now->dayName;
<span class="hljs-keyword">return</span> <span class="hljs-keyword">$this</span>->now->copy()
->setTimeFromTimeString(<span class="hljs-keyword">$this</span>->businessHoursSchedule[$dayName][<span class="hljs-string">'start'</span>])
->setTimeZone(config(<span class="hljs-string">'app.timezone'</span>));
}
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.
<span class="hljs-keyword">namespace</span> <span class="hljs-title">Tests</span><span class="hljs-title">Unit<span class="hljs-title">Services<span class="hljs-title">ValueObjects;
<p><span class="hljs-keyword">use</span> <span class="hljs-title">App</span><span class="hljs-title">Domain<span class="hljs-title">Team<span class="hljs-title">Models<span class="hljs-title">Team;
<span class="hljs-keyword">use</span> <span class="hljs-title">Carbon</span><span class="hljs-title">Carbon;
<span class="hljs-keyword">use</span> <span class="hljs-title">Spatie</span><span class="hljs-title">TestTime<span class="hljs-title">TestTime;
<span class="hljs-keyword">use</span> <span class="hljs-title">Tests</span><span class="hljs-title">TestCase;</p>
<p><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BusinessHoursTest</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">TestCase</span>
</span>{
<span class="hljs-keyword">protected</span> Team $team;</p>
<pre><code><span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setUp</span><span class="hljs-params">()</span>: <span class="hljs-title">void</span>
</span>{
<span class="hljs-keyword">parent</span>::setUp();
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 00:00:00'</span>);
<span class="hljs-keyword">$this</span>->team = Team::factory()->create([
<span class="hljs-string">'timezone'</span> => <span class="hljs-string">'UTC'</span>,
<span class="hljs-string">'business_hours'</span> =>
[
<span class="hljs-string">'Monday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
<span class="hljs-string">'Tuesday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
<span class="hljs-string">'Wednesday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
<span class="hljs-string">'Thursday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
<span class="hljs-string">'Friday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
],
]);
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">it_can_determine_the_start_of_the_next_business_day</span><span class="hljs-params">()</span>
</span>{
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 00:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-01 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'today at 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->humanReadableNextStartDateTime());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 09:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-02 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'tomorrow at 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->humanReadableNextStartDateTime());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-05 08:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-05 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'today at 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->humanReadableNextStartDateTime());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-05 18:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-08 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'Monday at 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->humanReadableNextStartDateTime());
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">it_respects_the_time_zone_of_the_team</span><span class="hljs-params">()</span>
</span>{
$team = Team::factory()->create([
<span class="hljs-string">'timezone'</span> => <span class="hljs-string">'CST'</span>,
<span class="hljs-string">'business_hours'</span> =>
[
<span class="hljs-string">'Monday'</span> => [<span class="hljs-string">'start'</span> => <span class="hljs-string">'09:00'</span>, <span class="hljs-string">'end'</span> => <span class="hljs-string">'17:00'</span>],
],
]);
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 14:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-01 15:00'</span>, $team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'today at 09:00'</span>, $team->businessHours()->humanReadableNextStartDateTime());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 19:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-08 15:00'</span>, $team->businessHours()->getNextStartDateTimeInAppTimeZone());
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'Monday at 09:00'</span>, $team->businessHours()->humanReadableNextStartDateTime());
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">when_no_schedule_is_set_it_will_default_to_nine_to_five_week_days</span><span class="hljs-params">()</span>
</span>{
<span class="hljs-keyword">$this</span>->team->update([<span class="hljs-string">'business_hours'</span> => []]);
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-01 00:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-01 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-02 00:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-02 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-02 13:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-03 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
TestTime::freeze(<span class="hljs-string">'Y-m-d H:i:s'</span>, <span class="hljs-string">'2021-02-05 13:00:00'</span>);
<span class="hljs-keyword">$this</span>->assertCarbon(<span class="hljs-string">'2021-02-08 09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->getNextStartDateTimeInAppTimeZone());
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">it_can_determine_that_is_open_on_a_given_day</span><span class="hljs-params">()</span>
</span>{
<span class="hljs-keyword">$this</span>->assertTrue(<span class="hljs-keyword">$this</span>->team->businessHours()->openOnDay(<span class="hljs-string">'Monday'</span>));
<span class="hljs-keyword">$this</span>->assertFalse(<span class="hljs-keyword">$this</span>->team->businessHours()->openOnDay(<span class="hljs-string">'Sunday'</span>));
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">it_can_get_the_start_hour_for_a_given_day</span><span class="hljs-params">()</span>
</span>{
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'09:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->startHour(<span class="hljs-string">'Monday'</span>));
<span class="hljs-keyword">$this</span>->assertNull(<span class="hljs-keyword">$this</span>->team->businessHours()->startHour(<span class="hljs-string">'Sunday'</span>));
}
<span class="hljs-comment">/** <span class="hljs-doctag">@test</span> */</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">it_can_get_the_end_hour_for_a_given_day</span><span class="hljs-params">()</span>
</span>{
<span class="hljs-keyword">$this</span>->assertEquals(<span class="hljs-string">'17:00'</span>, <span class="hljs-keyword">$this</span>->team->businessHours()->endHour(<span class="hljs-string">'Monday'</span>));
<span class="hljs-keyword">$this</span>->assertNull(<span class="hljs-keyword">$this</span>->team->businessHours()->endHour(<span class="hljs-string">'Sunday'</span>));
}
<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">assertCarbon</span><span class="hljs-params">(string $expectedDateTime, Carbon $actualCarbon)</span>: <span class="hljs-title">void</span>
</span>{
<span class="hljs-keyword">$this</span>->assertEquals($expectedDateTime, $actualCarbon->format(<span class="hljs-string">'Y-m-d H:i'</span>));
}
}
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.