Supporting Time Ranges for Manually Built Nova Value Metrics in Laravel

jacklowrie

Jack Lowrie

Posted on July 6, 2020

Supporting Time Ranges for Manually Built Nova Value Metrics in Laravel

tl;dr

Manually built metrics in Nova don't support the time-range dropdown that the Nova helper functions support, and those helper functions don't handle many<->many relationships well (at least not the way I needed).

To get around this, you can use two protected functions from the parent Value class: currentRange() and previousRange(). Just don't forget to pass in the current admin's timezone!

Just looking for a code snippet? Jump to the end.

Quick Links

Background

Metric cards in Nova can be produced rapidly, are straightforward to plan and explain, and provide high-impact 'quick wins' when using Nova to build out a dashboard for a Laravel application.

Class Structure

Value metrics generally consist of four methods: calculate(), ranges(), cacheFor(), and uriKey(). We're only concerned with the first two.

The ranges method ultimately populates the dropdown on the frontend. It returns an array of time ranges your metric will support and is pre-populated by Artisan. If you don't want to support ranges, you can remove this method; it's actually optional, and you can add/remove ranges from the returned array as you see fit.

The Calculate Method

Metrics are centered around that calculate method, which can frequently be a one-liner. The parent class for ranged metrics includes methods for the most frequent queries you'd want to make (count, sum, max, min, and average), so as long as you're creating a metric for an eloquent model -- say, users -- Nova (and Artisan) will do most of the work for you. The calculate method for metric measuring how your app is growing might look like this:

use App\User;

public function calculate(NovaRequest $request) {
    return $this->count( $request, User::class );
}
Enter fullscreen mode Exit fullscreen mode

and would return the number of users your app has acquired (over a given range), along with a percent increase or decrease compared to the previous period. You can also further specify your query for users matching a particular set of rules (for example, if your users had an account_status, you could alter the return statement like so:

public function calculate(NovaRequest $request) {
    $this->count( $request, User::where('account_status', 'active'))
}
Enter fullscreen mode Exit fullscreen mode

The metric looks pretty good on the frontend out of the box, too:

New User Metric Screenshot

Note the range dropdown in the upper right; this is where the ranges() method comes in -- you can choose what options appear in that dropdown by setting them in that method (or, if you're happy with the default options that are pre-populated, don't worry about it!). The actual implementation of this feature seems to be Nova magic.

This is great for simple metrics like counting how many active users there are in your application, or counting the number of posts that were published, but what if you want to report on a metric that isn't covered by Nova helper functions?

Manually Building Results Values

Manually building results is equally straightforward at first glance. Nova metrics support manually building result values. It even supports including reporting previous result values, so long as you calculate them yourself:

public function calculate(NovaRequest $request) {
    $result = //some query
    $previous = //another query (optional)

    return $this->result($result)->previous($previous);
}

Enter fullscreen mode Exit fullscreen mode

This works well for reporting a value in one time range, but when you build your results manually, you lose the ability to dynamically compare the metric across different time ranges (see the dropdown in the upper right of the screenshot above). What if you need that dropdown?

Problem

We needed to return a count of the records in the pivot table (in this case, we have a badges table and users, and need to report the total number of badges earned (by all users, over a time range).

Building that result manually is easy enough: We can use Laravel's database facade to count the records in the user_badges pivot table:

$result = DB::table('user_badges') ->count();
Enter fullscreen mode Exit fullscreen mode

We can even compare to a previous value if we calculate it ourselves, but it won't connect to the ranges() method, so this only works if we hardcode a fixed time range. What about that dropdown?

Total Badges Earned Hardcoded Time Range

I was unable to find anything in the documentation on how to handle ranges if building the result values manually, especially to support the dropdown that seems to be Nova magic for straightforward metrics. Fortunately, we can look at how Nova's metric helper functions are written for ideas. There is an answer in the source code!

Looking Under the Hood: How Nova implements Metrics classes

The metrics classes we generate with Artisan extend the abstract class Value. This class contains the helper methods you use for simple metrics. There isn't a whole lot happening in these helpers, however. They're all one-liners that call a protected method. It's that protected method, aggregate(), that's of interest to us:

protected function aggregate($request, $model, $function, $column = null, $dateColumn = null)
    {
        $query = $model instanceof Builder ? $model : (new $model)->newQuery();

        $column = $column ?? $query->getModel()->getQualifiedKeyName();

        $timezone = Nova::resolveUserTimezone($request) ?? $request->timezone;

        $previousValue = round(with(clone $query)->whereBetween(
            $dateColumn ?? $query->getModel()->getCreatedAtColumn(),
            $this->previousRange($request->range, $timezone)
        )->{$function}($column), $this->precision);

        return $this->result(
            round(with(clone $query)->whereBetween(
                $dateColumn ?? $query->getModel()->getCreatedAtColumn(),
                $this->currentRange($request->range, $timezone)
            )->{$function}($column), $this->precision)
        )->previous($previousValue);
    }
Enter fullscreen mode Exit fullscreen mode

This method may look like a lot, but at a bird's eye view, it's doing something that's already documented, both in this post and in Nova's official docs - it's manually building a result and a previous value, and returning them! To do this, it's using two more protected helper methods: currentRange() and previousRange(). So when we manually build results in our metrics class, we're overriding these!

It follows that we can do the same in our class to support time ranges. However, that method takes two inputs, which we must remember to pass in ourselves: the timezone of the current user (conveniently calculated in the third line of the aggregate method above) and the time range (which even more conveniently is passed in as part of the request).

So, the strategy is to use these two helper functions to help manually build our result.

My Final Calculate Function

public function calculate(NovaRequest $request) {
  $timezone = Nova::resolveUserTimezone($request) ?? $request->timezone;

  $result = DB::table('user_badges')
      ->whereBetween( 'created_at', $this->currentRange($request->range, $timezone) )
      ->count();
  $previous = DB::table('user_badges')
      ->whereBetween( 'created_at', $this->previousRange($request->range, $timezone) )
      ->count();

  return $this->result($result)->previous($previous);
}
Enter fullscreen mode Exit fullscreen mode

This approach, in combination with the built-in ranges() method, successfully counts the number of records in the pivot table that were created within the time range selected on the front end.

Total Badges Earned Complete

💖 💪 🙅 🚩
jacklowrie
Jack Lowrie

Posted on July 6, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related