discourse/app/assets/javascripts/admin/models/report.js

562 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import getURL from "discourse-common/lib/get-url";
import I18n from "I18n";
import discourseComputed from "discourse-common/utils/decorators";
import { makeArray } from "discourse-common/lib/helpers";
import { isEmpty } from "@ember/utils";
import EmberObject from "@ember/object";
import { escapeExpression } from "discourse/lib/utilities";
import { ajax } from "discourse/lib/ajax";
import round from "discourse/lib/round";
import {
fillMissingDates,
formatUsername,
toNumber
} from "discourse/lib/utilities";
import { number, durationTiny } from "discourse/lib/formatter";
import { renderAvatar } from "discourse/helpers/user-avatar";
// Change this line each time report format change
// and you want to ensure cache is reset
export const SCHEMA_VERSION = 4;
const Report = EmberObject.extend({
average: false,
percent: false,
higher_is_better: true,
description_link: null,
description: null,
@discourseComputed("type", "start_date", "end_date")
reportUrl(type, start_date, end_date) {
start_date = moment
.utc(start_date)
.locale("en")
.format("YYYY-MM-DD");
end_date = moment
.utc(end_date)
.locale("en")
.format("YYYY-MM-DD");
return getURL(
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
);
},
valueAt(numDaysAgo) {
if (this.data) {
const wantedDate = moment()
.subtract(numDaysAgo, "days")
.locale("en")
.format("YYYY-MM-DD");
const item = this.data.find(d => d.x === wantedDate);
if (item) {
return item.y;
}
}
return 0;
},
valueFor(startDaysAgo, endDaysAgo) {
if (this.data) {
const earliestDate = moment()
.subtract(endDaysAgo, "days")
.startOf("day");
const latestDate = moment()
.subtract(startDaysAgo, "days")
.startOf("day");
let d,
sum = 0,
count = 0;
this.data.forEach(datum => {
d = moment(datum.x);
if (d >= earliestDate && d <= latestDate) {
sum += datum.y;
count++;
}
});
if (this.method === "average" && count > 0) {
sum /= count;
}
return round(sum, -2);
}
},
@discourseComputed("data", "average")
todayCount() {
return this.valueAt(0);
},
@discourseComputed("data", "average")
yesterdayCount() {
return this.valueAt(1);
},
@discourseComputed("data", "average")
sevenDaysAgoCount() {
return this.valueAt(7);
},
@discourseComputed("data", "average")
thirtyDaysAgoCount() {
return this.valueAt(30);
},
@discourseComputed("data", "average")
lastSevenDaysCount() {
return this.averageCount(7, this.valueFor(1, 7));
},
@discourseComputed("data", "average")
lastThirtyDaysCount() {
return this.averageCount(30, this.valueFor(1, 30));
},
averageCount(count, value) {
return this.average ? value / count : value;
},
@discourseComputed("yesterdayCount", "higher_is_better")
yesterdayTrend(yesterdayCount, higherIsBetter) {
return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
},
@discourseComputed("lastSevenDaysCount", "higher_is_better")
sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
return this._computeTrend(
this.valueFor(8, 14),
lastSevenDaysCount,
higherIsBetter
);
},
@discourseComputed("data")
currentTotal(data) {
return data.reduce((cur, pair) => cur + pair.y, 0);
},
@discourseComputed("data", "currentTotal")
currentAverage(data, total) {
return makeArray(data).length === 0
? 0
: parseFloat((total / parseFloat(data.length)).toFixed(1));
},
@discourseComputed("trend", "higher_is_better")
trendIcon(trend, higherIsBetter) {
return this._iconForTrend(trend, higherIsBetter);
},
@discourseComputed("sevenDaysTrend", "higher_is_better")
sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
return this._iconForTrend(sevenDaysTrend, higherIsBetter);
},
@discourseComputed("thirtyDaysTrend", "higher_is_better")
thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
},
@discourseComputed("yesterdayTrend", "higher_is_better")
yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
return this._iconForTrend(yesterdayTrend, higherIsBetter);
},
@discourseComputed(
"prev_period",
"currentTotal",
"currentAverage",
"higher_is_better"
)
trend(prev, currentTotal, currentAverage, higherIsBetter) {
const total = this.average ? currentAverage : currentTotal;
return this._computeTrend(prev, total, higherIsBetter);
},
@discourseComputed("prev30Days", "lastThirtyDaysCount", "higher_is_better")
thirtyDaysTrend(prev30Days, lastThirtyDaysCount, higherIsBetter) {
return this._computeTrend(prev30Days, lastThirtyDaysCount, higherIsBetter);
},
@discourseComputed("type")
method(type) {
if (type === "time_to_first_response") {
return "average";
} else {
return "sum";
}
},
percentChangeString(val1, val2) {
const change = this._computeChange(val1, val2);
if (isNaN(change) || !isFinite(change)) {
return null;
} else if (change > 0) {
return "+" + change.toFixed(0) + "%";
} else {
return change.toFixed(0) + "%";
}
},
@discourseComputed("prev_period", "currentTotal", "currentAverage")
trendTitle(prev, currentTotal, currentAverage) {
let current = this.average ? currentAverage : currentTotal;
let percent = this.percentChangeString(prev, current);
if (this.average) {
prev = prev ? prev.toFixed(1) : "0";
if (this.percent) {
current += "%";
prev += "%";
}
} else {
prev = number(prev);
current = number(current);
}
return I18n.t("admin.dashboard.reports.trend_title", {
percent,
prev,
current
});
},
changeTitle(valAtT1, valAtT2, prevPeriodString) {
const change = this.percentChangeString(valAtT1, valAtT2);
let title = "";
if (change) {
title += `${change} change. `;
}
title += `Was ${number(valAtT1)} ${prevPeriodString}.`;
return title;
},
@discourseComputed("yesterdayCount")
yesterdayCountTitle(yesterdayCount) {
return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
},
@discourseComputed("lastSevenDaysCount")
sevenDaysCountTitle(lastSevenDaysCount) {
return this.changeTitle(
this.valueFor(8, 14),
lastSevenDaysCount,
"two weeks ago"
);
},
@discourseComputed("prev30Days", "lastThirtyDaysCount")
thirtyDaysCountTitle(prev30Days, lastThirtyDaysCount) {
return this.changeTitle(
prev30Days,
lastThirtyDaysCount,
"in the previous 30 day period"
);
},
@discourseComputed("data")
sortedData(data) {
return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
},
@discourseComputed("data")
xAxisIsDate() {
if (!this.data[0]) return false;
return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
},
@discourseComputed("labels")
computedLabels(labels) {
return labels.map(label => {
const type = label.type || "string";
let mainProperty;
if (label.property) mainProperty = label.property;
else if (type === "user") mainProperty = label.properties["username"];
else if (type === "topic") mainProperty = label.properties["title"];
else if (type === "post")
mainProperty = label.properties["truncated_raw"];
else mainProperty = label.properties[0];
return {
title: label.title,
sortProperty: label.sort_property || mainProperty,
mainProperty,
type,
compute: (row, opts = {}) => {
let value = null;
if (opts.useSortProperty) {
value = row[label.sort_property || mainProperty];
} else {
value = row[mainProperty];
}
if (type === "user") return this._userLabel(label.properties, row);
if (type === "post") return this._postLabel(label.properties, row);
if (type === "topic") return this._topicLabel(label.properties, row);
if (type === "seconds") return this._secondsLabel(value);
if (type === "link") return this._linkLabel(label.properties, row);
if (type === "percent") return this._percentLabel(value);
if (type === "bytes") return this._bytesLabel(value);
if (type === "number") {
return this._numberLabel(value, opts);
}
if (type === "date") {
const date = moment(value);
if (date.isValid()) return this._dateLabel(value, date);
}
if (type === "precise_date") {
const date = moment(value);
if (date.isValid()) return this._dateLabel(value, date, "LLL");
}
if (type === "text") return this._textLabel(value);
return {
value,
type,
property: mainProperty,
formatedValue: value ? escapeExpression(value) : "—"
};
}
};
});
},
_userLabel(properties, row) {
const username = row[properties.username];
const formatedValue = () => {
const userId = row[properties.id];
const user = EmberObject.create({
username,
name: formatUsername(username),
avatar_template: row[properties.avatar]
});
const href = getURL(`/admin/users/${userId}/${username}`);
const avatarImg = renderAvatar(user, {
imageSize: "tiny",
ignoreTitle: true,
siteSettings: this.siteSettings
});
return `<a href='${href}'>${avatarImg}<span class='username'>${user.name}</span></a>`;
};
return {
value: username,
formatedValue: username ? formatedValue() : "—"
};
},
_topicLabel(properties, row) {
const topicTitle = row[properties.title];
const formatedValue = () => {
const topicId = row[properties.id];
const href = getURL(`/t/-/${topicId}`);
return `<a href='${href}'>${escapeExpression(topicTitle)}</a>`;
};
return {
value: topicTitle,
formatedValue: topicTitle ? formatedValue() : "—"
};
},
_postLabel(properties, row) {
const postTitle = row[properties.truncated_raw];
const postNumber = row[properties.number];
const topicId = row[properties.topic_id];
const href = getURL(`/t/-/${topicId}/${postNumber}`);
return {
property: properties.title,
value: postTitle,
formatedValue:
postTitle && href
? `<a href='${href}'>${escapeExpression(postTitle)}</a>`
: "—"
};
},
_secondsLabel(value) {
return {
value: toNumber(value),
formatedValue: durationTiny(value)
};
},
_percentLabel(value) {
return {
value: toNumber(value),
formatedValue: value ? `${value}%` : "—"
};
},
_numberLabel(value, options = {}) {
const formatNumbers = isEmpty(options.formatNumbers)
? true
: options.formatNumbers;
const formatedValue = () => (formatNumbers ? number(value) : value);
return {
value: toNumber(value),
formatedValue: value ? formatedValue() : "—"
};
},
_bytesLabel(value) {
return {
value: toNumber(value),
formatedValue: I18n.toHumanSize(value)
};
},
_dateLabel(value, date, format = "LL") {
return {
value,
formatedValue: value ? date.format(format) : "—"
};
},
_textLabel(value) {
const escaped = escapeExpression(value);
return {
value,
formatedValue: value ? escaped : "—"
};
},
_linkLabel(properties, row) {
const property = properties[0];
const value = getURL(row[property]);
const formatedValue = (href, anchor) => {
return `<a href="${escapeExpression(href)}">${escapeExpression(
anchor
)}</a>`;
};
return {
value,
formatedValue: value ? formatedValue(value, row[properties[1]]) : "—"
};
},
_computeChange(valAtT1, valAtT2) {
return ((valAtT2 - valAtT1) / valAtT1) * 100;
},
_computeTrend(valAtT1, valAtT2, higherIsBetter) {
const change = this._computeChange(valAtT1, valAtT2);
if (change > 50) {
return higherIsBetter ? "high-trending-up" : "high-trending-down";
} else if (change > 2) {
return higherIsBetter ? "trending-up" : "trending-down";
} else if (change <= 2 && change >= -2) {
return "no-change";
} else if (change < -50) {
return higherIsBetter ? "high-trending-down" : "high-trending-up";
} else if (change < -2) {
return higherIsBetter ? "trending-down" : "trending-up";
}
},
_iconForTrend(trend, higherIsBetter) {
switch (trend) {
case "trending-up":
return higherIsBetter ? "angle-up" : "angle-down";
case "trending-down":
return higherIsBetter ? "angle-down" : "angle-up";
case "high-trending-up":
return higherIsBetter ? "angle-double-up" : "angle-double-down";
case "high-trending-down":
return higherIsBetter ? "angle-double-down" : "angle-double-up";
default:
return "minus";
}
}
});
Report.reopenClass({
fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (_.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
if (report.modes[0] === "stacked_chart") {
report[filledField] = report[dataField].map(rep => {
return {
req: rep.req,
label: rep.label,
color: rep.color,
data: fillMissingDates(
JSON.parse(JSON.stringify(rep.data)),
startDateFormatted,
endDateFormatted
)
};
});
} else {
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
}
}
},
find(type, startDate, endDate, categoryId, groupId) {
return ajax("/admin/reports/" + type, {
data: {
start_date: startDate,
end_date: endDate,
category_id: categoryId,
group_id: groupId
}
}).then(json => {
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type: type });
model.setProperties(json.report);
if (json.report.related_report) {
// TODO: fillMissingDates if xaxis is date
const related = Report.create({
type: json.report.related_report.type
});
related.setProperties(json.report.related_report);
model.set("relatedReport", related);
}
return model;
});
}
});
export default Report;