diff --git a/app/fonts/icomoon/icomoon.eot b/app/fonts/icomoon/icomoon.eot
old mode 100755
new mode 100644
index 331f69e6..7235e149
Binary files a/app/fonts/icomoon/icomoon.eot and b/app/fonts/icomoon/icomoon.eot differ
diff --git a/app/fonts/icomoon/icomoon.svg b/app/fonts/icomoon/icomoon.svg
old mode 100755
new mode 100644
index 997a0963..112a0ea4
--- a/app/fonts/icomoon/icomoon.svg
+++ b/app/fonts/icomoon/icomoon.svg
@@ -34,4 +34,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/fonts/icomoon/icomoon.ttf b/app/fonts/icomoon/icomoon.ttf
old mode 100755
new mode 100644
index f31210a1..569ac6d7
Binary files a/app/fonts/icomoon/icomoon.ttf and b/app/fonts/icomoon/icomoon.ttf differ
diff --git a/app/fonts/icomoon/icomoon.woff b/app/fonts/icomoon/icomoon.woff
old mode 100755
new mode 100644
index 39493114..16fe327f
Binary files a/app/fonts/icomoon/icomoon.woff and b/app/fonts/icomoon/icomoon.woff differ
diff --git a/app/fonts/icomoon/selection.json b/app/fonts/icomoon/selection.json
old mode 100755
new mode 100644
index 4dcb353f..3b5e9e64
--- a/app/fonts/icomoon/selection.json
+++ b/app/fonts/icomoon/selection.json
@@ -1,6 +1,94 @@
{
"IcoMoonType": "selection",
"icons": [
+ {
+ "icon": {
+ "paths": [
+ "M632.571 501.143l-424 424q-10.857 10.857-25.714 10.857t-25.714-10.857l-94.857-94.857q-10.857-10.857-10.857-25.714t10.857-25.714l303.429-303.429-303.429-303.429q-10.857-10.857-10.857-25.714t10.857-25.714l94.857-94.857q10.857-10.857 25.714-10.857t25.714 10.857l424 424q10.857 10.857 10.857 25.714t-10.857 25.714z"
+ ],
+ "attrs": [
+ {}
+ ],
+ "isMulticolor": false,
+ "width": 731,
+ "tags": [
+ "chevron-right"
+ ],
+ "grid": 14
+ },
+ "attrs": [
+ {}
+ ],
+ "properties": {
+ "order": 1,
+ "id": 2,
+ "prevSize": 28,
+ "code": 58891,
+ "name": "chevron-right"
+ },
+ "setIdx": 0,
+ "setId": 5,
+ "iconIdx": 0
+ },
+ {
+ "icon": {
+ "paths": [
+ "M669.143 172l-303.429 303.429 303.429 303.429q10.857 10.857 10.857 25.714t-10.857 25.714l-94.857 94.857q-10.857 10.857-25.714 10.857t-25.714-10.857l-424-424q-10.857-10.857-10.857-25.714t10.857-25.714l424-424q10.857-10.857 25.714-10.857t25.714 10.857l94.857 94.857q10.857 10.857 10.857 25.714t-10.857 25.714z"
+ ],
+ "attrs": [
+ {}
+ ],
+ "isMulticolor": false,
+ "width": 731,
+ "tags": [
+ "chevron-left"
+ ],
+ "grid": 14
+ },
+ "attrs": [
+ {}
+ ],
+ "properties": {
+ "order": 2,
+ "id": 1,
+ "prevSize": 28,
+ "code": 58893,
+ "name": "chevron-left"
+ },
+ "setIdx": 0,
+ "setId": 5,
+ "iconIdx": 1
+ },
+ {
+ "icon": {
+ "paths": [
+ "M365.714 512v292.571h-146.286v-292.571h146.286zM585.143 219.429v585.143h-146.286v-585.143h146.286zM1170.286 877.714v73.143h-1170.286v-877.714h73.143v804.571h1097.143zM804.571 365.714v438.857h-146.286v-438.857h146.286zM1024 146.286v658.286h-146.286v-658.286h146.286z"
+ ],
+ "attrs": [
+ {}
+ ],
+ "isMulticolor": false,
+ "width": 1170,
+ "tags": [
+ "bar-chart",
+ "bar-chart-o"
+ ],
+ "grid": 14
+ },
+ "attrs": [
+ {}
+ ],
+ "properties": {
+ "order": 1,
+ "id": 0,
+ "prevSize": 28,
+ "code": 58892,
+ "name": "stats"
+ },
+ "setIdx": 0,
+ "setId": 5,
+ "iconIdx": 2
+ },
{
"icon": {
"paths": [
@@ -10,10 +98,10 @@
{}
],
"isMulticolor": false,
- "grid": 0,
"tags": [
"svg"
- ]
+ ],
+ "grid": 0
},
"attrs": [
{}
@@ -25,8 +113,8 @@
"code": 58890,
"name": "playlist"
},
- "setIdx": 0,
- "setId": 5,
+ "setIdx": 1,
+ "setId": 4,
"iconIdx": 0
},
{
@@ -61,7 +149,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 0
+ "iconIdx": 1
},
{
"icon": {
@@ -87,7 +175,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 1
+ "iconIdx": 2
},
{
"icon": {
@@ -114,7 +202,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 2
+ "iconIdx": 3
},
{
"icon": {
@@ -141,7 +229,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 3
+ "iconIdx": 4
},
{
"icon": {
@@ -167,7 +255,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 4
+ "iconIdx": 5
},
{
"icon": {
@@ -193,7 +281,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 5
+ "iconIdx": 6
},
{
"icon": {
@@ -220,7 +308,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 6
+ "iconIdx": 7
},
{
"icon": {
@@ -246,7 +334,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 7
+ "iconIdx": 8
},
{
"icon": {
@@ -272,7 +360,7 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 8
+ "iconIdx": 9
},
{
"icon": {
@@ -299,7 +387,36 @@
},
"setIdx": 1,
"setId": 4,
- "iconIdx": 9
+ "iconIdx": 10
+ },
+ {
+ "icon": {
+ "paths": [
+ "M320 384h128v128h-128zM512 384h128v128h-128zM704 384h128v128h-128zM128 768h128v128h-128zM320 768h128v128h-128zM512 768h128v128h-128zM320 576h128v128h-128zM512 576h128v128h-128zM704 576h128v128h-128zM128 576h128v128h-128zM832 0v64h-128v-64h-448v64h-128v-64h-128v1024h960v-1024h-128zM896 960h-832v-704h832v704z"
+ ],
+ "attrs": [],
+ "tags": [
+ "calendar",
+ "date",
+ "schedule",
+ "time",
+ "day"
+ ],
+ "defaultCode": 57619,
+ "grid": 16
+ },
+ "attrs": [],
+ "properties": {
+ "id": 340,
+ "order": 16,
+ "prevSize": 32,
+ "code": 59731,
+ "ligatures": "calendar, date",
+ "name": "calendar"
+ },
+ "setIdx": 4,
+ "setId": 0,
+ "iconIdx": 83
}
],
"height": 1024,
diff --git a/app/index.html b/app/index.html
index 8c5b8216..2f5d91eb 100644
--- a/app/index.html
+++ b/app/index.html
@@ -77,6 +77,8 @@
+
+
@@ -103,6 +105,7 @@
+
@@ -131,6 +134,9 @@
+
+
+
diff --git a/app/js/api/api.js b/app/js/api/api.js
index fc6081fe..d9a90df4 100644
--- a/app/js/api/api.js
+++ b/app/js/api/api.js
@@ -11,6 +11,7 @@ angular.module("FM.api", [
"FM.api.PlayerRandomResource",
"FM.api.PlayerTransportResource",
"FM.api.PlayerVolumeResource",
+ "FM.api.StatsResource",
"FM.api.TracksResource",
"FM.api.UsersResource",
"FM.api.RequestInterceptor",
diff --git a/app/js/api/factories/StatsResource.js b/app/js/api/factories/StatsResource.js
new file mode 100644
index 00000000..80de1751
--- /dev/null
+++ b/app/js/api/factories/StatsResource.js
@@ -0,0 +1,26 @@
+"use strict";
+/**
+ * Factory which provides methods to perform on thisisoon FM API
+ * /player/stats endpoint.
+ * @module FM.api.StatsResource
+ * @author SOON_
+ */
+angular.module("FM.api.StatsResource", [
+ "config",
+ "ngResource"
+])
+/**
+ * @constructor
+ * @class StatsResource
+ * @param {Service} $resource
+ * @param {String} env
+ */
+.factory("StatsResource", [
+ "$resource",
+ "env",
+ function ($resource, env) {
+
+ return $resource(env.FM_API_SERVER_ADDRESS + "player/stats");
+
+ }
+]);
diff --git a/app/js/app.js b/app/js/app.js
index 531cdb20..68b807f4 100644
--- a/app/js/app.js
+++ b/app/js/app.js
@@ -18,6 +18,7 @@ angular.module("FM", [
"FM.sockets",
"FM.loadingScreen",
"FM.nav",
+ "FM.stats",
"ngRoute",
"notification",
"config"
diff --git a/app/js/stats/controllers/StatsCtrl.js b/app/js/stats/controllers/StatsCtrl.js
new file mode 100644
index 00000000..6d621021
--- /dev/null
+++ b/app/js/stats/controllers/StatsCtrl.js
@@ -0,0 +1,243 @@
+"use strict";
+/**
+ * @module FM.stats.StatsCtrl
+ * @author "SOON_"
+ */
+angular.module("FM.stats.StatsCtrl", [
+ "FM.api.StatsResource",
+ "ngRoute",
+ "chart.js",
+ "ui.bootstrap.datepicker"
+])
+/**
+ * @method config
+ * @param {Provider} $routeProvider
+ */
+.config([
+ "$routeProvider",
+ function ($routeProvider) {
+
+ $routeProvider
+ .when("/stats", {
+ templateUrl: "partials/stats.html",
+ controller: "StatsCtrl",
+ resolve: {
+ stats: ["StatsResource", "$route", function (StatsResource, $route){
+ return StatsResource.get($route.current.params).$promise;
+ }]
+ }
+ });
+
+ }
+])
+/**
+ * @constructor
+ * @param {Object} $scope Application data on scope
+ * @param {Object} stats Data resolved from API
+ */
+.controller("StatsCtrl", [
+ "$scope",
+ "$q",
+ "$filter",
+ "$location",
+ "CHART_COLOURS",
+ "CHART_OPTIONS",
+ "StatsResource",
+ "stats",
+ function ($scope, $q, $filter, $location, CHART_COLOURS, CHART_OPTIONS, StatsResource, stats) {
+
+ /**
+ * Current search params
+ * @property {Object} search
+ */
+ $scope.search = $location.search();
+
+ /**
+ * @property {Object} filter
+ */
+ $scope.filter = {};
+
+ /**
+ * Track open status of datepickers
+ * @property {Object} datepickerOpened
+ */
+ $scope.datepickerOpened = {
+ from: "",
+ to: ""
+ };
+
+ /**
+ * Stats resolved from the API
+ * @property {Object} stats
+ */
+ $scope.stats = stats;
+
+ /**
+ * List of colours to use for charts
+ * @property {Array} chartColours
+ */
+ $scope.chartColours = CHART_COLOURS;
+
+ /**
+ * ChartJS config for active DJs pie chart
+ * @property {Object} activeDj
+ */
+ $scope.activeDj = {
+ labels: [],
+ data: [],
+ options: CHART_OPTIONS
+ };
+
+ /**
+ * ChartJS config for play time line graph
+ * @property {Object} playTime
+ */
+ $scope.playTime = {
+ options: CHART_OPTIONS
+ };
+
+ /**
+ * Format series based stats data for chart and add to existing dataset
+ * @method addDataToSeries
+ * @param {Object} dataset ChartJs config object
+ * @param {Object} data New stat data from API
+ * @param {String} label Label for dataset
+ * @param {Number} maxSeries Maximum number of series to extract from data
+ */
+ $scope.addDataToSeries = function addDataToSeries (dataset, data, label, maxSeries) {
+
+ // initalise dataset properties
+ dataset.labels = dataset.labels || [];
+ dataset.data = dataset.data || [];
+ dataset.series = dataset.series || [];
+
+ // default maximum series length to 5
+ maxSeries = maxSeries || 5;
+
+ if (data && data.length) {
+
+ // add dataset label
+ dataset.labels.unshift(label);
+
+ // parse data from API to chart data array
+ data.forEach(function(item, index){
+ if (index < maxSeries) {
+ dataset.data[index] = dataset.data[index] || [];
+ dataset.series[index] = item.user.display_name; // jshint ignore:line
+ // convert milliseconds to minutes
+ dataset.data[index].unshift(Math.round(item.total/1000/60));
+ }
+ });
+ }
+ };
+
+ /**
+ * Load historic play time statistics for line chart based on filter start/end dates
+ * @method loadHistoricData
+ * @param {String} startDate Filter start date
+ * @param {String} endDate Filter end date
+ */
+ $scope.loadHistoricData = function loadHistoricData (startDate, endDate) {
+
+ startDate = new Date(startDate);
+ endDate = new Date(endDate);
+
+ /**
+ * Difference in days between filter start and end dates
+ * @property {Number} diff
+ */
+ var diff = (startDate.getTime() - endDate.getTime()) / (24 * 60 * 60 * 1000);
+
+ /**
+ * Calculated dates for historic data, based on filter dates
+ * Eg. if the filter period is 1 week this will be 1 week before, 2 weeks before and 3 weeks before
+ * @property {Array} dates
+ */
+ var dates = [{
+ from: $filter("date")(new Date().setDate(startDate.getDate() + diff), "yyyy-MM-dd"),
+ to: $filter("date")(new Date().setDate(startDate.getDate() + diff - 1), "yyyy-MM-dd"),
+ },{
+ from: $filter("date")(new Date().setDate(startDate.getDate() + (diff * 2)), "yyyy-MM-dd"),
+ to: $filter("date")(new Date().setDate(startDate.getDate() + (diff * 2) - 1), "yyyy-MM-dd"),
+ },{
+ from: $filter("date")(new Date().setDate(startDate.getDate() + (diff * 3)), "yyyy-MM-dd"),
+ to: $filter("date")(new Date().setDate(startDate.getDate() + (diff * 3) - 1), "yyyy-MM-dd"),
+ }];
+
+
+ // request historic data from API using calculated date ranges
+ $q.all([
+ StatsResource.get(dates[0]).$promise,
+ StatsResource.get(dates[1]).$promise,
+ StatsResource.get(dates[2]).$promise
+ ]).then(function (responses) {
+ responses.forEach(function (response, index) {
+ // add data to play time chart dataset
+ $scope.addDataToSeries($scope.playTime, response.total_play_time_per_user, $filter("date")(dates[index].from, "dd-MM-yyyy")); // jshint ignore:line
+ });
+ });
+ };
+
+ /**
+ * Filter stats by dates
+ * @method updateFilter
+ */
+ $scope.updateFilter = function updateFilter () {
+ $scope.search = $scope.filter;
+ $scope.search.to = $scope.search.to.toISOString ? $filter("date")($scope.search.to.toISOString(), "yyyy-MM-dd") : $scope.search.to;
+ $scope.search.from = $scope.search.from.toISOString ? $filter("date")($scope.search.from.toISOString(), "yyyy-MM-dd") : $scope.search.from;
+ $location.search($scope.search);
+ };
+
+ /**
+ * Open datepicker
+ * @method updateFilter
+ */
+ $scope.openDatepicker = function openDatepicker($event, id) {
+ $event.preventDefault();
+ $event.stopPropagation();
+ $scope.datepickerOpened[id] = !$scope.datepickerOpened[id] ;
+ };
+
+ /**
+ * Set current filter params and load historic data on initalise
+ * @method init
+ */
+ $scope.init = function init () {
+
+ /**
+ * Calculate date boundaries of last week
+ * @property {Array} lastWeek
+ */
+ var lastWeek = [new Date(), new Date()];
+ lastWeek[0].setDate(lastWeek[0].getDate() - 14);
+ lastWeek[1].setDate(lastWeek[1].getDate() - 7);
+
+ // set max datepicker date to be end of last week
+ $scope.datepickerMaxDate = lastWeek[1];
+
+ // default filter to last week
+ $scope.filter.from = $scope.search.from || lastWeek[0];
+ $scope.filter.to = $scope.search.to || lastWeek[1];
+
+
+ // Format most active DJ stats for charts
+ if (stats.most_active_djs) { // jshint ignore:line
+ stats.most_active_djs.forEach(function(item, index){ // jshint ignore:line
+ if (index < 5) {
+ $scope.activeDj.labels.push(item.user.display_name); // jshint ignore:line
+ $scope.activeDj.data.push(item.total);
+ }
+ });
+ }
+
+ // Format total play time per user stats for charts
+ $scope.addDataToSeries($scope.playTime, stats.total_play_time_per_user, $filter("date")($scope.filter.from, "dd-MM-yyyy")); // jshint ignore:line
+ // Load additional data for play time line chart
+ $scope.loadHistoricData($scope.filter.from, $scope.filter.to);
+ };
+
+ $scope.init();
+
+ }
+]);
diff --git a/app/js/stats/stats.js b/app/js/stats/stats.js
new file mode 100644
index 00000000..326398d0
--- /dev/null
+++ b/app/js/stats/stats.js
@@ -0,0 +1,30 @@
+"use strict";
+/**
+ * @module FM.stats
+ * @author SOON_
+ */
+angular.module("FM.stats", [
+ "FM.stats.StatsCtrl"
+])
+/**
+ * Colour scheme for charts
+ * @property {Array} CHART_COLOURS
+ */
+.value("CHART_COLOURS", [
+ "#08589e",
+ "#2b8cbe",
+ "#4eb3d3",
+ "#7bccc4",
+ "#a8ddb5",
+ "#ccebc5",
+ "#e0f3db",
+ "#f7fcf0"
+])
+/**
+ * ChartJS global chart options
+ * @property {Object} CHART_OPTIONS
+ */
+.constant("CHART_OPTIONS", {
+ segmentShowStroke : false,
+ animationEasing : "easeInOutQuart"
+});
diff --git a/app/less/default/core/forms.less b/app/less/default/core/forms.less
index bfafc689..acb1e4f6 100644
--- a/app/less/default/core/forms.less
+++ b/app/less/default/core/forms.less
@@ -6,6 +6,22 @@
.box-shadow(none);
}
+.input-group-btn button {
+ border-bottom-right-radius: @input-border-radius;
+ border-top-right-radius: @input-border-radius;
+}
+
+form.form-dark {
+ .form-control {
+ background-color: @light-black;
+ border-color: @light-black;
+ }
+ .input-group-btn button {
+ background-color: @lighter-black;
+ border-color: @lighter-black;
+ }
+}
+
// Style seek and volume sliders
// https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/
input[type=range] {
diff --git a/app/less/default/core/glyphicons.less b/app/less/default/core/glyphicons.less
index 5523bb6a..332836de 100644
--- a/app/less/default/core/glyphicons.less
+++ b/app/less/default/core/glyphicons.less
@@ -42,3 +42,9 @@
.icon-play { &:before { content: "\e608"; }}
.icon-playlist-add { &:before { content: "\e609"; }}
.icon-playlist { &:before { content: "\e60a"; }}
+.icon-calendar { &:before { content: "\e953"; }}
+.icon-stats { &:before { content: "\e60c"; }}
+.glyphicon-chevron-right,
+.icon-chevron-right { &:before { content: "\e60b"; }}
+.glyphicon-chevron-left,
+.icon-chevron-left { &:before { content: "\e60d"; }}
diff --git a/app/less/default/modules/stats.less b/app/less/default/modules/stats.less
new file mode 100644
index 00000000..c090086a
--- /dev/null
+++ b/app/less/default/modules/stats.less
@@ -0,0 +1,37 @@
+//
+// Stats
+// --------------------------------------------------
+
+.stats-grid {
+
+ margin-bottom: 20px;
+ margin-top: 20px;
+
+ .row:not(:last-child) {
+ border-bottom: 2px solid @light-black;
+ }
+
+ .row > div {
+ padding: 30px;
+
+ &:not(:first-child) {
+ border-left: 2px solid @light-black;
+ }
+ }
+}
+
+.genre {
+
+ h3, p {
+ display: inline-block;
+ }
+
+ .genre-name {
+ margin-left: 20px;
+ text-transform: capitalize;
+ }
+}
+
+.genre-list {
+ .content-columns(2);
+}
diff --git a/app/less/default/modules/track.less b/app/less/default/modules/track.less
index 72a700a4..8e094fff 100644
--- a/app/less/default/modules/track.less
+++ b/app/less/default/modules/track.less
@@ -20,6 +20,10 @@
padding-right: 20px;
}
+ .footer & {
+ margin-bottom: 0;
+ }
+
.footer .selected {
display: none;
}
diff --git a/app/less/main.less b/app/less/main.less
index 626bc4cf..736e2dd0 100644
--- a/app/less/main.less
+++ b/app/less/main.less
@@ -24,6 +24,7 @@
@import "@{bootstrap-less}grid.less";
@import "@{bootstrap-less}tables.less";
@import "@{bootstrap-less}forms.less";
+@import "@{bootstrap-less}input-groups.less";
@import "@{bootstrap-less}buttons.less";
// Components
@@ -65,6 +66,7 @@
@import "default/modules/alert.less";
@import "default/modules/animations.less";
@import "default/modules/progress.less";
+@import "default/modules/stats.less";
// Import less files for large screen devices here
@@ -97,4 +99,5 @@
@import "mobile/modules/controls.less";
@import "mobile/modules/images.less";
@import "mobile/modules/progress.less";
+ @import "mobile/modules/stats.less";
}
diff --git a/app/less/mobile/modules/stats.less b/app/less/mobile/modules/stats.less
new file mode 100644
index 00000000..4d53caff
--- /dev/null
+++ b/app/less/mobile/modules/stats.less
@@ -0,0 +1,24 @@
+//
+// Stats
+// --------------------------------------------------
+
+.stats-grid {
+
+ .row:not(:last-child) {
+ border-bottom: none;
+ }
+
+ .row > div {
+ border-bottom: 2px solid @light-black;
+ padding-left: 0;
+ padding-right: 0;
+
+ &:not(:first-child) {
+ border-left: none;
+ }
+ }
+}
+
+.genre-list {
+ .content-columns(1);
+}
diff --git a/app/partials/header.html b/app/partials/header.html
index f4b4b861..7cb60df4 100644
--- a/app/partials/header.html
+++ b/app/partials/header.html
@@ -34,6 +34,11 @@
Track history
+
+
+ Stats
+
+