Make the various "Overview" reports into API calls and convert page to Vue.
This commit is contained in:
parent
98b696e06e
commit
974c9b39bf
|
@ -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
|
||||
],
|
||||
];
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
||||
</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>
|
|
@ -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']
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
|
||||
});
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue