mirror of
https://github.com/discourse/discourse.git
synced 2025-03-06 03:09:43 +00:00
UX: improvements to new dashboard
- remove inactive user report and replace with posts - clean up internals so grouping by week happens on client - when switching periods old report was not destroyed leading to bugs - calculate trend based on previous interval ... not previous 30 days - show percentages for mau/dau - be more careful about utc date usage - show uniqu and click through rate on search panel - publish key of report with report so we only load the correct one - subscribe earlier in channel in case of concurrency issues
This commit is contained in:
parent
52d6b0f948
commit
8a783412b7
@ -4,21 +4,36 @@ import AsyncReport from "admin/mixins/async-report";
|
|||||||
import Report from "admin/models/report";
|
import Report from "admin/models/report";
|
||||||
import { number } from 'discourse/lib/formatter';
|
import { number } from 'discourse/lib/formatter';
|
||||||
|
|
||||||
|
function collapseWeekly(data, average) {
|
||||||
|
let aggregate = [];
|
||||||
|
let bucket, i;
|
||||||
|
let offset = data.length % 7;
|
||||||
|
for(i = offset; i < data.length; i++) {
|
||||||
|
|
||||||
|
if (bucket && (i % 7 === offset)) {
|
||||||
|
if (average) {
|
||||||
|
bucket.y = parseFloat((bucket.y / 7.0).toFixed(2));
|
||||||
|
}
|
||||||
|
aggregate.push(bucket);
|
||||||
|
bucket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket = bucket || { x: data[i].x, y: 0 };
|
||||||
|
bucket.y += data[i].y;
|
||||||
|
}
|
||||||
|
return aggregate;
|
||||||
|
}
|
||||||
|
|
||||||
export default Ember.Component.extend(AsyncReport, {
|
export default Ember.Component.extend(AsyncReport, {
|
||||||
classNames: ["dashboard-mini-chart"],
|
classNames: ["dashboard-mini-chart"],
|
||||||
classNameBindings: ["thirtyDayTrend", "oneDataPoint"],
|
classNameBindings: ["trend", "oneDataPoint"],
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"),
|
trend: Ember.computed.alias("report.trend"),
|
||||||
oneDataPoint: false,
|
oneDataPoint: false,
|
||||||
backgroundColor: "rgba(200,220,240,0.3)",
|
backgroundColor: "rgba(200,220,240,0.3)",
|
||||||
borderColor: "#08C",
|
borderColor: "#08C",
|
||||||
average: false,
|
average: false,
|
||||||
|
total: 0,
|
||||||
willDestroyEelement() {
|
|
||||||
this._super();
|
|
||||||
|
|
||||||
this.messageBus.unsubscribe(this.get("dataSource"));
|
|
||||||
},
|
|
||||||
|
|
||||||
@computed("dataSourceName")
|
@computed("dataSourceName")
|
||||||
dataSource(dataSourceName) {
|
dataSource(dataSourceName) {
|
||||||
@ -27,9 +42,9 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@computed("thirtyDayTrend")
|
@computed("trend")
|
||||||
trendIcon(thirtyDayTrend) {
|
trendIcon(trend) {
|
||||||
switch (thirtyDayTrend) {
|
switch (trend) {
|
||||||
case "trending-up":
|
case "trending-up":
|
||||||
return "angle-up";
|
return "angle-up";
|
||||||
case "trending-down":
|
case "trending-down":
|
||||||
@ -47,7 +62,7 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
this.set("isLoading", true);
|
this.set("isLoading", true);
|
||||||
|
|
||||||
let payload = {
|
let payload = {
|
||||||
data: { async: true }
|
data: { async: true, facets: ["prev_period"] }
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.get("startDate")) {
|
if (this.get("startDate")) {
|
||||||
@ -58,11 +73,18 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._chart) {
|
||||||
|
this._chart.destroy();
|
||||||
|
this._chart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.set("report", null);
|
||||||
|
|
||||||
ajax(this.get("dataSource"), payload)
|
ajax(this.get("dataSource"), payload)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// if (!Ember.isEmpty(response.report.data)) {
|
this.set('reportKey', response.report.report_key);
|
||||||
this._setPropertiesFromReport(Report.create(response.report));
|
|
||||||
// }
|
this.loadReport(response.report);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (this.get("oneDataPoint")) {
|
if (this.get("oneDataPoint")) {
|
||||||
@ -77,6 +99,19 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadReport(report) {
|
||||||
|
if (report.data) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
renderReport() {
|
renderReport() {
|
||||||
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
|
||||||
if (this.get("oneDataPoint")) return;
|
if (this.get("oneDataPoint")) return;
|
||||||
@ -96,6 +131,9 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (this._chart) {
|
||||||
|
this._chart.destroy();
|
||||||
|
}
|
||||||
this._chart = new window.Chart(context, this._buildChartConfig(data));
|
this._chart = new window.Chart(context, this._buildChartConfig(data));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -105,7 +143,6 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
this.get("startDate").isSame(this.get("endDate"), "day");
|
this.get("startDate").isSame(this.get("endDate"), "day");
|
||||||
|
|
||||||
report.set("average", this.get("average"));
|
report.set("average", this.get("average"));
|
||||||
|
|
||||||
this.setProperties({ oneDataPoint, report });
|
this.setProperties({ oneDataPoint, report });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -13,9 +13,8 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
values(report) {
|
values(report) {
|
||||||
if (!report) return;
|
if (!report) return;
|
||||||
return Ember.makeArray(report.data)
|
return Ember.makeArray(report.data)
|
||||||
.sort((a, b) => a.x >= b.x)
|
|
||||||
.map(x => {
|
.map(x => {
|
||||||
return [ x[0], number(x[1]), number(x[2]) ];
|
return [ x[0], number(x[1]), x[2] ];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -25,6 +24,10 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
return Ember.makeArray(report.labels);
|
return Ember.makeArray(report.labels);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadReport(report_json) {
|
||||||
|
this._setPropertiesFromReport(Report.create(report_json));
|
||||||
|
},
|
||||||
|
|
||||||
fetchReport() {
|
fetchReport() {
|
||||||
this.set("isLoading", true);
|
this.set("isLoading", true);
|
||||||
|
|
||||||
@ -40,7 +43,8 @@ export default Ember.Component.extend(AsyncReport, {
|
|||||||
|
|
||||||
ajax(this.get("dataSource"), payload)
|
ajax(this.get("dataSource"), payload)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
this._setPropertiesFromReport(Report.create(response.report));
|
this.set('reportKey', response.report.report_key);
|
||||||
|
this.loadReport(response.report);
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (!Ember.isEmpty(this.get("report.data"))) {
|
if (!Ember.isEmpty(this.get("report.data"))) {
|
||||||
this.set("isLoading", false);
|
this.set("isLoading", false);
|
||||||
|
@ -37,30 +37,34 @@ export default Ember.Controller.extend({
|
|||||||
|
|
||||||
@computed("period")
|
@computed("period")
|
||||||
startDate(period) {
|
startDate(period) {
|
||||||
|
let fullDay = moment().utc().subtract(1, "day");
|
||||||
|
|
||||||
switch (period) {
|
switch (period) {
|
||||||
case "yearly":
|
case "yearly":
|
||||||
return moment().subtract(1, "year").startOf("day");
|
return fullDay.subtract(1, "year").startOf("day");
|
||||||
break;
|
break;
|
||||||
case "quarterly":
|
case "quarterly":
|
||||||
return moment().subtract(3, "month").startOf("day");
|
return fullDay.subtract(3, "month").startOf("day");
|
||||||
break;
|
break;
|
||||||
case "weekly":
|
case "weekly":
|
||||||
return moment().subtract(1, "week").startOf("day");
|
return fullDay.subtract(1, "week").startOf("day");
|
||||||
break;
|
break;
|
||||||
case "monthly":
|
case "monthly":
|
||||||
return moment().subtract(1, "month").startOf("day");
|
return fullDay.subtract(1, "month").startOf("day");
|
||||||
break;
|
|
||||||
case "daily":
|
|
||||||
return moment().startOf("day");
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@computed("period")
|
@computed()
|
||||||
endDate(period) {
|
lastWeek() {
|
||||||
return period === "all" ? null : moment().endOf("day");
|
return moment().utc().endOf("day").subtract(1, "week");
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed()
|
||||||
|
endDate() {
|
||||||
|
return moment().utc().subtract(1, "day").endOf("day");
|
||||||
},
|
},
|
||||||
|
|
||||||
@computed("updated_at")
|
@computed("updated_at")
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import computed from 'ember-addons/ember-computed-decorators';
|
import computed from 'ember-addons/ember-computed-decorators';
|
||||||
import Report from "admin/models/report";
|
|
||||||
|
|
||||||
export default Ember.Mixin.create({
|
export default Ember.Mixin.create({
|
||||||
classNameBindings: ["isLoading"],
|
classNameBindings: ["isLoading"],
|
||||||
@ -9,23 +8,27 @@ export default Ember.Mixin.create({
|
|||||||
init() {
|
init() {
|
||||||
this._super();
|
this._super();
|
||||||
|
|
||||||
this.messageBus.subscribe(this.get("dataSource"), report => {
|
this._channel = this.get("dataSource");
|
||||||
const formatDate = (date) => moment(date).format("YYYYMMDD");
|
this._callback = (report) => {
|
||||||
|
if (report.report_key = this.get("reportKey")) {
|
||||||
// this check is done to avoid loading a chart after period has changed
|
Em.run.next(() => {
|
||||||
if (
|
if (report.report_key = this.get("reportKey")) {
|
||||||
(this.get("startDate") && formatDate(report.start_date) === formatDate(this.get("startDate"))) &&
|
this.loadReport(report);
|
||||||
(this.get("endDate") && formatDate(report.end_date) === formatDate(this.get("endDate")))
|
console.log(report);
|
||||||
) {
|
this.set("isLoading", false);
|
||||||
this._setPropertiesFromReport(Report.create(report));
|
this.renderReport();
|
||||||
this.set("isLoading", false);
|
}
|
||||||
this.renderReport();
|
});
|
||||||
} else {
|
|
||||||
this._setPropertiesFromReport(Report.create(report));
|
|
||||||
this.set("isLoading", false);
|
|
||||||
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);
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
this._super();
|
||||||
|
this.messageBus.unsubscribe(this._channel, this._callback);
|
||||||
},
|
},
|
||||||
|
|
||||||
didInsertElement() {
|
didInsertElement() {
|
||||||
@ -38,12 +41,13 @@ export default Ember.Mixin.create({
|
|||||||
|
|
||||||
didUpdateAttrs() {
|
didUpdateAttrs() {
|
||||||
this._super();
|
this._super();
|
||||||
|
|
||||||
this.fetchReport();
|
this.fetchReport();
|
||||||
},
|
},
|
||||||
|
|
||||||
renderReport() {},
|
renderReport() {},
|
||||||
|
|
||||||
|
loadReport() {},
|
||||||
|
|
||||||
@computed("dataSourceName")
|
@computed("dataSourceName")
|
||||||
dataSource(dataSourceName) {
|
dataSource(dataSourceName) {
|
||||||
return `/admin/reports/${dataSourceName}`;
|
return `/admin/reports/${dataSourceName}`;
|
||||||
|
@ -90,6 +90,34 @@ const Report = Discourse.Model.extend({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed('data')
|
||||||
|
currentTotal(data){
|
||||||
|
return _.reduce(data, (cur, pair) => cur + pair.y, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed('data', 'currentTotal')
|
||||||
|
currentAverage(data, total) {
|
||||||
|
return parseFloat((total / parseFloat(data.length)).toFixed(1));
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed('prev_period', 'currentTotal', 'currentAverage')
|
||||||
|
trend(prev, currentTotal, currentAverage) {
|
||||||
|
const total = this.get('average') ? currentAverage : currentTotal;
|
||||||
|
const change = ((total - prev) / total) * 100;
|
||||||
|
|
||||||
|
if (change > 50) {
|
||||||
|
return "high-trending-up";
|
||||||
|
} else if (change > 0) {
|
||||||
|
return "trending-up";
|
||||||
|
} else if (change === 0) {
|
||||||
|
return "no-change";
|
||||||
|
} else if (change < -50) {
|
||||||
|
return "high-trending-down";
|
||||||
|
} else if (change < 0) {
|
||||||
|
return "trending-down";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
@computed('prev30Days', 'lastThirtyDaysCount')
|
@computed('prev30Days', 'lastThirtyDaysCount')
|
||||||
thirtyDayTrend(prev30Days, lastThirtyDaysCount) {
|
thirtyDayTrend(prev30Days, lastThirtyDaysCount) {
|
||||||
const currentPeriod = lastThirtyDaysCount;
|
const currentPeriod = lastThirtyDaysCount;
|
||||||
@ -138,6 +166,20 @@ const Report = Discourse.Model.extend({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed('prev_period', 'currentTotal', 'currentAverage')
|
||||||
|
trendTitle(prev, currentTotal, currentAverage) {
|
||||||
|
let current = this.get('average') ? currentAverage : currentTotal;
|
||||||
|
let percent = this.percentChangeString(current, prev);
|
||||||
|
|
||||||
|
if (this.get('average')) {
|
||||||
|
prev = prev.toFixed(1);
|
||||||
|
current += '%';
|
||||||
|
prev += '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return I18n.t('admin.dashboard.reports.trend_title', {percent: percent, prev: prev, current: current});
|
||||||
|
},
|
||||||
|
|
||||||
changeTitle(val1, val2, prevPeriodString) {
|
changeTitle(val1, val2, prevPeriodString) {
|
||||||
const percentChange = this.percentChangeString(val1, val2);
|
const percentChange = this.percentChangeString(val1, val2);
|
||||||
var title = "";
|
var title = "";
|
||||||
@ -176,6 +218,14 @@ const Report = Discourse.Model.extend({
|
|||||||
|
|
||||||
Report.reopenClass({
|
Report.reopenClass({
|
||||||
|
|
||||||
|
fillMissingDates(report) {
|
||||||
|
if (report.data.length > 0) {
|
||||||
|
const startDateFormatted = moment.utc(report.start_date).format('YYYY-MM-DD');
|
||||||
|
const endDateFormatted = moment.utc(report.end_date).format('YYYY-MM-DD');
|
||||||
|
report.data = fillMissingDates(report.data, startDateFormatted, endDateFormatted);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
find(type, startDate, endDate, categoryId, groupId) {
|
find(type, startDate, endDate, categoryId, groupId) {
|
||||||
return ajax("/admin/reports/" + type, {
|
return ajax("/admin/reports/" + type, {
|
||||||
data: {
|
data: {
|
||||||
@ -186,11 +236,7 @@ Report.reopenClass({
|
|||||||
}
|
}
|
||||||
}).then(json => {
|
}).then(json => {
|
||||||
// Add zero values for missing dates
|
// Add zero values for missing dates
|
||||||
if (json.report.data.length > 0) {
|
Report.filleMissingDates(json.report);
|
||||||
const startDateFormatted = moment(json.report.start_date).utc().format('YYYY-MM-DD');
|
|
||||||
const endDateFormatted = moment(json.report.end_date).utc().format('YYYY-MM-DD');
|
|
||||||
json.report.data = fillMissingDates(json.report.data, startDateFormatted, endDateFormatted);
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = Report.create({ type: type });
|
const model = Report.create({ type: type });
|
||||||
model.setProperties(json.report);
|
model.setProperties(json.report);
|
||||||
|
@ -9,9 +9,15 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="chart-trend {{trend}}">
|
<div class="chart-trend {{trend}}">
|
||||||
<span title="{{report.thirtyDayCountTitle}}">
|
{{#if average}}
|
||||||
{{number report.lastThirtyDaysCount}}
|
<span title="{{report.trendTitle}}">
|
||||||
</span>
|
{{report.currentAverage}}%
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
<span title="{{report.trendTitle}}">
|
||||||
|
{{number report.currentTotal noTitle="true"}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{#if trendIcon}}
|
{{#if trendIcon}}
|
||||||
{{d-icon trendIcon}}
|
{{d-icon trendIcon}}
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
endDate=endDate}}
|
endDate=endDate}}
|
||||||
|
|
||||||
{{dashboard-mini-chart
|
{{dashboard-mini-chart
|
||||||
dataSourceName="new_contributors"
|
dataSourceName="posts"
|
||||||
startDate=startDate
|
startDate=startDate
|
||||||
endDate=endDate}}
|
endDate=endDate}}
|
||||||
|
|
||||||
@ -35,9 +35,10 @@
|
|||||||
endDate=endDate}}
|
endDate=endDate}}
|
||||||
|
|
||||||
{{dashboard-mini-chart
|
{{dashboard-mini-chart
|
||||||
dataSourceName="inactive_users"
|
dataSourceName="new_contributors"
|
||||||
startDate=startDate
|
startDate=startDate
|
||||||
endDate=endDate}}
|
endDate=endDate}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,7 +119,7 @@
|
|||||||
<div class="section-column">
|
<div class="section-column">
|
||||||
{{dashboard-table-trending-search
|
{{dashboard-table-trending-search
|
||||||
dataSourceName="trending_search"
|
dataSourceName="trending_search"
|
||||||
startDate=startDate
|
startDate=lastWeek
|
||||||
endDate=endDate}}
|
endDate=endDate}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,11 +22,17 @@ class Admin::ReportsController < Admin::AdminController
|
|||||||
group_id = nil
|
group_id = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
facets = nil
|
||||||
|
if Array === params[:facets]
|
||||||
|
facets = params[:facets].map { |s| s.to_s.to_sym }
|
||||||
|
end
|
||||||
|
|
||||||
report = Report.find(report_type,
|
report = Report.find(report_type,
|
||||||
start_date: start_date,
|
start_date: start_date,
|
||||||
end_date: end_date,
|
end_date: end_date,
|
||||||
category_id: category_id,
|
category_id: category_id,
|
||||||
group_id: group_id,
|
group_id: group_id,
|
||||||
|
facets: facets,
|
||||||
async: params[:async])
|
async: params[:async])
|
||||||
|
|
||||||
raise Discourse::NotFound if report.blank?
|
raise Discourse::NotFound if report.blank?
|
||||||
|
@ -13,12 +13,14 @@ module Jobs
|
|||||||
report.end_date = args["end_date"].to_date if args["end_date"]
|
report.end_date = args["end_date"].to_date if args["end_date"]
|
||||||
report.category_id = args["category_id"] if args["category_id"]
|
report.category_id = args["category_id"] if args["category_id"]
|
||||||
report.group_id = args["group_id"] if args["group_id"]
|
report.group_id = args["group_id"] if args["group_id"]
|
||||||
|
report.facets = args["facets"].map(&:to_sym) if args["facets"]
|
||||||
|
|
||||||
Report.send("report_#{type}", report)
|
Report.send("report_#{type}", report)
|
||||||
|
|
||||||
Discourse.cache.write(Report.cache_key(report), report.as_json, force: true, expires_in: 30.minutes)
|
json = report.as_json
|
||||||
|
Discourse.cache.write(Report.cache_key(report), json, force: true, expires_in: 30.minutes)
|
||||||
|
|
||||||
MessageBus.publish("/admin/reports/#{type}", report.as_json, user_ids: User.staff.pluck(:id))
|
MessageBus.publish("/admin/reports/#{type}", json, user_ids: User.staff.pluck(:id))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -3,7 +3,8 @@ require_dependency 'topic_subtype'
|
|||||||
class Report
|
class Report
|
||||||
|
|
||||||
attr_accessor :type, :data, :total, :prev30Days, :start_date,
|
attr_accessor :type, :data, :total, :prev30Days, :start_date,
|
||||||
:end_date, :category_id, :group_id, :labels, :async
|
:end_date, :category_id, :group_id, :labels, :async,
|
||||||
|
:prev_period, :facets
|
||||||
|
|
||||||
def self.default_days
|
def self.default_days
|
||||||
30
|
30
|
||||||
@ -16,7 +17,15 @@ class Report
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.cache_key(report)
|
def self.cache_key(report)
|
||||||
"reports:#{report.type}:#{report.start_date.to_date.strftime("%Y%m%d")}:#{report.end_date.to_date.strftime("%Y%m%d")}"
|
(+"reports:") <<
|
||||||
|
[
|
||||||
|
report.type,
|
||||||
|
report.category_id,
|
||||||
|
report.start_date.to_date.strftime("%Y%m%d"),
|
||||||
|
report.end_date.to_date.strftime("%Y%m%d"),
|
||||||
|
report.group_id,
|
||||||
|
report.facets
|
||||||
|
].map(&:to_s).join(':')
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.clear_cache
|
def self.clear_cache
|
||||||
@ -33,14 +42,18 @@ class Report
|
|||||||
yaxis: I18n.t("reports.#{type}.yaxis"),
|
yaxis: I18n.t("reports.#{type}.yaxis"),
|
||||||
description: I18n.t("reports.#{type}.description"),
|
description: I18n.t("reports.#{type}.description"),
|
||||||
data: data,
|
data: data,
|
||||||
total: total,
|
|
||||||
start_date: start_date,
|
start_date: start_date,
|
||||||
end_date: end_date,
|
end_date: end_date,
|
||||||
category_id: category_id,
|
category_id: category_id,
|
||||||
group_id: group_id,
|
group_id: group_id,
|
||||||
prev30Days: self.prev30Days,
|
prev30Days: self.prev30Days,
|
||||||
|
report_key: Report.cache_key(self),
|
||||||
labels: labels
|
labels: labels
|
||||||
}.tap do |json|
|
}.tap do |json|
|
||||||
|
json[:total] = total if total
|
||||||
|
json[:prev_period] = prev_period if prev_period
|
||||||
|
json[:prev30Days] = self.prev30Days if self.prev30Days
|
||||||
|
|
||||||
if type == 'page_view_crawler_reqs'
|
if type == 'page_view_crawler_reqs'
|
||||||
json[:related_report] = Report.find('web_crawlers', start_date: start_date, end_date: end_date)&.as_json
|
json[:related_report] = Report.find('web_crawlers', start_date: start_date, end_date: end_date)&.as_json
|
||||||
end
|
end
|
||||||
@ -61,6 +74,7 @@ class Report
|
|||||||
report.category_id = opts[:category_id] if opts[:category_id]
|
report.category_id = opts[:category_id] if opts[:category_id]
|
||||||
report.group_id = opts[:group_id] if opts[:group_id]
|
report.group_id = opts[:group_id] if opts[:group_id]
|
||||||
report.async = opts[:async] || false
|
report.async = opts[:async] || false
|
||||||
|
report.facets = opts[:facets] || [:total, :prev30Days]
|
||||||
report_method = :"report_#{type}"
|
report_method = :"report_#{type}"
|
||||||
|
|
||||||
if respond_to?(report_method)
|
if respond_to?(report_method)
|
||||||
@ -152,10 +166,19 @@ class Report
|
|||||||
|
|
||||||
data = User.real.count_by_first_post(report.start_date, report.end_date)
|
data = User.real.count_by_first_post(report.start_date, report.end_date)
|
||||||
|
|
||||||
prev30DaysData = User.real.count_by_first_post(report.start_date - 30.days, report.start_date)
|
if report.facets.include?(:prev30Days)
|
||||||
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
prev30DaysData = User.real.count_by_first_post(report.start_date - 30.days, report.start_date)
|
||||||
|
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
||||||
|
end
|
||||||
|
|
||||||
report.total = User.real.count_by_first_post
|
if report.facets.include?(:total)
|
||||||
|
report.total = User.real.count_by_first_post
|
||||||
|
end
|
||||||
|
|
||||||
|
if report.facets.include?(:prev_period)
|
||||||
|
prev_period_data = User.real.count_by_first_post(report.start_date - (report.end_date - report.start_date), report.start_date)
|
||||||
|
report.prev_period = prev_period_data.sum { |k, v| v }
|
||||||
|
end
|
||||||
|
|
||||||
data.each do |key, value|
|
data.each do |key, value|
|
||||||
report.data << { x: key, y: value }
|
report.data << { x: key, y: value }
|
||||||
@ -166,11 +189,20 @@ class Report
|
|||||||
report.data = []
|
report.data = []
|
||||||
|
|
||||||
data = UserAction.count_daily_engaged_users(report.start_date, report.end_date)
|
data = UserAction.count_daily_engaged_users(report.start_date, report.end_date)
|
||||||
prev30DaysData = UserAction.count_daily_engaged_users(report.start_date - 30.days, report.start_date)
|
|
||||||
|
|
||||||
report.total = UserAction.count_daily_engaged_users
|
if report.facets.include?(:prev30Days)
|
||||||
|
prev30DaysData = UserAction.count_daily_engaged_users(report.start_date - 30.days, report.start_date)
|
||||||
|
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
||||||
|
end
|
||||||
|
|
||||||
report.prev30Days = prev30DaysData.sum { |k, v| v }
|
if report.facets.include?(:total)
|
||||||
|
report.total = UserAction.count_daily_engaged_users
|
||||||
|
end
|
||||||
|
|
||||||
|
if report.facets.include?(:prev_period)
|
||||||
|
prev_data = UserAction.count_daily_engaged_users(report.start_date - (report.end_date - report.start_date), report.start_date)
|
||||||
|
report.prev_period = prev_data.sum { |k, v| v }
|
||||||
|
end
|
||||||
|
|
||||||
data.each do |key, value|
|
data.each do |key, value|
|
||||||
report.data << { x: key, y: value }
|
report.data << { x: key, y: value }
|
||||||
@ -186,7 +218,15 @@ class Report
|
|||||||
if data_point["mau"] == 0
|
if data_point["mau"] == 0
|
||||||
0
|
0
|
||||||
else
|
else
|
||||||
((data_point["dau"].to_f / data_point["mau"].to_f) * 100).ceil
|
((data_point["dau"].to_f / data_point["mau"].to_f) * 100).ceil(2)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
dau_avg = Proc.new { |start_date, end_date|
|
||||||
|
data_points = UserVisit.count_by_active_users(start_date, end_date)
|
||||||
|
if !data_points.empty?
|
||||||
|
sum = data_points.sum { |data_point| compute_dau_by_mau.call(data_point) }
|
||||||
|
(sum.to_f / data_points.count.to_f).ceil(2)
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,10 +234,12 @@ class Report
|
|||||||
report.data << { x: data_point["date"], y: compute_dau_by_mau.call(data_point) }
|
report.data << { x: data_point["date"], y: compute_dau_by_mau.call(data_point) }
|
||||||
end
|
end
|
||||||
|
|
||||||
prev_data_points = UserVisit.count_by_active_users(report.start_date - 30.days, report.start_date)
|
if report.facets.include?(:prev_period)
|
||||||
if !prev_data_points.empty?
|
report.prev_period = dau_avg.call(report.start_date - (report.end_date - report.start_date), report.start_date)
|
||||||
sum = prev_data_points.sum { |data_point| compute_dau_by_mau.call(data_point) }
|
end
|
||||||
report.prev30Days = sum / prev_data_points.count
|
|
||||||
|
if report.facets.include?(:prev30Days)
|
||||||
|
report.prev30Days = dau_avg.call(report.start_date - 30.days, report.start_date)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -260,8 +302,23 @@ class Report
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.add_counts(report, subject_class, query_column = 'created_at')
|
def self.add_counts(report, subject_class, query_column = 'created_at')
|
||||||
report.total = subject_class.count
|
if report.facets.include?(:prev_period)
|
||||||
report.prev30Days = subject_class.where("#{query_column} >= ? and #{query_column} < ?", report.start_date - 30.days, report.start_date).count
|
report.prev_period = subject_class
|
||||||
|
.where("#{query_column} >= ? and #{query_column} < ?",
|
||||||
|
(report.start_date - (report.end_date - report.start_date)),
|
||||||
|
report.start_date).count
|
||||||
|
end
|
||||||
|
|
||||||
|
if report.facets.include?(:total)
|
||||||
|
report.total = subject_class.count
|
||||||
|
end
|
||||||
|
|
||||||
|
if report.facets.include?(:prev30Days)
|
||||||
|
report.prev30Days = subject_class
|
||||||
|
.where("#{query_column} >= ? and #{query_column} < ?",
|
||||||
|
report.start_date - 30.days,
|
||||||
|
report.start_date).count
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.report_users_by_trust_level(report)
|
def self.report_users_by_trust_level(report)
|
||||||
@ -359,24 +416,39 @@ class Report
|
|||||||
def self.report_trending_search(report)
|
def self.report_trending_search(report)
|
||||||
report.data = []
|
report.data = []
|
||||||
|
|
||||||
trends = SearchLog.select("term,
|
select_sql = <<~SQL
|
||||||
COUNT(*) AS searches,
|
term,
|
||||||
SUM(CASE
|
COUNT(*) AS searches,
|
||||||
|
SUM(CASE
|
||||||
WHEN search_result_id IS NOT NULL THEN 1
|
WHEN search_result_id IS NOT NULL THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) AS click_through,
|
END) AS click_through,
|
||||||
COUNT(DISTINCT ip_address) AS unique")
|
COUNT(DISTINCT ip_address) AS unique_searches
|
||||||
|
SQL
|
||||||
|
|
||||||
|
trends = SearchLog.select(select_sql)
|
||||||
.where('created_at > ? AND created_at <= ?', report.start_date, report.end_date)
|
.where('created_at > ? AND created_at <= ?', report.start_date, report.end_date)
|
||||||
.group(:term)
|
.group(:term)
|
||||||
.order('COUNT(DISTINCT ip_address) DESC, COUNT(*) DESC')
|
.order('unique_searches DESC, click_through ASC, term ASC')
|
||||||
.limit(20).to_a
|
.limit(20).to_a
|
||||||
|
|
||||||
report.labels = [:term, :searches, :unique].map { |key|
|
report.labels = [:term, :searches, :click_through].map { |key|
|
||||||
I18n.t("reports.trending_search.labels.#{key}")
|
I18n.t("reports.trending_search.labels.#{key}")
|
||||||
}
|
}
|
||||||
|
|
||||||
trends.each do |trend|
|
trends.each do |trend|
|
||||||
report.data << [trend.term, trend.searches, trend.unique]
|
ctr =
|
||||||
|
if trend.click_through == 0
|
||||||
|
0
|
||||||
|
else
|
||||||
|
trend.click_through.to_f / trend.searches.to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
report.data << [
|
||||||
|
trend.term,
|
||||||
|
trend.unique_searches,
|
||||||
|
(ctr * 100).ceil(1).to_s + "%"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -459,7 +459,8 @@ class Topic < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.listable_count_per_day(start_date, end_date, category_id = nil)
|
def self.listable_count_per_day(start_date, end_date, category_id = nil)
|
||||||
result = listable_topics.smart_group_by_date("topics.created_at", start_date, end_date)
|
result = listable_topics.where("topics.created_at >= ? AND topics.created_at <= ?", start_date, end_date)
|
||||||
|
result = result.group('date(topics.created_at)').order('date(topics.created_at)')
|
||||||
result = result.where(category_id: category_id) if category_id
|
result = result.where(category_id: category_id) if category_id
|
||||||
result.count
|
result.count
|
||||||
end
|
end
|
||||||
|
@ -880,7 +880,9 @@ class User < ActiveRecord::Base
|
|||||||
result = self
|
result = self
|
||||||
|
|
||||||
if start_date && end_date
|
if start_date && end_date
|
||||||
result = result.smart_group_by_date("users.created_at", start_date, end_date)
|
result = result.group("date(users.created_at)")
|
||||||
|
result = result.where("users.created_at >= ? AND users.created_at <= ?", start_date, end_date)
|
||||||
|
result = result.order('date(users.created_at)')
|
||||||
end
|
end
|
||||||
|
|
||||||
if group_id
|
if group_id
|
||||||
@ -895,7 +897,9 @@ class User < ActiveRecord::Base
|
|||||||
result = joins('INNER JOIN user_stats AS us ON us.user_id = users.id')
|
result = joins('INNER JOIN user_stats AS us ON us.user_id = users.id')
|
||||||
|
|
||||||
if start_date && end_date
|
if start_date && end_date
|
||||||
result = result.smart_group_by_date("us.first_post_created_at", start_date, end_date)
|
result = result.group("date(us.first_post_created_at)")
|
||||||
|
result = result.where("us.first_post_created_at > ? AND us.first_post_created_at < ?", start_date, end_date)
|
||||||
|
result = result.order("date(us.first_post_created_at)")
|
||||||
end
|
end
|
||||||
|
|
||||||
result.count
|
result.count
|
||||||
|
@ -127,7 +127,9 @@ SQL
|
|||||||
.where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE])
|
.where(action_type: [LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE])
|
||||||
|
|
||||||
if start_date && end_date
|
if start_date && end_date
|
||||||
result = result.smart_group_by_date(:created_at, start_date, end_date)
|
result = result.group('date(created_at)')
|
||||||
|
result = result.where('created_at > ? AND created_at < ?', start_date, end_date)
|
||||||
|
result = result.order('date(created_at)')
|
||||||
end
|
end
|
||||||
|
|
||||||
result.count
|
result.count
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
class UserVisit < ActiveRecord::Base
|
class UserVisit < ActiveRecord::Base
|
||||||
include DateGroupable
|
|
||||||
|
|
||||||
def self.counts_by_day_query(start_date, end_date, group_id = nil)
|
def self.counts_by_day_query(start_date, end_date, group_id = nil)
|
||||||
result = where('visited_at >= ? and visited_at <= ?', start_date.to_date, end_date.to_date)
|
result = where('visited_at >= ? and visited_at <= ?', start_date.to_date, end_date.to_date)
|
||||||
|
|
||||||
@ -13,16 +11,14 @@ class UserVisit < ActiveRecord::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.count_by_active_users(start_date, end_date)
|
def self.count_by_active_users(start_date, end_date)
|
||||||
aggregation_unit = aggregation_unit_for_period(start_date, end_date)
|
|
||||||
|
|
||||||
sql = <<SQL
|
sql = <<SQL
|
||||||
WITH dau AS (
|
WITH dau AS (
|
||||||
SELECT date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE AS date,
|
SELECT date_trunc('day', user_visits.visited_at)::DATE AS date,
|
||||||
count(distinct user_visits.user_id) AS dau
|
count(distinct user_visits.user_id) AS dau
|
||||||
FROM user_visits
|
FROM user_visits
|
||||||
WHERE user_visits.visited_at::DATE BETWEEN '#{start_date}' AND '#{end_date}'
|
WHERE user_visits.visited_at::DATE >= :start_date::DATE AND user_visits.visited_at < :end_date::DATE
|
||||||
GROUP BY date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE
|
GROUP BY date_trunc('day', user_visits.visited_at)::DATE
|
||||||
ORDER BY date_trunc('#{aggregation_unit}', user_visits.visited_at)::DATE
|
ORDER BY date_trunc('day', user_visits.visited_at)::DATE
|
||||||
)
|
)
|
||||||
|
|
||||||
SELECT date, dau,
|
SELECT date, dau,
|
||||||
@ -33,7 +29,7 @@ class UserVisit < ActiveRecord::Base
|
|||||||
FROM dau
|
FROM dau
|
||||||
SQL
|
SQL
|
||||||
|
|
||||||
UserVisit.exec_sql(sql).to_a
|
UserVisit.exec_sql(sql, start_date: start_date, end_date: end_date).to_a
|
||||||
end
|
end
|
||||||
|
|
||||||
# A count of visits in a date range by day
|
# A count of visits in a date range by day
|
||||||
|
@ -2748,6 +2748,7 @@ en:
|
|||||||
activity_metrics: Activity Metrics
|
activity_metrics: Activity Metrics
|
||||||
|
|
||||||
reports:
|
reports:
|
||||||
|
trend_title: "%{percent} change. Currently %{current}, was %{prev} in previous period."
|
||||||
today: "Today"
|
today: "Today"
|
||||||
yesterday: "Yesterday"
|
yesterday: "Yesterday"
|
||||||
last_7_days: "Last 7 Days"
|
last_7_days: "Last 7 Days"
|
||||||
|
@ -880,6 +880,7 @@ en:
|
|||||||
title: "Posts"
|
title: "Posts"
|
||||||
xaxis: "Day"
|
xaxis: "Day"
|
||||||
yaxis: "Number of new posts"
|
yaxis: "Number of new posts"
|
||||||
|
description: "New posts created during this period"
|
||||||
likes:
|
likes:
|
||||||
title: "Likes"
|
title: "Likes"
|
||||||
xaxis: "Day"
|
xaxis: "Day"
|
||||||
@ -914,7 +915,7 @@ en:
|
|||||||
labels:
|
labels:
|
||||||
term: Term
|
term: Term
|
||||||
searches: Searches
|
searches: Searches
|
||||||
unique: Unique
|
click_through: Click Through Rate
|
||||||
emails:
|
emails:
|
||||||
title: "Emails Sent"
|
title: "Emails Sent"
|
||||||
xaxis: "Day"
|
xaxis: "Day"
|
||||||
|
@ -30,6 +30,7 @@ describe Report do
|
|||||||
|
|
||||||
describe "topics" do
|
describe "topics" do
|
||||||
before do
|
before do
|
||||||
|
Report.clear_cache
|
||||||
freeze_time DateTime.parse('2017-03-01 12:00')
|
freeze_time DateTime.parse('2017-03-01 12:00')
|
||||||
|
|
||||||
((0..32).to_a + [60, 61, 62, 63]).each do |i|
|
((0..32).to_a + [60, 61, 62, 63]).each do |i|
|
||||||
@ -37,11 +38,21 @@ describe Report do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
subject(:json) { Report.find("topics").as_json }
|
|
||||||
|
|
||||||
it "counts the correct records" do
|
it "counts the correct records" do
|
||||||
|
json = Report.find("topics").as_json
|
||||||
expect(json[:data].size).to eq(31)
|
expect(json[:data].size).to eq(31)
|
||||||
expect(json[:prev30Days]).to eq(3)
|
expect(json[:prev30Days]).to eq(3)
|
||||||
|
|
||||||
|
# lets make sure we can ask for the correct options for the report
|
||||||
|
json = Report.find("topics",
|
||||||
|
start_date: 5.days.ago.beginning_of_day,
|
||||||
|
end_date: 1.day.ago.end_of_day,
|
||||||
|
facets: [:prev_period]
|
||||||
|
).as_json
|
||||||
|
|
||||||
|
expect(json[:prev_period]).to eq(5)
|
||||||
|
expect(json[:data].length).to eq(5)
|
||||||
|
expect(json[:prev30Days]).to eq(nil)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -321,7 +332,9 @@ describe Report do
|
|||||||
context "with different searches" do
|
context "with different searches" do
|
||||||
before do
|
before do
|
||||||
SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1')
|
SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1')
|
||||||
SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1', user_id: Fabricate(:user).id)
|
|
||||||
|
SearchLog.create!(term: 'ruby', search_result_id: 1, search_type: 1, ip_address: '127.0.0.1', user_id: Fabricate(:user).id)
|
||||||
|
|
||||||
SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.2')
|
SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.2')
|
||||||
SearchLog.log(term: 'php', search_type: :header, ip_address: '127.0.0.1')
|
SearchLog.log(term: 'php', search_type: :header, ip_address: '127.0.0.1')
|
||||||
end
|
end
|
||||||
@ -332,12 +345,11 @@ describe Report do
|
|||||||
|
|
||||||
it "returns a report with data" do
|
it "returns a report with data" do
|
||||||
expect(report.data[0][0]).to eq("ruby")
|
expect(report.data[0][0]).to eq("ruby")
|
||||||
expect(report.data[0][1]).to eq(3)
|
expect(report.data[0][1]).to eq(2)
|
||||||
expect(report.data[0][2]).to eq(2)
|
expect(report.data[0][2]).to eq('33.4%')
|
||||||
|
|
||||||
expect(report.data[1][0]).to eq("php")
|
expect(report.data[1][0]).to eq("php")
|
||||||
expect(report.data[1][1]).to eq(1)
|
expect(report.data[1][1]).to eq(1)
|
||||||
expect(report.data[1][2]).to eq(1)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -373,7 +385,7 @@ describe Report do
|
|||||||
|
|
||||||
it "returns a report with data" do
|
it "returns a report with data" do
|
||||||
expect(report.data.first[:y]).to eq(100)
|
expect(report.data.first[:y]).to eq(100)
|
||||||
expect(report.data.last[:y]).to eq(34)
|
expect(report.data.last[:y]).to eq(33.34)
|
||||||
expect(report.prev30Days).to eq(75)
|
expect(report.prev30Days).to eq(75)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1746,7 +1746,7 @@ describe Topic do
|
|||||||
|
|
||||||
describe '#listable_count_per_day' do
|
describe '#listable_count_per_day' do
|
||||||
before(:each) do
|
before(:each) do
|
||||||
freeze_time
|
freeze_time DateTime.parse('2017-03-01 12:00')
|
||||||
|
|
||||||
Fabricate(:topic)
|
Fabricate(:topic)
|
||||||
Fabricate(:topic, created_at: 1.day.ago)
|
Fabricate(:topic, created_at: 1.day.ago)
|
||||||
|
@ -94,7 +94,7 @@ describe User do
|
|||||||
describe '#count_by_signup_date' do
|
describe '#count_by_signup_date' do
|
||||||
before(:each) do
|
before(:each) do
|
||||||
User.destroy_all
|
User.destroy_all
|
||||||
freeze_time
|
freeze_time DateTime.parse('2017-02-01 12:00')
|
||||||
Fabricate(:user)
|
Fabricate(:user)
|
||||||
Fabricate(:user, created_at: 1.day.ago)
|
Fabricate(:user, created_at: 1.day.ago)
|
||||||
Fabricate(:user, created_at: 1.day.ago)
|
Fabricate(:user, created_at: 1.day.ago)
|
||||||
|
18
test/javascripts/fixtures/posts.js.es6
Normal file
18
test/javascripts/fixtures/posts.js.es6
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export default {
|
||||||
|
"/admin/reports/posts": {
|
||||||
|
"report": {
|
||||||
|
"type": "topics",
|
||||||
|
"title": "Topics",
|
||||||
|
"xaxis": "Day",
|
||||||
|
"yaxis": "Number of new posts",
|
||||||
|
"data": null,
|
||||||
|
"total": null,
|
||||||
|
"start_date": "2018-03-26T00:00:00.000Z",
|
||||||
|
"end_date": "2018-04-25T23:59:59.999Z",
|
||||||
|
"category_id": null,
|
||||||
|
"group_id": null,
|
||||||
|
"prev30Days": 0,
|
||||||
|
"labels": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -60,4 +60,4 @@ QUnit.test("thirtyDayCountTitle", assert => {
|
|||||||
|
|
||||||
assert.ok(title.indexOf('+50%') !== -1);
|
assert.ok(title.indexOf('+50%') !== -1);
|
||||||
assert.ok(title.match(/Was 10/));
|
assert.ok(title.match(/Was 10/));
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user