Make the various "Overview" reports into API calls and convert page to Vue.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-05-03 10:40:21 -05:00
parent 98b696e06e
commit 974c9b39bf
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
12 changed files with 754 additions and 518 deletions

View File

@ -549,4 +549,10 @@ return [
'require' => ['vue-component-common', 'bootstrap-vue', 'moment'],
// Auto-managed by Assets
],
'Vue_StationsReportsOverview' => [
'order' => 10,
'require' => ['vue-component-common', 'bootstrap-vue', 'chartjs'],
// Auto-managed by Assets
],
];

View File

@ -393,6 +393,26 @@ return function (App $app) {
}
)->add(new Middleware\Permissions(Acl::STATION_MEDIA, true));
$group->group(
'/reports',
function (RouteCollectorProxy $group) {
$group->get(
'/overview/charts',
Controller\Api\Stations\Reports\Overview\ChartsAction::class
)->setName('api:stations:reports:overview-charts');
$group->get(
'/overview/best-and-worst',
Controller\Api\Stations\Reports\Overview\BestAndWorstAction::class
)->setName('api:stations:reports:best-and-worst');
$group->get(
'/overview/most-played',
Controller\Api\Stations\Reports\Overview\MostPlayedAction::class
)->setName('api:stations:reports:most-played');
}
)->add(new Middleware\Permissions(Acl::STATION_REPORTS, true));
$group->get(
'/streamers/schedule',
Controller\Api\Stations\StreamersController::class . ':scheduleAction'

View File

@ -0,0 +1,59 @@
<template>
<canvas ref="canvas">
<slot></slot>
</canvas>
</template>
<script>
import _ from 'lodash';
export default {
name: 'DayOfWeekChart',
inheritAttrs: true,
props: {
options: Object,
data: Array,
labels: Array
},
data () {
return {
_chart: null
};
},
mounted () {
Chart.platform.disableCSSInjection = true;
this.renderChart();
},
methods: {
renderChart () {
const defaultOptions = {
type: 'pie',
data: {
labels: this.labels,
datasets: this.data
},
options: {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
}
}
};
if (this._chart)
this._chart.destroy();
let chartOptions = _.defaultsDeep(_.clone(this.options), defaultOptions);
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
}
},
beforeDestroy () {
if (this._chart) {
this._chart.destroy();
}
}
};
</script>

View File

@ -0,0 +1,78 @@
<template>
<canvas ref="canvas">
<slot></slot>
</canvas>
</template>
<script>
export default {
name: 'HourChart',
inheritAttrs: true,
props: {
options: Object,
data: Array,
labels: Array
},
data () {
return {
_chart: null
};
},
mounted () {
Chart.platform.disableCSSInjection = true;
this.renderChart();
},
methods: {
renderChart () {
const defaultOptions = {
type: 'bar',
data: {
labels: this.labels,
datasets: this.data
},
options: {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
},
scales: {
xAxes: [
{
scaleLabel: {
display: true,
labelString: this.$gettext('Hour')
}
}
],
yAxes: [
{
scaleLabel: {
display: true,
labelString: this.$gettext('Listeners')
},
ticks: {
min: 0
}
}
]
}
}
};
if (this._chart) this._chart.destroy();
let chartOptions = _.defaultsDeep(_.clone(this.options), defaultOptions);
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
}
},
beforeDestroy () {
if (this._chart) {
this._chart.destroy();
}
}
};
</script>

View File

@ -0,0 +1,254 @@
<template>
<div id="reports-overview">
<section class="card mb-4" role="region">
<b-overlay variant="card" :show="chartsLoading">
<div class="card-body py-5" v-if="chartsLoading">
&nbsp;
</div>
<b-tabs pills card lazy v-else>
<b-tab :title="langListenersByDay" active>
<time-series-chart style="width: 100%;" :data="chartsData.daily.metrics">
<span v-html="chartsData.daily.alt"></span>
</time-series-chart>
</b-tab>
<b-tab :title="langListenersByDayOfWeek">
<day-of-week-chart style="width: 100%;" :data="chartsData.day_of_week.metrics" :labels="chartsData.day_of_week.labels">
<span v-html="chartsData.day_of_week.alt"></span>
</day-of-week-chart>
</b-tab>
<b-tab :title="langListenersByHour">
<hour-chart style="width: 100%;" :data="chartsData.hourly.metrics" :labels="chartsData.hourly.labels">
<span v-html="chartsData.hourly.alt"></span>
</hour-chart>
</b-tab>
</b-tabs>
</b-overlay>
</section>
<div class="row">
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_best_songs">Best Performing Songs</translate>
<small>
<translate key="reports_overview_timeframe">in the last 48 hours</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.best">
<td class="text-center text-success">
<icon icon="keyboard_arrow_up"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_worst_songs">Worst Performing Songs</translate>
<small>
<translate key="reports_overview_timeframe">in the last 48 hours</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_change">Change</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in bestAndWorst.best">
<td class="text-center text-danger">
<icon icon="keyboard_arrow_down"></icon>
{{ row.stat_delta }}
<br>
<small>{{ row.stat_start }} to {{ row.stat_end }}</small>
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<section class="card" role="region">
<div class="card-header bg-primary-dark">
<h3 class="card-title">
<translate key="reports_overview_most_played">Most Played Songs</translate>
<small>
<translate key="reports_overview_most_played_timeframe">in the last month</translate>
</small>
</h3>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="10%">
<col width="90%">
</colgroup>
<thead>
<tr>
<th>
<translate key="reports_overview_col_plays">Plays</translate>
</th>
<th>
<translate key="reports_overview_col_song">Song</translate>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mostPlayed">
<td class="text-center">
{{ row.num_plays }}
</td>
<td>
<span v-html="getSongText(row.song)"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</div>
</template>
<script>
import TimeSeriesChart from '../../Common/TimeSeriesChart';
import DataTable from '../../Common/DataTable';
import axios from 'axios';
import Icon from '../../Common/Icon';
import Avatar, { avatarProps } from '../../Common/Avatar';
import DayOfWeekChart from './DayOfWeekChart';
import HourChart from './HourChart';
export default {
components: { HourChart, DayOfWeekChart, Avatar, Icon, DataTable, TimeSeriesChart },
mixins: [avatarProps],
props: {
chartsUrl: String,
bestAndWorstUrl: String,
mostPlayedUrl: String
},
data () {
return {
chartsLoading: true,
chartsData: {
daily: {
metrics: [],
alt: ''
},
day_of_week: {
labels: [],
metrics: [],
alt: ''
},
hourly: {
labels: [],
metrics: [],
alt: ''
}
},
bestAndWorstLoading: true,
bestAndWorst: {
best: [],
worst: []
},
mostPlayedLoading: true,
mostPlayed: []
};
},
computed: {
langListenersByDay () {
return this.$gettext('Listeners by Day');
},
langListenersByDayOfWeek () {
return this.$gettext('Listeners by Day of Week');
},
langListenersByHour () {
return this.$gettext('Listeners by Hour');
}
},
created () {
moment.tz.setDefault('UTC');
axios.get(this.chartsUrl).then((response) => {
this.chartsData = response.data;
this.chartsLoading = false;
}).catch((error) => {
console.error(error);
});
axios.get(this.bestAndWorstUrl).then((response) => {
this.bestAndWorst = response.data;
this.bestAndWorstLoading = false;
}).catch((error) => {
console.error(error);
});
axios.get(this.mostPlayedUrl).then((response) => {
this.mostPlayed = response.data;
this.mostPlayedLoading = false;
}).catch((error) => {
console.error(error);
});
},
methods: {
getSongText (song) {
if (song.title !== '') {
return '<b>' + song.title + '</b><br>' + song.artist;
}
return song.text;
}
}
};
</script>

View File

@ -18,7 +18,8 @@ module.exports = {
StationsPlaylists: './vue/Stations/Playlists.vue',
StationsProfile: './vue/Stations/Profile.vue',
StationsQueue: './vue/Stations/Queue.vue',
StationsStreamers: './vue/Stations/Streamers.vue'
StationsStreamers: './vue/Stations/Streamers.vue',
StationsReportsOverview: './vue/Stations/Reports/Overview.vue'
},
resolve: {
extensions: ['*', '.js', '.vue', '.json']

View File

@ -0,0 +1,77 @@
<?php
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class BestAndWorstAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
Entity\Repository\SettingsRepository $settingsRepo,
Entity\ApiGenerator\SongApiGenerator $songApiGenerator
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
$settings = $settingsRepo->readSettings();
$analytics_level = $settings->getAnalytics();
if ($analytics_level === Entity\Analytics::LEVEL_NONE) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
/* Song "Deltas" (Changes in Listener Count) */
$songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp();
// Get all songs played in timeline.
$baseQuery = $em->createQueryBuilder()
->select('sh')
->from(Entity\SongHistory::class, 'sh')
->where('sh.station = :station')
->setParameter('station', $station)
->andWhere('sh.timestamp_start >= :timestamp')
->setParameter('timestamp', $songPerformanceThreshold)
->andWhere('sh.listeners_start IS NOT NULL')
->andWhere('sh.timestamp_end != 0')
->setMaxResults(5);
$rawStats = [
'best' => $baseQuery->orderBy('sh.delta_total', 'DESC')
->getQuery()->getArrayResult(),
'worst' => $baseQuery->orderBy('sh.delta_total', 'ASC')
->getQuery()->getArrayResult(),
];
$stats = [];
$baseUrl = $request->getRouter()->getBaseUrl(true);
foreach ($rawStats as $category => $rawRows) {
$stats[$category] = array_map(
function ($row) use ($songApiGenerator, $station, $baseUrl) {
$song = ($songApiGenerator)(Entity\Song::createFromArray($row), $station);
$song->resolveUrls($baseUrl);
return [
'song' => $song,
'stat_start' => $row['listeners_start'] ?? 0,
'stat_end' => $row['listeners_end'] ?? 0,
'stat_delta' => $row['delta_total'],
];
},
$rawRows
);
}
return $response->withJson($stats);
}
}

View File

@ -0,0 +1,176 @@
<?php
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Psr\Http\Message\ResponseInterface;
class ChartsAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\SettingsRepository $settingsRepo,
Entity\Repository\AnalyticsRepository $analyticsRepo,
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
$settings = $settingsRepo->readSettings();
$analytics_level = $settings->getAnalytics();
if ($analytics_level === Entity\Analytics::LEVEL_NONE) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
/* Statistics */
$statisticsThreshold = CarbonImmutable::parse('-1 month', $station_tz);
$stats = [];
// Statistics by day.
$dailyStats = $analyticsRepo->findForStationAfterTime(
$station,
$statisticsThreshold
);
$daily_chart = new \stdClass();
$daily_chart->label = __('Listeners by Day');
$daily_chart->type = 'line';
$daily_chart->fill = false;
$daily_alt = [
'<p>' . $daily_chart->label . '</p>',
'<dl>',
];
$daily_averages = [];
$days_of_week = [];
foreach ($dailyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$avg_row = new \stdClass();
$avg_row->t = $statTime->getTimestamp() * 1000;
$avg_row->y = round($stat['number_avg'], 2);
$daily_averages[] = $avg_row;
$row_date = $statTime->format('Y-m-d');
$daily_alt[] = '<dt><time data-original="' . $avg_row->t . '">' . $row_date . '</time></dt>';
$daily_alt[] = '<dd>' . $avg_row->y . ' ' . __('Listeners') . '</dd>';
$day_of_week = (int)$statTime->format('N') - 1;
$days_of_week[$day_of_week][] = $stat['number_avg'];
}
$daily_alt[] = '</dl>';
$daily_chart->data = $daily_averages;
$stats['daily'] = [
'metrics' => [
$daily_chart,
],
'alt' => implode('', $daily_alt),
];
$day_of_week_chart = new \stdClass();
$day_of_week_chart->label = __('Listeners by Day of Week');
$day_of_week_alt = [
'<p>' . $day_of_week_chart->label . '</p>',
'<dl>',
];
$days_of_week_names = [
__('Monday'),
__('Tuesday'),
__('Wednesday'),
__('Thursday'),
__('Friday'),
__('Saturday'),
__('Sunday'),
];
$day_of_week_stats = [];
foreach ($days_of_week_names as $day_index => $day_name) {
$day_totals = $days_of_week[$day_index] ?? [0];
$stat_value = round(array_sum($day_totals) / count($day_totals), 2);
$day_of_week_stats[] = $stat_value;
$day_of_week_alt[] = '<dt>' . $day_name . '</dt>';
$day_of_week_alt[] = '<dd>' . $stat_value . ' ' . __('Listeners') . '</dd>';
}
$day_of_week_alt[] = '</dl>';
$day_of_week_chart->data = $day_of_week_stats;
$stats['day_of_week'] = [
'labels' => $days_of_week_names,
'metrics' => [
$day_of_week_chart,
],
'alt' => implode('', $day_of_week_alt),
];
// Statistics by hour.
$hourlyStats = $analyticsRepo->findForStationAfterTime(
$station,
$statisticsThreshold,
Entity\Analytics::INTERVAL_HOURLY
);
$totals_by_hour = [];
foreach ($hourlyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$hour = (int)$statTime->hour;
$totals_by_hour[$hour][] = $stat['number_avg'];
}
$hourly_labels = [];
$hourly_chart = new \stdClass();
$hourly_chart->label = __('Listeners by Hour');
$hourly_rows = [];
$hourly_alt = [
'<p>' . $hourly_chart->label . '</p>',
'<dl>',
];
for ($i = 0; $i < 24; $i++) {
$hourly_labels[] = $i . ':00';
$totals = $totals_by_hour[$i] ?? [0];
$stat_value = round(array_sum($totals) / count($totals), 2);
$hourly_rows[] = $stat_value;
$hourly_alt[] = '<dt>' . $i . ':00</dt>';
$hourly_alt[] = '<dd>' . $stat_value . ' ' . __('Listeners') . '</dd>';
}
$hourly_alt[] = '</dl>';
$hourly_chart->data = $hourly_rows;
$stats['hourly'] = [
'labels' => $hourly_labels,
'metrics' => [
$hourly_chart,
],
'alt' => implode('', $hourly_alt),
];
return $response->withJson($stats);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Controller\Api\Stations\Reports\Overview;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class MostPlayedAction
{
public function __invoke(
ServerRequest $request,
Response $response,
EntityManagerInterface $em,
Entity\Repository\SettingsRepository $settingsRepo,
Entity\ApiGenerator\SongApiGenerator $songApiGenerator
): ResponseInterface {
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
// Get current analytics level.
$settings = $settingsRepo->readSettings();
$analytics_level = $settings->getAnalytics();
if ($analytics_level === Entity\Analytics::LEVEL_NONE) {
return $response->withStatus(400)
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
}
$statisticsThreshold = CarbonImmutable::parse('-1 month', $station_tz)
->getTimestamp();
/* Song "Deltas" (Changes in Listener Count) */
$rawRows = $em->createQuery(
<<<'DQL'
SELECT sh.song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS records
FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp
GROUP BY sh.song_id
ORDER BY records DESC
DQL
)->setParameter('station_id', $station->getId())
->setParameter('timestamp', $statisticsThreshold)
->setMaxResults(10)
->getArrayResult();
$baseUrl = $request->getRouter()->getBaseUrl(true);
$stats = array_map(
function ($row) use ($songApiGenerator, $station, $baseUrl) {
$song = ($songApiGenerator)(Entity\Song::createFromArray($row), $station);
$song->resolveUrls($baseUrl);
return [
'song' => $song,
'num_plays' => $row['records'],
];
},
$rawRows
);
return $response->withJson($stats);
}
}

View File

@ -5,30 +5,17 @@ namespace App\Controller\Stations\Reports;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Carbon\CarbonImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use stdClass;
use function array_reverse;
use function array_slice;
class OverviewController
{
public function __construct(
protected EntityManagerInterface $em,
protected Entity\Repository\SettingsRepository $settingsRepo,
protected Entity\Repository\AnalyticsRepository $analyticsRepo
) {
}
public function __invoke(ServerRequest $request, Response $response): ResponseInterface
{
$station = $request->getStation();
$station_tz = $station->getTimezoneObject();
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\SettingsRepository $settingsRepo
): ResponseInterface {
// Get current analytics level.
$settings = $this->settingsRepo->readSettings();
$settings = $settingsRepo->readSettings();
$analytics_level = $settings->getAnalytics();
if ($analytics_level === Entity\Analytics::LEVEL_NONE) {
@ -36,226 +23,9 @@ class OverviewController
return $request->getView()->renderToResponse($response, 'stations/reports/restricted');
}
/* Statistics */
$statisticsThreshold = CarbonImmutable::parse('-1 month', $station_tz);
// Statistics by day.
$dailyStats = $this->analyticsRepo->findForStationAfterTime(
$station,
$statisticsThreshold
);
$daily_chart = new stdClass();
$daily_chart->label = __('Listeners by Day');
$daily_chart->type = 'line';
$daily_chart->fill = false;
$daily_alt = [
'<p>' . $daily_chart->label . '</p>',
'<dl>',
];
$daily_averages = [];
$days_of_week = [];
foreach ($dailyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$avg_row = new stdClass();
$avg_row->t = $statTime->getTimestamp() * 1000;
$avg_row->y = round($stat['number_avg'], 2);
$daily_averages[] = $avg_row;
$row_date = $statTime->format('Y-m-d');
$daily_alt[] = '<dt><time data-original="' . $avg_row->t . '">' . $row_date . '</time></dt>';
$daily_alt[] = '<dd>' . $avg_row->y . ' ' . __('Listeners') . '</dd>';
$day_of_week = (int)$statTime->format('N') - 1;
$days_of_week[$day_of_week][] = $stat['number_avg'];
}
$daily_alt[] = '</dl>';
$daily_chart->data = $daily_averages;
$daily_data = [
'datasets' => [$daily_chart],
];
$day_of_week_chart = new stdClass();
$day_of_week_chart->label = __('Listeners by Day of Week');
$day_of_week_alt = [
'<p>' . $day_of_week_chart->label . '</p>',
'<dl>',
];
$days_of_week_names = [
__('Monday'),
__('Tuesday'),
__('Wednesday'),
__('Thursday'),
__('Friday'),
__('Saturday'),
__('Sunday'),
];
$day_of_week_stats = [];
foreach ($days_of_week_names as $day_index => $day_name) {
$day_totals = $days_of_week[$day_index] ?? [0];
$stat_value = round(array_sum($day_totals) / count($day_totals), 2);
$day_of_week_stats[] = $stat_value;
$day_of_week_alt[] = '<dt>' . $day_name . '</dt>';
$day_of_week_alt[] = '<dd>' . $stat_value . ' ' . __('Listeners') . '</dd>';
}
$day_of_week_alt[] = '</dl>';
$day_of_week_chart->data = $day_of_week_stats;
$day_of_week_data = [
'datasets' => [$day_of_week_chart],
'labels' => $days_of_week_names,
];
// Statistics by hour.
$hourlyStats = $this->analyticsRepo->findForStationAfterTime(
$station,
$statisticsThreshold,
Entity\Analytics::INTERVAL_HOURLY
);
$totals_by_hour = [];
foreach ($hourlyStats as $stat) {
/** @var CarbonImmutable $statTime */
$statTime = $stat['moment'];
$statTime = $statTime->shiftTimezone($station_tz);
$hour = (int)$statTime->hour;
$totals_by_hour[$hour][] = $stat['number_avg'];
}
$hourly_labels = [];
$hourly_chart = new stdClass();
$hourly_chart->label = __('Listeners by Hour');
$hourly_rows = [];
$hourly_alt = [
'<p>' . $hourly_chart->label . '</p>',
'<dl>',
];
for ($i = 0; $i < 24; $i++) {
$hourly_labels[] = $i . ':00';
$totals = $totals_by_hour[$i] ?? [0];
$stat_value = round(array_sum($totals) / count($totals), 2);
$hourly_rows[] = $stat_value;
$hourly_alt[] = '<dt>' . $i . ':00</dt>';
$hourly_alt[] = '<dd>' . $stat_value . ' ' . __('Listeners') . '</dd>';
}
$hourly_alt[] = '</dl>';
$hourly_chart->data = $hourly_rows;
$hourly_data = [
'datasets' => [$hourly_chart],
'labels' => $hourly_labels,
];
/* Play Count Statistics */
$song_totals_raw = [];
$song_totals_raw['played'] = $this->em->createQuery(
<<<'DQL'
SELECT sh.song_id, sh.text, sh.artist, sh.title, COUNT(sh.id) AS records
FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id AND sh.timestamp_start >= :timestamp
GROUP BY sh.song_id
ORDER BY records DESC
DQL
)->setParameter('station_id', $station->getId())
->setParameter('timestamp', $statisticsThreshold->getTimestamp())
->setMaxResults(40)
->getArrayResult();
// Compile the above data.
$song_totals = [];
foreach ($song_totals_raw as $total_type => $total_records) {
$song_totals[$total_type] = [];
foreach ($total_records as $total_record) {
$song_totals[$total_type][] = $total_record;
}
$song_totals[$total_type] = array_slice($song_totals[$total_type], 0, 10, true);
}
/* Song "Deltas" (Changes in Listener Count) */
$songPerformanceThreshold = CarbonImmutable::parse('-2 days', $station_tz)->getTimestamp();
// Get all songs played in timeline.
$songs_played_raw = $this->em->createQuery(
<<<'DQL'
SELECT sh
FROM App\Entity\SongHistory sh
WHERE sh.station_id = :station_id
AND sh.timestamp_start >= :timestamp
AND sh.listeners_start IS NOT NULL
ORDER BY sh.timestamp_start ASC
DQL
)->setParameter('station_id', $station->getId())
->setParameter('timestamp', $songPerformanceThreshold)
->getArrayResult();
$songs_played_raw = array_values($songs_played_raw);
$songs = [];
foreach ($songs_played_raw as $i => $song_row) {
// Song has no recorded ending.
if ($song_row['timestamp_end'] == 0) {
continue;
}
$song_row['stat_start'] = $song_row['listeners_start'];
$song_row['stat_end'] = $song_row['listeners_end'];
$song_row['stat_delta'] = $song_row['delta_total'];
$songs[] = $song_row;
}
usort(
$songs,
static function ($a_arr, $b_arr) {
$a = $a_arr['stat_delta'];
$b = $b_arr['stat_delta'];
return $a <=> $b;
}
);
return $request->getView()->renderToResponse(
$response,
'stations/reports/overview',
[
'charts' => [
'daily' => json_encode($daily_data, JSON_THROW_ON_ERROR),
'daily_alt' => implode('', $daily_alt),
'hourly' => json_encode($hourly_data, JSON_THROW_ON_ERROR),
'hourly_alt' => implode('', $hourly_alt),
'day_of_week' => json_encode($day_of_week_data, JSON_THROW_ON_ERROR),
'day_of_week_alt' => implode('', $day_of_week_alt),
],
'song_totals' => $song_totals,
'best_performing_songs' => array_reverse(array_slice($songs, -5)),
'worst_performing_songs' => array_slice($songs, 0, 5),
]
'stations/reports/overview'
);
}
}

View File

@ -1,101 +0,0 @@
$(function () {
Chart.platform.disableCSSInjection = true;
moment.tz.setDefault("UTC");
var daily_chart = new Chart(document.getElementById('listeners_by_day').getContext('2d'), {
type: 'bar',
data: <?=$charts['daily'] ?>,
options: {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
},
scales: {
xAxes: [{
type: 'time',
distribution: 'linear',
time: {
unit: 'day'
},
ticks: {
source: 'data',
autoSkip: true
}
}],
yAxes: [{
scaleLabel: {
display: true,
labelString: <?=$this->escapeJs(__('Listeners')) ?>
},
ticks: {
min: 0
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
callbacks: {
label: function(tooltipItem, myData) {
var label = myData.datasets[tooltipItem.datasetIndex].label || '';
if (label) {
label += ': ';
}
label += parseFloat(tooltipItem.value).toFixed(2);
return label;
}
}
}
}
});
var hourly_chart = new Chart(document.getElementById('listeners_by_hour').getContext('2d'), {
type: 'bar',
data: <?=$charts['hourly'] ?>,
options: {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
},
scales: {
xAxes: [{
scaleLabel: {
display: true,
labelString: <?=$this->escapeJs(__('Hour')) ?>
}
}],
yAxes: [{
scaleLabel: {
display: true,
labelString: <?=$this->escapeJs(__('Listeners')) ?>
},
ticks: {
min: 0
}
}]
}
}
});
var day_of_week_chart = new Chart(document.getElementById('listeners_by_day_of_week').getContext('2d'), {
type: 'pie',
data: <?=$charts['day_of_week'] ?>,
options: {
aspectRatio: 4,
plugins: {
colorschemes: {
scheme: 'tableau.Tableau20'
}
}
}
});
$('canvas time').each(function() {
$(this).text(moment.utc($(this).data('original')).format('ll'));
});
});

View File

@ -6,185 +6,14 @@
$this->layout('main', ['title' => __('Statistics Overview'), 'manual' => true]);
$props = [
'chartsUrl' => (string)$router->fromHere('api:stations:reports:overview-charts'),
'bestAndWorstUrl' => (string)$router->fromHere('api:stations:reports:best-and-worst'),
'mostPlayedUrl' => (string)$router->fromHere('api:stations:reports:most-played'),
];
$assets
->load('chartjs')
->addInlineJs($this->fetch('stations/reports/overview.js', ['charts' => $charts]));
->addVueRender('Vue_StationsReportsOverview', '#vue-reports-overview', $props);
?>
<div class="row">
<div class="col-sm-12">
<section class="card mb-3" role="region">
<div class="card-header">
<ul class="nav nav-pills card-header-pills">
<li class="nav-item">
<a class="nav-link active" role="tab" data-toggle="tab" aria-expanded="true" aria-controls="listeners-by-day" href="#listeners-by-day"><?=__('Listeners by Day')?></a>
</li>
<li class="nav-item">
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-hour" href="#listeners-by-hour"><?=__('Listeners by Hour')?></a>
</li>
<li class="nav-item">
<a class="nav-link" role="tab" data-toggle="tab" aria-controls="listeners-by-day-of-week" href="#listeners-by-day-of-week"><?=__('Listeners by Day of Week')?></a>
</li>
</ul>
</div>
<div class="tab-content">
<div class="tab-pane px-0 card-body active" id="listeners-by-day" role="tabpanel">
<canvas id="listeners_by_day" style="width: 100%;" aria-label="<?=__('Listeners by Day')?>" role="img">
<?=$charts['daily_alt']?>
</canvas>
</div>
<div class="tab-pane px-0 card-body" id="listeners-by-hour" role="tabpanel">
<canvas id="listeners_by_hour" style="width: 100%;" aria-label="<?=__('Listeners by Hour')?>" role="img">
<?=$charts['hourly_alt']?>
</canvas>
</div>
<div class="tab-pane px-0 card-body" id="listeners-by-day-of-week" role="tabpanel">
<canvas id="listeners_by_day_of_week" style="width: 100%;" aria-label="<?=__('Listeners by Day of Week')?>" role="img">
<?=$charts['day_of_week_alt']?>
</canvas>
</div>
</div>
</section>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h2 class="card-title">
<?=__('Best Performing Songs')?>
<small><?=__('in the last 48 hours')?></small>
</h2>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th><?=__('Change')?></th>
<th><?=__('Song')?></th>
</tr>
</thead>
<tbody>
<?php foreach ($best_performing_songs as $song_row): ?>
<tr>
<td class="text-center text-success">
<i class="material-icons" aria-hidden="true">keyboard_arrow_up</i> <?=abs(
$song_row['stat_delta']
)?>
<br>
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
</td>
<td>
<?php
if ($song_row['title']): ?>
<b><?=$song_row['title']?></b><br>
<?=$song_row['artist']?>
<?php
else: ?>
<?=$song_row['text']?>
<?php
endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</div>
<div class="col-sm-6">
<section class="card mb-3" role="region">
<div class="card-header bg-primary-dark">
<h2 class="card-title">
<?=__('Worst Performing Songs')?>
<small><?=__('in the last 48 hours')?></small>
</h2>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="20%">
<col width="80%">
</colgroup>
<thead>
<tr>
<th><?=__('Change')?></th>
<th><?=__('Song')?></th>
</tr>
</thead>
<tbody>
<?php foreach ($worst_performing_songs as $song_row): ?>
<tr>
<td class="text-center text-danger">
<i class="material-icons" aria-hidden="true">keyboard_arrow_down</i> <?=abs(
$song_row['stat_delta']
)?>
<br>
<small><?=$song_row['stat_start']?> to <?=$song_row['stat_end']?>
</td>
<td>
<?php
if ($song_row['title']): ?>
<b><?=$song_row['title']?></b><br>
<?=$song_row['artist']?>
<?php
else: ?>
<?=$song_row['text']?>
<?php
endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<section class="card" role="region">
<div class="card-header bg-primary-dark">
<h2 class="card-title">
<?=__('Most Played Songs')?>
<small><?=__('in the last month')?></small>
</h2>
</div>
<div class="table-responsive">
<table class="table table-striped table-condensed table-nopadding">
<colgroup>
<col width="10%">
<col width="90%">
</colgroup>
<thead>
<tr>
<th><?=__('Plays')?></th>
<th><?=__('Song')?></th>
</tr>
</thead>
<tbody>
<?php foreach ($song_totals['played'] as $song_row): ?>
<tr>
<td class="text-center"><?=$song_row['records']?></td>
<td>
<?php if ($song_row['title']): ?>
<b><?=$song_row['title']?></b><br>
<?=$song_row['artist']?>
<?php else: ?>
<?=$song_row['text']?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</div>
</div>
<div id="vue-reports-overview"></div>