From 9947c38e1c996562d653863705c777ff989e4b7d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 15 May 2018 20:12:03 +0200 Subject: [PATCH] UX: support for multiple datasets in one chart --- .../components/dashboard-inline-table.js.es6 | 30 ++--- .../components/dashboard-mini-chart.js.es6 | 116 +++++++----------- .../admin/mixins/async-report.js.es6 | 100 +++++++++------ .../javascripts/admin/models/report.js.es6 | 19 ++- .../components/dashboard-inline-table.hbs | 56 +++++---- .../components/dashboard-mini-chart.hbs | 58 +++++---- .../admin/templates/dashboard_next.hbs | 59 +++------ .../conditional-loading-section.js.es6 | 9 +- .../conditional-loading-section.hbs | 2 +- .../common/admin/dashboard_next.scss | 92 +++++++++----- app/jobs/regular/retrieve_report.rb | 3 + app/models/report.rb | 16 ++- 12 files changed, 302 insertions(+), 258 deletions(-) diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 index 7a1720db3e7..c37863e76d0 100644 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -1,21 +1,22 @@ -import { ajax } from 'discourse/lib/ajax'; +import { ajax } from "discourse/lib/ajax"; import Report from "admin/models/report"; import AsyncReport from "admin/mixins/async-report"; export default Ember.Component.extend(AsyncReport, { classNames: ["dashboard-table", "dashboard-inline-table", "fixed"], - isLoading: true, help: null, helpPage: null, + title: null, + loadingTitle: null, loadReport(report_json) { - this._setPropertiesFromReport(Report.create(report_json)); + return Report.create(report_json); }, fetchReport() { - this.set("isLoading", true); + this._super(); - let payload = { data: { async: true } }; + let payload = { data: { async: true, facets: ["total", "prev30Days"] } }; if (this.get("startDate")) { payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); @@ -29,14 +30,15 @@ export default Ember.Component.extend(AsyncReport, { payload.data.limit = this.get("limit"); } - ajax(this.get("dataSource"), payload) - .then((response) => { - this.set('reportKey', response.report.report_key); - this.loadReport(response.report); - }).finally(() => { - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - }; - }); + this.set("reports", Ember.Object.create()); + this.set("reportKeys", []); + + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.set(`reports.${response.report.report_key}`, this.loadReport(response.report)); + this.get("reportKeys").pushObject(response.report.report_key); + }); + })); } }); diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 index 9839e3296fb..866e287b4b5 100644 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -1,5 +1,4 @@ import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; import AsyncReport from "admin/mixins/async-report"; import Report from "admin/models/report"; import { number } from 'discourse/lib/formatter'; @@ -26,41 +25,20 @@ function collapseWeekly(data, average) { export default Ember.Component.extend(AsyncReport, { classNames: ["dashboard-mini-chart"], - classNameBindings: ["trend", "oneDataPoint"], - isLoading: true, - trend: Ember.computed.alias("report.trend"), - oneDataPoint: false, - backgroundColor: "rgba(200,220,240,0.3)", - borderColor: "#08C", - average: false, - percent: false, total: 0, - @computed("dataSourceName") - dataSource(dataSourceName) { - if (dataSourceName) { - return `/admin/reports/${dataSourceName}`; - } + init() { + this._super(); + + this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"]; }, - @computed("trend") - trendIcon(trend) { - switch (trend) { - case "trending-up": - return "angle-up"; - case "trending-down": - return "angle-down"; - case "high-trending-up": - return "angle-double-up"; - case "high-trending-down": - return "angle-double-down"; - default: - return null; - } + pickColorAtIndex(index) { + return this._colorsPool[index] || this._colorsPool[0]; }, fetchReport() { - this.set("isLoading", true); + this._super(); let payload = { data: { async: true, facets: ["prev_period"] } @@ -79,56 +57,56 @@ export default Ember.Component.extend(AsyncReport, { this._chart = null; } - this.set("report", null); + this.set("reports", Ember.Object.create()); + this.set("reportKeys", []); - ajax(this.get("dataSource"), payload) - .then((response) => { - this.set('reportKey', response.report.report_key); - this.loadReport(response.report); - }) - .finally(() => { - if (this.get("oneDataPoint")) { - this.set("isLoading", false); - return; - } - - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - this.renderReport(); - } - }); + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.set(`reports.${response.report.report_key}`, this.loadReport(response.report)); + this.get("reportKeys").pushObject(response.report.report_key); + }); + })); }, - loadReport(report) { - if (_.isArray(report.data)) { - Report.fillMissingDates(report); + loadReport(report, previousReport) { + Report.fillMissingDates(report); - if (report.data && report.data.length > 40) { - report.data = collapseWeekly(report.data, this.get("average")); - } - - const model = Report.create(report); - this._setPropertiesFromReport(model); + if (report.data && report.data.length > 40) { + report.data = collapseWeekly(report.data, report.average); } + + if (previousReport && previousReport.color.length) { + report.color = previousReport.color; + } else { + const dataSourceNameIndex = this.get("dataSourceNames").split(",").indexOf(report.type); + report.color = this.pickColorAtIndex(dataSourceNameIndex); + } + + return Report.create(report); }, renderReport() { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - if (this.get("oneDataPoint")) return; + this._super(); Ember.run.schedule("afterRender", () => { const $chartCanvas = this.$(".chart-canvas"); - if (!$chartCanvas.length) return; const context = $chartCanvas[0].getContext("2d"); + const reports = _.values(this.get("reports")); + + const labels = Ember.makeArray(reports.get("firstObject.data")).map(d => d.x); + const data = { - labels: this.get("labels"), - datasets: [{ - data: Ember.makeArray(this.get("values")), - backgroundColor: this.get("backgroundColor"), - borderColor: this.get("borderColor") - }] + labels, + datasets: reports.map(report => { + return { + data: Ember.makeArray(report.data).map(d => d.y), + backgroundColor: "rgba(200,220,240,0.3)", + borderColor: report.color + }; + }) }; if (this._chart) { @@ -138,15 +116,6 @@ export default Ember.Component.extend(AsyncReport, { }); }, - _setPropertiesFromReport(report) { - const oneDataPoint = (this.get("startDate") && this.get("endDate")) && - this.get("startDate").isSame(this.get("endDate"), "day"); - - report.set("average", this.get("average")); - report.set("percent", this.get("percent")); - this.setProperties({ oneDataPoint, report }); - }, - _buildChartConfig(data) { return { type: "line", @@ -171,6 +140,7 @@ export default Ember.Component.extend(AsyncReport, { }], xAxes: [{ display: true, + gridLines: { display: false }, type: "time", time: { parser: "YYYY-MM-DD" diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 index 12b267b059e..352c0cb4359 100644 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ b/app/assets/javascripts/admin/mixins/async-report.js.es6 @@ -1,77 +1,101 @@ -import computed from 'ember-addons/ember-computed-decorators'; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Mixin.create({ classNameBindings: ["isLoading"], - report: null, + reports: null, + reportKeys: null, + isLoading: false, + dataSourceNames: "", init() { this._super(); - this._channel = this.get("dataSource"); + this.set("reports", Ember.Object.create()); + this.set("reportKeys", []); + + this._channels = this.get("dataSources"); this._callback = (report) => { - if (report.report_key = this.get("reportKey")) { + if (this.get("reportKeys").includes(report.report_key)) { Em.run.next(() => { - if (report.report_key = this.get("reportKey")) { - this.loadReport(report); - this.set("isLoading", false); + if (this.get("reportKeys").includes(report.report_key)) { + const previousReport = this.get(`reports.${report.report_key}`); + this.set(`reports.${report.report_key}`, this.loadReport(report, previousReport)); this.renderReport(); } }); } }; + // in case we did not subscribe in time ensure we always grab the // last thing on the channel - this.messageBus.subscribe(this._channel, this._callback, -2); + this.subscribe(-2); + }, + + subscribe(position) { + this._channels.forEach(channel => { + this.messageBus.subscribe(channel, this._callback, position); + }); + }, + + unsubscribe() { + this._channels.forEach(channel => { + this.messageBus.unsubscribe(channel, this._callback); + }); + }, + + @computed("dataSourceNames") + dataSources(dataSourceNames) { + return dataSourceNames.split(",").map(source => `/admin/reports/${source}`); }, willDestroyElement() { this._super(); - this.messageBus.unsubscribe(this._channel, this._callback); + + this.unsubscribe(); }, didInsertElement() { this._super(); Ember.run.later(this, function() { - this.fetchReport(); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, 500); }, didUpdateAttrs() { this._super(); - this.fetchReport(); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, - renderReport() {}, + renderReport() { + if (!this.element || this.isDestroying || this.isDestroyed) return; + + const reports = _.values(this.get("reports")); + + if (!reports.length) return; + + const title = reports.map(report => report.title).join(", "); + + if (reports.map(report => report.processing).includes(true)) { + const loading = I18n.t("conditional_loading_section.loading"); + this.set("loadingTitle", `${loading}\n\n${title}`); + return; + } + + this.setProperties({ title, isLoading: false}); + }, loadReport() {}, - fetchReport() {}, - - @computed("dataSourceName") - dataSource(dataSourceName) { - return `/admin/reports/${dataSourceName}`; + fetchReport() { + this.set("isLoading", true); + this.set("loadingTitle", I18n.t("conditional_loading_section.loading")); }, - - @computed("report") - labels(report) { - if (!report) return; - if (report.labels) { - return Ember.makeArray(report.labels); - } else { - return Ember.makeArray(report.data).map(r => r.x); - } - }, - - @computed("report") - values(report) { - if (!report) return; - return Ember.makeArray(report.data).map(r => r.y); - }, - - _setPropertiesFromReport(report) { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - this.setProperties({ report }); - } }); diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 853457570d6..94e72eaac47 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -5,6 +5,7 @@ import computed from 'ember-addons/ember-computed-decorators'; const Report = Discourse.Model.extend({ average: false, + percent: false, @computed("type", "start_date", "end_date") reportUrl(type, start_date, end_date) { @@ -101,7 +102,23 @@ const Report = Discourse.Model.extend({ @computed('data', 'currentTotal') currentAverage(data, total) { - return data.length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); + return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); + }, + + @computed("trend") + trendIcon(trend) { + switch (trend) { + case "trending-up": + return "angle-up"; + case "trending-down": + return "angle-down"; + case "high-trending-up": + return "angle-double-up"; + case "high-trending-down": + return "angle-double-down"; + default: + return null; + } }, @computed('prev_period', 'currentTotal', 'currentAverage') diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs index ce075761a5d..90cba2318b5 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs @@ -1,32 +1,40 @@ -{{#conditional-loading-section isLoading=isLoading title=report.title}} +{{#conditional-loading-section isLoading=isLoading title=loadingTitle}}
-

{{report.title}}

+

{{title}}

{{#if help}} {{i18n help}} {{/if}}
-
- - - - {{#each labels as |label|}} - - {{/each}} - - - - {{#unless hasBlock}} - {{#each values as |value|}} - - - - {{/each}} - {{else}} - {{yield (hash report=report)}} - {{/unless}} - -
{{label}}
{{number value}}
-
+ {{#each-in reports as |key report|}} +
+ + + + {{#if report.labels}} + {{#each report.labels as |label|}} + + {{/each}} + {{else}} + {{#each report.data as |data|}} + + {{/each}} + {{/if}} + + + + {{#unless hasBlock}} + {{#each report.data as |data|}} + + + + {{/each}} + {{else}} + {{yield (hash report=report)}} + {{/unless}} + +
{{label}}{{data.x}}
{{number data.y}}
+
+ {{/each-in}} {{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs index 09aa8342539..2ad937b70ae 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs @@ -1,35 +1,33 @@ -{{#conditional-loading-section isLoading=isLoading title=report.title}} -
-

- - {{report.title}} - -

+{{#conditional-loading-section isLoading=isLoading title=loadingTitle}} +
+ {{#each-in reports as |key report|}} +
+ +
+

+ + {{report.title}} + +

-
- {{#if average}} - - {{report.currentAverage}}{{if percent "%"}} - - {{else}} - - {{number report.currentTotal noTitle="true"}} - - {{/if}} - - {{#if trendIcon}} - {{d-icon trendIcon}} - {{/if}} -
+
+ + {{#if report.average}} + {{report.currentAverage}} + {{else}} + {{number report.currentTotal noTitle="true"}} + {{/if}} + + {{#if report.trendIcon}} + {{d-icon report.trendIcon}} + {{/if}} +
+
+
+ {{/each-in}}
-
- {{#if oneDataPoint}} - - {{number values.lastObject.y}} - - {{else}} - - {{/if}} +
+
{{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index d42c25a0417..8772f87e197 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -21,38 +21,29 @@
{{dashboard-mini-chart - dataSourceName="signups" + dataSourceNames="signups" startDate=startDate endDate=endDate}} {{dashboard-mini-chart - dataSourceName="topics" + dataSourceNames="topics,posts" startDate=startDate endDate=endDate}} {{dashboard-mini-chart - dataSourceName="posts" + dataSourceNames="dau_by_mau" startDate=startDate endDate=endDate}} {{dashboard-mini-chart - dataSourceName="dau_by_mau" - average=true - percent=true + dataSourceNames="daily_engaged_users" startDate=startDate endDate=endDate}} {{dashboard-mini-chart - dataSourceName="daily_engaged_users" - average=true + dataSourceNames="new_contributors" startDate=startDate endDate=endDate}} - - {{dashboard-mini-chart - dataSourceName="new_contributors" - startDate=startDate - endDate=endDate}} -
@@ -86,27 +77,15 @@ {{/conditional-loading-section}}
- {{#dashboard-inline-table dataSourceName="users_by_type" lastRefreshedAt=lastRefreshedAt as |context|}} + {{#dashboard-inline-table dataSourceNames="users_by_trust_level,users_by_type" lastRefreshedAt=lastRefreshedAt as |context|}} - {{#each context.report.data as |data|}} - - - {{number data.y}} - - - {{/each}} - - {{/dashboard-inline-table}} - - {{#dashboard-inline-table dataSourceName="users_by_trust_level" lastRefreshedAt=lastRefreshedAt as |context|}} - - {{#each context.report.data as |data|}} - - - {{number data.y}} - - - {{/each}} + {{#each context.report.data as |data|}} + + + {{number data.y}} + + + {{/each}} {{/dashboard-inline-table}} @@ -115,7 +94,9 @@
{{#if currentUser.admin}}
-

{{i18n "admin.dashboard.backups"}}

+

+ {{d-icon "archive"}} {{i18n "admin.dashboard.backups"}} +

{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
@@ -125,7 +106,7 @@ {{/if}}

-

{{i18n "admin.dashboard.uploads"}}

+

{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}

{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})

@@ -153,7 +134,7 @@
{{#dashboard-inline-table - dataSourceName="top_referred_topics" + dataSourceNames="top_referred_topics" lastRefreshedAt=lastRefreshedAt limit=8 as |context|}} @@ -173,7 +154,7 @@ {{#dashboard-inline-table limit=8 - dataSourceName="trending_search" + dataSourceNames="trending_search" isEnabled=logSearchQueriesEnabled disabledLabel="admin.dashboard.reports.trending_search.disabled" startDate=lastWeek @@ -194,5 +175,5 @@ {{{i18n "admin.dashboard.reports.trending_search.more"}}} {{/dashboard-inline-table}} -
+
diff --git a/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 b/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 index 7e9323eb5ed..084513463f7 100644 --- a/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 +++ b/app/assets/javascripts/discourse/components/conditional-loading-section.js.es6 @@ -1,14 +1,7 @@ -import computed from 'ember-addons/ember-computed-decorators'; - export default Ember.Component.extend({ classNames: ["conditional-loading-section"], classNameBindings: ["isLoading"], - isLoading: false, - - @computed("title") - computedTitle(title) { - return title || I18n.t("conditional_loading_section.loading"); - } + isLoading: false }); diff --git a/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs b/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs index 2cc7599b98e..b97ce25a01f 100644 --- a/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs +++ b/app/assets/javascripts/discourse/templates/components/conditional-loading-section.hbs @@ -1,5 +1,5 @@ {{#if isLoading}} - {{computedTitle}} + {{title}}
{{else}} {{yield}} diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 3e9a9fbcf4f..cea2fc24435 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -155,14 +155,69 @@ .charts { display: flex; - justify-content: space-between; + justify-content: flex-start; flex-wrap: wrap; - .dashboard-mini-chart { - max-width: calc(100% * (1/3)); - width: 100%; + .dashboard-mini-statuses { margin-bottom: 1em; + display: inline-flex; + } + + .dashboard-mini-status { + flex-direction: row; + margin-right: 1em; + display: flex; + + .indicator { + margin-right: .5em; + width: .33em; + height: 35px; + } + + .legend { + display: flex; + flex-direction: column; + + .title { + a {color: black;} + + font-size: $font-down-2; + font-weight: 700; + margin: 0; + } + + .trend { + flex-direction: row; + + .d-icon { + font-weight: 700; + + &.d-icon-angle-down, &.d-icon-angle-double-down { + color: $danger; + } + + &.d-icon-angle-up, &.d-icon-angle-double-up { + color: rgb(17, 141, 0); + } + } + } + } + } + + .dashboard-mini-chart { + max-width: calc(100% * 1/3); + width: 100%; flex-grow: 1; + flex-basis: 100%; + display: flex; + margin-bottom: 1em; + + .conditional-loading-section { + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + } @include small-width { max-width: 100%; @@ -207,25 +262,6 @@ color: $danger; } } - - &.one-data-point { - .chart-container { - min-height: 150px; - justify-content: center; - align-items: center; - display: flex; - } - - .data-point { - width: 100%; - font-size: 6em; - font-weight: bold; - border-radius: 3px; - background: rgba(200,220,240,0.3); - text-align: center; - padding: .5em 0; - } - } } @include small-width { @@ -234,11 +270,6 @@ } } - .chart-container { - position: relative; - padding: 0 1em 0 0; - } - .chart-trend { font-size: $font-up-3; display: flex; @@ -248,6 +279,11 @@ margin-right: 1em; } + .chart-canvas-container { + position: relative; + padding: 0 1em 0 0; + } + .chart-canvas { width: 100%; height: 100%; diff --git a/app/jobs/regular/retrieve_report.rb b/app/jobs/regular/retrieve_report.rb index 2d00ae39838..b1a000766a2 100644 --- a/app/jobs/regular/retrieve_report.rb +++ b/app/jobs/regular/retrieve_report.rb @@ -15,6 +15,9 @@ module Jobs report.group_id = args['group_id'] if args['group_id'] report.facets = args['facets'].map(&:to_sym) if args['facets'] report.limit = args['limit'].to_i if args['limit'] + report.processing = false + report.average = args[:average] || false + report.percent = args[:percent] || false Report.send("report_#{type}", report) json = report.as_json diff --git a/app/models/report.rb b/app/models/report.rb index 53e1b1bbb10..c3ba6bd8a4a 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -4,7 +4,7 @@ class Report attr_accessor :type, :data, :total, :prev30Days, :start_date, :end_date, :category_id, :group_id, :labels, :async, - :prev_period, :facets, :limit + :prev_period, :facets, :limit, :processing, :average, :percent def self.default_days 30 @@ -51,7 +51,10 @@ class Report group_id: group_id, prev30Days: self.prev30Days, report_key: Report.cache_key(self), - labels: labels + labels: labels, + processing: self.processing, + average: self.average, + percent: self.percent }.tap do |json| json[:total] = total if total json[:prev_period] = prev_period if prev_period @@ -80,6 +83,9 @@ class Report report.async = opts[:async] || false report.facets = opts[:facets] || [:total, :prev30Days] report.limit = opts[:limit] if opts[:limit] + report.processing = false + report.average = opts[:average] || false + report.percent = opts[:percent] || false report_method = :"report_#{type}" if respond_to?(report_method) @@ -89,6 +95,7 @@ class Report return cached_report else Jobs.enqueue(:retrieve_report, opts.merge(report_type: type)) + report.processing = true end else send(report_method, report) @@ -176,6 +183,8 @@ class Report end def self.report_daily_engaged_users(report) + report.average = true + report.data = [] data = UserAction.count_daily_engaged_users(report.start_date, report.end_date) @@ -205,6 +214,9 @@ class Report end def self.report_dau_by_mau(report) + report.average = true + report.percent = true + data_points = UserVisit.count_by_active_users(report.start_date, report.end_date) report.data = []