On a personal project, I have recently been working through showing a chart with total monthly views for movies and TV episodes over the last 12 months.

When implementing the process of calculating the total views, I came up with a few options in getting the data from the backend to the chart.

TLDR: See Option 3

Option 1 - Database

First impulse would be to use group by on your database relationship and select a custom set of columns. You can use the count() function get you the necessary total views and the date_format() to generate labels for each month.

/**
 * Get the views for the movie.
 */
public function views()  
{
    $lastYear = Carbon::now()
        ->addMonth()
        ->startOfMonth()
        ->subYear();

    return $this->hasMany(MovieView::class)
        ->where('created_at', '>=', $lastYear)
        ->selectRaw(
            'movie_id, '.
            'COUNT(movie_id) as views, '.
            'DATE_FORMAT(created_at, \'%m/%y\') as label, '.
            'YEAR(created_at) as year, '.
            'MONTH(created_at) as month'
        )
        ->groupBy('year', 'month')
        ->orderBy('year', 'desc')
        ->orderBy('month', 'desc');
}

This method obviously has downsides:

  1. The grouped columns are set as though they are attributes of a MovieView model. This is not an ideal option since your models should always stay clean.

  2. You do not have any representation of past months with zero viewings.

Option 2 - Collections

You could move back to loading the actual activity records from the database. Then you can create an additional method on the model to generate a collection of all past 12 month totals.

I also brought in the League's Period package because it allows for ease in working with timespans, and solves the problem of creating those pesky zero total months.

use League\Period\Period;  
// ...
/**
 * Get the views for the movie.
 */
public function views()  
{
    $lastYear = Carbon::now()
        ->addMonth()
        ->startOfMonth()
        ->subYear();

    return $this->hasMany(MovieView::class)
        ->where('created_at', '>=', $lastYear);
}

/**
 * Get the monthly views for the movie.
 */
public function monthlyViews()  
{
    // Group and map views to a collection of totals
    $views = $this->views
        ->groupBy(function($view) {
            return $view->created_at->format('m/y');
        })
        ->map(function ($views) {
            $view = $views->first();

            return collect([
                'id' => $view->created_at->format('m-y-').
                        $view->movie_uuid,
                'movie_id' => $view->movie_uuid,
                'total' => $views->count(),
                'label' => $view->created_at->format('m/y'),
            ]);
        });

    $months = $this->collectPastTwelveMonths();

    // Merge the views per month into all months
    return $months->merge($views);
}

/**
 * Generate zero totals for the past twelve months.
 */
protected function collectPastTwelveMonths()  
{
    $months = collect();

    // Create a Period of the last 12 months
    $nextMonth = Carbon::now()
        ->addMonth()
        ->startOfMonth();
    $lastYear = $nextMonth->copy()
        ->subYear();
    $period = new Period(
        $lastYear, 
        $nextMonth
    );

    // Create zero count records for all months
    foreach ($period->getDatePeriod('1 MONTH') as $month) {
        $months->put(
            $month->format('m/y'), 
            collect([
                'id' => $month->format('m-y-').$this->uuid,
                'movie_uuid' => $this->uuid,
                'total' => 0,
                'label' => $month->format('m/y'),
            ])
        );
    }

    return $months;
}

This is a potentially a good option to go with because it allows you to generate a new collection of totals without making your database models dirty.

Option 3 - Frontend

First impulse, you could simply send all view records as a resource to your frontend. You would have a client's browser calculate the totals from all of the results using the Array.prototype map/reduce functions. However, then you have a variable amount of data being sent in the response. In the case of a personal media server, this would not be a major issue.

However, to reduce data in the response (i.e. 12 or less resource records), you could go with a variation of Option 2 where only the months with totals are sent from the backend and the totals collection is built on the frontend.

/**
 * Get the monthly views for the movie.
 */
public function monthlyViews()  
{
    // Group and map views to a collection of totals
    return $this->views
        ->groupBy(function($view) {
            return $view->created_at->format('m/y');
        })
        ->map(function ($views) {
            $view = $views->first();

            return collect([
                'id' => $view->created_at->format('m-y-').
                        $view->movie_uuid,
                'movie_id' => $view->movie_uuid,
                'total' => $views->count(),
                'label' => $view->created_at->format('m/y'),
            ]);
        });
}

On the frontend, I use a package called moment and a plugin for it called moment-range to generate the zero total months. I use an iterator function on the range to merge the totals coming from the API. Here is a vuex getter that I've written to generate the necessary array for my chart.

monthlyViews: ({ movies, views }) => {  
  let movie = movies.all
    .find(m => m.id === movies.currentID)
  let movieViews = movie.relationships
    .views.data.map(({ id }) => 
      views.movies.find(m => m.id === id))

  // Create a moment.js range of the past 12 months
  let currentMonth = moment().startOf('month')
  let lastYear = currentMonth.clone().subtract(11, 'M')
  let range = moment.range(lastYear, currentMonth)
  let months = []

  // Create records for past 12 months
  range.by('months', function(month) {
    let label = month.format('MM/YY')
    let view = movieViews.find(
      v => v.attributes.label === label
    )
    months.push({
      id: label,
      total: view ? view.attributes.total : 0,
    })
  })

  return months
}