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 @@ + + + + +