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.

Dashboard Date Comparisons

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.

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'));
💡
Sometimes you don't need to create Carbon instances manually because Laravel can do this for you, like with Model casting.

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');
    }
}
💡
Our calculate method is returning an array here, but this would be a great place to return a DTO if you wanted to be able to declare a return type. There's a package called spatie/laravel-data that would fit great here. A plain ol' php class would work just fine also.

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.