0
Fork 0
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:
Fabien 'egg' O'Carroll 2022-04-27 15:11:01 +01:00 committed by GitHub
parent e5d5e359d7
commit b0a875f733
4 changed files with 174 additions and 91 deletions

View file

@ -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 {

View file

@ -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],

View file

@ -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 = [

View file

@ -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');
}