Dashboard Date Comparisons
Dashboards like Stripe's, let you see values compared to a previous time period. Let's take a quick look at how we might begin to integrate this kind of functionality into your Laravel app's dashboard.
Dashboards often show metrics compared to a previous time period in order to give context and see how the current values compare to a baseline. This can be helpful in identifying trends and changes over time, and can also help highlight areas that may need attention or improvement.
A date range object
We need an object to encapsulate some range-transforming logic. A simple, bare PHP class that accepts a start
date and end
date will do nicely.
class DateRange
{
public function __construct(
public Carbon $start,
public Carbon $end,
) {
$this->start->startOfDay();
$this->end->endOfDay();
}
}
I like to force the start date to represent midnight of that date, and the end date to represent the entire end date (one second before the next day). Doing this makes sure that when your user picks and date range, we get back everything and between those dates.
Methods for related ranges
Our DateRange
object can house all the methods for calculating previous ranges based on our current range. Because I'm using Carbon
here, we don't want to mutate our original range, so we create copies and pass them to a new instance.
class DateRange
{
// __construct()
public function previousPeriod(): static
{
$diff = $this->start->diffInSeconds($this->end);
return new static(
$this->start->copy()->subSeconds($diff)->subSecond(),
$this->end->copy()->subSeconds($diff)->subSecond()
);
}
public function previousMonth(): static
{
return new static(
$this->start->copy()->subMonth(),
$this->end->copy()->subMonth()
);
}
public function previousQuarter(): static
{
return new static(
$this->start->copy()->subQuarter(),
$this->end->copy()->subQuarter()
);
}
}
Preparing range for Laravel
Laravel's whereBetween method will accept a tuple as its second argument. Providing a consistent way of getting this is just a tiny quality of life improvement.
class DateRange implements Arrayable
{
// __construct()
// previousPeriod(): static
public function toArray(): array
{
return [$this->start, $this->end];
}
}
Then use like this:
$range = new DateRange(
new Carbon(request('start')),
new Carbon(request('end')),
);
User::whereBetween('created_at', $range->toArray())
->count();
Instantiation helper
Creating a new instance of a DateRange
is a little verbose when having to new up two instances of Carbon
along with it.
class DateRange
{
// __construct()
// previousPeriod(): static
// previousMonth(): static
// previousQuarter(): static
// toArray(): array
public static function parse($start, $end): static
{
return new static(
Carbon::parse($start),
Carbon::parse($end),
);
}
}
$range = DateRange::parse(request('start'), request('end'));
Human friendly dates
For human-friendly dates, we can use the __toString()
magic method so that anytime we cast this object to to a string, it looks nice and readable.
class DateRange
{
// __construct()
// previousPeriod(): static
// previousMonth(): static
// previousQuarter(): static
// toArray(): array
// parse()
public function __toString()
{
return "{$this->start->shortEnglishMonth} {$this->start->day} - {$this->end->shortEnglishMonth} {$this->end->day}";
}
}
Rendering the form
To render the form view we'll need to know the "current" date range and the other date range options for comparison.
$start = request('start', '7 days ago');
$end = request('end', 'now');
$current = DateRange::parse($start, $end);
$comparisons = [
'previous-period' => $current->previousPeriod(), // between 14 and 7 days ago
'previous-month' => $current->previousMonth(), // same 7 day range last month
'previous-quarter' => $current->previousQuarter(), // same 7 day range last quarter
];
Casting the comparison date ranges to strings inside the view allows us to render our ranges with ease.
<select name="comparison">
<option value="previous-period">
Previous Period {{ $comparisons['previous-period'] }}
</option>
<option value="previous-month">
Previous Month {{ $comparisons['previous-month'] }}
</option>
<option value="previous-quarter">
Previous Quarter {{ $comparisons['previous-quarter'] }}
</option>
<option value="custom">
Custom
</option>
<option>
No Comparison
</option>
</select>
Metric object
I love throwing this kind of metric calculation logic into a pure PHP metric class in order to group all this logic in one consistent place. It's really convenient when you decide to implement things like caching later on.
abstract class OverTimeMetric
{
public ?Carbon $current = null;
public ?Carbon $comparison = null;
public function during(DateRange $dateRange): static
{
$this->current = $dateRange;
return $this;
}
public function comparedTo(DateRange $dateRange): static
{
$this->comparison = $dateRange;
return $this;
}
abstract public function calculate();
}
class NewMembers extends OverTimeMetric
{
public function calculate()
{
$count = User::whereBetween(
'created_at',
$this->current->toArray()
)->count();
$comparison = User::whereBetween(
'created_at',
$this->comparison->toArray()
)->count();
return compact('current', 'comparison');
}
}
Handling the request
The form request will have a current date range and the desired comparison option. From there we'll figure out what the comparison range is, pass it to our metric object and sent it to the view.
$currentPeriod = DateRange::parse(
request('start'),
request('end')
);
$comparisonPeriod = match (request('comparison')) {
'previous-period' => $current->previousPeriod(),
'previous-month' => $current->previousMonth(),
'previous-quarter' => $current->previousQuarter(),
'custom' => DateRange::parse(
request('custom-start'),
request('custom-end')
),
};
return view('dashboard', [
'newMembers' => (new NewMembers)
->during($currentPeriod)
->comparedTo($comparisonPeriod)
->calculate(),
]);
Conclusion
There's a lot more that we could dive into here, but this should provide a good baseline to get you started. I've used this kind of strategy on enterprise grade apps with success.
The DateRange
object can be expanded with more comparisons and casting helpers when needed. When we build more metrics for our dashboard, extending the OverTimeMetric
object is a breeze.