mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-17 23:44:39 -05:00
Wired up subscription stats api (#2350)
refs https://github.com/TryGhost/Team/issues/1512 Adds the new endpoint `/stats/subscriptions/` which provides data for the paid mix and paid breakdown charts. Made the filledMissingDates function more generic by passing in a `copyData` function which is used to populate a date from the previous days data, if the data for that date is missing.
This commit is contained in:
parent
e5d5e359d7
commit
b0a875f733
4 changed files with 174 additions and 91 deletions
|
@ -11,11 +11,11 @@ export default class PaidBreakdown extends Component {
|
|||
|
||||
@action
|
||||
loadCharts() {
|
||||
// todo: load the new data here
|
||||
this.dashboardStats.loadSubscriptionCountStats();
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this.dashboardStats.memberCountStats === null;
|
||||
return this.dashboardStats.subscriptionCountStats === null;
|
||||
}
|
||||
|
||||
get chartTitle() {
|
||||
|
@ -27,11 +27,11 @@ export default class PaidBreakdown extends Component {
|
|||
}
|
||||
|
||||
get chartData() {
|
||||
const stats = this.dashboardStats.filledMemberCountStats;
|
||||
const stats = this.dashboardStats.filledSubscriptionCountStats;
|
||||
const labels = stats.map(stat => stat.date);
|
||||
const newData = stats.map(stat => stat.paidSubscribed);
|
||||
const canceledData = stats.map(stat => -stat.paidCanceled);
|
||||
const netData = stats.map(stat => stat.paidSubscribed - stat.paidCanceled);
|
||||
const newData = stats.map(stat => stat.positiveDelta);
|
||||
const canceledData = stats.map(stat => -stat.negativeDelta);
|
||||
const netData = stats.map(stat => stat.positiveDelta - stat.negativeDelta);
|
||||
const barThickness = 5;
|
||||
|
||||
return {
|
||||
|
|
|
@ -60,14 +60,14 @@ export default class PaidMix extends Component {
|
|||
}
|
||||
|
||||
get chartData() {
|
||||
const totalCadence = this.dashboardStats.paidMembersByCadence.monthly + this.dashboardStats.paidMembersByCadence.annual;
|
||||
const monthlyPercentage = Math.round(this.dashboardStats.paidMembersByCadence.monthly / totalCadence * 100);
|
||||
const annualPercentage = Math.round(this.dashboardStats.paidMembersByCadence.annual / totalCadence * 100);
|
||||
const totalCadence = this.dashboardStats.paidMembersByCadence.month + this.dashboardStats.paidMembersByCadence.year;
|
||||
const monthlyPercentage = Math.round(this.dashboardStats.paidMembersByCadence.month / totalCadence * 100);
|
||||
const annualPercentage = Math.round(this.dashboardStats.paidMembersByCadence.year / totalCadence * 100);
|
||||
const barThickness = 5;
|
||||
|
||||
if (this.mode === 'cadence') {
|
||||
return {
|
||||
labels: ['Candence'],
|
||||
labels: ['Cadence'],
|
||||
datasets: [{
|
||||
label: 'Monthly',
|
||||
data: [monthlyPercentage],
|
||||
|
@ -195,4 +195,4 @@ export default class PaidMix extends Component {
|
|||
get isChartTiers() {
|
||||
return (this.mode === 'tiers');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -283,6 +283,14 @@ export default class DashboardMocksService extends Service {
|
|||
}
|
||||
|
||||
this.memberCountStats = stats;
|
||||
this.subscriptionCountStats = stats.map((data) => {
|
||||
return {
|
||||
date: data.date,
|
||||
count: data.paid,
|
||||
positiveDelta: data.paidSubscribed,
|
||||
negativeDelta: data.paidCanceled
|
||||
};
|
||||
});
|
||||
const currentCounts = {
|
||||
total: (stats[stats.length - 1]?.paid ?? 0) + (stats[stats.length - 1]?.free ?? 0) + (stats[stats.length - 1]?.comped ?? 0),
|
||||
paid: stats[stats.length - 1]?.paid ?? 0,
|
||||
|
@ -292,8 +300,8 @@ export default class DashboardMocksService extends Service {
|
|||
const cadenceRate = Math.random();
|
||||
|
||||
this.paidMembersByCadence = {
|
||||
annual: Math.floor(currentCounts.paid * cadenceRate),
|
||||
monthly: Math.floor(currentCounts.paid * (1 - cadenceRate))
|
||||
year: Math.floor(currentCounts.paid * cadenceRate),
|
||||
month: Math.floor(currentCounts.paid * (1 - cadenceRate))
|
||||
};
|
||||
|
||||
this.paidMembersByTier = [
|
||||
|
|
|
@ -79,6 +79,9 @@ export default class DashboardStatsService extends Service {
|
|||
@tracked
|
||||
memberCountStats = null;
|
||||
|
||||
@tracked
|
||||
subscriptionCountStats = null;
|
||||
|
||||
/**
|
||||
* @type {?MrrStat[]}
|
||||
*/
|
||||
|
@ -229,14 +232,46 @@ export default class DashboardStatsService extends Service {
|
|||
if (this.memberCountStats === null) {
|
||||
return null;
|
||||
}
|
||||
return this.fillMissingDates(this.memberCountStats, {paid: 0, free: 0, comped: 0, paidCanceled: 0, paidSubscribed: 0}, this.chartDays);
|
||||
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);
|
||||
}
|
||||
|
||||
get filledMrrStats() {
|
||||
if (this.mrrStats === null) {
|
||||
return null;
|
||||
}
|
||||
return this.fillMissingDates(this.mrrStats, {mrr: 0}, this.chartDays);
|
||||
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);
|
||||
}
|
||||
|
||||
loadSiteStatus() {
|
||||
|
@ -266,6 +301,112 @@ export default class DashboardStatsService extends Service {
|
|||
};
|
||||
}
|
||||
|
||||
loadPaidMembersByCadence() {
|
||||
this.loadSubscriptionCountStats();
|
||||
}
|
||||
|
||||
loadPaidMembersByTier() {
|
||||
this.loadSubscriptionCountStats();
|
||||
}
|
||||
|
||||
loadSubscriptionCountStats() {
|
||||
if (this.paidMembersByCadence && this.paidMembersByTier && this.subscriptionCountStats) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
|
||||
const paidMembersByCadence = {};
|
||||
|
||||
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) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
loadMemberCountStats() {
|
||||
if (this._loadMemberCountStats.isRunning) {
|
||||
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
||||
|
@ -390,43 +531,6 @@ export default class DashboardStatsService extends Service {
|
|||
this.membersLastSeen7d = result7d;
|
||||
}
|
||||
|
||||
loadPaidMembersByCadence() {
|
||||
if (this._loadPaidMembersByCadence.isRunning) {
|
||||
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
||||
return this._loadPaidMembersByCadence.last;
|
||||
}
|
||||
return this._loadPaidMembersByCadence.perform();
|
||||
}
|
||||
|
||||
@task
|
||||
*_loadPaidMembersByCadence() {
|
||||
this.paidMembersByCadence = null;
|
||||
|
||||
if (this.dashboardMocks.enabled) {
|
||||
yield this.dashboardMocks.waitRandom();
|
||||
this.paidMembersByCadence = {...this.dashboardMocks.paidMembersByCadence};
|
||||
return;
|
||||
}
|
||||
|
||||
// We can use the total count to save a call to the API
|
||||
if (!this.memberCounts) {
|
||||
yield this.loadMemberCountStats();
|
||||
|
||||
if (!this.memberCounts) {
|
||||
// console.warn('Failed to fetch member count by cadence: total paid is missing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const monthCount = yield this.membersCountCache.count('subscriptions.plan_interval:month+status:paid');
|
||||
const totalCount = this.memberCounts.paid;
|
||||
|
||||
this.paidMembersByCadence = {
|
||||
monthly: monthCount,
|
||||
annual: totalCount - monthCount
|
||||
};
|
||||
}
|
||||
|
||||
loadPaidProducts() {
|
||||
if (this.paidProducts !== null) {
|
||||
return;
|
||||
|
@ -447,42 +551,6 @@ export default class DashboardStatsService extends Service {
|
|||
this.paidProducts = data.toArray();
|
||||
}
|
||||
|
||||
loadPaidMembersByTier() {
|
||||
if (this._loadPaidMembersByTier.isRunning) {
|
||||
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
||||
return this._loadPaidMembersByTier.last;
|
||||
}
|
||||
return this._loadPaidMembersByTier.perform();
|
||||
}
|
||||
|
||||
@task
|
||||
*_loadPaidMembersByTier() {
|
||||
this.paidMembersByTier = null;
|
||||
|
||||
if (this.dashboardMocks.enabled) {
|
||||
yield this.dashboardMocks.waitRandom();
|
||||
this.paidMembersByTier = this.dashboardMocks.paidMembersByTier.slice();
|
||||
return;
|
||||
}
|
||||
|
||||
yield this.loadPaidProducts();
|
||||
if (!this.paidProducts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paidMembersByTier = [];
|
||||
|
||||
for (const product of this.paidProducts) {
|
||||
const members = yield this.membersCountCache.count(`product:[${product.slug}]`);
|
||||
paidMembersByTier.push({
|
||||
tier: product,
|
||||
members
|
||||
});
|
||||
}
|
||||
|
||||
this.paidMembersByTier = paidMembersByTier;
|
||||
}
|
||||
|
||||
loadNewsletterSubscribers() {
|
||||
if (this._loadNewsletterSubscribers.isRunning) {
|
||||
// We need to explicitly wait for the already running task instead of dropping it and returning immediately
|
||||
|
@ -597,8 +665,8 @@ export default class DashboardStatsService extends Service {
|
|||
await this._loadSiteStatus.cancelAll();
|
||||
await this._loadMrrStats.cancelAll();
|
||||
await this._loadMemberCountStats.cancelAll();
|
||||
await this._loadSubscriptionCountStats.cancelAll();
|
||||
await this._loadLastSeen.cancelAll();
|
||||
await this._loadPaidMembersByCadence.cancelAll();
|
||||
await this._loadNewsletterSubscribers.cancelAll();
|
||||
await this._loadEmailsSent.cancelAll();
|
||||
await this._loadEmailOpenRateStats.cancelAll();
|
||||
|
@ -623,7 +691,7 @@ export default class DashboardStatsService extends Service {
|
|||
* @param {MemberCountStat|MrrStat} defaultData
|
||||
* @param {number|'all'} days Amount of days to fill the graph with
|
||||
*/
|
||||
fillMissingDates(data, defaultData, days) {
|
||||
fillMissingDates(data, defaultData, copyData, days) {
|
||||
let currentRangeDate;
|
||||
|
||||
if (days === 'all') {
|
||||
|
@ -666,7 +734,14 @@ export default class DashboardStatsService extends Service {
|
|||
while (currentRangeDate.isBefore(endDate)) {
|
||||
let dateStr = currentRangeDate.format('YYYY-MM-DD');
|
||||
const dataOnDate = data.find(d => d.date === dateStr);
|
||||
lastVal = dataOnDate ? dataOnDate : {...lastVal, date: dateStr, paidCanceled: 0, paidSubscribed: 0};
|
||||
if (dataOnDate) {
|
||||
lastVal = dataOnDate;
|
||||
} else {
|
||||
lastVal = {
|
||||
date: dateStr,
|
||||
...copyData(lastVal)
|
||||
};
|
||||
}
|
||||
output.push(lastVal);
|
||||
currentRangeDate = currentRangeDate.add(1, 'day');
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue