From 9fabf2543bcd573fb112aa92f0661e795695b1be Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 26 Apr 2018 14:49:41 +0200 Subject: [PATCH] dashboard next: activity metrics and new contributors This commit also introduces a better grouping of data points. --- .../components/dashboard-inline-table.js.es6 | 2 +- .../components/dashboard-mini-chart.js.es6 | 147 +++++++++--------- .../controllers/admin-dashboard-next.js.es6 | 16 +- .../admin/models/admin-dashboard-next.js.es6 | 29 +++- .../javascripts/admin/models/report.js.es6 | 14 +- .../components/dashboard-mini-chart.hbs | 8 +- .../admin/templates/dashboard_next.hbs | 52 +++++-- .../stylesheets/common/admin/admin_base.scss | 8 +- .../common/admin/dashboard_next.scss | 43 ++++- app/models/admin_dashboard_next_data.rb | 3 +- app/models/report.rb | 5 + app/models/topic.rb | 5 +- app/models/user.rb | 12 +- config/locales/client.en.yml | 7 +- config/locales/server.en.yml | 7 + spec/models/report_spec.rb | 30 ++++ 16 files changed, 260 insertions(+), 128 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 070625e9e47..dd8582461bf 100644 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -2,7 +2,7 @@ import { ajax } from 'discourse/lib/ajax'; import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Component.extend({ - classNames: ["dashboard-table", "dashboard-inline-table"], + classNames: ["dashboard-table", "dashboard-inline-table", "fixed"], classNameBindings: ["isLoading"], 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 eea4bc917cd..3b56917f997 100644 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -1,6 +1,7 @@ -import { ajax } from 'discourse/lib/ajax'; -import computed from 'ember-addons/ember-computed-decorators'; -import loadScript from 'discourse/lib/load-script'; +import { ajax } from "discourse/lib/ajax"; +import computed from "ember-addons/ember-computed-decorators"; +import loadScript from "discourse/lib/load-script"; +import Report from "admin/models/report"; export default Ember.Component.extend({ classNames: ["dashboard-mini-chart"], @@ -17,12 +18,26 @@ export default Ember.Component.extend({ didInsertElement() { this._super(); - this._initializeChart(); + + if (this.get("model")) { + loadScript("/javascripts/Chart.min.js").then(() => { + this._setPropertiesFromModel(this.get("model")); + this._drawChart(); + }); + } }, didUpdateAttrs() { this._super(); - this._initializeChart(); + + loadScript("/javascripts/Chart.min.js").then(() => { + if (this.get("model") && !this.get("values")) { + this._setPropertiesFromModel(this.get("model")); + this._drawChart(); + } else if (this.get("dataSource")) { + this._fetchReport(); + } + }); }, @computed("dataSourceName") @@ -34,10 +49,17 @@ export default Ember.Component.extend({ @computed("trend") trendIcon(trend) { - if (trend === "stable") { - return null; - } else { - return `angle-${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; } }, @@ -46,7 +68,9 @@ export default Ember.Component.extend({ this.set("isLoading", true); - let payload = {data: {}}; + let payload = { + data: {} + }; if (this.get("startDate")) { payload.data.start_date = this.get("startDate").toISOString(); @@ -58,7 +82,7 @@ export default Ember.Component.extend({ ajax(this.get("dataSource"), payload) .then((response) => { - this._setPropertiesFromModel(response.report); + this._setPropertiesFromModel(Report.create(response.report)); }) .finally(() => { this.set("isLoading", false); @@ -71,17 +95,6 @@ export default Ember.Component.extend({ }); }, - _initializeChart() { - loadScript("/javascripts/Chart.min.js").then(() => { - if (this.get("model") && !this.get("values")) { - this._setPropertiesFromModel(this.get("model")); - this._drawChart(); - } else if (this.get("dataSource")) { - this._fetchReport(); - } - }); - }, - _drawChart() { const $chartCanvas = this.$(".chart-canvas"); if (!$chartCanvas.length) return; @@ -91,7 +104,7 @@ export default Ember.Component.extend({ const data = { labels: this.get("labels"), datasets: [{ - data: this.get("values"), + data: Ember.makeArray(this.get("values")), backgroundColor: this.get("backgroundColor"), borderColor: this.get("borderColor") }] @@ -100,72 +113,64 @@ export default Ember.Component.extend({ this._chart = new window.Chart(context, this._buildChartConfig(data)); }, - _setPropertiesFromModel(model) { + _setPropertiesFromModel(report) { + const oneDataPoint = (this.get("startDate") && this.get("endDate")) && + this.get("startDate").isSame(this.get("endDate"), "day"); + this.setProperties({ - labels: model.data.map(r => r.x), - values: model.data.map(r => r.y), - oneDataPoint: (this.get("startDate") && this.get("endDate")) && - this.get("startDate").isSame(this.get("endDate"), 'day'), - total: model.total, - title: model.title, - trend: this._computeTrend(model.total, model.prev30Days) + oneDataPoint, + labels: report.get("data").map(r => r.x), + values: report.get("data").map(r => r.y), + total: report.get("total"), + description: report.get("description"), + title: report.get("title"), + trend: report.get("sevenDayTrend"), + prev30Days: report.get("prev30Days"), }); }, _buildChartConfig(data) { - const values = this.get("values"); + const values = data.datasets[0].data; const max = Math.max(...values); const min = Math.min(...values); - const stepSize = Math.max(...[Math.ceil((max - min)/5), 20]); - const startDate = this.get("startDate") || moment(); - const endDate = this.get("endDate") || moment(); - const datesDifference = startDate.diff(endDate, "days"); - let unit = "day"; - if (datesDifference >= 366) { - unit = "quarter"; - } else if (datesDifference >= 61) { - unit = "month"; - } else if (datesDifference >= 14) { - unit = "week"; - } + const stepSize = Math.max(...[Math.ceil((max - min) / 5) * 5, 20]); return { type: "line", data, options: { - legend: { display: false }, + legend: { + display: false + }, responsive: true, - layout: { padding: { left: 0, top: 0, right: 0, bottom: 0 } }, + maintainAspectRatio: false, + layout: { + padding: { + left: 0, + top: 0, + right: 0, + bottom: 0 + } + }, scales: { - yAxes: [ - { - display: true, - ticks: { suggestedMin: 0, stepSize, suggestedMax: max + stepSize } + yAxes: [{ + display: true, + ticks: { + suggestedMin: 0, + stepSize, + suggestedMax: max + stepSize } - ], - xAxes: [ - { - display: true, - type: "time", - time: { - parser: "YYYY-MM-DD", - unit - } + }], + xAxes: [{ + display: true, + type: "time", + time: { + parser: "YYYY-MM-DD" } - ], + }], } }, }; - }, - - _computeTrend(total, prevTotal) { - const percentChange = ((total - prevTotal) / prevTotal) * 100; - - if (percentChange > 50) return "double-up"; - if (percentChange > 0) return "up"; - if (percentChange === 0) return "stable"; - if (percentChange < 50) return "double-down"; - if (percentChange < 0) return "down"; - }, + } }); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 index 3afb55343dd..0c56cfe971a 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 @@ -1,11 +1,6 @@ import DiscourseURL from "discourse/lib/url"; import computed from "ember-addons/ember-computed-decorators"; import AdminDashboardNext from 'admin/models/admin-dashboard-next'; -import Report from 'admin/models/report'; - -const ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at"]; - -const REPORTS = [ "global_reports", "user_reports" ]; export default Ember.Controller.extend({ queryParams: ["period"], @@ -14,20 +9,17 @@ export default Ember.Controller.extend({ dashboardFetchedAt: null, exceptionController: Ember.inject.controller('exception'), + diskSpace: Ember.computed.alias("model.attributes.disk_space"), + fetchDashboard() { if (this.get("isLoading")) return; if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) { this.set("isLoading", true); - AdminDashboardNext.find().then(d => { + AdminDashboardNext.find().then(adminDashboardNextModel => { this.set("dashboardFetchedAt", new Date()); - - const reports = {}; - REPORTS.forEach(name => d[name].forEach(r => reports[`${name}_${r.type}`] = Report.create(r))); - this.setProperties(reports); - - ATTRIBUTES.forEach(a => this.set(a, d[a])); + this.set("model", adminDashboardNextModel); }).catch(e => { this.get("exceptionController").set("thrown", e.jqXHR); this.replaceRoute("exception"); diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 index fb4d7519c30..880c2d5488f 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 @@ -1,9 +1,13 @@ -import { ajax } from 'discourse/lib/ajax'; +import { ajax } from "discourse/lib/ajax"; +import Report from "admin/models/report"; + +const ATTRIBUTES = [ "disk_space", "updated_at", "last_backup_taken_at"]; + +const REPORTS = [ "global_reports", "user_reports" ]; const AdminDashboardNext = Discourse.Model.extend({}); AdminDashboardNext.reopenClass({ - /** Fetch all dashboard data. This can be an expensive request when the cached data has expired and the server must collect the data again. @@ -11,13 +15,26 @@ AdminDashboardNext.reopenClass({ @method find @return {jqXHR} a jQuery Promise object **/ - find: function() { + find() { return ajax("/admin/dashboard-next.json").then(function(json) { - var model = AdminDashboardNext.create(json); - model.set('loaded', true); + var model = AdminDashboardNext.create(); + + const reports = {}; + REPORTS.forEach(name => json[name].forEach(r => { + if (!reports[name]) reports[name] = {}; + reports[name][r.type] = Report.create(r); + })); + model.set("reports", reports); + + const attributes = {}; + ATTRIBUTES.forEach(a => attributes[a] = json[a]); + model.set("attributes", attributes); + + model.set("loaded", true); + return model; }); - }, + } }); export default AdminDashboardNext; diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 3ed442bb0d2..fac048a62a4 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -60,12 +60,18 @@ const Report = Discourse.Model.extend({ sevenDayTrend() { const currentPeriod = this.valueFor(1, 7); const prevPeriod = this.valueFor(8, 14); - if (currentPeriod > prevPeriod) { + const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100; + + if (change > 50) { + return "high-trending-up"; + } else if (change > 0) { return "trending-up"; - } else if (currentPeriod < prevPeriod) { - return "trending-down"; - } else { + } else if (change === 0) { return "no-change"; + } else if (change < -50) { + return "high-trending-down"; + } else if (change < 0) { + return "trending-down"; } }, 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 7f12611bc98..20fb90063d6 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs @@ -2,8 +2,10 @@

{{title}}

- {{#if help}} - {{d-icon "question-circle" title=help}} + {{#if description}} + + {{d-icon "question-circle"}} + {{/if}}
@@ -14,7 +16,7 @@ {{else}}
- {{number total}} + {{number prev30Days}} {{#if trendIcon}} {{d-icon trendIcon}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index 99f3de35bd1..3d0cca00c3d 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -1,5 +1,5 @@ {{plugin-outlet name="admin-dashboard-top"}} -{{lastRefreshedAt}} +

{{i18n "admin.dashboard.community_health"}}

@@ -9,31 +9,61 @@
{{dashboard-mini-chart - model=global_reports_signups + model=model.reports.global_reports.signups dataSourceName="signups" startDate=startDate - endDate=endDate - help="admin.dashboard.charts.signups.help"}} + endDate=endDate}} {{dashboard-mini-chart - model=global_reports_topics + model=model.reports.global_reports.topics dataSourceName="topics" startDate=startDate - endDate=endDate - help="admin.dashboard.charts.topics.help"}} + endDate=endDate}} + + {{dashboard-mini-chart + model=model.reports.global_reports.new_contributors + dataSourceName="new_contributors" + startDate=startDate + endDate=endDate}}
+
+
+

{{i18n "admin.dashboard.activity_metrics"}}

+
+ +
+ + + + + + + + + + + + + {{admin-report-counts report=model.reports.global_reports.topics}} + {{admin-report-counts report=model.reports.global_reports.signups}} + {{admin-report-counts report=model.reports.global_reports.new_contributors}} + +
{{i18n 'admin.dashboard.reports.today'}}{{i18n 'admin.dashboard.reports.yesterday'}}{{i18n 'admin.dashboard.reports.last_7_days'}}{{i18n 'admin.dashboard.reports.last_30_days'}}{{i18n 'admin.dashboard.reports.all'}}
+
+
+ {{dashboard-inline-table - model=user_reports_users_by_type + model=model.reports.user_reports.users_by_type lastRefreshedAt=lastRefreshedAt isLoading=isLoading}} {{dashboard-inline-table - model=user_reports_users_by_trust_level + model=model.reports.user_reports.users_by_trust_level lastRefreshedAt=lastRefreshedAt isLoading=isLoading}} @@ -44,7 +74,7 @@

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

- {{disk_space.backups_used}} ({{i18n "admin.dashboard.space_free" size=disk_space.backups_free}}) + {{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
{{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}}

@@ -54,7 +84,7 @@

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

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

diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index d86e5144992..d8c90a7b685 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -965,13 +965,13 @@ table.api-keys { display: none; } - &.trending-up { + &.high-trending-up, &.trending-up { i.up { color: $success; display: inline; } } - &.trending-down { + &.high-trending-down, &.trending-down { i.down { color: $danger; display: inline; @@ -986,10 +986,10 @@ table.api-keys { } tr.reverse-colors { - td.value.trending-down i.down { + td.value.high-trending-down i.down, td.value.trending-down i.down { color: $success; } - td.value.trending-up i.up { + td.value.high-trending-up i.up, td.value.trending-up i.up { color: $danger; } } diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index b0ade25fb53..88654d1de8f 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -43,6 +43,10 @@ .dashboard-table { margin-bottom: 1em; + &.fixed table { + table-layout: fixed; + } + &.is-loading { height: 150px; } @@ -59,7 +63,6 @@ table { border: 1px solid $primary-low-mid; - table-layout: fixed; thead { tr { @@ -67,6 +70,10 @@ th { border: 1px solid $primary-low-mid; text-align: center; + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; } } } @@ -77,6 +84,31 @@ border: 1px solid $primary-low-mid; text-align: center; } + + td.value { + i { + display: none; + } + + &.high-trending-up, &.trending-up { + i.up { + color: $success; + display: inline; + } + } + &.high-trending-down, &.trending-down { + i.down { + color: $danger; + display: inline; + } + } + &.no-change { + i.down { + display: inline; + visibility: hidden; + } + } + } } } } @@ -110,13 +142,13 @@ } } - &.double-up, &.up { + &.high-trending-up, &.trending-up { .chart-trend, .data-point { color: rgb(17, 141, 0); } } - &.double-down, &.down { + &.high-trending-down, &.trending-down { .chart-trend, .data-point { color: $danger; } @@ -145,13 +177,14 @@ .chart-container { position: relative; padding: 0 1em; + min-height: 200px; } .chart-trend { font-size: $font-up-5; position: absolute; - right: 1.5em; - top: .5em; + right: 40px; + top: 5px; display: flex; justify-content: space-between; align-items: center; diff --git a/app/models/admin_dashboard_next_data.rb b/app/models/admin_dashboard_next_data.rb index ca6ce399cdc..2bd8c567bfa 100644 --- a/app/models/admin_dashboard_next_data.rb +++ b/app/models/admin_dashboard_next_data.rb @@ -4,7 +4,8 @@ class AdminDashboardNextData GLOBAL_REPORTS ||= [ 'signups', 'topics', - 'trending_search' + 'trending_search', + 'new_contributors' ] USER_REPORTS ||= [ diff --git a/app/models/report.rb b/app/models/report.rb index 522b51776b7..e6f789ac7e6 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -20,6 +20,7 @@ class Report title: I18n.t("reports.#{type}.title"), xaxis: I18n.t("reports.#{type}.xaxis"), yaxis: I18n.t("reports.#{type}.yaxis"), + description: I18n.t("reports.#{type}.description"), data: data, total: total, start_date: start_date, @@ -109,6 +110,10 @@ class Report end end + def self.report_new_contributors(report) + report_about report, User.real, :count_by_first_post + end + def self.report_profile_views(report) start_date = report.start_date.to_date end_date = report.end_date.to_date diff --git a/app/models/topic.rb b/app/models/topic.rb index efc4cf1ae5d..b8be34167ee 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -21,6 +21,7 @@ class Topic < ActiveRecord::Base include Searchable include LimitedEdit extend Forwardable + include DateGroupable def_delegator :featured_users, :user_ids, :featured_user_ids def_delegator :featured_users, :choose, :feature_topic_users @@ -458,9 +459,9 @@ class Topic < ActiveRecord::Base end def self.listable_count_per_day(start_date, end_date, category_id = nil) - result = listable_topics.where('created_at >= ? and created_at <= ?', start_date, end_date) + result = listable_topics.smart_group_by_date("topics.created_at", start_date, end_date) result = result.where(category_id: category_id) if category_id - result.group('date(created_at)').order('date(created_at)').count + result.count end def private_message? diff --git a/app/models/user.rb b/app/models/user.rb index 08beb52ac09..369b92636fc 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,7 @@ class User < ActiveRecord::Base include Roleable include HasCustomFields include SecondFactorManager + include DateGroupable # TODO: Remove this after 7th Jan 2018 self.ignored_columns = %w{email} @@ -829,13 +830,20 @@ class User < ActiveRecord::Base end def self.count_by_signup_date(start_date, end_date, group_id = nil) - result = where('users.created_at >= ? AND users.created_at <= ?', start_date, end_date) + result = smart_group_by_date("users.created_at", start_date, end_date) if group_id result = result.joins("INNER JOIN group_users ON group_users.user_id = users.id") result = result.where("group_users.group_id = ?", group_id) end - result.group('date(users.created_at)').order('date(users.created_at)').count + + result.count + end + + def self.count_by_first_post(start_date, end_date) + joins('INNER JOIN user_stats AS us ON us.user_id = users.id') + .smart_group_by_date("us.first_post_created_at", start_date, end_date) + .count end def secure_category_ids diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6ef8662b25f..45575a270f0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2742,12 +2742,7 @@ en: show_traffic_report: "Show Detailed Traffic Report" community_health: Community health whats_new_in_discourse: What’s new in Discourse? - - charts: - signups: - help: Users created for this period - topics: - help: Topics created for this period + activity_metrics: Activity Metrics reports: today: "Today" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d98a3696063..a98ebe93d25 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -840,6 +840,12 @@ en: title: "New Users" xaxis: "Day" yaxis: "Number of new users" + description: "Users created for this period" + new_contributors: + title: "New Contributors" + xaxis: "Day" + yaxis: "Number of new contributors" + description: "Number of users who made their first contribution" profile_views: title: "User Profile Views" xaxis: "Day" @@ -848,6 +854,7 @@ en: title: "Topics" xaxis: "Day" yaxis: "Number of new topics" + description: "Topics created for this period" posts: title: "Posts" xaxis: "Day" diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 23ff10b1224..98758d5938a 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -250,6 +250,36 @@ describe Report do end end + describe 'new contributors report' do + let(:report) { Report.find('new_contributors') } + + context "no contributors" do + it "returns an empty report" do + expect(report.data).to be_blank + end + end + + context "with contributors" do + before do + jeff = Fabricate(:user) + jeff.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 1.day.ago) + + regis = Fabricate(:user) + regis.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 2.days.ago) + + hawk = Fabricate(:user) + hawk.user_stat = UserStat.new(new_since: 1.hour.ago, first_post_created_at: 2.days.ago) + end + + it "returns a report with data" do + expect(report.data).to be_present + + expect(report.data[0][:y]).to eq 2 + expect(report.data[1][:y]).to eq 1 + end + end + end + describe 'users by types level report' do let(:report) { Report.find('users_by_type') }