2022-03-23 13:53:32 +03:00
|
|
|
import Service, {inject as service} from '@ember/service';
|
|
|
|
import moment from 'moment';
|
2022-03-24 17:32:34 +03:00
|
|
|
import {task} from 'ember-concurrency';
|
2022-03-23 11:51:53 +03:00
|
|
|
import {tracked} from '@glimmer/tracking';
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @typedef MrrStat
|
|
|
|
* @type {Object}
|
|
|
|
* @property {string} date The date (YYYY-MM-DD) on which this MRR was recorded
|
|
|
|
* @property {number} mrr The MRR on this date
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef MemberCountStat
|
|
|
|
* @type {Object}
|
|
|
|
* @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
|
|
|
|
* @property {number} paid Amount of paid members
|
|
|
|
* @property {number} free Amount of free members
|
|
|
|
* @property {number} comped Amount of comped members
|
2022-03-31 10:33:18 +03:00
|
|
|
* @property {number} paidSubscribed Amount of new paid members
|
|
|
|
* @property {number} paidCanceled Amount of canceled paid members
|
2022-03-23 18:38:16 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef MemberCounts
|
|
|
|
* @type {Object}
|
|
|
|
* @property {number} total Total amount of members
|
|
|
|
* @property {number} paid Amount of paid members
|
|
|
|
* @property {number} free Amount of free members
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
2022-03-28 15:20:07 +03:00
|
|
|
* @typedef EmailOpenRateStat
|
2022-03-23 18:38:16 +03:00
|
|
|
* @type {Object}
|
2022-03-28 15:20:07 +03:00
|
|
|
* @property {string} subject Email title
|
|
|
|
* @property {number} openRate Email openRate
|
|
|
|
* @property {Date} submittedAt Date
|
2022-03-23 18:38:16 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef PaidMembersByCadence
|
|
|
|
* @type {Object}
|
|
|
|
* @property {number} annual Paid memebrs on annual plan
|
|
|
|
* @property {number} monthly Paid memebrs on monthly plan
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef PaidMembersForTier
|
|
|
|
* @type {Object}
|
|
|
|
* @property {Object} tier Tier object
|
|
|
|
* @property {number} members Paid members on this tier
|
|
|
|
*/
|
|
|
|
|
2022-03-24 14:04:18 +03:00
|
|
|
/**
|
|
|
|
* @typedef SiteStatus Contains information on what graphs need to be shown
|
|
|
|
* @type {Object}
|
|
|
|
* @property {boolean} hasPaidTiers Whether the site has paid tiers
|
2022-04-01 11:48:01 +03:00
|
|
|
* @property {boolean} hasMultipleTiers Whether the site has multiple paid tiers
|
2022-03-24 14:04:18 +03:00
|
|
|
* @property {boolean} newslettersEnabled Whether the site has newsletters
|
|
|
|
* @property {boolean} membersEnabled Whether the site has members enabled
|
|
|
|
*/
|
|
|
|
|
2022-03-23 11:51:53 +03:00
|
|
|
export default class DashboardStatsService extends Service {
|
2022-03-23 13:53:32 +03:00
|
|
|
@service dashboardMocks;
|
2022-03-24 20:14:51 +03:00
|
|
|
@service store;
|
2022-03-28 11:47:09 +03:00
|
|
|
@service ajax;
|
|
|
|
@service ghostPaths;
|
2022-03-31 10:53:59 +03:00
|
|
|
@service membersCountCache;
|
2022-04-01 11:48:01 +03:00
|
|
|
@service settings;
|
2022-05-09 18:55:29 +03:00
|
|
|
@service membersUtils;
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-24 14:04:18 +03:00
|
|
|
/**
|
|
|
|
* @type {?SiteStatus} Contains information on what graphs need to be shown
|
|
|
|
*/
|
|
|
|
@tracked siteStatus = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?MemberCountStat[]}
|
|
|
|
*/
|
|
|
|
@tracked
|
|
|
|
memberCountStats = null;
|
|
|
|
|
2022-04-27 17:11:01 +03:00
|
|
|
@tracked
|
|
|
|
subscriptionCountStats = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?MrrStat[]}
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
2022-03-23 18:38:16 +03:00
|
|
|
mrrStats = null;
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {PaidMembersByCadence} Number of members for annual and monthly plans
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
2022-03-23 18:38:16 +03:00
|
|
|
paidMembersByCadence = null;
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {PaidMembersForTier[]} Number of members for each tier
|
|
|
|
*/
|
|
|
|
@tracked
|
|
|
|
paidMembersByTier = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @type {?number} Number of members last seen in last 30 days (could differ if filtered by member status)
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
|
|
|
membersLastSeen30d = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?number} Number of members last seen in last 7 days (could differ if filtered by member status)
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
|
|
|
membersLastSeen7d = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?MemberCounts} Number of members that are subscribed (grouped by status)
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
|
|
|
newsletterSubscribers = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?number} Number of emails sent in last 30 days
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
|
|
|
emailsSent30d = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
|
|
|
* @type {?EmailOpenRateStat[]}
|
|
|
|
*/
|
2022-03-23 11:51:53 +03:00
|
|
|
@tracked
|
|
|
|
emailOpenRateStats = null;
|
|
|
|
|
2022-03-24 14:04:18 +03:00
|
|
|
/**
|
2022-03-31 15:20:25 +03:00
|
|
|
* @type {number|'all'}
|
2022-03-24 14:04:18 +03:00
|
|
|
* Amount of days to load for member count and MRR related charts
|
|
|
|
*/
|
2022-05-04 19:02:08 +03:00
|
|
|
@tracked chartDays = 30;
|
2022-03-24 14:04:18 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Filter last seen by this status
|
|
|
|
* @type {'free'|'paid'|'total'}
|
|
|
|
*/
|
|
|
|
@tracked lastSeenFilterStatus = 'total';
|
2022-04-01 11:48:01 +03:00
|
|
|
|
|
|
|
paidProducts = null;
|
2022-03-31 10:33:18 +03:00
|
|
|
|
|
|
|
/**
|
|
|
|
* @type {?MemberCounts}
|
|
|
|
*/
|
|
|
|
get memberCounts() {
|
|
|
|
if (!this.memberCountStats) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const stat = this.memberCountStats[this.memberCountStats.length - 1];
|
|
|
|
return {
|
|
|
|
total: stat.paid + stat.comped + stat.free,
|
2022-04-20 15:45:34 +03:00
|
|
|
paid: stat.paid + stat.comped,
|
|
|
|
free: stat.free
|
2022-03-31 10:33:18 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-04-01 16:53:55 +03:00
|
|
|
get currentMRR() {
|
|
|
|
if (!this.mrrStats) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const stat = this.mrrStats[this.mrrStats.length - 1];
|
|
|
|
return stat.mrr;
|
|
|
|
}
|
|
|
|
|
2022-03-31 10:33:18 +03:00
|
|
|
/**
|
2022-04-01 11:48:01 +03:00
|
|
|
* @type {?MemberCounts}
|
2022-03-31 10:33:18 +03:00
|
|
|
*/
|
|
|
|
get memberCountsTrend() {
|
|
|
|
if (!this.memberCountStats) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this.chartDays === 'all') {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2022-03-31 10:33:18 +03:00
|
|
|
// Search for the value at chartDays ago (if any, else the first before it, or the next one if not one before it)
|
|
|
|
const searchDate = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
for (let index = this.memberCountStats.length - 1; index >= 0; index -= 1) {
|
|
|
|
const stat = this.memberCountStats[index];
|
|
|
|
if (stat.date <= searchDate) {
|
|
|
|
return {
|
|
|
|
total: stat.paid + stat.comped + stat.free,
|
2022-04-20 15:45:34 +03:00
|
|
|
paid: stat.paid + stat.comped,
|
|
|
|
free: stat.free
|
2022-03-31 10:33:18 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// We don't have any statistic from more than x days ago.
|
|
|
|
// Return all zero values
|
|
|
|
return {
|
|
|
|
total: 0,
|
|
|
|
paid: 0,
|
|
|
|
free: 0
|
|
|
|
};
|
|
|
|
}
|
2022-03-24 14:04:18 +03:00
|
|
|
|
2022-04-01 16:53:55 +03:00
|
|
|
get currentMRRTrend() {
|
|
|
|
if (!this.mrrStats) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.chartDays === 'all') {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Search for the value at chartDays ago (if any, else the first before it, or the next one if not one before it)
|
|
|
|
const searchDate = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
for (let index = this.mrrStats.length - 1; index >= 0; index -= 1) {
|
|
|
|
const stat = this.mrrStats[index];
|
|
|
|
if (stat.date <= searchDate) {
|
|
|
|
return stat.mrr;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// We don't have any statistic from more than x days ago.
|
|
|
|
// Return all zero values
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
|
2022-03-31 15:20:25 +03:00
|
|
|
get filledMemberCountStats() {
|
|
|
|
if (this.memberCountStats === null) {
|
|
|
|
return null;
|
|
|
|
}
|
2022-04-27 17:11:01 +03:00
|
|
|
function copyData(obj) {
|
|
|
|
return {
|
|
|
|
paid: obj.paid,
|
|
|
|
free: obj.free,
|
|
|
|
comped: obj.comped,
|
|
|
|
paidCanceled: 0,
|
|
|
|
paidSubscribed: 0
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return this.fillMissingDates(this.memberCountStats, {paid: 0, free: 0, comped: 0, paidCanceled: 0, paidSubscribed: 0}, copyData, this.chartDays);
|
2022-03-31 15:20:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
get filledMrrStats() {
|
|
|
|
if (this.mrrStats === null) {
|
|
|
|
return null;
|
|
|
|
}
|
2022-04-27 17:11:01 +03:00
|
|
|
function copyData(obj) {
|
|
|
|
return {
|
|
|
|
mrr: obj.mrr
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return this.fillMissingDates(this.mrrStats, {mrr: 0}, copyData, this.chartDays);
|
|
|
|
}
|
|
|
|
|
|
|
|
get filledSubscriptionCountStats() {
|
|
|
|
if (!this.subscriptionCountStats) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
function copyData(obj) {
|
|
|
|
return {
|
|
|
|
count: obj.count,
|
|
|
|
positiveDelta: 0,
|
|
|
|
negativeDelta: 0
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return this.fillMissingDates(this.subscriptionCountStats, {
|
|
|
|
positiveDelta: 0,
|
|
|
|
negativeDelta: 0,
|
|
|
|
count: 0
|
|
|
|
}, copyData, this.chartDays);
|
2022-03-31 15:20:25 +03:00
|
|
|
}
|
|
|
|
|
2022-03-24 14:04:18 +03:00
|
|
|
loadSiteStatus() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadSiteStatus.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadSiteStatus.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadSiteStatus.perform();
|
|
|
|
}
|
|
|
|
|
|
|
|
@task
|
|
|
|
*_loadSiteStatus() {
|
2022-03-24 14:04:18 +03:00
|
|
|
this.siteStatus = null;
|
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.loadSiteStatus();
|
|
|
|
this.siteStatus = {...this.dashboardMocks.siteStatus};
|
2022-03-24 14:04:18 +03:00
|
|
|
return;
|
|
|
|
}
|
2022-04-01 11:48:01 +03:00
|
|
|
|
2022-05-09 18:55:29 +03:00
|
|
|
if (this.membersUtils.isStripeEnabled) {
|
|
|
|
yield this.loadPaidProducts();
|
|
|
|
}
|
|
|
|
|
|
|
|
const hasPaidTiers = this.membersUtils.isStripeEnabled && this.paidProducts && this.paidProducts.length > 0;
|
2022-04-01 11:48:01 +03:00
|
|
|
|
2022-03-24 20:14:51 +03:00
|
|
|
this.siteStatus = {
|
2022-05-09 18:55:29 +03:00
|
|
|
hasPaidTiers,
|
|
|
|
hasMultipleTiers: hasPaidTiers && this.paidProducts.length > 1,
|
2022-04-01 11:48:01 +03:00
|
|
|
newslettersEnabled: this.settings.get('editorDefaultEmailRecipients') !== 'disabled',
|
2022-05-09 18:55:29 +03:00
|
|
|
membersEnabled: this.membersUtils.isMembersEnabled
|
2022-03-24 20:14:51 +03:00
|
|
|
};
|
2022-03-24 14:04:18 +03:00
|
|
|
}
|
|
|
|
|
2022-04-27 17:11:01 +03:00
|
|
|
loadPaidMembersByCadence() {
|
|
|
|
this.loadSubscriptionCountStats();
|
|
|
|
}
|
|
|
|
|
|
|
|
loadPaidMembersByTier() {
|
|
|
|
this.loadSubscriptionCountStats();
|
|
|
|
}
|
|
|
|
|
|
|
|
loadSubscriptionCountStats() {
|
|
|
|
if (this._loadSubscriptionCountStats.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadSubscriptionCountStats.last;
|
|
|
|
}
|
|
|
|
return this._loadSubscriptionCountStats.perform();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads the subscriptions count history
|
|
|
|
*/
|
|
|
|
@task
|
|
|
|
*_loadSubscriptionCountStats() {
|
|
|
|
this.subscriptionCountStats = null;
|
|
|
|
if (this.dashboardMocks.enabled) {
|
|
|
|
yield this.dashboardMocks.waitRandom();
|
|
|
|
|
|
|
|
if (this.dashboardMocks.subscriptionCountStats === null) {
|
|
|
|
// Note: that this shouldn't happen
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
this.subscriptionCountStats = this.dashboardMocks.subscriptionCountStats;
|
|
|
|
this.paidMembersByCadence = {...this.dashboardMocks.paidMembersByCadence};
|
|
|
|
this.paidMembersByTier = [...this.dashboardMocks.paidMembersByTier];
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let statsUrl = this.ghostPaths.url.api('stats/subscriptions');
|
|
|
|
let result = yield this.ajax.request(statsUrl);
|
|
|
|
|
2022-05-10 19:05:10 +03:00
|
|
|
const paidMembersByCadence = {
|
|
|
|
month: 0,
|
|
|
|
year: 0
|
|
|
|
};
|
2022-04-27 17:11:01 +03:00
|
|
|
|
|
|
|
for (const cadence of result.meta.cadences) {
|
|
|
|
paidMembersByCadence[cadence] = result.meta.totals.reduce((sum, total) => {
|
|
|
|
if (total.cadence !== cadence) {
|
|
|
|
return sum;
|
|
|
|
}
|
|
|
|
return sum + total.count;
|
|
|
|
}, 0);
|
|
|
|
}
|
|
|
|
|
|
|
|
yield this.loadPaidProducts();
|
|
|
|
|
|
|
|
const paidMembersByTier = [];
|
|
|
|
|
|
|
|
for (const tier of result.meta.tiers) {
|
|
|
|
const product = this.paidProducts.find(x => x.id === tier);
|
|
|
|
paidMembersByTier.push({
|
|
|
|
tier: {
|
|
|
|
name: product.name
|
|
|
|
},
|
|
|
|
members: result.meta.totals.reduce((sum, total) => {
|
|
|
|
if (total.tier !== tier) {
|
|
|
|
return sum;
|
|
|
|
}
|
|
|
|
return sum + total.count;
|
|
|
|
}, 0)
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function mergeDates(list, entry) {
|
|
|
|
const [current, ...rest] = list;
|
|
|
|
|
|
|
|
if (!current) {
|
2022-05-03 14:01:23 +03:00
|
|
|
return entry ? [entry] : [];
|
2022-04-27 17:11:01 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
if (!entry) {
|
|
|
|
return mergeDates(rest, {
|
|
|
|
date: current.date,
|
|
|
|
count: current.count,
|
|
|
|
positiveDelta: current.positive_delta,
|
|
|
|
negativeDelta: current.negative_delta
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (current.date === entry.date) {
|
|
|
|
return mergeDates(rest, {
|
|
|
|
date: entry.date,
|
|
|
|
count: entry.count + current.count,
|
|
|
|
positiveDelta: entry.positiveDelta + current.positive_delta,
|
|
|
|
negativeDelta: entry.negativeDelta + current.negative_delta
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
return [entry].concat(mergeDates(rest));
|
|
|
|
}
|
|
|
|
|
|
|
|
const subscriptionCountStats = mergeDates(result.stats);
|
|
|
|
|
|
|
|
this.paidMembersByCadence = paidMembersByCadence;
|
|
|
|
this.paidMembersByTier = paidMembersByTier;
|
|
|
|
this.subscriptionCountStats = subscriptionCountStats;
|
|
|
|
}
|
|
|
|
|
2022-03-24 17:32:34 +03:00
|
|
|
loadMemberCountStats() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadMemberCountStats.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadMemberCountStats.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadMemberCountStats.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-23 11:51:53 +03:00
|
|
|
/**
|
2022-03-31 10:33:18 +03:00
|
|
|
* Loads the members count history
|
2022-03-23 11:51:53 +03:00
|
|
|
*/
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadMemberCountStats() {
|
|
|
|
this.memberCountStats = null;
|
2022-03-23 13:53:32 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
if (this.dashboardMocks.memberCountStats === null) {
|
2022-03-24 17:32:34 +03:00
|
|
|
// Note: that this shouldn't happen
|
2022-03-23 18:38:16 +03:00
|
|
|
return null;
|
|
|
|
}
|
2022-03-31 10:33:18 +03:00
|
|
|
this.memberCountStats = this.dashboardMocks.memberCountStats;
|
2022-03-23 11:51:53 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-03-31 18:11:36 +03:00
|
|
|
let statsUrl = this.ghostPaths.url.api('stats/member_count');
|
2022-03-28 11:47:09 +03:00
|
|
|
let stats = yield this.ajax.request(statsUrl);
|
2022-03-31 10:33:18 +03:00
|
|
|
this.memberCountStats = stats.stats.map((d) => {
|
|
|
|
return {
|
|
|
|
...d,
|
|
|
|
paidCanceled: d.paid_canceled,
|
|
|
|
paidSubscribed: d.paid_subscribed
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-03-24 17:32:34 +03:00
|
|
|
loadMrrStats() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadMrrStats.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadMrrStats.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadMrrStats.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-23 11:51:53 +03:00
|
|
|
/**
|
2022-03-24 14:04:18 +03:00
|
|
|
* Loads the mrr graphs for the current chartDays days
|
2022-03-23 11:51:53 +03:00
|
|
|
*/
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadMrrStats() {
|
|
|
|
this.mrrStats = null;
|
2022-03-23 13:53:32 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
2022-03-23 18:38:16 +03:00
|
|
|
if (this.dashboardMocks.mrrStats === null) {
|
|
|
|
return null;
|
|
|
|
}
|
2022-03-31 15:20:25 +03:00
|
|
|
this.mrrStats = this.dashboardMocks.mrrStats;
|
2022-03-23 11:51:53 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-04-08 10:51:30 +03:00
|
|
|
let statsUrl = this.ghostPaths.url.api('stats/mrr');
|
2022-03-28 12:12:01 +03:00
|
|
|
let stats = yield this.ajax.request(statsUrl);
|
|
|
|
|
2022-04-08 10:51:30 +03:00
|
|
|
// Only show the highest value currency and filter the other ones out
|
|
|
|
const totals = stats.meta.totals;
|
|
|
|
let currentMax = totals[0];
|
|
|
|
if (!currentMax) {
|
|
|
|
// No valid data
|
|
|
|
this.mrrStats = [];
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const total of totals) {
|
|
|
|
if (total.mrr > currentMax.mrr) {
|
|
|
|
currentMax = total;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const useCurrency = currentMax.currency;
|
|
|
|
this.mrrStats = stats.stats.filter(d => d.currency === useCurrency);
|
2022-03-23 11:51:53 +03:00
|
|
|
}
|
|
|
|
|
2022-03-24 17:32:34 +03:00
|
|
|
loadLastSeen() {
|
|
|
|
// todo: add proper logic to prevent duplicate calls + reuse results if nothing has changed
|
|
|
|
return this._loadLastSeen.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
/**
|
2022-03-31 10:53:59 +03:00
|
|
|
* Loads the last seen counts
|
2022-03-23 18:38:16 +03:00
|
|
|
*/
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadLastSeen() {
|
|
|
|
this.membersLastSeen30d = null;
|
|
|
|
this.membersLastSeen7d = null;
|
|
|
|
|
2022-03-23 13:53:32 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
2022-03-23 13:53:32 +03:00
|
|
|
this.membersLastSeen30d = this.dashboardMocks.membersLastSeen30d;
|
|
|
|
this.membersLastSeen7d = this.dashboardMocks.membersLastSeen7d;
|
2022-03-23 11:51:53 +03:00
|
|
|
return;
|
|
|
|
}
|
2022-03-28 10:54:21 +03:00
|
|
|
|
2022-03-28 17:15:35 +03:00
|
|
|
const start30d = new Date(Date.now() - 30 * 86400 * 1000);
|
|
|
|
const start7d = new Date(Date.now() - 7 * 86400 * 1000);
|
2022-03-28 10:54:21 +03:00
|
|
|
|
2022-03-31 10:53:59 +03:00
|
|
|
// The cache is useless if we don't round on a fixed date.
|
|
|
|
start30d.setHours(0, 0, 0, 0);
|
|
|
|
start7d.setHours(0, 0, 0, 0);
|
|
|
|
|
2022-03-28 10:54:21 +03:00
|
|
|
let extraFilter = '';
|
|
|
|
if (this.lastSeenFilterStatus === 'paid') {
|
|
|
|
extraFilter = '+status:paid';
|
|
|
|
} else if (this.lastSeenFilterStatus === 'free') {
|
|
|
|
extraFilter = '+status:-paid';
|
|
|
|
}
|
|
|
|
|
2022-03-31 10:53:59 +03:00
|
|
|
const [result30d, result7d] = yield Promise.all([
|
|
|
|
this.membersCountCache.count('last_seen_at:>' + start30d.toISOString() + extraFilter),
|
|
|
|
this.membersCountCache.count('last_seen_at:>' + start7d.toISOString() + extraFilter)
|
|
|
|
]);
|
2022-03-28 10:54:21 +03:00
|
|
|
|
2022-03-31 10:53:59 +03:00
|
|
|
this.membersLastSeen30d = result30d;
|
|
|
|
this.membersLastSeen7d = result7d;
|
2022-03-23 11:51:53 +03:00
|
|
|
}
|
|
|
|
|
2022-04-01 11:48:01 +03:00
|
|
|
loadPaidProducts() {
|
|
|
|
if (this.paidProducts !== null) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (this._loadPaidProducts.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadPaidProducts.last;
|
|
|
|
}
|
|
|
|
return this._loadPaidProducts.perform();
|
|
|
|
}
|
|
|
|
|
|
|
|
@task
|
|
|
|
*_loadPaidProducts() {
|
|
|
|
const data = yield this.store.query('product', {
|
|
|
|
filter: 'type:paid+active:true',
|
|
|
|
limit: 'all'
|
|
|
|
});
|
|
|
|
this.paidProducts = data.toArray();
|
|
|
|
}
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
loadNewsletterSubscribers() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadNewsletterSubscribers.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadNewsletterSubscribers.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadNewsletterSubscribers.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadNewsletterSubscribers() {
|
|
|
|
this.newsletterSubscribers = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
2022-03-23 18:38:16 +03:00
|
|
|
this.newsletterSubscribers = this.dashboardMocks.newsletterSubscribers;
|
|
|
|
return;
|
|
|
|
}
|
2022-03-28 14:19:56 +03:00
|
|
|
|
2022-03-31 10:53:59 +03:00
|
|
|
const [paid, free] = yield Promise.all([
|
2022-04-21 17:32:12 +03:00
|
|
|
this.membersCountCache.count('newsletters.status:active+status:-free'),
|
|
|
|
this.membersCountCache.count('newsletters.status:active+status:free')
|
2022-03-31 10:53:59 +03:00
|
|
|
]);
|
|
|
|
|
2022-03-28 14:19:56 +03:00
|
|
|
this.newsletterSubscribers = {
|
2022-03-31 10:53:59 +03:00
|
|
|
total: paid + free,
|
|
|
|
free,
|
|
|
|
paid
|
2022-03-28 14:19:56 +03:00
|
|
|
};
|
2022-03-23 18:38:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
loadEmailsSent() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadEmailsSent.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadEmailsSent.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadEmailsSent.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadEmailsSent() {
|
|
|
|
this.emailsSent30d = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
2022-03-23 18:38:16 +03:00
|
|
|
this.emailsSent30d = this.dashboardMocks.emailsSent30d;
|
|
|
|
return;
|
|
|
|
}
|
2022-03-28 14:49:44 +03:00
|
|
|
|
2022-03-28 17:15:35 +03:00
|
|
|
const start30d = new Date(Date.now() - 30 * 86400 * 1000);
|
2022-03-28 14:49:44 +03:00
|
|
|
const result = yield this.store.query('email', {limit: 100, filter: 'submitted_at:>' + start30d.toISOString()});
|
|
|
|
this.emailsSent30d = result.reduce((c, email) => c + email.emailCount, 0);
|
2022-03-23 18:38:16 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
loadEmailOpenRateStats() {
|
2022-03-31 15:20:25 +03:00
|
|
|
if (this._loadEmailOpenRateStats.isRunning) {
|
|
|
|
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
|
|
|
return this._loadEmailOpenRateStats.last;
|
|
|
|
}
|
2022-03-24 17:32:34 +03:00
|
|
|
return this._loadEmailOpenRateStats.perform();
|
|
|
|
}
|
|
|
|
|
2022-03-31 15:20:25 +03:00
|
|
|
@task
|
2022-03-24 17:32:34 +03:00
|
|
|
*_loadEmailOpenRateStats() {
|
|
|
|
this.emailOpenRateStats = null;
|
|
|
|
|
2022-03-23 18:38:16 +03:00
|
|
|
if (this.dashboardMocks.enabled) {
|
2022-03-24 17:32:34 +03:00
|
|
|
yield this.dashboardMocks.waitRandom();
|
2022-03-23 18:38:16 +03:00
|
|
|
this.emailOpenRateStats = this.dashboardMocks.emailOpenRateStats;
|
|
|
|
return;
|
|
|
|
}
|
2022-03-24 20:14:51 +03:00
|
|
|
|
2022-03-28 15:20:07 +03:00
|
|
|
const limit = 8;
|
|
|
|
let query = {
|
|
|
|
filter: 'email_count:-0',
|
|
|
|
order: 'submitted_at desc',
|
|
|
|
limit: limit
|
|
|
|
};
|
|
|
|
const results = yield this.store.query('email', query);
|
|
|
|
const data = results.toArray();
|
|
|
|
let stats = data.map((d) => {
|
|
|
|
return {
|
|
|
|
subject: d.subject,
|
|
|
|
submittedAt: moment(d.submittedAtUTC).format('YYYY-MM-DD'),
|
|
|
|
openRate: d.openRate
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
const paddedResults = [];
|
|
|
|
if (data.length < limit) {
|
|
|
|
const pad = limit - data.length;
|
|
|
|
const lastSubmittedAt = data.length > 0 ? data[results.length - 1].submittedAtUTC : moment();
|
|
|
|
for (let i = 0; i < pad; i++) {
|
|
|
|
paddedResults.push({
|
|
|
|
subject: '',
|
|
|
|
submittedAt: moment(lastSubmittedAt).subtract(i + 1, 'days').format('YYYY-MM-DD'),
|
|
|
|
openRate: 0
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
stats = stats.concat(paddedResults);
|
|
|
|
stats.reverse();
|
|
|
|
this.emailOpenRateStats = stats;
|
2022-03-23 18:38:16 +03:00
|
|
|
}
|
|
|
|
|
2022-03-23 13:53:32 +03:00
|
|
|
/**
|
|
|
|
* For now this is only used when reloading all the graphs after changing the mocked data
|
|
|
|
* @todo: reload only data that we loaded earlier
|
|
|
|
*/
|
2022-04-13 15:52:12 +03:00
|
|
|
async reloadAll() {
|
|
|
|
// Clear all pending tasks (if any)
|
|
|
|
// Promise.all doesn't work here because they sometimes return undefined
|
|
|
|
await this._loadSiteStatus.cancelAll();
|
|
|
|
await this._loadMrrStats.cancelAll();
|
|
|
|
await this._loadMemberCountStats.cancelAll();
|
2022-04-27 17:11:01 +03:00
|
|
|
await this._loadSubscriptionCountStats.cancelAll();
|
2022-04-13 15:52:12 +03:00
|
|
|
await this._loadLastSeen.cancelAll();
|
|
|
|
await this._loadNewsletterSubscribers.cancelAll();
|
|
|
|
await this._loadEmailsSent.cancelAll();
|
|
|
|
await this._loadEmailOpenRateStats.cancelAll();
|
|
|
|
|
|
|
|
// Restart tasks
|
|
|
|
this.loadSiteStatus();
|
|
|
|
|
2022-03-24 14:04:18 +03:00
|
|
|
this.loadMrrStats();
|
|
|
|
this.loadMemberCountStats();
|
2022-05-06 15:35:13 +03:00
|
|
|
this.loadSubscriptionCountStats();
|
2022-03-24 14:04:18 +03:00
|
|
|
this.loadLastSeen();
|
2022-03-23 18:38:16 +03:00
|
|
|
this.loadPaidMembersByCadence();
|
|
|
|
this.loadPaidMembersByTier();
|
|
|
|
|
|
|
|
this.loadNewsletterSubscribers();
|
|
|
|
this.loadEmailsSent();
|
|
|
|
this.loadEmailOpenRateStats();
|
2022-03-23 13:53:32 +03:00
|
|
|
}
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-23 13:53:32 +03:00
|
|
|
/**
|
|
|
|
* Fill data to match a given amount of days
|
2022-03-23 18:38:16 +03:00
|
|
|
* @param {MemberCountStat[]|MrrStat[]} data
|
|
|
|
* @param {MemberCountStat|MrrStat} defaultData
|
2022-03-31 15:20:25 +03:00
|
|
|
* @param {number|'all'} days Amount of days to fill the graph with
|
2022-03-23 13:53:32 +03:00
|
|
|
*/
|
2022-04-27 17:11:01 +03:00
|
|
|
fillMissingDates(data, defaultData, copyData, days) {
|
2022-03-31 15:20:25 +03:00
|
|
|
let currentRangeDate;
|
|
|
|
|
|
|
|
if (days === 'all') {
|
2022-04-08 11:04:42 +03:00
|
|
|
const MINIMUM_DAYS = 90;
|
|
|
|
currentRangeDate = moment().subtract(MINIMUM_DAYS - 1, 'days');
|
|
|
|
|
|
|
|
// Make sure all charts are synced correctly and have the same start date when choosing 'all time'
|
|
|
|
if (this.mrrStats !== null && this.mrrStats.length > 0) {
|
|
|
|
const date = moment(this.mrrStats[0].date);
|
|
|
|
if (date.toDate() < currentRangeDate.toDate()) {
|
|
|
|
currentRangeDate = date;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.memberCountStats !== null && this.memberCountStats.length > 0) {
|
|
|
|
const date = moment(this.memberCountStats[0].date);
|
|
|
|
if (date.toDate() < currentRangeDate.toDate()) {
|
|
|
|
currentRangeDate = date;
|
|
|
|
}
|
|
|
|
}
|
2022-03-31 15:20:25 +03:00
|
|
|
} else {
|
|
|
|
currentRangeDate = moment().subtract(days - 1, 'days');
|
|
|
|
}
|
2022-03-23 13:53:32 +03:00
|
|
|
|
|
|
|
let endDate = moment().add(1, 'hour');
|
|
|
|
const output = [];
|
|
|
|
const firstDateInRangeIndex = data.findIndex((val) => {
|
|
|
|
return moment(val.date).isAfter(currentRangeDate);
|
|
|
|
});
|
|
|
|
let initialDateInRangeVal = firstDateInRangeIndex > 0 ? data[firstDateInRangeIndex - 1] : null;
|
|
|
|
if (firstDateInRangeIndex === 0 && !initialDateInRangeVal) {
|
|
|
|
initialDateInRangeVal = data[firstDateInRangeIndex];
|
|
|
|
}
|
|
|
|
if (data.length > 0 && !initialDateInRangeVal && firstDateInRangeIndex !== 0) {
|
|
|
|
initialDateInRangeVal = data[data.length - 1];
|
|
|
|
}
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-23 13:53:32 +03:00
|
|
|
let lastVal = initialDateInRangeVal ? initialDateInRangeVal : defaultData;
|
2022-03-23 11:51:53 +03:00
|
|
|
|
2022-03-23 13:53:32 +03:00
|
|
|
while (currentRangeDate.isBefore(endDate)) {
|
|
|
|
let dateStr = currentRangeDate.format('YYYY-MM-DD');
|
|
|
|
const dataOnDate = data.find(d => d.date === dateStr);
|
2022-04-27 17:11:01 +03:00
|
|
|
if (dataOnDate) {
|
|
|
|
lastVal = dataOnDate;
|
|
|
|
} else {
|
|
|
|
lastVal = {
|
|
|
|
date: dateStr,
|
|
|
|
...copyData(lastVal)
|
|
|
|
};
|
|
|
|
}
|
2022-03-23 13:53:32 +03:00
|
|
|
output.push(lastVal);
|
|
|
|
currentRangeDate = currentRangeDate.add(1, 'day');
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
}
|
2022-03-23 11:51:53 +03:00
|
|
|
}
|