Merge commit '3c78bd0a30f6f9cf175ea6bcc63d246951b8437b'
This commit is contained in:
parent
a1cd3f3007
commit
4c9c773a28
|
@ -172,7 +172,7 @@ return function (App\Event\BuildStationMenu $e) {
|
|||
'permission' => StationPermissions::Reports,
|
||||
'items' => [
|
||||
'reports_overview' => [
|
||||
'label' => __('Statistics Overview'),
|
||||
'label' => __('Station Statistics'),
|
||||
'url' => (string)$router->fromHere('stations:reports:overview'),
|
||||
],
|
||||
'reports_listeners' => [
|
||||
|
|
|
@ -508,9 +508,14 @@ return static function (RouteCollectorProxy $group) {
|
|||
)->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');
|
||||
'/overview/by-browser',
|
||||
Controller\Api\Stations\Reports\Overview\ByBrowser::class
|
||||
)->setName('api:stations:reports:by-browser');
|
||||
|
||||
$group->get(
|
||||
'/overview/by-country',
|
||||
Controller\Api\Stations\Reports\Overview\ByCountry::class
|
||||
)->setName('api:stations:reports:by-country');
|
||||
|
||||
$group->get(
|
||||
'/soundexchange',
|
||||
|
|
|
@ -10,23 +10,27 @@ import {Chart} from 'chart.js';
|
|||
import {Tableau20} from '~/vendor/chartjs-colorschemes/colorschemes.tableau.js';
|
||||
|
||||
export default {
|
||||
name: 'DayOfWeekChart',
|
||||
name: 'PieChart',
|
||||
inheritAttrs: true,
|
||||
props: {
|
||||
options: Object,
|
||||
data: Array,
|
||||
labels: Array
|
||||
labels: Array,
|
||||
aspectRatio: {
|
||||
type: Number,
|
||||
default: 2
|
||||
}
|
||||
},
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
_chart: null
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
mounted() {
|
||||
this.renderChart();
|
||||
},
|
||||
methods: {
|
||||
renderChart () {
|
||||
renderChart() {
|
||||
const defaultOptions = {
|
||||
type: 'pie',
|
||||
data: {
|
||||
|
@ -34,7 +38,7 @@ export default {
|
|||
datasets: this.data
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 4,
|
||||
aspectRatio: this.aspectRatio,
|
||||
plugins: {
|
||||
colorschemes: {
|
||||
scheme: Tableau20
|
||||
|
@ -50,7 +54,7 @@ export default {
|
|||
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
|
||||
}
|
||||
},
|
||||
beforeDestroy () {
|
||||
beforeDestroy() {
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
}
|
|
@ -1,251 +1,65 @@
|
|||
<template>
|
||||
<div id="reports-overview">
|
||||
<h2 class="outside-card-header mb-1">
|
||||
<translate key="hdr">Statistics Overview</translate>
|
||||
</h2>
|
||||
|
||||
<section class="card mb-4" role="region">
|
||||
<b-overlay variant="card" :show="chartsLoading">
|
||||
<div class="card-body py-5" v-if="chartsLoading">
|
||||
|
||||
<section class="card mb-4" role="region">
|
||||
<div class="card-header bg-primary-dark">
|
||||
<div class="d-flex align-items-center">
|
||||
<h2 class="card-title flex-fill my-0">
|
||||
<translate key="hdr">Station Statistics</translate>
|
||||
</h2>
|
||||
<div class="flex-shrink">
|
||||
<date-range-dropdown time-picker v-model="dateRange" :tz="stationTimeZone"></date-range-dropdown>
|
||||
</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.worst">
|
||||
<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>
|
||||
<b-tabs pills lazy nav-class="card-header-pills" nav-wrapper-class="card-header">
|
||||
<best-and-worst-tab :api-url="bestAndWorstUrl" :date-range="dateRange">
|
||||
</best-and-worst-tab>
|
||||
|
||||
<listeners-by-time-period-tab :api-url="listenersByTimePeriodUrl" :date-range="dateRange">
|
||||
</listeners-by-time-period-tab>
|
||||
|
||||
<browsers-tab v-if="showFullAnalytics" :api-url="byBrowserUrl" :date-range="dateRange">
|
||||
</browsers-tab>
|
||||
|
||||
<countries-tab v-if="showFullAnalytics" :api-url="byCountryUrl" :date-range="dateRange">
|
||||
</countries-tab>
|
||||
</b-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TimeSeriesChart from '~/components/Common/TimeSeriesChart';
|
||||
import DataTable from '~/components/Common/DataTable';
|
||||
import Icon from '~/components/Common/Icon';
|
||||
import DayOfWeekChart from './Overview/DayOfWeekChart';
|
||||
import HourChart from './Overview/HourChart';
|
||||
import {DateTime} from "luxon";
|
||||
import DateRangeDropdown from "~/components/Common/DateRangeDropdown";
|
||||
import ListenersByTimePeriodTab from "./Overview/ListenersByTimePeriodTab";
|
||||
import BestAndWorstTab from "./Overview/BestAndWorstTab";
|
||||
import BrowsersTab from "./Overview/BrowsersTab";
|
||||
import CountriesTab from "~/components/Stations/Reports/Overview/CountriesTab";
|
||||
|
||||
export default {
|
||||
components: {HourChart, DayOfWeekChart, Icon, DataTable, TimeSeriesChart},
|
||||
components: {
|
||||
CountriesTab,
|
||||
BrowsersTab,
|
||||
BestAndWorstTab,
|
||||
ListenersByTimePeriodTab,
|
||||
DateRangeDropdown
|
||||
},
|
||||
props: {
|
||||
chartsUrl: String,
|
||||
stationTimeZone: String,
|
||||
showFullAnalytics: Boolean,
|
||||
listenersByTimePeriodUrl: String,
|
||||
bestAndWorstUrl: String,
|
||||
mostPlayedUrl: String
|
||||
byBrowserUrl: String,
|
||||
byCountryUrl: String,
|
||||
},
|
||||
data() {
|
||||
let nowTz = DateTime.now().setZone(this.stationTimeZone);
|
||||
|
||||
return {
|
||||
chartsLoading: true,
|
||||
chartsData: {
|
||||
daily: {
|
||||
metrics: [],
|
||||
alt: ''
|
||||
},
|
||||
day_of_week: {
|
||||
labels: [],
|
||||
metrics: [],
|
||||
alt: ''
|
||||
},
|
||||
hourly: {
|
||||
labels: [],
|
||||
metrics: [],
|
||||
alt: ''
|
||||
}
|
||||
dateRange: {
|
||||
startDate: nowTz.minus({days: 13}).toJSDate(),
|
||||
endDate: nowTz.toJSDate(),
|
||||
},
|
||||
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 () {
|
||||
this.axios.get(this.chartsUrl).then((response) => {
|
||||
this.chartsData = response.data;
|
||||
this.chartsLoading = false;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
this.axios.get(this.bestAndWorstUrl).then((response) => {
|
||||
this.bestAndWorst = response.data;
|
||||
this.bestAndWorstLoading = false;
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
this.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>
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
<template>
|
||||
<b-tab :title="langTitle" active>
|
||||
<b-overlay variant="card" :show="loading">
|
||||
<div class="card-body py-5" v-if="loading">
|
||||
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<b-row>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="reports_overview_best_songs">Best Performing Songs</translate>
|
||||
</legend>
|
||||
|
||||
<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>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="reports_overview_worst_songs">Worst Performing Songs</translate>
|
||||
</legend>
|
||||
|
||||
<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.worst">
|
||||
<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>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
|
||||
<b-col md="12" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="reports_overview_most_played">Most Played Songs</translate>
|
||||
</legend>
|
||||
|
||||
<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>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
</b-overlay>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {DateTime} from "luxon";
|
||||
import Icon from "~/components/Common/Icon";
|
||||
|
||||
export default {
|
||||
name: 'BestAndWorstTab',
|
||||
components: {Icon},
|
||||
props: {
|
||||
dateRange: Object,
|
||||
apiUrl: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
bestAndWorst: {
|
||||
best: [],
|
||||
worst: []
|
||||
},
|
||||
mostPlayed: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
dateRange() {
|
||||
this.relist();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
langTitle() {
|
||||
return this.$gettext('Best & Worst Tracks');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.relist();
|
||||
},
|
||||
methods: {
|
||||
relist() {
|
||||
this.loading = true;
|
||||
this.axios.get(this.apiUrl, {
|
||||
params: {
|
||||
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
|
||||
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
|
||||
}
|
||||
}).then((response) => {
|
||||
this.bestAndWorst = response.data.bestAndWorst;
|
||||
this.mostPlayed = response.data.mostPlayed;
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
getSongText(song) {
|
||||
if (song.title !== '') {
|
||||
return '<b>' + song.title + '</b><br>' + song.artist;
|
||||
}
|
||||
|
||||
return song.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<b-tab :title="langTitle">
|
||||
<b-overlay variant="card" :show="loading">
|
||||
<div class="card-body py-5" v-if="loading">
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="card-body">
|
||||
<b-row>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="hdr_top_by_listeners">Top Browsers by Listeners</translate>
|
||||
</legend>
|
||||
|
||||
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
|
||||
:labels="top_listeners.labels">
|
||||
<span v-html="top_listeners.alt"></span>
|
||||
</pie-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate
|
||||
key="hdr_top_by_connected_seconds">Top Browsers by Connected Time</translate>
|
||||
</legend>
|
||||
|
||||
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
|
||||
:labels="top_connected_time.labels">
|
||||
<span v-html="top_connected_time.alt"></span>
|
||||
</pie-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
|
||||
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
|
||||
:fields="fields" :responsive="false" :items="all">
|
||||
<template #cell(connected_seconds_calc)="row">
|
||||
{{ formatTime(row.item.connected_seconds) }}
|
||||
</template>
|
||||
</data-table>
|
||||
</div>
|
||||
</b-overlay>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {DateTime} from "luxon";
|
||||
import PieChart from "~/components/Common/PieChart";
|
||||
import formatTime from "~/functions/formatTime";
|
||||
import DataTable from "~/components/Common/DataTable";
|
||||
|
||||
export default {
|
||||
name: 'BrowsersTab',
|
||||
components: {DataTable, PieChart},
|
||||
props: {
|
||||
dateRange: Object,
|
||||
apiUrl: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
all: [],
|
||||
top_listeners: {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
alt: ''
|
||||
},
|
||||
top_connected_time: {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
alt: ''
|
||||
},
|
||||
fields: [
|
||||
{key: 'browser', label: this.$gettext('Browser'), sortable: true},
|
||||
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
|
||||
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
|
||||
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
|
||||
]
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
dateRange() {
|
||||
this.relist();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
langTitle() {
|
||||
return this.$gettext('Browsers');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.relist();
|
||||
},
|
||||
methods: {
|
||||
relist() {
|
||||
this.loading = true;
|
||||
this.axios.get(this.apiUrl, {
|
||||
params: {
|
||||
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
|
||||
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
|
||||
}
|
||||
}).then((response) => {
|
||||
this.all = response.data.all;
|
||||
this.top_listeners = response.data.top_listeners;
|
||||
this.top_connected_time = response.data.top_connected_time;
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
formatTime(time) {
|
||||
return formatTime(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<b-tab :title="langTitle">
|
||||
<b-overlay variant="card" :show="loading">
|
||||
<div class="card-body py-5" v-if="loading">
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="card-body">
|
||||
<b-row>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="hdr_top_by_listeners">Top Countries by Listeners</translate>
|
||||
</legend>
|
||||
|
||||
<pie-chart style="width: 100%;" :data="top_listeners.datasets"
|
||||
:labels="top_listeners.labels">
|
||||
<span v-html="top_listeners.alt"></span>
|
||||
</pie-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate
|
||||
key="hdr_top_by_connected_seconds">Top Countries by Connected Time</translate>
|
||||
</legend>
|
||||
|
||||
<pie-chart style="width: 100%;" :data="top_connected_time.datasets"
|
||||
:labels="top_connected_time.labels">
|
||||
<span v-html="top_connected_time.alt"></span>
|
||||
</pie-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
|
||||
<data-table ref="datatable" id="browsers_table" paginated handle-client-side
|
||||
:fields="fields" :responsive="false" :items="all">
|
||||
<template #cell(connected_seconds_calc)="row">
|
||||
{{ formatTime(row.item.connected_seconds) }}
|
||||
</template>
|
||||
</data-table>
|
||||
</div>
|
||||
</b-overlay>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {DateTime} from "luxon";
|
||||
import PieChart from "~/components/Common/PieChart";
|
||||
import formatTime from "~/functions/formatTime";
|
||||
import DataTable from "~/components/Common/DataTable";
|
||||
|
||||
export default {
|
||||
name: 'CountriesTab',
|
||||
components: {DataTable, PieChart},
|
||||
props: {
|
||||
dateRange: Object,
|
||||
apiUrl: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
all: [],
|
||||
top_listeners: {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
alt: ''
|
||||
},
|
||||
top_connected_time: {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
alt: ''
|
||||
},
|
||||
fields: [
|
||||
{key: 'country', label: this.$gettext('Country'), sortable: true},
|
||||
{key: 'listeners', label: this.$gettext('Listeners'), sortable: true},
|
||||
{key: 'connected_seconds_calc', label: this.$gettext('Time'), sortable: false},
|
||||
{key: 'connected_seconds', label: this.$gettext('Time (sec)'), sortable: true}
|
||||
]
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
dateRange() {
|
||||
this.relist();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
langTitle() {
|
||||
return this.$gettext('Countries');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.relist();
|
||||
},
|
||||
methods: {
|
||||
relist() {
|
||||
this.loading = true;
|
||||
this.axios.get(this.apiUrl, {
|
||||
params: {
|
||||
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
|
||||
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
|
||||
}
|
||||
}).then((response) => {
|
||||
this.all = response.data.all;
|
||||
this.top_listeners = response.data.top_listeners;
|
||||
this.top_connected_time = response.data.top_connected_time;
|
||||
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
formatTime(time) {
|
||||
return formatTime(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -16,16 +16,16 @@ export default {
|
|||
data: Array,
|
||||
labels: Array
|
||||
},
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
_chart: null
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
mounted() {
|
||||
this.renderChart();
|
||||
},
|
||||
methods: {
|
||||
renderChart () {
|
||||
renderChart() {
|
||||
const defaultOptions = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
|
@ -33,7 +33,7 @@ export default {
|
|||
datasets: this.data
|
||||
},
|
||||
options: {
|
||||
aspectRatio: 4,
|
||||
aspectRatio: 2,
|
||||
plugins: {
|
||||
colorschemes: {
|
||||
scheme: Tableau20
|
||||
|
@ -65,7 +65,7 @@ export default {
|
|||
this._chart = new Chart(this.$refs.canvas.getContext('2d'), chartOptions);
|
||||
}
|
||||
},
|
||||
beforeDestroy () {
|
||||
beforeDestroy() {
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<b-tab :title="langTitle">
|
||||
<b-overlay variant="card" :show="loading">
|
||||
<div class="card-body py-5" v-if="loading">
|
||||
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<b-row>
|
||||
<b-col md="12" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="hdr_listeners_by_day">Listeners by Day</translate>
|
||||
</legend>
|
||||
|
||||
<time-series-chart style="width: 100%;" :data="chartData.daily.metrics">
|
||||
<span v-html="chartData.daily.alt"></span>
|
||||
</time-series-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="hdr_listeners_by_dow">Listeners by Day of Week</translate>
|
||||
</legend>
|
||||
|
||||
<pie-chart style="width: 100%;" :data="chartData.day_of_week.metrics"
|
||||
:labels="chartData.day_of_week.labels">
|
||||
<span v-html="chartData.day_of_week.alt"></span>
|
||||
</pie-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
<b-col md="6" class="mb-4">
|
||||
<fieldset>
|
||||
<legend>
|
||||
<translate key="hdr_listeners_by_hour">Listeners by Hour</translate>
|
||||
</legend>
|
||||
|
||||
<hour-chart style="width: 100%;" :data="chartData.hourly.metrics"
|
||||
:labels="chartData.hourly.labels">
|
||||
<span v-html="chartData.hourly.alt"></span>
|
||||
</hour-chart>
|
||||
</fieldset>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
</b-overlay>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TimeSeriesChart from "~/components/Common/TimeSeriesChart";
|
||||
import HourChart from "~/components/Stations/Reports/Overview/HourChart";
|
||||
import {DateTime} from "luxon";
|
||||
import PieChart from "~/components/Common/PieChart";
|
||||
|
||||
export default {
|
||||
name: 'ListenersByTimePeriodTab',
|
||||
components: {PieChart, HourChart, TimeSeriesChart},
|
||||
props: {
|
||||
dateRange: Object,
|
||||
apiUrl: String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
chartData: {
|
||||
daily: {},
|
||||
day_of_week: {
|
||||
labels: [],
|
||||
metrics: [],
|
||||
alt: ''
|
||||
},
|
||||
hourly: {
|
||||
labels: [],
|
||||
metrics: [],
|
||||
alt: ''
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
dateRange() {
|
||||
this.relist();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
langTitle() {
|
||||
return this.$gettext('Listeners by Time Period');
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.relist();
|
||||
},
|
||||
methods: {
|
||||
relist() {
|
||||
this.loading = true;
|
||||
this.axios.get(this.apiUrl, {
|
||||
params: {
|
||||
start: DateTime.fromJSDate(this.dateRange.startDate).toISO(),
|
||||
end: DateTime.fromJSDate(this.dateRange.endDate).toISO()
|
||||
}
|
||||
}).then((response) => {
|
||||
this.chartData = response.data;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\Reports\Overview;
|
||||
|
||||
use App\Controller\Api\Traits\AcceptsDateRange;
|
||||
use App\Entity\Enums\AnalyticsLevel;
|
||||
use App\Entity\Repository\SettingsRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
abstract class AbstractReportAction
|
||||
{
|
||||
use AcceptsDateRange;
|
||||
|
||||
public function __construct(
|
||||
protected readonly SettingsRepository $settingsRepo,
|
||||
protected readonly EntityManagerInterface $em,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function isAllAnalyticsEnabled(): bool
|
||||
{
|
||||
return AnalyticsLevel::All !== $this->settingsRepo->readSettings()->getAnalyticsEnum();
|
||||
}
|
||||
|
||||
protected function isAnalyticsEnabled(): bool
|
||||
{
|
||||
return $this->settingsRepo->readSettings()->isAnalyticsEnabled();
|
||||
}
|
||||
|
||||
protected function buildChart(
|
||||
array $rows,
|
||||
string $valueLabel
|
||||
): array {
|
||||
arsort($rows);
|
||||
$topRows = array_slice($rows, 0, 10);
|
||||
|
||||
$alt = ['<dl>'];
|
||||
$labels = [];
|
||||
$data = [];
|
||||
|
||||
foreach ($topRows as $key => $value) {
|
||||
$labels[] = $key;
|
||||
$data[] = (int)$value;
|
||||
|
||||
$alt[] = '<dt>' . $key . '</dt>';
|
||||
$alt[] = '<dd>' . $value . ' ' . $valueLabel . '</dd>';
|
||||
}
|
||||
|
||||
$alt[] = '</dl>';
|
||||
|
||||
return [
|
||||
'labels' => $labels,
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => $valueLabel,
|
||||
'data' => $data,
|
||||
],
|
||||
],
|
||||
'alt' => implode('', $alt),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -7,17 +7,18 @@ namespace App\Controller\Api\Stations\Reports\Overview;
|
|||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Carbon\CarbonImmutable;
|
||||
use App\Utilities\DateRange;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class BestAndWorstAction
|
||||
final class BestAndWorstAction extends AbstractReportAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Entity\Repository\SettingsRepository $settingsRepo,
|
||||
Entity\Repository\SettingsRepository $settingsRepo,
|
||||
EntityManagerInterface $em,
|
||||
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator
|
||||
) {
|
||||
parent::__construct($settingsRepo, $em);
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
|
@ -25,17 +26,25 @@ final class BestAndWorstAction
|
|||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
$station = $request->getStation();
|
||||
$station_tz = $station->getTimezoneObject();
|
||||
|
||||
// Get current analytics level.
|
||||
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
|
||||
if (!$this->isAnalyticsEnabled()) {
|
||||
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();
|
||||
$dateRange = $this->getDateRange($request, $request->getStation()->getTimezoneObject());
|
||||
|
||||
return $response->withJson([
|
||||
'bestAndWorst' => $this->getBestAndWorst($request, $dateRange),
|
||||
'mostPlayed' => $this->getMostPlayed($request, $dateRange),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getBestAndWorst(
|
||||
ServerRequest $request,
|
||||
DateRange $dateRange
|
||||
): array {
|
||||
$station = $request->getStation();
|
||||
|
||||
// Get all songs played in timeline.
|
||||
$baseQuery = $this->em->createQueryBuilder()
|
||||
|
@ -43,8 +52,9 @@ final class BestAndWorstAction
|
|||
->from(Entity\SongHistory::class, 'sh')
|
||||
->where('sh.station = :station')
|
||||
->setParameter('station', $station)
|
||||
->andWhere('sh.timestamp_start >= :timestamp')
|
||||
->setParameter('timestamp', $songPerformanceThreshold)
|
||||
->andWhere('sh.timestamp_start <= :end AND sh.timestamp_end >= :start')
|
||||
->setParameter('start', $dateRange->getStartTimestamp())
|
||||
->setParameter('end', $dateRange->getEndTimestamp())
|
||||
->andWhere('sh.listeners_start IS NOT NULL')
|
||||
->andWhere('sh.timestamp_end != 0')
|
||||
->setMaxResults(5);
|
||||
|
@ -76,6 +86,44 @@ final class BestAndWorstAction
|
|||
);
|
||||
}
|
||||
|
||||
return $response->withJson($stats);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function getMostPlayed(
|
||||
ServerRequest $request,
|
||||
DateRange $dateRange
|
||||
): array {
|
||||
$station = $request->getStation();
|
||||
|
||||
$rawRows = $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 = :station
|
||||
AND sh.timestamp_start <= :end
|
||||
AND sh.timestamp_end >= :start
|
||||
GROUP BY sh.song_id
|
||||
ORDER BY records DESC
|
||||
DQL
|
||||
)->setParameter('station', $request->getStation())
|
||||
->setParameter('start', $dateRange->getStartTimestamp())
|
||||
->setParameter('end', $dateRange->getEndTimestamp())
|
||||
->setMaxResults(10)
|
||||
->getArrayResult();
|
||||
|
||||
$baseUrl = $request->getRouter()->getBaseUrl();
|
||||
|
||||
return array_map(
|
||||
function ($row) use ($station, $baseUrl) {
|
||||
$song = ($this->songApiGenerator)(Entity\Song::createFromArray($row), $station);
|
||||
$song->resolveUrls($baseUrl);
|
||||
|
||||
return [
|
||||
'song' => $song,
|
||||
'num_plays' => $row['records'],
|
||||
];
|
||||
},
|
||||
$rawRows
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\Reports\Overview;
|
||||
|
||||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class ByBrowser extends AbstractReportAction
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
// Get current analytics level.
|
||||
if ($this->isAllAnalyticsEnabled()) {
|
||||
return $response->withStatus(400)
|
||||
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$stationTz = $station->getTimezoneObject();
|
||||
|
||||
$dateRange = $this->getDateRange($request, $stationTz);
|
||||
|
||||
$stats = $this->em->getConnection()->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT DISTINCT l.device_browser_family AS browser,
|
||||
COUNT(l.listener_hash) AS listeners,
|
||||
SUM(l.connected_seconds) AS connected_seconds
|
||||
FROM (
|
||||
SELECT device_browser_family,
|
||||
SUM(timestamp_end - timestamp_start) AS connected_seconds,
|
||||
listener_hash
|
||||
FROM listener
|
||||
WHERE station_id = :station_id
|
||||
AND device_browser_family IS NOT NULL
|
||||
AND timestamp_end >= :start
|
||||
AND timestamp_start <= :end
|
||||
AND device_is_browser = 1
|
||||
GROUP BY listener_hash
|
||||
) AS l
|
||||
SQL,
|
||||
[
|
||||
'station_id' => $station->getIdRequired(),
|
||||
'start' => $dateRange->getStartTimestamp(),
|
||||
'end' => $dateRange->getEndTimestamp(),
|
||||
]
|
||||
);
|
||||
|
||||
$listenersByBrowser = array_column($stats, 'listeners', 'browser');
|
||||
$connectedTimeByBrowser = array_column($stats, 'connected_seconds', 'browser');
|
||||
|
||||
return $response->withJson([
|
||||
'all' => $stats,
|
||||
'top_listeners' => $this->buildChart($listenersByBrowser, __('Listeners')),
|
||||
'top_connected_time' => $this->buildChart($connectedTimeByBrowser, __('Connected Seconds')),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\Reports\Overview;
|
||||
|
||||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Intl\Countries;
|
||||
|
||||
final class ByCountry extends AbstractReportAction
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
// Get current analytics level.
|
||||
if ($this->isAllAnalyticsEnabled()) {
|
||||
return $response->withStatus(400)
|
||||
->withJson(new Entity\Api\Status(false, 'Reporting is restricted due to system analytics level.'));
|
||||
}
|
||||
|
||||
$station = $request->getStation();
|
||||
$stationTz = $station->getTimezoneObject();
|
||||
|
||||
$dateRange = $this->getDateRange($request, $stationTz);
|
||||
|
||||
$statsRaw = $this->em->getConnection()->fetchAllAssociative(
|
||||
<<<'SQL'
|
||||
SELECT DISTINCT l.location_country AS country_code,
|
||||
COUNT(l.listener_hash) AS listeners,
|
||||
SUM(l.connected_seconds) AS connected_seconds
|
||||
FROM (
|
||||
SELECT location_country, SUM(timestamp_end - timestamp_start) AS connected_seconds, listener_hash
|
||||
FROM listener
|
||||
WHERE station_id = :station_id
|
||||
AND location_country IS NOT NULL
|
||||
AND timestamp_end >= :start
|
||||
AND timestamp_start <= :end
|
||||
GROUP BY listener_hash
|
||||
) AS l
|
||||
SQL,
|
||||
[
|
||||
'station_id' => $station->getIdRequired(),
|
||||
'start' => $dateRange->getStartTimestamp(),
|
||||
'end' => $dateRange->getEndTimestamp(),
|
||||
]
|
||||
);
|
||||
|
||||
$countryNames = Countries::getNames($request->getLocale()->getLocaleWithoutEncoding());
|
||||
|
||||
$listenersByCountry = [];
|
||||
$connectedTimeByCountry = [];
|
||||
$stats = [];
|
||||
|
||||
foreach ($statsRaw as $stat) {
|
||||
if (empty($stat['country_code'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stat['country'] = $countryNames[$stat['country_code']];
|
||||
$stats[] = $stat;
|
||||
|
||||
$listenersByCountry[$stat['country']] = $stat['listeners'];
|
||||
$connectedTimeByCountry[$stat['country']] = $stat['connected_seconds'];
|
||||
}
|
||||
|
||||
return $response->withJson([
|
||||
'all' => $stats,
|
||||
'top_listeners' => $this->buildChart($listenersByCountry, __('Listeners')),
|
||||
'top_connected_time' => $this->buildChart($connectedTimeByCountry, __('Connected Seconds')),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -8,15 +8,18 @@ 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;
|
||||
|
||||
final class ChartsAction
|
||||
final class ChartsAction extends AbstractReportAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Entity\Repository\SettingsRepository $settingsRepo,
|
||||
Entity\Repository\SettingsRepository $settingsRepo,
|
||||
EntityManagerInterface $em,
|
||||
private readonly Entity\Repository\AnalyticsRepository $analyticsRepo,
|
||||
) {
|
||||
parent::__construct($settingsRepo, $em);
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
|
@ -24,24 +27,23 @@ final class ChartsAction
|
|||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
$station = $request->getStation();
|
||||
$station_tz = $station->getTimezoneObject();
|
||||
|
||||
// Get current analytics level.
|
||||
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
|
||||
if (!$this->isAnalyticsEnabled()) {
|
||||
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);
|
||||
$station = $request->getStation();
|
||||
$stationTz = $station->getTimezoneObject();
|
||||
|
||||
$dateRange = $this->getDateRange($request, $stationTz);
|
||||
|
||||
$stats = [];
|
||||
|
||||
// Statistics by day.
|
||||
$dailyStats = $this->analyticsRepo->findForStationAfterTime(
|
||||
$dailyStats = $this->analyticsRepo->findForStationInRange(
|
||||
$station,
|
||||
$statisticsThreshold
|
||||
$dateRange
|
||||
);
|
||||
|
||||
$daily_chart = new stdClass();
|
||||
|
@ -60,7 +62,7 @@ final class ChartsAction
|
|||
foreach ($dailyStats as $stat) {
|
||||
/** @var CarbonImmutable $statTime */
|
||||
$statTime = $stat['moment'];
|
||||
$statTime = $statTime->shiftTimezone($station_tz);
|
||||
$statTime = $statTime->shiftTimezone($stationTz);
|
||||
|
||||
$avg_row = new stdClass();
|
||||
$avg_row->x = $statTime->getTimestampMs();
|
||||
|
@ -127,9 +129,9 @@ final class ChartsAction
|
|||
];
|
||||
|
||||
// Statistics by hour.
|
||||
$hourlyStats = $this->analyticsRepo->findForStationAfterTime(
|
||||
$hourlyStats = $this->analyticsRepo->findForStationInRange(
|
||||
$station,
|
||||
$statisticsThreshold,
|
||||
$dateRange,
|
||||
Entity\Analytics::INTERVAL_HOURLY
|
||||
);
|
||||
|
||||
|
@ -138,7 +140,7 @@ final class ChartsAction
|
|||
foreach ($hourlyStats as $stat) {
|
||||
/** @var CarbonImmutable $statTime */
|
||||
$statTime = $stat['moment'];
|
||||
$statTime = $statTime->shiftTimezone($station_tz);
|
||||
$statTime = $statTime->shiftTimezone($stationTz);
|
||||
|
||||
$hour = $statTime->hour;
|
||||
$totals_by_hour[$hour][] = $stat['number_avg'];
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
|
||||
final class MostPlayedAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Entity\Repository\SettingsRepository $settingsRepo,
|
||||
private readonly Entity\ApiGenerator\SongApiGenerator $songApiGenerator
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
$station = $request->getStation();
|
||||
$station_tz = $station->getTimezoneObject();
|
||||
|
||||
// Get current analytics level.
|
||||
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
|
||||
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 = $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)
|
||||
->setMaxResults(10)
|
||||
->getArrayResult();
|
||||
|
||||
$baseUrl = $request->getRouter()->getBaseUrl();
|
||||
|
||||
$stats = array_map(
|
||||
function ($row) use ($station, $baseUrl) {
|
||||
$song = ($this->songApiGenerator)(Entity\Song::createFromArray($row), $station);
|
||||
$song->resolveUrls($baseUrl);
|
||||
|
||||
return [
|
||||
'song' => $song,
|
||||
'num_plays' => $row['records'],
|
||||
];
|
||||
},
|
||||
$rawRows
|
||||
);
|
||||
|
||||
return $response->withJson($stats);
|
||||
}
|
||||
}
|
|
@ -22,22 +22,28 @@ final class OverviewAction
|
|||
string $station_id
|
||||
): ResponseInterface {
|
||||
// Get current analytics level.
|
||||
if (!$this->settingsRepo->readSettings()->isAnalyticsEnabled()) {
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
|
||||
if (!$settings->isAnalyticsEnabled()) {
|
||||
// The entirety of the dashboard can't be shown, so redirect user to the profile page.
|
||||
return $request->getView()->renderToResponse($response, 'stations/reports/restricted');
|
||||
}
|
||||
|
||||
$router = $request->getRouter();
|
||||
$analyticsLevel = $settings->getAnalyticsEnum();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Vue_StationsReportsOverview',
|
||||
id: 'vue-reports-overview',
|
||||
title: __('Statistics Overview'),
|
||||
title: __('Station Statistics'),
|
||||
props: [
|
||||
'chartsUrl' => (string)$router->fromHere('api:stations:reports:overview-charts'),
|
||||
'stationTz' => $request->getStation()->getTimezone(),
|
||||
'showFullAnalytics' => Entity\Enums\AnalyticsLevel::All === $analyticsLevel,
|
||||
'listenersByTimePeriodUrl' => (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'),
|
||||
'byBrowserUrl' => (string)$router->fromHere('api:stations:reports:by-browser'),
|
||||
'byCountryUrl' => (string)$router->fromHere('api:stations:reports:by-country'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,9 +6,9 @@ namespace App\Entity\Repository;
|
|||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use App\Utilities\DateRange;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* @extends Repository<Entity\Analytics>
|
||||
|
@ -18,19 +18,20 @@ final class AnalyticsRepository extends Repository
|
|||
/**
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function findForStationAfterTime(
|
||||
public function findForStationInRange(
|
||||
Entity\Station $station,
|
||||
DateTimeInterface $threshold,
|
||||
DateRange $dateRange,
|
||||
string $type = Entity\Analytics::INTERVAL_DAILY
|
||||
): array {
|
||||
return $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT a FROM App\Entity\Analytics a
|
||||
WHERE a.station = :station AND a.type = :type AND a.moment >= :threshold
|
||||
WHERE a.station = :station AND a.type = :type AND a.moment BETWEEN :start AND :end
|
||||
DQL
|
||||
)->setParameter('station', $station)
|
||||
->setParameter('type', $type)
|
||||
->setParameter('threshold', $threshold)
|
||||
->setParameter('start', $dateRange->getStart())
|
||||
->setParameter('end', $dateRange->getEnd())
|
||||
->getArrayResult();
|
||||
}
|
||||
|
||||
|
|
|
@ -21,15 +21,15 @@ class Api_Stations_ReportsCest extends CestAbstract
|
|||
$uriBase = '/api/station/' . $station->getId();
|
||||
|
||||
$I->sendGet($uriBase . '/reports/overview/charts');
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
|
||||
$I->sendGet($uriBase . '/reports/overview/best-and-worst');
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
|
||||
$I->sendGet($uriBase . '/reports/overview/most-played');
|
||||
$I->sendGet($uriBase . '/reports/overview/by-browser');
|
||||
$I->seeResponseCodeIs(200);
|
||||
|
||||
$I->sendGet($uriBase . '/reports/overview/by-country');
|
||||
$I->seeResponseCodeIs(200);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ class Station_ReportsCest extends CestAbstract
|
|||
$I->amOnPAge('/station/' . $station_id . '/reports/overview');
|
||||
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->see('Statistics Overview');
|
||||
$I->see('Station Statistics');
|
||||
|
||||
$I->amOnPage('/station/' . $station_id . '/reports/timeline');
|
||||
|
||||
|
|
Loading…
Reference in New Issue