TryGhost/Ghost

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

Summary

Maintainability
A
0 mins
Test Coverage
import Service, {inject as service} from '@ember/service';
import moment from 'moment-timezone';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';

import mergeStatsByDate from 'ghost-admin/utils/merge-stats-by-date';

/**
 * @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
 * @property {number} paidSubscribed Amount of new paid members
 * @property {number} paidCanceled Amount of canceled paid members
 */

/**
 * @typedef AttributionCountStat
 * @type {Object}
 * @property {string} date The date (YYYY-MM-DD) on which these counts were recorded
 * @property {number} source Attribution Source
 * @property {number} signups Total free members signed up for this source
 * @property {number} paidConversions Total paid conversions for this source
 */

/**
 * @typedef SourceAttributionCount
 * @type {Object}
 * @property {string} source Attribution Source
 * @property {number} signups Total free members signed up for this source
 * @property {number} paidConversions Total paid conversions for this source
 */

/**
 * @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
 */

/**
 * @typedef EmailOpenRateStat
 * @type {Object}
 * @property {string} subject Email title
 * @property {number} openRate Email openRate
 * @property {Date} submittedAt Date
 */

/**
 * @typedef PaidMembersByCadence
 * @type {Object}
 * @property {number} year Paid members on annual plan
 * @property {number} month Paid members on monthly plan
 */

/**
 * @typedef PaidMembersForTier
 * @type {Object}
 * @property {Object} tier Tier object
 * @property {number} members Paid members on this tier
 */

/**
 * @typedef SiteStatus Contains information on what graphs need to be shown
 * @type {Object}
 * @property {boolean} hasPaidTiers Whether the site has paid tiers
 * @property {boolean} hasMultipleTiers Whether the site has multiple paid tiers
 * @property {boolean} newslettersEnabled Whether the site has newsletters
 * @property {boolean} membersEnabled Whether the site has members enabled
 */

export default class DashboardStatsService extends Service {
    @service dashboardMocks;
    @service store;
    @service ajax;
    @service ghostPaths;
    @service membersCountCache;
    @service settings;
    @service membersUtils;
    @service membersStats;

    /**
     * @type {?SiteStatus} Contains information on what graphs need to be shown
     */
    @tracked siteStatus = null;

    /**
     * @type {?MemberCountStat[]}
     */
    @tracked
        memberCountStats = null;

    @tracked
        subscriptionCountStats = 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 {AttributionCountStat[]} Count of all attribution sources by date
     */
     @tracked
         memberAttributionStats = 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;

    /**
     * @type {number|'all'}
     * Amount of days to load for member count and MRR related charts
     */
    @tracked chartDays = 30 + 1;

    /**
     * Filter last seen by this status
     * @type {'free'|'paid'|'total'}
     */
    @tracked lastSeenFilterStatus = 'total';

    paidTiers = null;

    get activePaidTiers() {
        return this.paidTiers ? this.paidTiers.filter(tier => tier.active) : null;
    }

    /**
     * @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,
            paid: stat.paid + stat.comped,
            free: stat.free
        };
    }

    get currentMRR() {
        if (!this.mrrStats) {
            return null;
        }

        const stat = this.mrrStats[this.mrrStats.length - 1];
        return stat.mrr;
    }

    /**
     * @type {?MemberCounts}
     */
    get memberCountsTrend() {
        if (!this.memberCountStats) {
            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.memberCountStats.length - 1; index >= 0; index -= 1) {
            const stat = this.memberCountStats[index];
            if (stat.date <= searchDate) {
                return {
                    total: stat.paid + stat.comped + stat.free,
                    paid: stat.paid + stat.comped,
                    free: stat.free
                };
            }
        }

        // We don't have any statistic from more than x days ago.
        // Return all zero values
        return {
            total: 0,
            paid: 0,
            free: 0
        };
    }

    /**
     * @type {SourceAttributionCount[]}
     */
    get memberSourceAttributionCounts() {
        if (!this.memberAttributionStats) {
            return [];
        }

        const firstChartDay = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');

        return this.memberAttributionStats.filter((stat) => {
            if (this.chartDays === 'all') {
                return true;
            }
            return stat.date >= firstChartDay;
        }).reduce((acc, stat) => {
            const statSource = stat.source ?? '';
            const existingSource = acc.find(s => s.source.toLowerCase() === statSource.toLowerCase());
            if (existingSource) {
                existingSource.signups += stat.signups || 0;
                existingSource.paidConversions += stat.paidConversions || 0;
            } else {
                acc.push({
                    source: statSource,
                    signups: stat.signups || 0,
                    paidConversions: stat.paidConversions || 0
                });
            }
            return acc;
        }, []).sort((a, b) => {
            return b.signups - a.signups;
        });
    }

    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;
    }

    get filledMemberCountStats() {
        if (this.memberCountStats === null) {
            return null;
        }
        function copyData(obj) {
            return {
                paid: obj.paid,
                free: obj.free,
                comped: obj.comped,
                paidCanceled: 0,
                paidSubscribed: 0,
                signups: 0,
                cancellations: 0
            };
        }
        return this.fillMissingDates(this.memberCountStats, {paid: 0, free: 0, comped: 0, paidCanceled: 0, paidSubscribed: 0, signups: 0, cancellations: 0}, copyData, this.chartDays);
    }

    get filledMrrStats() {
        if (this.mrrStats === null) {
            return null;
        }
        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,
                signups: 0,
                cancellations: 0
            };
        }
        return this.fillMissingDates(this.subscriptionCountStats, {
            positiveDelta: 0,
            negativeDelta: 0,
            count: 0,
            signups: 0,
            cancellations: 0
        }, copyData, this.chartDays);
    }

    loadSiteStatus() {
        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;
        }
        return this._loadSiteStatus.perform();
    }

    @task
    *_loadSiteStatus() {
        this.siteStatus = null;
        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.loadSiteStatus();
            this.siteStatus = {...this.dashboardMocks.siteStatus};
            return;
        }

        if (this.membersUtils.paidMembersEnabled) {
            yield this.loadPaidTiers();
        }

        const hasPaidTiers = this.membersUtils.paidMembersEnabled && this.activePaidTiers && this.activePaidTiers.length > 0;

        this.siteStatus = {
            hasPaidTiers,
            hasMultipleTiers: hasPaidTiers && this.activePaidTiers.length > 1,
            newslettersEnabled: this.settings.editorDefaultEmailRecipients !== 'disabled',
            membersEnabled: this.membersUtils.isMembersEnabled
        };
    }

    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);

        const paidMembersByCadence = {
            month: 0,
            year: 0
        };

        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.loadPaidTiers();

        const paidMembersByTier = [];

        for (const tier of result.meta.tiers) {
            const _tier = this.paidTiers.find(x => x.id === tier);

            if (!_tier) {
                continue;
            }
            paidMembersByTier.push({
                tier: {
                    id: _tier.id,
                    name: _tier.name
                },
                members: result.meta.totals.reduce((sum, total) => {
                    if (total.tier !== tier) {
                        return sum;
                    }
                    return sum + total.count;
                }, 0)
            });
        }

        // Add all missing tiers without members
        for (const tier of this.activePaidTiers) {
            if (!paidMembersByTier.find(t => t.tier.id === tier.id)) {
                paidMembersByTier.push({
                    tier: {
                        id: tier.id,
                        name: tier.name
                    },
                    members: 0
                });
            }
        }

        const subscriptionCountStats = mergeStatsByDate(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
            return this._loadMemberCountStats.last;
        }
        return this._loadMemberCountStats.perform();
    }

    /**
     * Loads the members count history
     */
    @task
    *_loadMemberCountStats() {
        this.memberCountStats = null;
        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();

            if (this.dashboardMocks.memberCountStats === null) {
                // Note: that this shouldn't happen
                return null;
            }
            this.memberCountStats = this.dashboardMocks.memberCountStats;
            return;
        }

        const stats = yield this.membersStats.fetchMemberCount();
        this.memberCountStats = stats.stats.map((d) => {
            return {
                ...d,
                paidCanceled: d.paid_canceled,
                paidSubscribed: d.paid_subscribed
            };
        });
    }

    loadMemberAttributionStats() {
        if (this._loadMemberAttributionStats.isRunning) {
            // We need to explicitly wait for the already running task instead of dropping it and returning immediately
            return this._loadMemberAttributionStats.last;
        }
        return this._loadMemberAttributionStats.perform();
    }

    /**
     * Loads the members attribution stats
     */
     @task
    *_loadMemberAttributionStats() {
        this.memberAttributionStats = [];

        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();
            this.memberAttributionStats = this.dashboardMocks.memberAttributionStats;
            return;
        }
        let statsUrl = this.ghostPaths.url.api('stats/referrers');
        let stats = yield this.ajax.request(statsUrl);

        this.memberAttributionStats = stats.stats.map((stat) => {
            return {
                ...stat,
                paidConversions: stat.paid_conversions
            };
        });
    }

     loadMrrStats() {
         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;
         }
         return this._loadMrrStats.perform();
     }

    /**
     * Loads the mrr graphs for the current chartDays days
     */
    @task
     *_loadMrrStats() {
         this.mrrStats = null;
         if (this.dashboardMocks.enabled) {
             yield this.dashboardMocks.waitRandom();
             if (this.dashboardMocks.mrrStats === null) {
                 return null;
             }
             this.mrrStats = this.dashboardMocks.mrrStats;
             return;
         }

         let statsUrl = this.ghostPaths.url.api('stats/mrr');
         let stats = yield this.ajax.request(statsUrl);

         // 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);
     }

    loadLastSeen() {
        // todo: add proper logic to prevent duplicate calls + reuse results if nothing has changed
        return this._loadLastSeen.perform();
    }

    /**
     * Loads the last seen counts
     */
    @task
    *_loadLastSeen() {
        this.membersLastSeen30d = null;
        this.membersLastSeen7d = null;

        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();
            this.membersLastSeen30d = this.dashboardMocks.membersLastSeen30d;
            this.membersLastSeen7d = this.dashboardMocks.membersLastSeen7d;
            return;
        }

        const start30d = new Date(Date.now() - 30 * 86400 * 1000);
        const start7d = new Date(Date.now() - 7 * 86400 * 1000);

        // 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);

        let extraFilter = '';
        if (this.lastSeenFilterStatus === 'paid') {
            extraFilter = '+status:paid';
        } else if (this.lastSeenFilterStatus === 'free') {
            extraFilter = '+status:-paid';
        }

        const [result30d, result7d] = yield Promise.all([
            this.membersCountCache.count('last_seen_at:>' + start30d.toISOString() + extraFilter),
            this.membersCountCache.count('last_seen_at:>' + start7d.toISOString() + extraFilter)
        ]);

        this.membersLastSeen30d = result30d;
        this.membersLastSeen7d = result7d;
    }

    loadPaidTiers() {
        if (this.paidTiers !== null) {
            return;
        }
        if (this._loadPaidTiers.isRunning) {
            // We need to explicitly wait for the already running task instead of dropping it and returning immediately
            return this._loadPaidTiers.last;
        }
        return this._loadPaidTiers.perform();
    }

    @task
    *_loadPaidTiers() {
        const data = yield this.store.query('tier', {
            filter: 'type:paid',
            limit: 'all'
        });
        this.paidTiers = data.toArray();
    }

    loadNewsletterSubscribers() {
        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;
        }
        return this._loadNewsletterSubscribers.perform();
    }

    @task
    *_loadNewsletterSubscribers() {
        this.newsletterSubscribers = null;

        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();
            this.newsletterSubscribers = this.dashboardMocks.newsletterSubscribers;
            return;
        }

        const [paid, free] = yield Promise.all([
            this.membersCountCache.count('newsletters.status:active+status:-free+email_disabled:0'),
            this.membersCountCache.count('newsletters.status:active+status:free+email_disabled:0')
        ]);

        this.newsletterSubscribers = {
            total: paid + free,
            free,
            paid
        };
    }

    loadEmailsSent() {
        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;
        }
        return this._loadEmailsSent.perform();
    }

    @task
    *_loadEmailsSent() {
        this.emailsSent30d = null;

        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();
            this.emailsSent30d = this.dashboardMocks.emailsSent30d;
            return;
        }

        const start30d = new Date(Date.now() - 30 * 86400 * 1000);
        const result = yield this.store.query('email', {limit: 100, filter: `submitted_at:>'${start30d.toISOString()}'`});
        this.emailsSent30d = result.reduce((c, email) => c + email.emailCount, 0);
    }

    loadEmailOpenRateStats() {
        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;
        }
        return this._loadEmailOpenRateStats.perform();
    }

    @task
    *_loadEmailOpenRateStats() {
        this.emailOpenRateStats = null;

        if (this.dashboardMocks.enabled) {
            yield this.dashboardMocks.waitRandom();
            this.emailOpenRateStats = this.dashboardMocks.emailOpenRateStats;
            return;
        }

        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;
    }

    /**
     * For now this is only used when reloading all the graphs after changing the mocked data
     * @todo: reload only data that we loaded earlier
     */
    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();
        await this._loadSubscriptionCountStats.cancelAll();
        await this._loadLastSeen.cancelAll();
        await this._loadNewsletterSubscribers.cancelAll();
        await this._loadEmailsSent.cancelAll();
        await this._loadEmailOpenRateStats.cancelAll();
        await this._loadMemberAttributionStats.cancelAll();

        // Restart tasks
        this.loadSiteStatus();

        this.loadMrrStats();
        this.loadMemberCountStats();
        this.loadSubscriptionCountStats();
        this.loadLastSeen();
        this.loadPaidMembersByCadence();
        this.loadPaidMembersByTier();

        this.loadNewsletterSubscribers();
        this.loadEmailsSent();
        this.loadEmailOpenRateStats();
        this.loadMemberAttributionStats();
    }

    /**
     * Fill data to match a given amount of days
     * @param {MemberCountStat[]|MrrStat[]} data
     * @param {MemberCountStat|MrrStat} defaultData
     * @param {number|'all'} days Amount of days to fill the graph with
     */
    fillMissingDates(data, defaultData, copyData, days) {
        let currentRangeDate;

        if (days === 'all') {
            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;
                }
            }
        } else {
            currentRangeDate = moment().subtract(days - 1, 'days');
        }

        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];
        }

        let lastVal = initialDateInRangeVal ? initialDateInRangeVal : defaultData;

        while (currentRangeDate.isBefore(endDate)) {
            let dateStr = currentRangeDate.format('YYYY-MM-DD');
            const dataOnDate = data.find(d => d.date === dateStr);
            if (dataOnDate) {
                lastVal = dataOnDate;
            } else {
                lastVal = {
                    date: dateStr,
                    ...copyData(lastVal)
                };
            }
            output.push(lastVal);
            currentRangeDate = currentRangeDate.add(1, 'day');
        }
        return output;
    }
}