Supporting Time Ranges for Manually Built Nova Value Metrics in Laravel
Tags: laravel, php, tutorial, nova
Published: 2020-07-06 14:07:02

## 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.](#solution)

## Quick Links
* [Background](#background) (What does the metric class look like?)
  * [Class Structure](#structure)
  * [The Calculate Method](#calculate)
  * [Manually Building Results Values](#manual-results)
* [Problem](#problem) (Where I got stuck)
* [How I Solved: Looking Under the Hood](#source-code) (How I got unstuck)
* [My Workaround](#solution) (my code)


## Background <a name="background"></a>
[Metric cards in Nova](https://nova.laravel.com/docs/3.0/metrics/defining-metrics.html) 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 <a name="structure"></a>
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](https://laravel.com/docs/master/artisan#introduction). 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 <a name="calculate"></a>

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:

```php
use App\User;

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

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: 
```php
public function calculate(NovaRequest $request) {
    $this->count( $request, User::where('account_status', 'active'))
}
```
The metric looks pretty good on the frontend out of the box, too:

![New User Metric Screenshot](https://dev-to-uploads.s3.amazonaws.com/i/1hrdxlbsmfl4xg6a9s1p.png)

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 <a name="manual-results"></a>
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:

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

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

```

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 <a name="problem"></a>
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:

```php
$result = DB::table('user_badges') ->count();
```

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](https://dev-to-uploads.s3.amazonaws.com/i/pgy879kz42969bckkij8.png)

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 <a name="source-code"></a>
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:

```php
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);
    }
```
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 <a name="solution"></a>

```php
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);
}
```

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](https://dev-to-uploads.s3.amazonaws.com/i/d6vr78jystsis39rx26a.png)