ghost/admin/app/services/dashboard-mocks.js
import Service from '@ember/service';
import {tracked} from '@glimmer/tracking';
/**
* @typedef {import('./dashboard-stats').MemberCountStat} MemberCountStat
* @typedef {import('./dashboard-stats').MemberCounts} MemberCounts
* @typedef {import('./dashboard-stats').MrrStat} MrrStat
* @typedef {import('./dashboard-stats').EmailOpenRateStat} EmailOpenRateStat
* @typedef {import('./dashboard-stats').PaidMembersByCadence} PaidMembersByCadence
* @typedef {import('./dashboard-stats').PaidMembersForTier} PaidMembersForTier
* @typedef {import('./dashboard-stats').SiteStatus} SiteStatus
*/
/**
* Service that contains fake data to be used by the DashboardStatsService if useMocks is enabled
*/
export default class DashboardMocksService extends Service {
@tracked enabled = false;
/**
* Just a setting for generating mocked data, for how long this site has been active.
*/
@tracked generateDays = 30;
/**
* @type {?SiteStatus} Contains information on what graphs need to be shown
*/
@tracked siteStatus = null;
/**
* @type {?MemberCountStat[]}
*/
@tracked
memberCountStats = null;
/**
* @type {?MrrStat[]}
*/
@tracked
mrrStats = null;
/**
* @type {PaidMembersByCadence} Number of members for annual and monthly plans
*/
@tracked
paidMembersByCadence = null;
/**
* @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)
*/
@tracked
membersLastSeen30d = null;
/**
* @type {?number} Number of members last seen in last 7 days (could differ if filtered by member status)
*/
@tracked
membersLastSeen7d = null;
/**
* @type {?MemberCounts} Number of members that are subscribed (grouped by status)
*/
@tracked
newsletterSubscribers = null;
/**
* @type {?number} Number of emails sent in last 30 days
*/
@tracked
emailsSent30d = null;
/**
* @type {?EmailOpenRateStat[]}
*/
@tracked
emailOpenRateStats = null;
async waitRandom() {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 100 + Math.random() * 1000);
});
}
async loadSiteStatus() {
if (this.siteStatus !== null) {
return;
}
await this.waitRandom();
this.siteStatus = {
hasPaidTiers: true,
hasMultipleTiers: true,
newslettersEnabled: true,
membersEnabled: true
};
}
_updateGrow(settings) {
const change = Math.round(Math.random() * (settings.growRate - settings.shrinkOffset));
if (settings.growPeriod) {
settings.growCount += 1;
if (settings.growCount > settings.growLength) {
settings.growPeriod = false;
settings.growCount = 0;
settings.growLength = Math.floor(Math.random() * settings.maxPeriod) + 20;
}
} else {
settings.growCount += 1;
if (settings.growCount > settings.growLength) {
settings.growPeriod = true;
settings.growCount = 0;
settings.growLength = Math.floor(Math.random() * settings.maxPeriod) + 20;
}
}
if (settings.growPeriod) {
if (settings.growRate < settings.maxGrowRate) {
settings.growRate *= settings.increaseSpeed;
}
} else {
if (settings.growRate > 2) {
settings.growRate *= settings.decreaseSpeed;
}
}
return change;
}
/**
* This method generates new data and forces a reload for all the charts
* Might be better to move this code to a temporary mocking service
*/
updateMockedData({days}) {
const generateDays = days;
const startDate = new Date();
startDate.setDate(startDate.getDate() - generateDays + 1);
/**
* @type {MemberCountStat[]}
*/
const stats = [];
let viralCounter = Math.floor(Math.random() * 90);
let paidSubscribedGrowthTier1 = {
value: 0,
growPeriod: true,
growCount: 0,
growLength: 3 + Math.floor(Math.random() * 7),
growRate: 10,
shrinkOffset: 3,
maxGrowRate: 200,
increaseSpeed: 1.04,
decreaseSpeed: 0.99,
maxPeriod: 180
};
let paidCanceledGrowthTier1 = {
growPeriod: false,
growCount: 0,
growLength: Math.floor(Math.random() * 30),
growRate: 1,
shrinkOffset: 4,
maxGrowRate: 50,
increaseSpeed: 1.03,
decreaseSpeed: 0.99,
maxPeriod: 60
};
let paidSubscribedGrowthTier2 = {
growPeriod: false,
growCount: 0,
growLength: Math.floor(Math.random() * 60),
growRate: 1,
shrinkOffset: 2,
maxGrowRate: 50,
increaseSpeed: 1.04,
decreaseSpeed: 0.99,
maxPeriod: 180
};
let paidCanceledGrowthTier2 = {
growPeriod: false,
growCount: 0,
growLength: Math.floor(Math.random() * 7),
growRate: 1,
shrinkOffset: 4,
maxGrowRate: 10,
increaseSpeed: 1.03,
decreaseSpeed: 0.99,
maxPeriod: 60
};
let freeGrowth = {
growPeriod: true,
growCount: 0,
growLength: Math.floor(Math.random() * 30),
growRate: 20,
shrinkOffset: 2,
maxGrowRate: 200,
increaseSpeed: 1.02,
decreaseSpeed: 0.99,
maxPeriod: 90
};
this.memberAttributionStats = [];
for (let index = 0; index < generateDays; index++) {
const date = new Date(startDate.getTime());
date.setDate(date.getDate() + index);
if (index === 0) {
stats.push({
date: date.toISOString().split('T')[0],
free: 0,
tier1: 0,
tier2: 0,
paid: 0,
comped: 0,
paidSubscribed: 0,
paidCanceled: 0
});
continue;
}
const previous = stats[stats.length - 1];
let paidSubscribed1 = Math.max(0, this._updateGrow(paidSubscribedGrowthTier1));
const paidCanceled1 = Math.min(previous.tier1, Math.max(0, this._updateGrow(paidCanceledGrowthTier1)));
const paidSubscribed2 = Math.max(0, this._updateGrow(paidSubscribedGrowthTier2));
const paidCanceled2 = Math.min(previous.tier2, Math.max(0, this._updateGrow(paidCanceledGrowthTier2)));
let freeDelta = Math.max(0, this._updateGrow(freeGrowth));
viralCounter -= 1;
if (viralCounter <= 0) {
viralCounter = Math.floor(Math.random() * 900);
freeDelta += Math.floor(Math.random() * 20 * index);
paidSubscribed1 += Math.floor(Math.random() * 20 * index);
// End grow periods
freeGrowth.growPeriod = true;
freeGrowth.growLength = Math.floor(Math.random() * 5);
freeGrowth.growRate = freeDelta;
paidSubscribedGrowthTier1.growPeriod = true;
paidSubscribedGrowthTier1.growLength = 0;
paidCanceledGrowthTier1.growLength = 14;
paidCanceledGrowthTier1.growPeriod = false;
}
const tier1 = Math.max(0, previous.tier1 + paidSubscribed1 - paidCanceled1);
const tier2 = Math.max(0, previous.tier2 + paidSubscribed2 - paidCanceled2);
stats.push({
date: date.toISOString().split('T')[0],
free: previous.free + freeDelta,
tier1,
tier2,
paid: tier1 + tier2,
comped: 0,
paidSubscribed: paidSubscribed1 + paidSubscribed2,
paidCanceled: paidCanceled1 + paidCanceled2
});
// More than 5 sources
let attributionSources = ['Twitter', 'Ghost Network', 'Product Hunt', 'Direct', 'Ghost Newsletter', 'Rediverge Newsletter', 'Reddit', 'The Lever Newsletter', 'The Browser Newsletter', 'Green Newsletter', 'Yellow Newsletter', 'Brown Newsletter', 'Red Newsletter'];
const hasPaidConversions = true;
const hasFreeSignups = true;
const showEmptyState = false;
const hasUnavailableSources = true;
const hasExtraSources = true;
if (!hasExtraSources) {
attributionSources = attributionSources.slice(0, 5);
}
if (!showEmptyState) {
this.memberAttributionStats.push({
date: date.toISOString().split('T')[0],
source: attributionSources[Math.floor(Math.random() * attributionSources.length)],
signups: hasFreeSignups ? Math.floor(Math.random() * 50) : 0,
paidConversions: hasPaidConversions ? Math.floor(Math.random() * 30) : 0
});
if (hasUnavailableSources) {
this.memberAttributionStats.push({
date: date.toISOString().split('T')[0],
source: null,
signups: hasFreeSignups ? Math.floor(Math.random() * 5) : 0,
paidConversions: hasPaidConversions ? Math.floor(Math.random() * 3) : 0
});
}
}
}
if (stats.length === 0) {
stats.push(
{
date: new Date().toISOString().split('T')[0],
free: 0,
paid: 0,
comped: 0,
paidSubscribed: 0,
paidCanceled: 0
}
);
}
this.memberCountStats = stats;
this.subscriptionCountStats = stats.map((data) => {
const signups = (data.paidSubscribed - data.paidCanceled);
return {
date: data.date,
count: data.paid,
positiveDelta: data.paidSubscribed,
negativeDelta: data.paidCanceled,
signups: signups < 0 ? 0 : signups,
cancellations: Math.floor(signups * 0.3) ? Math.floor(signups * 0.3) : 0
};
});
const lastStat = stats[stats.length - 1];
const currentCounts = {
total: lastStat.paid + lastStat.free + lastStat.comped,
paid: lastStat.paid,
free: lastStat.free + lastStat.comped
};
const cadenceRate = Math.random();
this.paidMembersByCadence = {
year: Math.floor(currentCounts.paid * cadenceRate),
month: Math.floor(currentCounts.paid * (1 - cadenceRate))
};
this.paidMembersByTier = [
{
tier: {
name: 'Bronze tier'
},
members: Math.floor(currentCounts.paid * 0.6)
},
{
tier: {
name: 'Silver tier'
},
members: Math.floor(currentCounts.paid * 0.25)
},
{
tier: {
name: 'Gold tier'
},
members: Math.floor(currentCounts.paid * 0.15)
}
];
this.newsletterSubscribers = {
paid: Math.floor(currentCounts.paid * 0.9),
free: Math.floor(currentCounts.free * 0.5),
total: Math.floor(currentCounts.paid * 0.9) + Math.floor(currentCounts.free * 0.5)
};
this.emailsSent30d = Math.floor(days * 123 / 90);
this.membersLastSeen7d = Math.round(Math.random() * currentCounts.free / 2);
this.membersLastSeen30d = this.membersLastSeen7d + Math.round(Math.random() * currentCounts.free / 2);
this.emailOpenRateStats = [];
if (days >= 7) {
this.emailOpenRateStats.push(
{
subject: '💸 The best way to get paid to create',
openRate: 58,
submittedAt: new Date()
}
);
}
if (days >= 28) {
this.emailOpenRateStats.push(
{
subject: '🎒How to start a blog and make money',
openRate: 42,
submittedAt: new Date()
},
{
subject: 'How to turn your amateur blogging into a real business',
openRate: 89,
submittedAt: new Date()
},
{
subject: '💸 The best way to get paid to create',
openRate: 58,
submittedAt: new Date()
}
);
}
if (days >= 40) {
this.emailOpenRateStats.push(
{
subject: '🎒How to start a blog and make money',
openRate: 42,
submittedAt: new Date()
},
{
subject: 'How to turn your amateur blogging into a real business',
openRate: 70,
submittedAt: new Date()
},
{
subject: '🎒How to start a blog and make money',
openRate: 90,
submittedAt: new Date()
},
{
subject: 'How to turn your amateur blogging into a real business',
openRate: 89,
submittedAt: new Date()
}
);
}
this.mrrStats = stats.map((s) => {
return {
date: s.date,
mrr: s.tier1 * 501 + s.tier2 * 2500,
currency: 'usd'
};
});
}
}