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 Discourse.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 = Discourse.getURL(`/admin/users/${userId}/${username}`);
const avatarImg = renderAvatar(user, {
imageSize: "tiny",
ignoreTitle: true
});
return `${avatarImg}${user.name}`;
};
return {
value: username,
formatedValue: username ? formatedValue(username) : "—"
};
},
_topicLabel(properties, row) {
const topicTitle = row[properties.title];
const formatedValue = () => {
const topicId = row[properties.id];
const href = Discourse.getURL(`/t/-/${topicId}`);
return `${escapeExpression(topicTitle)}`;
};
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 = Discourse.getURL(`/t/-/${topicId}/${postNumber}`);
return {
property: properties.title,
value: postTitle,
formatedValue:
postTitle && href
? `${escapeExpression(postTitle)}`
: "—"
};
},
_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 = Discourse.getURL(row[property]);
const formatedValue = (href, anchor) => {
return `${escapeExpression(
anchor
)}`;
};
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 => {
// don’t 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;