TryGhost/Ghost

View on GitHub
ghost/admin/app/services/dashboard-mocks.js

Summary

Maintainability
F
4 days
Test Coverage
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'
            };
        });
    }
}